kin/web/frontend/src/__tests__/watchdog-toast.test.ts
2026-03-17 16:50:44 +02:00

386 lines
16 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-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<string, string> = {}
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
})
it('KIN-OBS-016: vi.getTimerCount() === 0 после unmount — все таймеры очищены', async () => {
vi.mocked(api.notifications).mockResolvedValue([
makeNotification('KIN-016', 'Process died unexpectedly (PID 99999)'),
])
const wrapper = mount(EscalationBanner)
await flushPromises()
// Toast с активным timerId должен быть виден
expect(wrapper.find('.border-red-700').exists()).toBe(true)
// После unmount — оба таймера (setInterval pollTimer + setTimeout toast) очищены
wrapper.unmount()
expect(vi.getTimerCount()).toBe(0)
})
})
// ─────────────────────────────────────────────────────────────
// Критерий 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
})
})
// ─────────────────────────────────────────────────────────────
// KIN-OBS-017: saveDismissedWatchdog ограничивает рост localStorage
// WATCHDOG_MAX_STORED = 100: при >100 id сохраняются только последние 100
// ─────────────────────────────────────────────────────────────
describe('KIN-OBS-017: saveDismissedWatchdog не превышает 100 записей в localStorage', () => {
it('При добавлении 101-го id — в localStorage остаётся не более 100 записей', async () => {
// Предзаполняем localStorage 100 существующими id
const existingIds = Array.from({ length: 100 }, (_, i) => `KIN-OLD-${String(i).padStart(3, '0')}`)
localStorageMock.setItem('kin_dismissed_watchdog_toasts', JSON.stringify(existingIds))
vi.mocked(api.notifications).mockResolvedValue([
makeNotification('KIN-NEW-101', 'Process died unexpectedly (PID 9999)'),
])
const wrapper = mount(EscalationBanner)
await flushPromises()
// Dismiss нового toast — итого 101 id
await wrapper.find('.border-red-700 button').trigger('click')
await flushPromises()
const stored = localStorageMock.getItem('kin_dismissed_watchdog_toasts')
expect(stored).toBeTruthy()
const parsed = JSON.parse(stored!)
expect(parsed.length).toBeLessThanOrEqual(100)
})
it('Сохраняются последние (самые новые) id — самый старый обрезается', async () => {
const existingIds = Array.from({ length: 100 }, (_, i) => `KIN-OLD-${String(i).padStart(3, '0')}`)
localStorageMock.setItem('kin_dismissed_watchdog_toasts', JSON.stringify(existingIds))
vi.mocked(api.notifications).mockResolvedValue([
makeNotification('KIN-NEWEST', 'Process died unexpectedly (PID 8888)'),
])
const wrapper = mount(EscalationBanner)
await flushPromises()
await wrapper.find('.border-red-700 button').trigger('click')
await flushPromises()
const stored = localStorageMock.getItem('kin_dismissed_watchdog_toasts')
const parsed = JSON.parse(stored!)
// Новейший id должен остаться
expect(parsed).toContain('KIN-NEWEST')
// Самый старый id должен быть вытеснен
expect(parsed).not.toContain('KIN-OLD-000')
})
it('При ровно 99 существующих + 1 новый = 100 — нет усечения, все 100 сохранены', async () => {
const existingIds = Array.from({ length: 99 }, (_, i) => `KIN-FIT-${String(i).padStart(3, '0')}`)
localStorageMock.setItem('kin_dismissed_watchdog_toasts', JSON.stringify(existingIds))
vi.mocked(api.notifications).mockResolvedValue([
makeNotification('KIN-FIT-099', 'Process died unexpectedly (PID 7777)'),
])
const wrapper = mount(EscalationBanner)
await flushPromises()
await wrapper.find('.border-red-700 button').trigger('click')
await flushPromises()
const stored = localStorageMock.getItem('kin_dismissed_watchdog_toasts')
const parsed = JSON.parse(stored!)
// Ровно 100 — без усечения
expect(parsed.length).toBe(100)
// Первый старый id не вытеснен
expect(parsed).toContain('KIN-FIT-000')
// Новый id присутствует
expect(parsed).toContain('KIN-FIT-099')
})
})