/** * 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 | 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.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.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.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() }) }) // ───────────────────────────────────────────────────────────── // 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() }) })