/** * KIN-099: Watchdog toast-уведомления в EscalationBanner * * Проверяет: * 1. Toast появляется когда reason содержит 'Process died' — task_id и reason в тексте * 2. Toast НЕ появляется для обычной (не-watchdog) эскалации * 3. После dismiss — тот же toast не появляется снова при следующем polling (localStorage) * 4. Toast исчезает автоматически через 8 секунд (vi.useFakeTimers) */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { mount, flushPromises } from '@vue/test-utils' import EscalationBanner from '../components/EscalationBanner.vue' vi.mock('../api', () => ({ api: { notifications: vi.fn(), }, })) import { api } from '../api' const localStorageMock = (() => { let store: Record = {} return { getItem: (k: string) => store[k] ?? null, setItem: (k: string, v: string) => { store[k] = v }, removeItem: (k: string) => { delete store[k] }, clear: () => { store = {} }, } })() Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock, configurable: true }) function makeNotification(taskId: string, reason: string) { return { task_id: taskId, project_id: 'KIN', agent_role: 'developer', reason, pipeline_step: null, blocked_at: '2026-03-17T10:00:00', telegram_sent: false, } } beforeEach(() => { localStorageMock.clear() vi.clearAllMocks() vi.useFakeTimers() }) afterEach(() => { vi.useRealTimers() vi.restoreAllMocks() }) // ───────────────────────────────────────────────────────────── // Критерий 1: Toast появляется при "Process died" // ───────────────────────────────────────────────────────────── describe('KIN-099: watchdog toast появляется при "Process died"', () => { it('Toast с task_id и reason отображается когда reason содержит "Process died unexpectedly (PID XXXX)"', async () => { vi.mocked(api.notifications).mockResolvedValue([ makeNotification('KIN-042', 'Process died unexpectedly (PID 12345)'), ]) const wrapper = mount(EscalationBanner) await flushPromises() expect(wrapper.text()).toContain('KIN-042') expect(wrapper.text()).toContain('Process died unexpectedly (PID 12345)') }) it('Toast отображается для "Parent process died" reason', async () => { vi.mocked(api.notifications).mockResolvedValue([ makeNotification('KIN-043', 'Parent process died unexpectedly'), ]) const wrapper = mount(EscalationBanner) await flushPromises() expect(wrapper.text()).toContain('KIN-043') expect(wrapper.text()).toContain('Parent process died') }) it('Toast рендерится с красной рамкой border-red-700', async () => { vi.mocked(api.notifications).mockResolvedValue([ makeNotification('KIN-044', 'Process died unexpectedly (PID 9999)'), ]) const wrapper = mount(EscalationBanner) await flushPromises() expect(wrapper.find('.border-red-700').exists()).toBe(true) }) }) // ───────────────────────────────────────────────────────────── // Критерий 2: Toast НЕ появляется при обычной эскалации // ───────────────────────────────────────────────────────────── describe('KIN-099: watchdog toast НЕ появляется при обычной эскалации', () => { it('Toast НЕ появляется когда reason не содержит "Process died"', async () => { vi.mocked(api.notifications).mockResolvedValue([ makeNotification('KIN-045', 'Agent requested human input: unclear requirements'), ]) const wrapper = mount(EscalationBanner) await flushPromises() expect(wrapper.find('.border-red-700').exists()).toBe(false) expect(wrapper.text()).not.toContain('Watchdog:') }) it('При пустом списке уведомлений toast отсутствует', async () => { vi.mocked(api.notifications).mockResolvedValue([]) const wrapper = mount(EscalationBanner) await flushPromises() expect(wrapper.find('.border-red-700').exists()).toBe(false) }) }) // ───────────────────────────────────────────────────────────── // Критерий 3: dismiss сохраняется — toast не появляется при следующем polling // ───────────────────────────────────────────────────────────── describe('KIN-099: dismiss сохраняет состояние в localStorage', () => { it('После нажатия × toast исчезает', async () => { vi.mocked(api.notifications).mockResolvedValue([ makeNotification('KIN-046', 'Process died unexpectedly (PID 8888)'), ]) const wrapper = mount(EscalationBanner) await flushPromises() expect(wrapper.find('.border-red-700').exists()).toBe(true) await wrapper.find('.border-red-700 button').trigger('click') await flushPromises() expect(wrapper.find('.border-red-700').exists()).toBe(false) }) it('После dismiss — при следующем polling тот же toast не появляется снова', async () => { vi.mocked(api.notifications).mockResolvedValue([ makeNotification('KIN-046', 'Process died unexpectedly (PID 8888)'), ]) const wrapper = mount(EscalationBanner) await flushPromises() await wrapper.find('.border-red-700 button').trigger('click') await flushPromises() // Следующий polling через 10 сек vi.advanceTimersByTime(10000) await flushPromises() expect(wrapper.find('.border-red-700').exists()).toBe(false) }) it('task_id сохраняется в localStorage (kin_dismissed_watchdog_toasts) после dismiss', async () => { vi.mocked(api.notifications).mockResolvedValue([ makeNotification('KIN-047', 'Process died unexpectedly (PID 7777)'), ]) const wrapper = mount(EscalationBanner) await flushPromises() await wrapper.find('.border-red-700 button').trigger('click') const stored = localStorageMock.getItem('kin_dismissed_watchdog_toasts') expect(stored).toBeTruthy() expect(JSON.parse(stored!)).toContain('KIN-047') }) }) // ───────────────────────────────────────────────────────────── // Критерий 4: auto-dismiss через 8 секунд // ───────────────────────────────────────────────────────────── describe('KIN-099: toast auto-dismiss через 8 секунд', () => { it('Toast исчезает автоматически ровно через 8 секунд', async () => { vi.mocked(api.notifications).mockResolvedValue([ makeNotification('KIN-048', 'Process died unexpectedly (PID 6666)'), ]) const wrapper = mount(EscalationBanner) await flushPromises() expect(wrapper.find('.border-red-700').exists()).toBe(true) vi.advanceTimersByTime(8000) await flushPromises() expect(wrapper.find('.border-red-700').exists()).toBe(false) }) it('Toast ещё виден через 7.9 секунд (до истечения таймера)', async () => { vi.mocked(api.notifications).mockResolvedValue([ makeNotification('KIN-049', 'Process died unexpectedly (PID 5555)'), ]) const wrapper = mount(EscalationBanner) await flushPromises() vi.advanceTimersByTime(7999) await flushPromises() expect(wrapper.find('.border-red-700').exists()).toBe(true) }) }) // ───────────────────────────────────────────────────────────── // Критерий 5 (KIN-099 issue severity=medium): // onUnmounted очищает setTimeout таймеры watchdog-тостов // ───────────────────────────────────────────────────────────── describe('KIN-099: onUnmounted очищает setTimeout таймеры watchdog-тостов', () => { it('clearTimeout вызывается для активного таймера при unmount компонента', async () => { vi.mocked(api.notifications).mockResolvedValue([ makeNotification('KIN-099', 'Process died unexpectedly (PID 12345)'), ]) const clearTimeoutSpy = vi.spyOn(globalThis, 'clearTimeout') const wrapper = mount(EscalationBanner) await flushPromises() // Toast must be visible with an active auto-dismiss timer expect(wrapper.find('.border-red-700').exists()).toBe(true) wrapper.unmount() // onUnmounted should have called clearTimeout for the toast timer expect(clearTimeoutSpy).toHaveBeenCalled() }) it('После unmount таймер авто-dismiss не срабатывает (нет ошибок)', async () => { vi.mocked(api.notifications).mockResolvedValue([ makeNotification('KIN-050', 'Process died unexpectedly (PID 11111)'), ]) const wrapper = mount(EscalationBanner) await flushPromises() expect(wrapper.find('.border-red-700').exists()).toBe(true) // Unmount before 8 second timer fires — timer should be cancelled wrapper.unmount() // Advance past 8 seconds — should not throw even though component is gone vi.advanceTimersByTime(10000) await flushPromises() // Pass = no errors thrown after unmount }) }) // ───────────────────────────────────────────────────────────── // Критерий 6 (KIN-099 issue severity=low): // localStorage kin_dismissed_watchdog_toasts — сохранение работает // Лимит реализован в KIN-OBS-017 (WATCHDOG_MAX_STORED = 100) // ───────────────────────────────────────────────────────────── describe('KIN-099: localStorage dismissed watchdog — сохранение (лимит: KIN-OBS-017)', () => { it('Несколько dismissed task_id корректно сохраняются в localStorage', async () => { const notifications = ['KIN-051', 'KIN-052', 'KIN-053'].map(id => makeNotification(id, 'Process died unexpectedly (PID 1234)') ) vi.mocked(api.notifications).mockResolvedValue(notifications) const wrapper = mount(EscalationBanner) await flushPromises() // Dismiss all toasts one by one for (let i = 0; i < 3; i++) { const btn = wrapper.find('.border-red-700 button') if (btn.exists()) await btn.trigger('click') await flushPromises() } const stored = localStorageMock.getItem('kin_dismissed_watchdog_toasts') expect(stored).toBeTruthy() const parsed = JSON.parse(stored!) expect(parsed).toContain('KIN-051') expect(parsed).toContain('KIN-052') expect(parsed).toContain('KIN-053') // Size limit is capped at WATCHDOG_MAX_STORED (100) — only last 100 IDs stored }) })