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:
Gros Frumos 2026-03-16 15:48:09 +02:00
parent a80679ae72
commit bfc8f1c0bb
18 changed files with 1390 additions and 57 deletions

View 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')
})
})

View file

@ -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()
}

View file

@ -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">&#9888; 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">

View file

@ -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">&#9888; 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'">