kin/web/frontend/src/__tests__/live-console.test.ts

517 lines
20 KiB
TypeScript
Raw Normal View History

2026-03-17 17:39:40 +02:00
/**
* KIN-084: LiveConsole панель лога в реальном времени
*
* Проверяет:
* 1. Тогл видимости (по умолчанию скрыта, открывается/закрывается по клику)
* 2. Поллинг (запуск при открытии+running, since_id, остановка, onUnmounted)
* 3. Отображение логов (формат, цвета уровней, extra_json)
* 4. Автоскролл (scrollTop=scrollHeight при новых логах, отключение при ручном скролле)
* 5. Ограничение массива до 500 строк (FIFO)
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import LiveConsole from '../components/LiveConsole.vue'
vi.mock('../api', () => ({
api: {
getPipelineLogs: vi.fn(),
},
}))
import { api } from '../api'
function makeLog(id: number, level: 'INFO' | 'DEBUG' | 'ERROR' | 'WARN' = 'INFO', extra: Record<string, unknown> | null = null) {
return { id, ts: `2026-03-17T10:00:0${id}`, level, message: `Message ${id}`, extra_json: extra }
}
function mountConsole(pipelineStatus = 'running') {
return mount(LiveConsole, {
props: { pipelineId: 'pipeline-1', pipelineStatus },
attachTo: document.body,
})
}
beforeEach(() => {
vi.useFakeTimers()
vi.clearAllMocks()
vi.mocked(api.getPipelineLogs).mockResolvedValue([])
})
afterEach(() => {
vi.useRealTimers()
vi.restoreAllMocks()
})
// ─────────────────────────────────────────────────────────────
// 1. Тогл видимости
// ─────────────────────────────────────────────────────────────
describe('тогл видимости', () => {
it('панель лога скрыта по умолчанию', () => {
const wrapper = mountConsole()
const panel = wrapper.find('[class*="bg-gray-950"]')
// v-show=false — элемент в DOM, но display: none
2026-03-17 21:25:12 +02:00
expect((panel.element as HTMLElement).style.display).toBe('none')
2026-03-17 17:39:40 +02:00
wrapper.unmount()
})
it('кнопка показывает «Показать лог» при скрытой панели', () => {
const wrapper = mountConsole()
expect(wrapper.find('button').text()).toContain('Показать лог')
wrapper.unmount()
})
it('клик по кнопке открывает панель', async () => {
const wrapper = mountConsole()
await wrapper.find('button').trigger('click')
await flushPromises()
const panel = wrapper.find('[class*="bg-gray-950"]')
2026-03-17 21:25:12 +02:00
expect((panel.element as HTMLElement).style.display).not.toBe('none')
2026-03-17 17:39:40 +02:00
wrapper.unmount()
})
it('кнопка показывает «Скрыть лог» когда панель открыта', async () => {
const wrapper = mountConsole()
await wrapper.find('button').trigger('click')
await flushPromises()
expect(wrapper.find('button').text()).toContain('Скрыть лог')
wrapper.unmount()
})
it('повторный клик скрывает панель', async () => {
const wrapper = mountConsole()
await wrapper.find('button').trigger('click')
await flushPromises()
await wrapper.find('button').trigger('click')
await flushPromises()
const panel = wrapper.find('[class*="bg-gray-950"]')
2026-03-17 21:25:12 +02:00
expect((panel.element as HTMLElement).style.display).toBe('none')
2026-03-17 17:39:40 +02:00
wrapper.unmount()
})
})
// ─────────────────────────────────────────────────────────────
// 2. Поллинг
// ─────────────────────────────────────────────────────────────
describe('поллинг', () => {
it('при открытии панели с pipelineStatus=running запускается поллинг', async () => {
const wrapper = mountConsole('running')
await wrapper.find('button').trigger('click')
await flushPromises()
expect(vi.getTimerCount()).toBeGreaterThan(0)
wrapper.unmount()
})
it('при открытии панели с pipelineStatus=in_progress запускается поллинг', async () => {
const wrapper = mountConsole('in_progress')
await wrapper.find('button').trigger('click')
await flushPromises()
expect(vi.getTimerCount()).toBeGreaterThan(0)
wrapper.unmount()
})
it('при открытии панели с pipelineStatus=done поллинг НЕ запускается', async () => {
const wrapper = mountConsole('done')
await wrapper.find('button').trigger('click')
await flushPromises()
expect(vi.getTimerCount()).toBe(0)
wrapper.unmount()
})
it('since_id=0 при первом запросе', async () => {
const wrapper = mountConsole('running')
await wrapper.find('button').trigger('click')
await flushPromises()
expect(vi.mocked(api.getPipelineLogs)).toHaveBeenCalledWith('pipeline-1', 0)
wrapper.unmount()
})
it('since_id = max(id) из предыдущего ответа для следующего запроса', async () => {
vi.mocked(api.getPipelineLogs)
.mockResolvedValueOnce([makeLog(3), makeLog(7), makeLog(5)] as any)
.mockResolvedValue([])
const wrapper = mountConsole('running')
await wrapper.find('button').trigger('click')
await flushPromises()
await vi.advanceTimersByTimeAsync(2000)
await flushPromises()
const calls = vi.mocked(api.getPipelineLogs).mock.calls
expect(calls[0][1]).toBe(0)
expect(calls[1][1]).toBe(7)
wrapper.unmount()
})
it('поллинг тикает каждые 2 секунды', async () => {
vi.mocked(api.getPipelineLogs).mockResolvedValue([])
const wrapper = mountConsole('running')
await wrapper.find('button').trigger('click')
await flushPromises()
const callsAfterOpen = vi.mocked(api.getPipelineLogs).mock.calls.length
await vi.advanceTimersByTimeAsync(2000)
await flushPromises()
await vi.advanceTimersByTimeAsync(2000)
await flushPromises()
expect(vi.mocked(api.getPipelineLogs).mock.calls.length).toBe(callsAfterOpen + 2)
wrapper.unmount()
})
it('при закрытии панели поллинг останавливается', async () => {
const wrapper = mountConsole('running')
await wrapper.find('button').trigger('click')
await flushPromises()
await wrapper.find('button').trigger('click')
await flushPromises()
expect(vi.getTimerCount()).toBe(0)
wrapper.unmount()
})
it('onUnmounted — clearInterval вызван, vi.getTimerCount() === 0', async () => {
const wrapper = mountConsole('running')
await wrapper.find('button').trigger('click')
await flushPromises()
expect(vi.getTimerCount()).toBeGreaterThan(0)
wrapper.unmount()
expect(vi.getTimerCount()).toBe(0)
})
it('смена pipelineStatus на done останавливает поллинг и делает финальный fetch', async () => {
vi.mocked(api.getPipelineLogs).mockResolvedValue([])
const wrapper = mountConsole('running')
await wrapper.find('button').trigger('click')
await flushPromises()
const callsBefore = vi.mocked(api.getPipelineLogs).mock.calls.length
await wrapper.setProps({ pipelineStatus: 'done' })
await flushPromises()
// Финальный fetch
expect(vi.mocked(api.getPipelineLogs).mock.calls.length).toBeGreaterThan(callsBefore)
// Поллинг остановлен
expect(vi.getTimerCount()).toBe(0)
wrapper.unmount()
})
})
// ─────────────────────────────────────────────────────────────
// 3. Отображение логов
// ─────────────────────────────────────────────────────────────
describe('отображение логов', () => {
it('строка лога отображается с временем и сообщением', async () => {
vi.mocked(api.getPipelineLogs).mockResolvedValueOnce([makeLog(1)] as any)
const wrapper = mountConsole('done')
await wrapper.find('button').trigger('click')
await flushPromises()
expect(wrapper.text()).toContain('2026-03-17T10:00:01')
expect(wrapper.text()).toContain('Message 1')
wrapper.unmount()
})
it('строка лога отображает метку уровня [INFO]', async () => {
vi.mocked(api.getPipelineLogs).mockResolvedValueOnce([makeLog(1, 'INFO')] as any)
const wrapper = mountConsole('done')
await wrapper.find('button').trigger('click')
await flushPromises()
expect(wrapper.text()).toContain('[INFO]')
wrapper.unmount()
})
it('ERROR → класс text-red-400', async () => {
vi.mocked(api.getPipelineLogs).mockResolvedValueOnce([makeLog(1, 'ERROR')] as any)
const wrapper = mountConsole('done')
await wrapper.find('button').trigger('click')
await flushPromises()
const redEl = wrapper.find('.text-red-400')
expect(redEl.exists()).toBe(true)
expect(redEl.text()).toContain('[ERROR]')
wrapper.unmount()
})
it('WARN → класс text-yellow-400', async () => {
vi.mocked(api.getPipelineLogs).mockResolvedValueOnce([makeLog(1, 'WARN')] as any)
const wrapper = mountConsole('done')
await wrapper.find('button').trigger('click')
await flushPromises()
const yellowEl = wrapper.find('.text-yellow-400')
expect(yellowEl.exists()).toBe(true)
expect(yellowEl.text()).toContain('[WARN]')
wrapper.unmount()
})
it('INFO → класс text-gray-300', async () => {
vi.mocked(api.getPipelineLogs).mockResolvedValueOnce([makeLog(1, 'INFO')] as any)
const wrapper = mountConsole('done')
await wrapper.find('button').trigger('click')
await flushPromises()
const infoEl = wrapper.find('.text-gray-300')
expect(infoEl.exists()).toBe(true)
wrapper.unmount()
})
it('DEBUG → класс text-gray-500', async () => {
vi.mocked(api.getPipelineLogs).mockResolvedValueOnce([makeLog(1, 'DEBUG')] as any)
const wrapper = mountConsole('done')
await wrapper.find('button').trigger('click')
await flushPromises()
const debugEls = wrapper.findAll('.text-gray-500')
// text-gray-500 применяется к метке DEBUG и к extra_json
const hasDebug = debugEls.some(el => el.text().includes('[DEBUG]'))
expect(hasDebug).toBe(true)
wrapper.unmount()
})
it('extra_json отображается через JSON.stringify когда не null', async () => {
const extra = { key: 'value', num: 42 }
vi.mocked(api.getPipelineLogs).mockResolvedValueOnce([makeLog(1, 'INFO', extra)] as any)
const wrapper = mountConsole('done')
await wrapper.find('button').trigger('click')
await flushPromises()
expect(wrapper.text()).toContain('"key"')
expect(wrapper.text()).toContain('"value"')
wrapper.unmount()
})
it('extra_json НЕ отображается когда null', async () => {
vi.mocked(api.getPipelineLogs).mockResolvedValueOnce([makeLog(1, 'INFO', null)] as any)
const wrapper = mountConsole('done')
await wrapper.find('button').trigger('click')
await flushPromises()
const pres = wrapper.findAll('pre')
expect(pres.length).toBe(0)
wrapper.unmount()
})
it('«Нет записей» отображается когда логов нет', async () => {
vi.mocked(api.getPipelineLogs).mockResolvedValue([])
const wrapper = mountConsole('done')
await wrapper.find('button').trigger('click')
await flushPromises()
expect(wrapper.text()).toContain('Нет записей')
wrapper.unmount()
})
it('ошибка API отображается в красном блоке', async () => {
vi.mocked(api.getPipelineLogs).mockRejectedValueOnce(new Error('Network fail'))
const wrapper = mountConsole('done')
await wrapper.find('button').trigger('click')
await flushPromises()
expect(wrapper.text()).toContain('Ошибка')
expect(wrapper.text()).toContain('Network fail')
wrapper.unmount()
})
2026-03-17 18:26:19 +02:00
// KIN-OBS-026: catch (e: unknown) — type narrowing
it('не-Error строка отображается через String(e) (catch unknown narrowing)', async () => {
vi.mocked(api.getPipelineLogs).mockRejectedValueOnce('строковая ошибка')
const wrapper = mountConsole('done')
await wrapper.find('button').trigger('click')
await flushPromises()
expect(wrapper.text()).toContain('Ошибка')
expect(wrapper.text()).toContain('строковая ошибка')
wrapper.unmount()
})
it('не-Error число отображается через String(e) (catch unknown narrowing)', async () => {
vi.mocked(api.getPipelineLogs).mockRejectedValueOnce(500)
const wrapper = mountConsole('done')
await wrapper.find('button').trigger('click')
await flushPromises()
expect(wrapper.text()).toContain('Ошибка')
expect(wrapper.text()).toContain('500')
wrapper.unmount()
})
})
// ─────────────────────────────────────────────────────────────
// KIN-OBS-026: scrollTimer cleanup
// ─────────────────────────────────────────────────────────────
describe('scrollTimer cleanup (KIN-OBS-026)', () => {
it('onUnmounted очищает scrollTimer если он был активен', async () => {
// fetchLogs с логами создаёт scrollTimer = setTimeout(scrollToBottom, 0)
vi.mocked(api.getPipelineLogs).mockResolvedValueOnce([makeLog(1)] as any)
const wrapper = mountConsole('done')
await wrapper.find('button').trigger('click')
await flushPromises()
// С fake timers setTimeout(scrollToBottom, 0) ещё не выполнен — таймер висит
expect(vi.getTimerCount()).toBeGreaterThan(0)
wrapper.unmount()
// stopPolling() очищает scrollTimer через clearTimeout
expect(vi.getTimerCount()).toBe(0)
})
it('закрытие панели очищает scrollTimer вместе с setInterval', async () => {
vi.mocked(api.getPipelineLogs)
.mockResolvedValueOnce([makeLog(1)] as any)
.mockResolvedValue([])
const wrapper = mountConsole('running')
await wrapper.find('button').trigger('click')
await flushPromises()
// Есть как минимум setInterval + scrollTimer
expect(vi.getTimerCount()).toBeGreaterThanOrEqual(2)
// Закрываем панель → stopPolling() вызывает clearInterval + clearTimeout
await wrapper.find('button').trigger('click')
await flushPromises()
expect(vi.getTimerCount()).toBe(0)
wrapper.unmount()
})
2026-03-17 17:39:40 +02:00
})
// ─────────────────────────────────────────────────────────────
// 4. Автоскролл
// ─────────────────────────────────────────────────────────────
describe('автоскролл', () => {
it('при добавлении логов scrollTop устанавливается в scrollHeight', async () => {
vi.mocked(api.getPipelineLogs).mockResolvedValueOnce([makeLog(1)] as any)
const wrapper = mountConsole('done')
await wrapper.find('button').trigger('click')
await flushPromises()
const panel = wrapper.find('[class*="bg-gray-950"]').element as HTMLElement
// В jsdom scrollHeight = 0 (контент не рендерится), поэтому scrollTop тоже 0
// Проверяем что scrollToBottom вызывается — косвенно через scrollTop = scrollHeight
expect(panel.scrollTop).toBe(panel.scrollHeight)
wrapper.unmount()
})
it('если userScrolled=true — scrollTop НЕ меняется при новых логах', async () => {
vi.mocked(api.getPipelineLogs)
.mockResolvedValueOnce([makeLog(1)] as any)
.mockResolvedValue([makeLog(2)] as any)
const wrapper = mountConsole('running')
await wrapper.find('button').trigger('click')
await flushPromises()
const panel = wrapper.find('[class*="bg-gray-950"]').element as HTMLElement
// Симулируем ручной скролл вверх
Object.defineProperty(panel, 'scrollTop', { value: 0, writable: true, configurable: true })
Object.defineProperty(panel, 'scrollHeight', { value: 500, writable: true, configurable: true })
Object.defineProperty(panel, 'clientHeight', { value: 200, writable: true, configurable: true })
// Триггерим событие scroll — компонент установит userScrolled=true
await wrapper.find('[class*="bg-gray-950"]').trigger('scroll')
panel.scrollTop = 10 // запомним текущий scrollTop
// Следующий fetch добавляет лог — scrollToBottom должен быть пропущен
await vi.advanceTimersByTimeAsync(2000)
await flushPromises()
// scrollTop не сбрасывается в 0 (scrollHeight=500)
expect(panel.scrollTop).not.toBe(500)
wrapper.unmount()
})
})
// ─────────────────────────────────────────────────────────────
// 5. Ограничение массива до 500 строк (FIFO)
// ─────────────────────────────────────────────────────────────
describe('ограничение массива до MAX_LOGS=500', () => {
// Нулевое дополнение — уникальные строки без substring-коллизий
function makeLogs(count: number, startId = 1) {
return Array.from({ length: count }, (_, i) => {
const id = startId + i
return {
id,
ts: `ts-${String(id).padStart(6, '0')}`,
level: 'INFO' as const,
message: `Msg-${String(id).padStart(6, '0')}`,
extra_json: null,
}
})
}
it('при 499 строках — все сохранены (N-1)', async () => {
vi.mocked(api.getPipelineLogs).mockResolvedValueOnce(makeLogs(499) as any)
const wrapper = mountConsole('done')
await wrapper.find('button').trigger('click')
await flushPromises()
// Проверяем количество строк в DOM
const rows = wrapper.findAll('[class*="mb-1"]')
expect(rows.length).toBe(499)
wrapper.unmount()
})
it('при 500 строках — все сохранены (N)', async () => {
vi.mocked(api.getPipelineLogs).mockResolvedValueOnce(makeLogs(500) as any)
const wrapper = mountConsole('done')
await wrapper.find('button').trigger('click')
await flushPromises()
const rows = wrapper.findAll('[class*="mb-1"]')
expect(rows.length).toBe(500)
wrapper.unmount()
})
it('при 501 строке — обрезка до 500, самая старая удалена (N+1)', async () => {
// Первый fetch: 500 логов
vi.mocked(api.getPipelineLogs)
.mockResolvedValueOnce(makeLogs(500, 1) as any)
.mockResolvedValueOnce(makeLogs(1, 501) as any)
.mockResolvedValue([])
const wrapper = mountConsole('running')
await wrapper.find('button').trigger('click')
await flushPromises()
// Второй fetch: +1 лог → итого 501 → обрезка до 500
await vi.advanceTimersByTimeAsync(2000)
await flushPromises()
const rows = wrapper.findAll('[class*="mb-1"]')
expect(rows.length).toBe(500)
// Первый лог (id=1, msg="Msg-000001") удалён
const texts = rows.map(r => r.text())
expect(texts.some(t => t.includes('Msg-000001'))).toBe(false)
// Последний лог (id=501) присутствует
expect(texts.some(t => t.includes('Msg-000501'))).toBe(true)
wrapper.unmount()
})
})