kin: auto-commit after pipeline

This commit is contained in:
Gros Frumos 2026-03-17 20:32:49 +02:00
parent 05bcb14b99
commit 02b53e82ca
8 changed files with 555 additions and 68 deletions

View file

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

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

View file

@ -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 () => {