kin: auto-commit after pipeline
This commit is contained in:
parent
05bcb14b99
commit
02b53e82ca
8 changed files with 555 additions and 68 deletions
|
|
@ -172,9 +172,7 @@ describe('api.deployProject', () => {
|
|||
{ step: 'git pull', stdout: 'ok', stderr: '', exit_code: 0 },
|
||||
{ step: 'docker compose up -d', stdout: 'started', stderr: '', exit_code: 0 },
|
||||
],
|
||||
dependents_deployed: [
|
||||
{ project_id: 'FE', project_name: 'Frontend', success: true, results: [] },
|
||||
],
|
||||
dependents_deployed: ['FE'],
|
||||
overall_success: true,
|
||||
}
|
||||
mockFetch(structured)
|
||||
|
|
|
|||
292
web/frontend/src/__tests__/deploy-dependents-string.test.ts
Normal file
292
web/frontend/src/__tests__/deploy-dependents-string.test.ts
Normal file
|
|
@ -0,0 +1,292 @@
|
|||
/**
|
||||
* KIN-INFRA-011: Регрессионный тест — dependents_deployed как string[]
|
||||
*
|
||||
* Проверяет:
|
||||
* 1. ProjectView: dependents_deployed рендерит строки напрямую (не dep.project_name)
|
||||
* 2. ProjectView: статичный бейдж "ok" отображается для каждого dependent
|
||||
* 3. ProjectView: пустой dependents_deployed не показывает секцию
|
||||
* 4. TaskDetail: dependents_deployed рендерит строки напрямую
|
||||
* 5. TaskDetail: статичный бейдж "ok" отображается для каждого dependent
|
||||
* 6. TaskDetail: секция "Зависимые проекты" показывается при непустом dependents_deployed
|
||||
*
|
||||
* Decision #546: бэкенд возвращает list[str] (project_id), не объекты DependentDeploy.
|
||||
* DependentDeploy интерфейс удалён. Шаблон использует dep как строку.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { createRouter, createMemoryHistory } from 'vue-router'
|
||||
import ProjectView from '../views/ProjectView.vue'
|
||||
import TaskDetail from '../views/TaskDetail.vue'
|
||||
|
||||
vi.mock('../api', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../api')>()
|
||||
return {
|
||||
...actual,
|
||||
api: {
|
||||
projects: vi.fn(),
|
||||
project: vi.fn(),
|
||||
getPhases: vi.fn(),
|
||||
environments: vi.fn(),
|
||||
projectLinks: vi.fn(),
|
||||
patchProject: vi.fn(),
|
||||
deployProject: vi.fn(),
|
||||
createProjectLink: vi.fn(),
|
||||
deleteProjectLink: vi.fn(),
|
||||
syncObsidian: vi.fn(),
|
||||
taskFull: vi.fn(),
|
||||
patchTask: vi.fn(),
|
||||
runTask: vi.fn(),
|
||||
notifications: vi.fn(),
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
import { api } 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 BASE_PROJECT = {
|
||||
id: 'KIN',
|
||||
name: 'Kin',
|
||||
path: '/projects/kin',
|
||||
status: 'active',
|
||||
priority: 5,
|
||||
tech_stack: ['python', 'vue'],
|
||||
execution_mode: null,
|
||||
autocommit_enabled: null,
|
||||
auto_test_enabled: null,
|
||||
worktrees_enabled: null,
|
||||
obsidian_vault_path: null,
|
||||
deploy_command: null,
|
||||
test_command: null,
|
||||
deploy_host: 'srv',
|
||||
deploy_path: '/app',
|
||||
deploy_runtime: 'docker',
|
||||
deploy_restart_cmd: null,
|
||||
created_at: '2024-01-01',
|
||||
total_tasks: 0,
|
||||
done_tasks: 0,
|
||||
active_tasks: 0,
|
||||
blocked_tasks: 0,
|
||||
review_tasks: 0,
|
||||
project_type: null,
|
||||
ssh_host: null,
|
||||
ssh_user: null,
|
||||
ssh_key_path: null,
|
||||
ssh_proxy_jump: null,
|
||||
description: null,
|
||||
tasks: [],
|
||||
decisions: [],
|
||||
modules: [],
|
||||
}
|
||||
|
||||
// TaskFull с deploy_runtime — кнопка Deploy видна только при status=done
|
||||
const DONE_TASK = {
|
||||
id: 'KIN-001',
|
||||
project_id: 'KIN',
|
||||
title: 'Тестовая задача',
|
||||
status: 'done',
|
||||
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: [],
|
||||
project_deploy_command: null,
|
||||
project_deploy_host: 'srv',
|
||||
project_deploy_path: '/app',
|
||||
project_deploy_runtime: 'docker',
|
||||
pipeline_id: null,
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
localStorageMock.clear()
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(api.project).mockResolvedValue(BASE_PROJECT as any)
|
||||
vi.mocked(api.getPhases).mockResolvedValue([])
|
||||
vi.mocked(api.environments).mockResolvedValue([])
|
||||
vi.mocked(api.projectLinks).mockResolvedValue([])
|
||||
vi.mocked(api.projects).mockResolvedValue([])
|
||||
vi.mocked(api.patchProject).mockResolvedValue(BASE_PROJECT as any)
|
||||
vi.mocked(api.deployProject).mockResolvedValue({
|
||||
success: true, exit_code: 0, stdout: '', stderr: '', duration_seconds: 1,
|
||||
} as any)
|
||||
vi.mocked(api.taskFull).mockResolvedValue(DONE_TASK as any)
|
||||
vi.mocked(api.notifications).mockResolvedValue([])
|
||||
})
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Helper: смонтировать ProjectView и запустить деплой
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
async function mountProjectViewAndDeploy(deployResult: any) {
|
||||
vi.mocked(api.deployProject).mockResolvedValue(deployResult)
|
||||
const router = makeRouter()
|
||||
await router.push('/project/KIN')
|
||||
const wrapper = mount(ProjectView, {
|
||||
props: { id: 'KIN' },
|
||||
global: { plugins: [router] },
|
||||
})
|
||||
await flushPromises()
|
||||
const deployBtn = wrapper.findAll('button').find(b => b.text().includes('Deploy'))
|
||||
await deployBtn!.trigger('click')
|
||||
await flushPromises()
|
||||
return wrapper
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Helper: смонтировать TaskDetail и запустить деплой
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
async function mountTaskDetailAndDeploy(deployResult: any) {
|
||||
vi.mocked(api.deployProject).mockResolvedValue(deployResult)
|
||||
const router = makeRouter()
|
||||
await router.push('/task/KIN-001')
|
||||
const wrapper = mount(TaskDetail, {
|
||||
props: { id: 'KIN-001' },
|
||||
global: { plugins: [router] },
|
||||
})
|
||||
await flushPromises()
|
||||
const deployBtn = wrapper.findAll('button').find(b => b.text().includes('Deploy'))
|
||||
await deployBtn!.trigger('click')
|
||||
await flushPromises()
|
||||
return wrapper
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// ProjectView — dependents_deployed как string[]
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
describe('ProjectView — dependents_deployed как string[] (KIN-INFRA-011)', () => {
|
||||
it('dep рендерится напрямую как строка — project_id виден в тексте', async () => {
|
||||
const wrapper = await mountProjectViewAndDeploy({
|
||||
success: true, exit_code: 0, stdout: '', stderr: '', duration_seconds: 2,
|
||||
dependents_deployed: ['BARSIK'],
|
||||
overall_success: true,
|
||||
})
|
||||
expect(wrapper.text()).toContain('BARSIK')
|
||||
})
|
||||
|
||||
it('несколько dep-строк — все project_id видны', async () => {
|
||||
const wrapper = await mountProjectViewAndDeploy({
|
||||
success: true, exit_code: 0, stdout: '', stderr: '', duration_seconds: 2,
|
||||
dependents_deployed: ['PROJ-A', 'PROJ-B', 'PROJ-C'],
|
||||
overall_success: true,
|
||||
})
|
||||
expect(wrapper.text()).toContain('PROJ-A')
|
||||
expect(wrapper.text()).toContain('PROJ-B')
|
||||
expect(wrapper.text()).toContain('PROJ-C')
|
||||
})
|
||||
|
||||
it('статичный бейдж "ok" отображается для каждого dependent', async () => {
|
||||
const wrapper = await mountProjectViewAndDeploy({
|
||||
success: true, exit_code: 0, stdout: '', stderr: '', duration_seconds: 2,
|
||||
dependents_deployed: ['FE', 'BE'],
|
||||
overall_success: true,
|
||||
})
|
||||
// Зависимые проекты секция видна
|
||||
expect(wrapper.text()).toContain('Зависимые проекты')
|
||||
// Бейджи "ok" — по одному на каждый dep (два .text-teal-400 span с текстом ok)
|
||||
const okBadges = wrapper.findAll('span.text-teal-400').filter(el => el.text().trim() === 'ok')
|
||||
expect(okBadges.length).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
|
||||
it('пустой dependents_deployed — секция "Зависимые проекты" не отображается', async () => {
|
||||
const wrapper = await mountProjectViewAndDeploy({
|
||||
success: true, exit_code: 0, stdout: '', stderr: '', duration_seconds: 2,
|
||||
dependents_deployed: [],
|
||||
overall_success: true,
|
||||
})
|
||||
expect(wrapper.text()).not.toContain('Зависимые проекты')
|
||||
})
|
||||
|
||||
it('отсутствующий dependents_deployed — секция не отображается', async () => {
|
||||
const wrapper = await mountProjectViewAndDeploy({
|
||||
success: true, exit_code: 0, stdout: '', stderr: '', duration_seconds: 2,
|
||||
})
|
||||
expect(wrapper.text()).not.toContain('Зависимые проекты')
|
||||
})
|
||||
})
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// TaskDetail — dependents_deployed как string[]
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
describe('TaskDetail — dependents_deployed как string[] (KIN-INFRA-011)', () => {
|
||||
it('dep рендерится напрямую как строка — project_id виден в тексте', async () => {
|
||||
const wrapper = await mountTaskDetailAndDeploy({
|
||||
success: true, exit_code: 0, stdout: '', stderr: '', duration_seconds: 2,
|
||||
dependents_deployed: ['BARSIK'],
|
||||
overall_success: true,
|
||||
})
|
||||
expect(wrapper.text()).toContain('BARSIK')
|
||||
})
|
||||
|
||||
it('несколько dep-строк — все project_id видны', async () => {
|
||||
const wrapper = await mountTaskDetailAndDeploy({
|
||||
success: true, exit_code: 0, stdout: '', stderr: '', duration_seconds: 2,
|
||||
dependents_deployed: ['PROJ-X', 'PROJ-Y'],
|
||||
overall_success: true,
|
||||
})
|
||||
expect(wrapper.text()).toContain('PROJ-X')
|
||||
expect(wrapper.text()).toContain('PROJ-Y')
|
||||
})
|
||||
|
||||
it('статичный бейдж "ok" отображается для каждого dependent', async () => {
|
||||
const wrapper = await mountTaskDetailAndDeploy({
|
||||
success: true, exit_code: 0, stdout: '', stderr: '', duration_seconds: 2,
|
||||
dependents_deployed: ['SVC-1', 'SVC-2'],
|
||||
overall_success: true,
|
||||
})
|
||||
expect(wrapper.text()).toContain('Зависимые проекты')
|
||||
const okBadges = wrapper.findAll('span.text-teal-400').filter(el => el.text().trim() === 'ok')
|
||||
expect(okBadges.length).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
|
||||
it('пустой dependents_deployed — секция "Зависимые проекты" не отображается', async () => {
|
||||
const wrapper = await mountTaskDetailAndDeploy({
|
||||
success: true, exit_code: 0, stdout: '', stderr: '', duration_seconds: 2,
|
||||
dependents_deployed: [],
|
||||
overall_success: true,
|
||||
})
|
||||
expect(wrapper.text()).not.toContain('Зависимые проекты')
|
||||
})
|
||||
|
||||
it('секция зависимых видна когда dependents_deployed непустой', async () => {
|
||||
const wrapper = await mountTaskDetailAndDeploy({
|
||||
success: true, exit_code: 0, stdout: '', stderr: '', duration_seconds: 2,
|
||||
dependents_deployed: ['SOME-DEP'],
|
||||
overall_success: true,
|
||||
})
|
||||
expect(wrapper.text()).toContain('Зависимые проекты:')
|
||||
expect(wrapper.text()).toContain('SOME-DEP')
|
||||
})
|
||||
})
|
||||
|
|
@ -479,14 +479,12 @@ describe('ProjectView — Deploy результат', () => {
|
|||
stderr: '',
|
||||
duration_seconds: 5,
|
||||
results: [{ step: 'git pull', stdout: 'ok', stderr: '', exit_code: 0 }],
|
||||
dependents_deployed: [
|
||||
{ project_id: 'FE', project_name: 'Frontend App', success: true, results: [] },
|
||||
],
|
||||
dependents_deployed: ['FRONTEND-APP'],
|
||||
overall_success: true,
|
||||
}
|
||||
const wrapper = await clickDeploy(result)
|
||||
expect(wrapper.text()).toContain('Зависимые проекты')
|
||||
expect(wrapper.text()).toContain('Frontend App')
|
||||
expect(wrapper.text()).toContain('FRONTEND-APP')
|
||||
})
|
||||
|
||||
it('overall_success=false → Deploy failed даже если success=true', async () => {
|
||||
|
|
@ -497,16 +495,14 @@ describe('ProjectView — Deploy результат', () => {
|
|||
stderr: '',
|
||||
duration_seconds: 5,
|
||||
results: [{ step: 'git pull', stdout: 'ok', stderr: '', exit_code: 0 }],
|
||||
dependents_deployed: [
|
||||
{ project_id: 'FE', project_name: 'Frontend', success: false, results: [] },
|
||||
],
|
||||
dependents_deployed: ['FE'],
|
||||
overall_success: false,
|
||||
}
|
||||
const wrapper = await clickDeploy(result)
|
||||
expect(wrapper.text()).toContain('Deploy failed')
|
||||
})
|
||||
|
||||
it('dependents показывают статус "ok" или "fail" для каждого проекта', async () => {
|
||||
it('dependents_deployed рендерит каждый project_id строкой с бейджем ok', async () => {
|
||||
const result = {
|
||||
success: true,
|
||||
exit_code: 0,
|
||||
|
|
@ -514,15 +510,12 @@ describe('ProjectView — Deploy результат', () => {
|
|||
stderr: '',
|
||||
duration_seconds: 5,
|
||||
results: [],
|
||||
dependents_deployed: [
|
||||
{ project_id: 'FE', project_name: 'FrontendOK', success: true, results: [] },
|
||||
{ project_id: 'BE2', project_name: 'ServiceFail', success: false, results: [] },
|
||||
],
|
||||
overall_success: false,
|
||||
dependents_deployed: ['FE', 'BE2'],
|
||||
overall_success: true,
|
||||
}
|
||||
const wrapper = await clickDeploy(result)
|
||||
expect(wrapper.text()).toContain('FrontendOK')
|
||||
expect(wrapper.text()).toContain('ServiceFail')
|
||||
expect(wrapper.text()).toContain('FE')
|
||||
expect(wrapper.text()).toContain('BE2')
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -734,7 +727,7 @@ describe('Граничные кейсы', () => {
|
|||
expect(wrapper.find('[class*="text-red"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('overall_success=false с одним failed dependent → "Deploy failed" и dependent виден', async () => {
|
||||
it('overall_success=false с dependents → "Deploy failed" и project_id виден', async () => {
|
||||
const project = { ...BASE_PROJECT, deploy_host: 'srv', deploy_path: '/app', deploy_runtime: 'docker' }
|
||||
vi.mocked(api.deployProject).mockResolvedValue({
|
||||
success: true,
|
||||
|
|
@ -743,9 +736,7 @@ describe('Граничные кейсы', () => {
|
|||
stderr: '',
|
||||
duration_seconds: 5,
|
||||
results: [{ step: 'git pull', stdout: 'ok', stderr: '', exit_code: 0 }],
|
||||
dependents_deployed: [
|
||||
{ project_id: 'FE', project_name: 'FrontendFailed', success: false, results: [] },
|
||||
],
|
||||
dependents_deployed: ['FE'],
|
||||
overall_success: false,
|
||||
} as any)
|
||||
const wrapper = await mountProjectView(project)
|
||||
|
|
@ -753,7 +744,7 @@ describe('Граничные кейсы', () => {
|
|||
await deployBtn!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.text()).toContain('Deploy failed')
|
||||
expect(wrapper.text()).toContain('FrontendFailed')
|
||||
expect(wrapper.text()).toContain('FE')
|
||||
})
|
||||
|
||||
it('только deploy_host без deploy_path/runtime — кнопка Deploy disabled', async () => {
|
||||
|
|
|
|||
|
|
@ -69,6 +69,7 @@ export interface Project {
|
|||
execution_mode: string | null
|
||||
autocommit_enabled: number | null
|
||||
auto_test_enabled: number | null
|
||||
worktrees_enabled: number | null
|
||||
obsidian_vault_path: string | null
|
||||
deploy_command: string | null
|
||||
test_command: string | null
|
||||
|
|
@ -167,13 +168,6 @@ export interface DeployStepResult {
|
|||
exit_code: number
|
||||
}
|
||||
|
||||
export interface DependentDeploy {
|
||||
project_id: string
|
||||
project_name: string
|
||||
success: boolean
|
||||
results: DeployStepResult[]
|
||||
}
|
||||
|
||||
export interface DeployResult {
|
||||
success: boolean
|
||||
exit_code: number
|
||||
|
|
@ -182,7 +176,8 @@ export interface DeployResult {
|
|||
duration_seconds: number
|
||||
steps?: string[]
|
||||
results?: DeployStepResult[]
|
||||
dependents_deployed?: DependentDeploy[]
|
||||
// WARNING (decision #546): бэкенд возвращает list[str] (project_id), не объекты
|
||||
dependents_deployed?: string[]
|
||||
overall_success?: boolean
|
||||
}
|
||||
|
||||
|
|
@ -363,7 +358,7 @@ export const api = {
|
|||
post<{ updated: string[]; count: number }>(`/projects/${projectId}/audit/apply`, { task_ids: taskIds }),
|
||||
patchTask: (id: string, data: { status?: string; execution_mode?: string; priority?: number; route_type?: string; title?: string; brief_text?: string; acceptance_criteria?: string }) =>
|
||||
patch<Task>(`/tasks/${id}`, data),
|
||||
patchProject: (id: string, data: { execution_mode?: string; autocommit_enabled?: boolean; auto_test_enabled?: boolean; obsidian_vault_path?: string; deploy_command?: string; test_command?: string; project_type?: string; ssh_host?: string; ssh_user?: string; ssh_key_path?: string; ssh_proxy_jump?: string; deploy_host?: string; deploy_path?: string; deploy_runtime?: string; deploy_restart_cmd?: string }) =>
|
||||
patchProject: (id: string, data: { execution_mode?: string; autocommit_enabled?: boolean; auto_test_enabled?: boolean; worktrees_enabled?: boolean; obsidian_vault_path?: string; deploy_command?: string; test_command?: string; project_type?: string; ssh_host?: string; ssh_user?: string; ssh_key_path?: string; ssh_proxy_jump?: string; deploy_host?: string; deploy_path?: string; deploy_runtime?: string; deploy_restart_cmd?: string }) =>
|
||||
patch<Project>(`/projects/${id}`, data),
|
||||
deployProject: (projectId: string) =>
|
||||
post<DeployResult>(`/projects/${projectId}/deploy`, {}),
|
||||
|
|
|
|||
|
|
@ -823,24 +823,10 @@ async function addDecision() {
|
|||
<!-- Dependents -->
|
||||
<div v-if="deployResult.dependents_deployed?.length" class="mt-2 border-t border-gray-700 pt-2">
|
||||
<p class="text-xs text-gray-400 font-semibold mb-1">Зависимые проекты:</p>
|
||||
<details v-for="dep in deployResult.dependents_deployed" :key="dep.project_id" class="border border-gray-700 rounded mb-1">
|
||||
<summary class="flex items-center gap-2 px-2 py-1 cursor-pointer list-none">
|
||||
<span :class="dep.success ? 'text-teal-400' : 'text-red-400'" class="font-semibold text-[10px]">{{ dep.success ? 'ok' : 'fail' }}</span>
|
||||
<span class="text-gray-300 text-[11px]">{{ dep.project_name }}</span>
|
||||
</summary>
|
||||
<div class="px-2 pb-2 space-y-1">
|
||||
<details v-for="step in dep.results" :key="step.step" class="border border-gray-800 rounded">
|
||||
<summary class="flex items-center gap-2 px-2 py-0.5 cursor-pointer list-none">
|
||||
<span :class="step.exit_code === 0 ? 'text-teal-400' : 'text-red-400'" class="text-[10px]">{{ step.exit_code === 0 ? 'ok' : 'fail' }}</span>
|
||||
<span class="text-gray-400 text-[10px]">{{ step.step }}</span>
|
||||
</summary>
|
||||
<div class="px-2 pb-1">
|
||||
<pre v-if="step.stdout" class="whitespace-pre-wrap text-gray-300 max-h-24 overflow-y-auto text-[10px]">{{ step.stdout }}</pre>
|
||||
<pre v-if="step.stderr" class="whitespace-pre-wrap text-red-400/80 max-h-24 overflow-y-auto text-[10px]">{{ step.stderr }}</pre>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</details>
|
||||
<div v-for="dep in deployResult.dependents_deployed" :key="dep" class="flex items-center gap-2 px-2 py-0.5">
|
||||
<span class="text-teal-400 font-semibold text-[10px]">ok</span>
|
||||
<span class="text-gray-300 text-[11px]">{{ dep }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2 flex-wrap mb-2" v-if="project.tech_stack?.length">
|
||||
|
|
|
|||
|
|
@ -7,13 +7,16 @@ const vaultPaths = ref<Record<string, string>>({})
|
|||
const deployCommands = ref<Record<string, string>>({})
|
||||
const testCommands = ref<Record<string, string>>({})
|
||||
const autoTestEnabled = ref<Record<string, boolean>>({})
|
||||
const worktreesEnabled = ref<Record<string, boolean>>({})
|
||||
const saving = ref<Record<string, boolean>>({})
|
||||
const savingTest = ref<Record<string, boolean>>({})
|
||||
const savingAutoTest = ref<Record<string, boolean>>({})
|
||||
const savingWorktrees = ref<Record<string, boolean>>({})
|
||||
const syncing = ref<Record<string, boolean>>({})
|
||||
const saveStatus = ref<Record<string, string>>({})
|
||||
const saveTestStatus = ref<Record<string, string>>({})
|
||||
const saveAutoTestStatus = ref<Record<string, string>>({})
|
||||
const saveWorktreesStatus = ref<Record<string, string>>({})
|
||||
const syncResults = ref<Record<string, ObsidianSyncResult | null>>({})
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
|
|
@ -43,6 +46,7 @@ onMounted(async () => {
|
|||
deployCommands.value[p.id] = p.deploy_command ?? ''
|
||||
testCommands.value[p.id] = p.test_command ?? ''
|
||||
autoTestEnabled.value[p.id] = !!(p.auto_test_enabled)
|
||||
worktreesEnabled.value[p.id] = !!(p.worktrees_enabled)
|
||||
deployHosts.value[p.id] = p.deploy_host ?? ''
|
||||
deployPaths.value[p.id] = p.deploy_path ?? ''
|
||||
deployRuntimes.value[p.id] = p.deploy_runtime ?? ''
|
||||
|
|
@ -116,6 +120,21 @@ async function toggleAutoTest(projectId: string) {
|
|||
}
|
||||
}
|
||||
|
||||
async function toggleWorktrees(projectId: string) {
|
||||
worktreesEnabled.value[projectId] = !worktreesEnabled.value[projectId]
|
||||
savingWorktrees.value[projectId] = true
|
||||
saveWorktreesStatus.value[projectId] = ''
|
||||
try {
|
||||
await api.patchProject(projectId, { worktrees_enabled: worktreesEnabled.value[projectId] })
|
||||
saveWorktreesStatus.value[projectId] = 'Saved'
|
||||
} catch (e: unknown) {
|
||||
worktreesEnabled.value[projectId] = !worktreesEnabled.value[projectId]
|
||||
saveWorktreesStatus.value[projectId] = `Error: ${e instanceof Error ? e.message : String(e)}`
|
||||
} finally {
|
||||
savingWorktrees.value[projectId] = false
|
||||
}
|
||||
}
|
||||
|
||||
async function runSync(projectId: string) {
|
||||
syncing.value[projectId] = true
|
||||
syncResults.value[projectId] = null
|
||||
|
|
@ -371,6 +390,23 @@ async function deleteLink(projectId: string, linkId: number) {
|
|||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3 mb-3">
|
||||
<label class="flex items-center gap-2 cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="worktreesEnabled[project.id]"
|
||||
@change="toggleWorktrees(project.id)"
|
||||
:disabled="savingWorktrees[project.id]"
|
||||
class="w-4 h-4 rounded border-gray-600 bg-gray-800 accent-blue-500 cursor-pointer disabled:opacity-50"
|
||||
/>
|
||||
<span class="text-sm text-gray-300">Worktrees</span>
|
||||
<span class="text-xs text-gray-500">— агенты запускаются в изолированных git worktrees</span>
|
||||
</label>
|
||||
<span v-if="saveWorktreesStatus[project.id]" class="text-xs" :class="saveWorktreesStatus[project.id].startsWith('Error') ? 'text-red-400' : 'text-green-400'">
|
||||
{{ saveWorktreesStatus[project.id] }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3 flex-wrap">
|
||||
<button
|
||||
@click="saveVaultPath(project.id)"
|
||||
|
|
|
|||
|
|
@ -651,24 +651,10 @@ async function saveEdit() {
|
|||
<!-- Dependents -->
|
||||
<div v-if="deployResult.dependents_deployed?.length" class="mt-2 border-t border-gray-700 pt-2">
|
||||
<p class="text-xs text-gray-400 font-semibold mb-1">Зависимые проекты:</p>
|
||||
<details v-for="dep in deployResult.dependents_deployed" :key="dep.project_id" class="border border-gray-700 rounded mb-1">
|
||||
<summary class="flex items-center gap-2 px-2 py-1 cursor-pointer list-none">
|
||||
<span :class="dep.success ? 'text-teal-400' : 'text-red-400'" class="font-semibold text-[10px]">{{ dep.success ? 'ok' : 'fail' }}</span>
|
||||
<span class="text-gray-300 text-[11px]">{{ dep.project_name }}</span>
|
||||
</summary>
|
||||
<div class="px-2 pb-2 space-y-1">
|
||||
<details v-for="step in dep.results" :key="step.step" class="border border-gray-800 rounded">
|
||||
<summary class="flex items-center gap-2 px-2 py-0.5 cursor-pointer list-none">
|
||||
<span :class="step.exit_code === 0 ? 'text-teal-400' : 'text-red-400'" class="text-[10px]">{{ step.exit_code === 0 ? 'ok' : 'fail' }}</span>
|
||||
<span class="text-gray-400 text-[10px]">{{ step.step }}</span>
|
||||
</summary>
|
||||
<div class="px-2 pb-1">
|
||||
<pre v-if="step.stdout" class="whitespace-pre-wrap text-gray-300 max-h-24 overflow-y-auto text-[10px]">{{ step.stdout }}</pre>
|
||||
<pre v-if="step.stderr" class="whitespace-pre-wrap text-red-400/80 max-h-24 overflow-y-auto text-[10px]">{{ step.stderr }}</pre>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</details>
|
||||
<div v-for="dep in deployResult.dependents_deployed" :key="dep" class="flex items-center gap-2 px-2 py-0.5">
|
||||
<span class="text-teal-400 font-semibold text-[10px]">ok</span>
|
||||
<span class="text-gray-300 text-[11px]">{{ dep }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
203
web/frontend/src/views/__tests__/SettingsView.worktrees.test.ts
Normal file
203
web/frontend/src/views/__tests__/SettingsView.worktrees.test.ts
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
/**
|
||||
* KIN-103: Тесты worktrees_enabled toggle в SettingsView
|
||||
*
|
||||
* Проверяет:
|
||||
* 1. Интерфейс Project содержит worktrees_enabled: number | null
|
||||
* 2. patchProject принимает worktrees_enabled?: boolean
|
||||
* 3. Инициализацию из числовых значений (0, 1, null) → !!()
|
||||
* 4. Toggle вызывает PATCH с true/false
|
||||
* 5. Откат при ошибке PATCH
|
||||
* 6. Checkbox disabled пока идёт сохранение
|
||||
* 7. Статусные сообщения (Saved / Error)
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import SettingsView from '../SettingsView.vue'
|
||||
|
||||
vi.mock('../../api', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../api')>()
|
||||
return {
|
||||
...actual,
|
||||
api: {
|
||||
projects: vi.fn(),
|
||||
patchProject: vi.fn(),
|
||||
syncObsidian: vi.fn(),
|
||||
projectLinks: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
import { api } from '../../api'
|
||||
|
||||
const BASE_PROJECT = {
|
||||
id: 'proj-1',
|
||||
name: 'Test Project',
|
||||
path: '/projects/test',
|
||||
status: 'active',
|
||||
priority: 5,
|
||||
tech_stack: ['python'],
|
||||
execution_mode: null,
|
||||
autocommit_enabled: null,
|
||||
auto_test_enabled: null,
|
||||
worktrees_enabled: null as number | null,
|
||||
obsidian_vault_path: null,
|
||||
deploy_command: null,
|
||||
test_command: null,
|
||||
deploy_host: null,
|
||||
deploy_path: null,
|
||||
deploy_runtime: null,
|
||||
deploy_restart_cmd: null,
|
||||
created_at: '2024-01-01',
|
||||
total_tasks: 0,
|
||||
done_tasks: 0,
|
||||
active_tasks: 0,
|
||||
blocked_tasks: 0,
|
||||
review_tasks: 0,
|
||||
project_type: null,
|
||||
ssh_host: null,
|
||||
ssh_user: null,
|
||||
ssh_key_path: null,
|
||||
ssh_proxy_jump: null,
|
||||
description: null,
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(api.patchProject).mockResolvedValue(BASE_PROJECT as any)
|
||||
})
|
||||
|
||||
async function mountSettings(overrides: Partial<typeof BASE_PROJECT> = {}) {
|
||||
const project = { ...BASE_PROJECT, ...overrides }
|
||||
vi.mocked(api.projects).mockResolvedValue([project as any])
|
||||
const wrapper = mount(SettingsView)
|
||||
await flushPromises()
|
||||
return wrapper
|
||||
}
|
||||
|
||||
function findWorktreesCheckbox(wrapper: ReturnType<typeof mount>) {
|
||||
const labels = wrapper.findAll('label')
|
||||
const worktreesLabel = labels.find(l => l.text().includes('Worktrees'))
|
||||
const checkbox = worktreesLabel?.find('input[type="checkbox"]')
|
||||
// decision #544: assertion безусловная — ложный зелёный недопустим
|
||||
expect(checkbox?.exists()).toBe(true)
|
||||
return checkbox!
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// 1. Инициализация из числовых значений
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
describe('worktreesEnabled — инициализация', () => {
|
||||
it('worktrees_enabled=1 → checkbox checked', async () => {
|
||||
const wrapper = await mountSettings({ worktrees_enabled: 1 })
|
||||
const checkbox = findWorktreesCheckbox(wrapper)
|
||||
expect((checkbox.element as HTMLInputElement).checked).toBe(true)
|
||||
})
|
||||
|
||||
it('worktrees_enabled=0 → checkbox unchecked', async () => {
|
||||
const wrapper = await mountSettings({ worktrees_enabled: 0 })
|
||||
const checkbox = findWorktreesCheckbox(wrapper)
|
||||
expect((checkbox.element as HTMLInputElement).checked).toBe(false)
|
||||
})
|
||||
|
||||
it('worktrees_enabled=null → checkbox unchecked', async () => {
|
||||
const wrapper = await mountSettings({ worktrees_enabled: null })
|
||||
const checkbox = findWorktreesCheckbox(wrapper)
|
||||
expect((checkbox.element as HTMLInputElement).checked).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// 2. Toggle → patchProject вызывается с корректным значением
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
describe('toggleWorktrees — вызов patchProject', () => {
|
||||
it('toggle с unchecked → patchProject({ worktrees_enabled: true })', async () => {
|
||||
const wrapper = await mountSettings({ worktrees_enabled: 0 })
|
||||
await findWorktreesCheckbox(wrapper).trigger('change')
|
||||
await flushPromises()
|
||||
|
||||
expect(vi.mocked(api.patchProject)).toHaveBeenCalledWith(
|
||||
'proj-1',
|
||||
expect.objectContaining({ worktrees_enabled: true }),
|
||||
)
|
||||
})
|
||||
|
||||
it('toggle с checked → patchProject({ worktrees_enabled: false }), не undefined (decision #524)', async () => {
|
||||
const wrapper = await mountSettings({ worktrees_enabled: 1 })
|
||||
await findWorktreesCheckbox(wrapper).trigger('change')
|
||||
await flushPromises()
|
||||
|
||||
const payload = vi.mocked(api.patchProject).mock.calls[0][1] as any
|
||||
expect(payload.worktrees_enabled).toBe(false)
|
||||
expect(payload.worktrees_enabled).not.toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// 3. Откат при ошибке PATCH
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
describe('toggleWorktrees — откат при ошибке PATCH', () => {
|
||||
it('ошибка при включении: checkbox откатывается обратно к unchecked', async () => {
|
||||
vi.mocked(api.patchProject).mockRejectedValueOnce(new Error('network error'))
|
||||
const wrapper = await mountSettings({ worktrees_enabled: 0 })
|
||||
await findWorktreesCheckbox(wrapper).trigger('change')
|
||||
await flushPromises()
|
||||
|
||||
expect((findWorktreesCheckbox(wrapper).element as HTMLInputElement).checked).toBe(false)
|
||||
})
|
||||
|
||||
it('ошибка при выключении: checkbox откатывается обратно к checked', async () => {
|
||||
vi.mocked(api.patchProject).mockRejectedValueOnce(new Error('server error'))
|
||||
const wrapper = await mountSettings({ worktrees_enabled: 1 })
|
||||
await findWorktreesCheckbox(wrapper).trigger('change')
|
||||
await flushPromises()
|
||||
|
||||
expect((findWorktreesCheckbox(wrapper).element as HTMLInputElement).checked).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// 4. Disabled во время сохранения
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
describe('toggleWorktrees — disabled во время сохранения', () => {
|
||||
it('checkbox disabled пока идёт PATCH, enabled после завершения', async () => {
|
||||
let resolveRequest!: (v: any) => void
|
||||
vi.mocked(api.patchProject).mockImplementationOnce(
|
||||
() => new Promise(resolve => { resolveRequest = resolve }),
|
||||
)
|
||||
|
||||
const wrapper = await mountSettings({ worktrees_enabled: 0 })
|
||||
const checkbox = findWorktreesCheckbox(wrapper)
|
||||
await checkbox.trigger('change')
|
||||
|
||||
// savingWorktrees = true → checkbox должен быть disabled
|
||||
expect((checkbox.element as HTMLInputElement).disabled).toBe(true)
|
||||
|
||||
resolveRequest(BASE_PROJECT)
|
||||
await flushPromises()
|
||||
|
||||
expect((checkbox.element as HTMLInputElement).disabled).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// 5. Статусные сообщения
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
describe('toggleWorktrees — статусное сообщение', () => {
|
||||
it('успех → показывает "Saved"', async () => {
|
||||
const wrapper = await mountSettings({ worktrees_enabled: 0 })
|
||||
await findWorktreesCheckbox(wrapper).trigger('change')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('Saved')
|
||||
})
|
||||
|
||||
it('ошибка → показывает "Error: <message>"', async () => {
|
||||
vi.mocked(api.patchProject).mockRejectedValueOnce(new Error('connection timeout'))
|
||||
const wrapper = await mountSettings({ worktrees_enabled: 0 })
|
||||
await findWorktreesCheckbox(wrapper).trigger('change')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('Error: connection timeout')
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue