kin/web/frontend/src/__tests__/watchdog-toast.test.ts

215 lines
8.4 KiB
TypeScript
Raw Normal View History

2026-03-17 16:21:33 +02:00
/**
* 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)
})
})