277 lines
9.5 KiB
TypeScript
277 lines
9.5 KiB
TypeScript
/**
|
|
* KIN-083: Тесты healthcheck Claude CLI auth — frontend баннеры
|
|
*
|
|
* Проверяет:
|
|
* 1. TaskDetail.vue: при ошибке claude_auth_required от runTask — показывает баннер
|
|
* 2. TaskDetail.vue: баннер закрывается кнопкой ✕
|
|
* 3. TaskDetail.vue: happy path — баннер не появляется при успешном runTask
|
|
* 4. ProjectView.vue: при ошибке claude_auth_required от startPhase — показывает баннер
|
|
* 5. ProjectView.vue: happy path — баннер не появляется при успешном startPhase
|
|
*/
|
|
|
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
import { mount, flushPromises } from '@vue/test-utils'
|
|
import { createRouter, createMemoryHistory } from 'vue-router'
|
|
import TaskDetail from '../views/TaskDetail.vue'
|
|
import ProjectView from '../views/ProjectView.vue'
|
|
|
|
// importOriginal сохраняет реальный ApiError — нужен для instanceof-проверки в компоненте
|
|
vi.mock('../api', async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import('../api')>()
|
|
return {
|
|
...actual,
|
|
api: {
|
|
project: vi.fn(),
|
|
taskFull: vi.fn(),
|
|
runTask: vi.fn(),
|
|
startPhase: vi.fn(),
|
|
getPhases: vi.fn(),
|
|
patchTask: vi.fn(),
|
|
patchProject: vi.fn(),
|
|
auditProject: vi.fn(),
|
|
createTask: vi.fn(),
|
|
deployProject: vi.fn(),
|
|
notifications: vi.fn(),
|
|
},
|
|
}
|
|
})
|
|
|
|
import { api, ApiError } from '../api'
|
|
|
|
const Stub = { template: '<div />' }
|
|
|
|
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: TaskDetail, props: true },
|
|
],
|
|
})
|
|
}
|
|
|
|
const MOCK_TASK = {
|
|
id: 'KIN-001',
|
|
project_id: 'KIN',
|
|
title: 'Тестовая задача',
|
|
status: 'pending',
|
|
priority: 5,
|
|
assigned_role: null,
|
|
parent_task_id: null,
|
|
brief: null,
|
|
spec: null,
|
|
execution_mode: null,
|
|
blocked_reason: null,
|
|
dangerously_skipped: null,
|
|
category: null,
|
|
acceptance_criteria: null,
|
|
created_at: '2024-01-01',
|
|
updated_at: '2024-01-01',
|
|
pipeline_steps: [],
|
|
related_decisions: [],
|
|
pending_actions: [],
|
|
}
|
|
|
|
const MOCK_PROJECT = {
|
|
id: 'KIN',
|
|
name: 'Kin',
|
|
path: '/projects/kin',
|
|
status: 'active',
|
|
priority: 5,
|
|
tech_stack: ['python', 'vue'],
|
|
execution_mode: 'review',
|
|
autocommit_enabled: 0,
|
|
obsidian_vault_path: null,
|
|
deploy_command: null,
|
|
created_at: '2024-01-01',
|
|
total_tasks: 1,
|
|
done_tasks: 0,
|
|
active_tasks: 1,
|
|
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 MOCK_ACTIVE_PHASE = {
|
|
id: 1,
|
|
project_id: 'KIN',
|
|
role: 'pm',
|
|
phase_order: 1,
|
|
status: 'active',
|
|
task_id: 'KIN-R-001',
|
|
revise_count: 0,
|
|
revise_comment: null,
|
|
created_at: '2024-01-01',
|
|
updated_at: '2024-01-01',
|
|
task: {
|
|
id: 'KIN-R-001',
|
|
status: 'pending',
|
|
title: 'Research',
|
|
priority: 5,
|
|
assigned_role: 'pm',
|
|
parent_task_id: null,
|
|
brief: null,
|
|
spec: null,
|
|
execution_mode: null,
|
|
blocked_reason: null,
|
|
dangerously_skipped: null,
|
|
category: null,
|
|
acceptance_criteria: null,
|
|
project_id: 'KIN',
|
|
created_at: '2024-01-01',
|
|
updated_at: '2024-01-01',
|
|
},
|
|
}
|
|
|
|
beforeEach(() => {
|
|
localStorageMock.clear()
|
|
vi.clearAllMocks()
|
|
vi.mocked(api.project).mockResolvedValue(MOCK_PROJECT as any)
|
|
vi.mocked(api.taskFull).mockResolvedValue(MOCK_TASK as any)
|
|
vi.mocked(api.runTask).mockResolvedValue({ status: 'started' } as any)
|
|
vi.mocked(api.startPhase).mockResolvedValue({ status: 'started', phase_id: 1, task_id: 'KIN-R-001' })
|
|
vi.mocked(api.getPhases).mockResolvedValue([])
|
|
vi.mocked(api.notifications).mockResolvedValue([])
|
|
})
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks()
|
|
})
|
|
|
|
// ─────────────────────────────────────────────────────────────
|
|
// TaskDetail: баннер при claude_auth_required
|
|
// ─────────────────────────────────────────────────────────────
|
|
|
|
describe('KIN-083: TaskDetail — claude auth banner', () => {
|
|
async function mountTaskDetail() {
|
|
const router = makeRouter()
|
|
await router.push('/task/KIN-001')
|
|
const wrapper = mount(TaskDetail, {
|
|
props: { id: 'KIN-001' },
|
|
global: { plugins: [router] },
|
|
})
|
|
await flushPromises()
|
|
return wrapper
|
|
}
|
|
|
|
it('показывает баннер "Claude CLI requires login" при ошибке claude_auth_required от runTask', async () => {
|
|
vi.mocked(api.runTask).mockRejectedValue(
|
|
new ApiError('claude_auth_required', 'Claude CLI requires login. Run: claude login'),
|
|
)
|
|
|
|
const wrapper = await mountTaskDetail()
|
|
|
|
const runBtn = wrapper.findAll('button').find(b => b.text().includes('Run Pipeline'))
|
|
expect(runBtn?.exists(), 'Кнопка Run Pipeline должна быть видна для pending задачи').toBe(true)
|
|
|
|
await runBtn!.trigger('click')
|
|
await flushPromises()
|
|
|
|
expect(wrapper.text(), 'Баннер должен содержать текст ошибки аутентификации').toContain('Claude CLI requires login')
|
|
})
|
|
|
|
it('баннер закрывается кнопкой ✕', async () => {
|
|
vi.mocked(api.runTask).mockRejectedValue(
|
|
new ApiError('claude_auth_required', 'Claude CLI requires login. Run: claude login'),
|
|
)
|
|
|
|
const wrapper = await mountTaskDetail()
|
|
|
|
const runBtn = wrapper.findAll('button').find(b => b.text().includes('Run Pipeline'))
|
|
await runBtn!.trigger('click')
|
|
await flushPromises()
|
|
|
|
expect(wrapper.text()).toContain('Claude CLI requires login')
|
|
|
|
const closeBtn = wrapper.findAll('button').find(b => b.text().trim() === '✕')
|
|
expect(closeBtn?.exists(), 'Кнопка ✕ должна быть видна').toBe(true)
|
|
await closeBtn!.trigger('click')
|
|
await flushPromises()
|
|
|
|
expect(wrapper.text(), 'После закрытия баннер не должен быть виден').not.toContain('Claude CLI requires login')
|
|
})
|
|
|
|
it('не показывает баннер когда runTask успешен (happy path)', async () => {
|
|
const wrapper = await mountTaskDetail()
|
|
|
|
const runBtn = wrapper.findAll('button').find(b => b.text().includes('Run Pipeline'))
|
|
if (runBtn?.exists()) {
|
|
await runBtn.trigger('click')
|
|
await flushPromises()
|
|
}
|
|
|
|
expect(wrapper.text(), 'Баннер не должен появляться при успешном запуске').not.toContain('Claude CLI requires login')
|
|
})
|
|
})
|
|
|
|
// ─────────────────────────────────────────────────────────────
|
|
// ProjectView: баннер при claude_auth_required
|
|
// ─────────────────────────────────────────────────────────────
|
|
|
|
describe('KIN-083: ProjectView — claude auth banner', () => {
|
|
async function mountOnPhases() {
|
|
vi.mocked(api.getPhases).mockResolvedValue([MOCK_ACTIVE_PHASE] as any)
|
|
|
|
const router = makeRouter()
|
|
await router.push('/project/KIN')
|
|
const wrapper = mount(ProjectView, {
|
|
props: { id: 'KIN' },
|
|
global: { plugins: [router] },
|
|
})
|
|
await flushPromises()
|
|
|
|
const phasesTab = wrapper.findAll('button').find(b => b.text().includes('Phases'))
|
|
await phasesTab!.trigger('click')
|
|
await flushPromises()
|
|
|
|
return wrapper
|
|
}
|
|
|
|
it('показывает баннер "Claude CLI requires login" при ошибке claude_auth_required от startPhase', async () => {
|
|
vi.mocked(api.startPhase).mockRejectedValue(
|
|
new ApiError('claude_auth_required', 'Claude CLI requires login. Run: claude login'),
|
|
)
|
|
|
|
const wrapper = await mountOnPhases()
|
|
|
|
const startBtn = wrapper.findAll('button').find(b => b.text().includes('Start Research'))
|
|
expect(startBtn?.exists(), 'Кнопка Start Research должна быть видна').toBe(true)
|
|
|
|
await startBtn!.trigger('click')
|
|
await flushPromises()
|
|
|
|
expect(wrapper.text(), 'Баннер должен содержать текст ошибки аутентификации').toContain('Claude CLI requires login')
|
|
})
|
|
|
|
it('не показывает баннер когда startPhase успешен (happy path)', async () => {
|
|
const wrapper = await mountOnPhases()
|
|
|
|
const startBtn = wrapper.findAll('button').find(b => b.text().includes('Start Research'))
|
|
if (startBtn?.exists()) {
|
|
await startBtn.trigger('click')
|
|
await flushPromises()
|
|
}
|
|
|
|
expect(wrapper.text(), 'Баннер не должен появляться при успешном запуске фазы').not.toContain('Claude CLI requires login')
|
|
})
|
|
})
|