kin: KIN-083 Healthcheck claude CLI auth: перед запуском pipeline проверять что claude залогинен (быстрый claude -p 'ok' --output-format json, проверить is_error и 'Not logged in'). Если не залогинен — не запускать pipeline, а показать ошибку 'Claude CLI requires login' в GUI с инструкцией.
This commit is contained in:
parent
a80679ae72
commit
bfc8f1c0bb
18 changed files with 1390 additions and 57 deletions
277
web/frontend/src/__tests__/claude-auth.test.ts
Normal file
277
web/frontend/src/__tests__/claude-auth.test.ts
Normal file
|
|
@ -0,0 +1,277 @@
|
|||
/**
|
||||
* 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')
|
||||
})
|
||||
})
|
||||
|
|
@ -1,8 +1,28 @@
|
|||
const BASE = '/api'
|
||||
|
||||
export class ApiError extends Error {
|
||||
code: string
|
||||
constructor(code: string, message: string) {
|
||||
super(message)
|
||||
this.name = 'ApiError'
|
||||
this.code = code
|
||||
}
|
||||
}
|
||||
|
||||
async function throwApiError(res: Response): Promise<never> {
|
||||
let code = ''
|
||||
let msg = `${res.status} ${res.statusText}`
|
||||
try {
|
||||
const data = await res.json()
|
||||
if (data.error) code = data.error
|
||||
if (data.message) msg = data.message
|
||||
} catch {}
|
||||
throw new ApiError(code, msg)
|
||||
}
|
||||
|
||||
async function get<T>(path: string): Promise<T> {
|
||||
const res = await fetch(`${BASE}${path}`)
|
||||
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`)
|
||||
if (!res.ok) await throwApiError(res)
|
||||
return res.json()
|
||||
}
|
||||
|
||||
|
|
@ -12,7 +32,7 @@ async function patch<T>(path: string, body: unknown): Promise<T> {
|
|||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`)
|
||||
if (!res.ok) await throwApiError(res)
|
||||
return res.json()
|
||||
}
|
||||
|
||||
|
|
@ -22,7 +42,7 @@ async function post<T>(path: string, body: unknown): Promise<T> {
|
|||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`)
|
||||
if (!res.ok) await throwApiError(res)
|
||||
return res.json()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, computed, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { api, type ProjectDetail, type AuditResult, type Phase, type Task } from '../api'
|
||||
import { api, ApiError, type ProjectDetail, type AuditResult, type Phase, type Task } from '../api'
|
||||
import Badge from '../components/Badge.vue'
|
||||
import Modal from '../components/Modal.vue'
|
||||
|
||||
|
|
@ -18,6 +18,7 @@ const activeTab = ref<'tasks' | 'phases' | 'decisions' | 'modules' | 'kanban'>('
|
|||
const phases = ref<Phase[]>([])
|
||||
const phasesLoading = ref(false)
|
||||
const phaseError = ref('')
|
||||
const claudeLoginError = ref(false)
|
||||
const showReviseModal = ref(false)
|
||||
const revisePhaseId = ref<number | null>(null)
|
||||
const reviseComment = ref('')
|
||||
|
|
@ -76,11 +77,16 @@ async function approvePhase(phaseId: number) {
|
|||
async function startPhase() {
|
||||
startPhaseSaving.value = true
|
||||
phaseError.value = ''
|
||||
claudeLoginError.value = false
|
||||
try {
|
||||
await api.startPhase(props.id)
|
||||
await loadPhases()
|
||||
} catch (e: any) {
|
||||
phaseError.value = e.message
|
||||
if (e instanceof ApiError && e.code === 'claude_auth_required') {
|
||||
claudeLoginError.value = true
|
||||
} else {
|
||||
phaseError.value = e.message
|
||||
}
|
||||
} finally {
|
||||
startPhaseSaving.value = false
|
||||
}
|
||||
|
|
@ -702,6 +708,17 @@ async function addDecision() {
|
|||
|
||||
<!-- Phases Tab -->
|
||||
<div v-if="activeTab === 'phases'">
|
||||
<div v-if="claudeLoginError" class="mb-3 px-4 py-3 border border-yellow-700 bg-yellow-950/30 rounded">
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-yellow-300">⚠ Claude CLI requires login</p>
|
||||
<p class="text-xs text-yellow-200/80 mt-1">Откройте терминал и выполните:</p>
|
||||
<code class="text-xs text-yellow-400 font-mono bg-black/30 px-2 py-0.5 rounded mt-1 inline-block">claude login</code>
|
||||
<p class="text-xs text-gray-500 mt-1">После входа повторите запуск pipeline.</p>
|
||||
</div>
|
||||
<button @click="claudeLoginError = false" class="text-gray-600 hover:text-gray-400 bg-transparent border-none cursor-pointer text-xs shrink-0">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="phasesLoading" class="text-gray-500 text-sm">Loading phases...</p>
|
||||
<p v-else-if="phaseError" class="text-red-400 text-sm">{{ phaseError }}</p>
|
||||
<div v-else-if="phases.length === 0" class="text-gray-600 text-sm">
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { api, type TaskFull, type PipelineStep, type PendingAction, type DeployResult } from '../api'
|
||||
import { api, ApiError, type TaskFull, type PipelineStep, type PendingAction, type DeployResult } from '../api'
|
||||
import Badge from '../components/Badge.vue'
|
||||
import Modal from '../components/Modal.vue'
|
||||
|
||||
|
|
@ -12,6 +12,7 @@ const router = useRouter()
|
|||
const task = ref<TaskFull | null>(null)
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
const claudeLoginError = ref(false)
|
||||
const selectedStep = ref<PipelineStep | null>(null)
|
||||
const polling = ref(false)
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
|
@ -206,12 +207,17 @@ async function revise() {
|
|||
}
|
||||
|
||||
async function runPipeline() {
|
||||
claudeLoginError.value = false
|
||||
try {
|
||||
await api.runTask(props.id)
|
||||
startPolling()
|
||||
await load()
|
||||
} catch (e: any) {
|
||||
error.value = e.message
|
||||
if (e instanceof ApiError && e.code === 'claude_auth_required') {
|
||||
claudeLoginError.value = true
|
||||
} else {
|
||||
error.value = e.message
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -521,6 +527,19 @@ async function saveEdit() {
|
|||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Claude login error banner -->
|
||||
<div v-if="claudeLoginError" class="mt-3 px-4 py-3 border border-yellow-700 bg-yellow-950/30 rounded">
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-yellow-300">⚠ Claude CLI requires login</p>
|
||||
<p class="text-xs text-yellow-200/80 mt-1">Откройте терминал и выполните:</p>
|
||||
<code class="text-xs text-yellow-400 font-mono bg-black/30 px-2 py-0.5 rounded mt-1 inline-block">claude login</code>
|
||||
<p class="text-xs text-gray-500 mt-1">После входа повторите запуск pipeline.</p>
|
||||
</div>
|
||||
<button @click="claudeLoginError = false" class="text-gray-600 hover:text-gray-400 bg-transparent border-none cursor-pointer text-xs shrink-0">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Deploy result inline block -->
|
||||
<div v-if="deployResult" class="mx-0 mt-2 p-3 rounded border text-xs font-mono"
|
||||
:class="deployResult.success ? 'border-teal-800 bg-teal-950/30 text-teal-300' : 'border-red-800 bg-red-950/30 text-red-300'">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue