kin/web/frontend/src/__tests__/live-console.test.ts
2026-03-17 21:25:12 +02:00

516 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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
expect((panel.element as HTMLElement).style.display).toBe('none')
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"]')
expect((panel.element as HTMLElement).style.display).not.toBe('none')
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"]')
expect((panel.element as HTMLElement).style.display).toBe('none')
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()
})
// 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()
})
})
// ─────────────────────────────────────────────────────────────
// 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()
})
})