386 lines
16 KiB
TypeScript
386 lines
16 KiB
TypeScript
/**
|
||
* 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')
|
||
})
|
||
})
|