kin: auto-commit after pipeline
This commit is contained in:
parent
e0511eac0c
commit
143a393ba7
3 changed files with 616 additions and 0 deletions
225
web/frontend/src/__tests__/blocked-reason-list.test.ts
Normal file
225
web/frontend/src/__tests__/blocked-reason-list.test.ts
Normal file
|
|
@ -0,0 +1,225 @@
|
|||
/**
|
||||
* KIN-099: blocked_reason в списке задач (ProjectView)
|
||||
*
|
||||
* Проверяет:
|
||||
* 1. Задача со статусом 'blocked' и blocked_reason — reason отображается в карточке
|
||||
* 2. Задача со статусом 'blocked' без blocked_reason — дополнительный текст НЕ отображается
|
||||
* 3. Задача с другим статусом — blocked_reason НЕ отображается
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { createRouter, createMemoryHistory } from 'vue-router'
|
||||
import ProjectView from '../views/ProjectView.vue'
|
||||
|
||||
vi.mock('../api', () => ({
|
||||
api: {
|
||||
project: vi.fn(),
|
||||
taskFull: vi.fn(),
|
||||
runTask: vi.fn(),
|
||||
auditProject: vi.fn(),
|
||||
createTask: vi.fn(),
|
||||
patchTask: vi.fn(),
|
||||
patchProject: vi.fn(),
|
||||
deployProject: vi.fn(),
|
||||
getPhases: vi.fn(),
|
||||
uploadAttachment: vi.fn(),
|
||||
environments: vi.fn(),
|
||||
reviseTask: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
import { api } from '../api'
|
||||
|
||||
const Stub = { template: '<div />' }
|
||||
|
||||
function makeTask(id: string, status: string, blocked_reason: string | null = null) {
|
||||
return {
|
||||
id,
|
||||
project_id: 'KIN',
|
||||
title: `Task ${id}`,
|
||||
status,
|
||||
priority: 5,
|
||||
assigned_role: null,
|
||||
parent_task_id: null,
|
||||
brief: null,
|
||||
spec: null,
|
||||
execution_mode: null,
|
||||
blocked_reason,
|
||||
dangerously_skipped: null,
|
||||
category: null,
|
||||
acceptance_criteria: null,
|
||||
created_at: '2024-01-01',
|
||||
updated_at: '2024-01-01',
|
||||
}
|
||||
}
|
||||
|
||||
function makeMockProject(tasks: ReturnType<typeof makeTask>[]) {
|
||||
return {
|
||||
id: 'KIN',
|
||||
name: 'Kin',
|
||||
path: '/projects/kin',
|
||||
status: 'active',
|
||||
priority: 5,
|
||||
tech_stack: ['python', 'vue'],
|
||||
execution_mode: 'review',
|
||||
autocommit_enabled: 0,
|
||||
auto_test_enabled: 0,
|
||||
test_command: null,
|
||||
obsidian_vault_path: null,
|
||||
deploy_command: null,
|
||||
created_at: '2024-01-01',
|
||||
total_tasks: tasks.length,
|
||||
done_tasks: 0,
|
||||
active_tasks: 0,
|
||||
blocked_tasks: 0,
|
||||
review_tasks: 0,
|
||||
project_type: 'development',
|
||||
ssh_host: null,
|
||||
ssh_user: null,
|
||||
ssh_key_path: null,
|
||||
ssh_proxy_jump: null,
|
||||
description: null,
|
||||
tasks,
|
||||
decisions: [],
|
||||
modules: [],
|
||||
}
|
||||
}
|
||||
|
||||
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 makeRouter() {
|
||||
return createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [
|
||||
{ path: '/', component: Stub },
|
||||
{ path: '/project/:id', component: ProjectView, props: true },
|
||||
{ path: '/task/:id', component: Stub },
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
async function mountProjectView(tasks: ReturnType<typeof makeTask>[]) {
|
||||
vi.mocked(api.project).mockResolvedValue(makeMockProject(tasks) as any)
|
||||
const router = makeRouter()
|
||||
await router.push('/project/KIN')
|
||||
const wrapper = mount(ProjectView, {
|
||||
props: { id: 'KIN' },
|
||||
global: { plugins: [router] },
|
||||
})
|
||||
await flushPromises()
|
||||
return wrapper
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
localStorageMock.clear()
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(api.getPhases).mockResolvedValue([])
|
||||
vi.mocked(api.environments).mockResolvedValue([])
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Критерий 1: blocked + blocked_reason → reason отображается
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('KIN-099: blocked_reason отображается для задач со статусом blocked', () => {
|
||||
it('Задача со статусом blocked и blocked_reason — reason виден в тексте карточки', async () => {
|
||||
const wrapper = await mountProjectView([
|
||||
makeTask('KIN-001', 'blocked', 'Process died unexpectedly (PID 12345)'),
|
||||
])
|
||||
|
||||
expect(wrapper.text()).toContain('Process died unexpectedly (PID 12345)')
|
||||
})
|
||||
|
||||
it('blocked_reason отображается в div с классами text-red-400 truncate', async () => {
|
||||
const wrapper = await mountProjectView([
|
||||
makeTask('KIN-002', 'blocked', 'Agent needs human decision'),
|
||||
])
|
||||
|
||||
// Ищем div с blocked_reason — он имеет класс text-xs text-red-400 truncate
|
||||
const reasonDiv = wrapper.find('.text-red-400.truncate')
|
||||
expect(reasonDiv.exists()).toBe(true)
|
||||
expect(reasonDiv.text()).toContain('Agent needs human decision')
|
||||
})
|
||||
|
||||
it('Сам task_id и title также присутствуют вместе с reason', async () => {
|
||||
const wrapper = await mountProjectView([
|
||||
makeTask('KIN-003', 'blocked', 'Process died unexpectedly (PID 99)'),
|
||||
])
|
||||
|
||||
const text = wrapper.text()
|
||||
expect(text).toContain('KIN-003')
|
||||
expect(text).toContain('Task KIN-003')
|
||||
expect(text).toContain('Process died unexpectedly (PID 99)')
|
||||
})
|
||||
})
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Критерий 2: blocked без blocked_reason → div с reason НЕ рендерится
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('KIN-099: blocked_reason НЕ отображается если поле пустое (null)', () => {
|
||||
it('Задача со статусом blocked без blocked_reason — div text-red-400.truncate отсутствует', async () => {
|
||||
const wrapper = await mountProjectView([
|
||||
makeTask('KIN-004', 'blocked', null),
|
||||
])
|
||||
|
||||
// Задача отображается
|
||||
expect(wrapper.text()).toContain('KIN-004')
|
||||
|
||||
// Но div для blocked_reason отсутствует
|
||||
expect(wrapper.find('.text-red-400.truncate').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Критерий 3: другой статус → blocked_reason НЕ отображается
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('KIN-099: blocked_reason НЕ отображается для не-blocked статусов', () => {
|
||||
it('Задача со статусом pending и blocked_reason — reason НЕ отображается', async () => {
|
||||
const wrapper = await mountProjectView([
|
||||
makeTask('KIN-005', 'pending', 'Should not be visible'),
|
||||
])
|
||||
|
||||
expect(wrapper.text()).toContain('KIN-005')
|
||||
expect(wrapper.text()).not.toContain('Should not be visible')
|
||||
})
|
||||
|
||||
it('Задача со статусом in_progress и blocked_reason — reason НЕ отображается', async () => {
|
||||
const wrapper = await mountProjectView([
|
||||
makeTask('KIN-006', 'in_progress', 'Hidden in progress reason'),
|
||||
])
|
||||
|
||||
expect(wrapper.text()).not.toContain('Hidden in progress reason')
|
||||
})
|
||||
|
||||
it('Задача со статусом done и blocked_reason — reason НЕ отображается', async () => {
|
||||
const wrapper = await mountProjectView([
|
||||
makeTask('KIN-007', 'done', 'Past blocked reason'),
|
||||
])
|
||||
|
||||
expect(wrapper.text()).not.toContain('Past blocked reason')
|
||||
})
|
||||
|
||||
it('div text-red-400.truncate отсутствует для non-blocked задачи с blocked_reason', async () => {
|
||||
const wrapper = await mountProjectView([
|
||||
makeTask('KIN-008', 'review', 'Hidden review reason'),
|
||||
])
|
||||
|
||||
expect(wrapper.find('.text-red-400.truncate').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
214
web/frontend/src/__tests__/watchdog-toast.test.ts
Normal file
214
web/frontend/src/__tests__/watchdog-toast.test.ts
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
/**
|
||||
* 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)
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue