From f0a69ed1d329c2a902918ae577645820fadb5495 Mon Sep 17 00:00:00 2001 From: Gros Frumos Date: Tue, 17 Mar 2026 18:31:33 +0200 Subject: [PATCH] kin: auto-commit after pipeline --- core/db.py | 3 +- .../__tests__/agent-output-parsing.test.ts | 244 ++++++++++++++++++ .../deploy-config-clear-fields.test.ts | 190 ++++++++++++++ 3 files changed, 436 insertions(+), 1 deletion(-) create mode 100644 web/frontend/src/__tests__/agent-output-parsing.test.ts create mode 100644 web/frontend/src/__tests__/deploy-config-clear-fields.test.ts diff --git a/core/db.py b/core/db.py index d35c90a..dcc40e9 100644 --- a/core/db.py +++ b/core/db.py @@ -219,7 +219,8 @@ CREATE TABLE IF NOT EXISTS project_links ( to_project TEXT NOT NULL REFERENCES projects(id), type TEXT NOT NULL, description TEXT, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(from_project, to_project, type) ); CREATE INDEX IF NOT EXISTS idx_project_links_to ON project_links(to_project); diff --git a/web/frontend/src/__tests__/agent-output-parsing.test.ts b/web/frontend/src/__tests__/agent-output-parsing.test.ts new file mode 100644 index 0000000..02deafe --- /dev/null +++ b/web/frontend/src/__tests__/agent-output-parsing.test.ts @@ -0,0 +1,244 @@ +/** + * KIN-100: parseAgentOutput — человекочитаемый вывод агентов + * + * Acceptance Criteria (проверяем через рендеринг TaskDetail.vue): + * 1. Вывод с ## Verdict → verdict текст показывается читаемым текстом + * 2. Вывод с ## Details → JSON скрыт за
с "подробнее" + * 3. Старый формат (без ## Verdict) → fallback на
 без 
+ * 4. Null output → нет ни verdict, ни details элементов + * 5. ## Verdict без ## Details → verdict показывается,
нет + * 6. Невалидный JSON в ## Details → сырой текст в
+ */ + +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { mount, flushPromises } from '@vue/test-utils' +import { createRouter, createMemoryHistory } from 'vue-router' +import TaskDetail from '../views/TaskDetail.vue' + +vi.mock('../api', () => ({ + api: { + taskFull: vi.fn(), + patchTask: vi.fn(), + runTask: vi.fn(), + getAttachments: vi.fn(), + approveTask: vi.fn(), + rejectTask: vi.fn(), + reviseTask: vi.fn(), + deployProject: vi.fn(), + }, +})) + +import { api } from '../api' + +const Stub = { template: '
' } + +function makeRouter() { + return createRouter({ + history: createMemoryHistory(), + routes: [ + { path: '/', component: Stub }, + { path: '/task/:id', component: TaskDetail, props: true }, + ], + }) +} + +// Вывод в новом формате: ## Verdict + ## Details с JSON +const NEW_FORMAT_OUTPUT = `## Verdict +Написано 4 теста, все прошли успешно. Покрытие полное. Можно закрывать. + +## Details +\`\`\`json +{"status": "passed", "tests_run": 10, "tests_passed": 10, "tests_failed": 0} +\`\`\`` + +// Вывод только с Verdict, без Details +const VERDICT_ONLY_OUTPUT = `## Verdict +Реализация корректна, безопасность соблюдена. Задачу можно закрывать.` + +// Вывод в старом формате (plain JSON, без ## Verdict) +const OLD_FORMAT_OUTPUT = JSON.stringify({ + status: 'done', + changes: [{ file: 'core/models.py', description: 'Fixed the bug' }], +}) + +// Вывод с невалидным JSON в Details +const INVALID_JSON_OUTPUT = `## Verdict +Тесты прошли, всё ок. + +## Details +\`\`\`json +{not valid json here +\`\`\`` + +function makeTask(outputSummary: string | null) { + return { + id: 'KIN-001', + project_id: 'KIN', + title: 'Test Task', + status: 'done', + priority: 5, + assigned_role: null, + parent_task_id: null, + brief: null, + spec: null, + execution_mode: null, + blocked_reason: null, + pipeline_id: null, + dangerously_skipped: false, + category: null, + acceptance_criteria: null, + created_at: '2024-01-01', + updated_at: '2024-01-01', + pipeline_steps: [ + { + id: 1, + agent_role: 'tester', + action: 'test', + output_summary: outputSummary, + success: true, + duration_seconds: 30, + tokens_used: 1000, + cost_usd: 0.01, + model: null, + created_at: '2024-01-01', + }, + ], + related_decisions: [], + project_deploy_command: null, + project_deploy_runtime: null, + } +} + +beforeEach(() => { + vi.clearAllMocks() + vi.mocked(api.getAttachments).mockResolvedValue([] as any) + vi.mocked(api.patchTask).mockResolvedValue({} as any) +}) + +async function mountAndSelectStep(outputSummary: string | null) { + vi.mocked(api.taskFull).mockResolvedValue(makeTask(outputSummary) as any) + + const router = makeRouter() + await router.push('/task/KIN-001') + + const wrapper = mount(TaskDetail, { + props: { id: 'KIN-001' }, + global: { plugins: [router] }, + }) + await flushPromises() + + // Находим и кликаем кнопку pipeline шага (border + rounded-lg) + const stepBtn = wrapper.find('button.rounded-lg') + expect(stepBtn.exists(), 'кнопка pipeline шага должна быть в DOM').toBe(true) + await stepBtn.trigger('click') + await flushPromises() + + return wrapper +} + +describe('KIN-100: parseAgentOutput — новый формат (Verdict + Details)', () => { + it('показывает текст Verdict читаемым текстом (не JSON)', async () => { + const wrapper = await mountAndSelectStep(NEW_FORMAT_OUTPUT) + + const text = wrapper.text() + expect(text).toContain('Написано 4 теста, все прошли успешно. Покрытие полное. Можно закрывать.') + }) + + it('скрывает JSON Details за
элементом', async () => { + const wrapper = await mountAndSelectStep(NEW_FORMAT_OUTPUT) + + const details = wrapper.find('details') + expect(details.exists(), '
должен присутствовать для скрытия JSON').toBe(true) + }) + + it('summary в
содержит текст "подробнее"', async () => { + const wrapper = await mountAndSelectStep(NEW_FORMAT_OUTPUT) + + const summary = wrapper.find('details summary') + expect(summary.exists()).toBe(true) + expect(summary.text()).toContain('подробнее') + }) + + it('JSON из Details виден внутри
', async () => {
+    const wrapper = await mountAndSelectStep(NEW_FORMAT_OUTPUT)
+
+    const pre = wrapper.find('details pre')
+    expect(pre.exists(), '
 с JSON должен быть внутри 
').toBe(true) + expect(pre.text()).toContain('"status"') + expect(pre.text()).toContain('"passed"') + }) + + it('Verdict рендерится как параграф, не как
', async () => {
+    const wrapper = await mountAndSelectStep(NEW_FORMAT_OUTPUT)
+
+    // Verdict должен быть в 

, не в

+    const p = wrapper.findAll('p').find(el =>
+      el.text().includes('Написано 4 теста')
+    )
+    expect(p, 'verdict должен быть в 

элементе').toBeDefined() + }) +}) + +describe('KIN-100: parseAgentOutput — только Verdict без Details', () => { + it('показывает Verdict текст', async () => { + const wrapper = await mountAndSelectStep(VERDICT_ONLY_OUTPUT) + + expect(wrapper.text()).toContain('Реализация корректна, безопасность соблюдена.') + }) + + it('не показывает

когда ## Details отсутствует', async () => { + const wrapper = await mountAndSelectStep(VERDICT_ONLY_OUTPUT) + + const details = wrapper.find('details') + expect(details.exists(), '
не должен быть без ## Details секции').toBe(false) + }) +}) + +describe('KIN-100: parseAgentOutput — fallback для старого формата', () => { + it('не показывает
для старого JSON-формата', async () => { + const wrapper = await mountAndSelectStep(OLD_FORMAT_OUTPUT) + + const details = wrapper.find('details') + expect(details.exists(), 'старый формат не должен создавать
').toBe(false) + }) + + it('показывает содержимое через formatOutput (pre с JSON)', async () => { + const wrapper = await mountAndSelectStep(OLD_FORMAT_OUTPUT) + + // В fallback режиме содержимое JSON должно быть видно + expect(wrapper.text()).toContain('core/models.py') + }) +}) + +describe('KIN-100: parseAgentOutput — null/пустой вывод', () => { + it('не падает при null output_summary', async () => { + // Не должно быть exception, компонент рендерится нормально + const wrapper = await mountAndSelectStep(null) + expect(wrapper.exists()).toBe(true) + }) + + it('не создаёт
для null вывода', async () => { + const wrapper = await mountAndSelectStep(null) + + const details = wrapper.find('details') + expect(details.exists()).toBe(false) + }) +}) + +describe('KIN-100: parseAgentOutput — невалидный JSON в Details', () => { + it('показывает raw текст в
когда JSON невалидный', async () => { + const wrapper = await mountAndSelectStep(INVALID_JSON_OUTPUT) + + // Verdict должен показываться + expect(wrapper.text()).toContain('Тесты прошли, всё ок.') + + //
должен присутствовать (так как ## Details секция есть) + const details = wrapper.find('details') + expect(details.exists(), '
должен быть даже при невалидном JSON').toBe(true) + + // Raw text должен быть виден в
+    const pre = details.find('pre')
+    expect(pre.exists()).toBe(true)
+    expect(pre.text()).toContain('not valid json here')
+  })
+})
diff --git a/web/frontend/src/__tests__/deploy-config-clear-fields.test.ts b/web/frontend/src/__tests__/deploy-config-clear-fields.test.ts
new file mode 100644
index 0000000..2e592d9
--- /dev/null
+++ b/web/frontend/src/__tests__/deploy-config-clear-fields.test.ts
@@ -0,0 +1,190 @@
+/**
+ * KIN-INFRA-012: Регрессионный тест — saveDeployConfig не теряет пустые строки
+ *
+ * Проверяет, что пустая строка в deploy-полях:
+ * 1. Передаётся в patchProject как "" (не undefined)
+ * 2. Переживает JSON.stringify — т.е. бэкенд получает ключ со значением ""
+ * 3. Непустые значения тоже передаются корректно
+ *
+ * До фикса: `deploy_host: deployHosts.value[id] || undefined` → пустая строка
+ * превращалась в undefined и JSON.stringify отбрасывал поле. Бэкенд не получал
+ * сигнал очистки. После фикса: пустая строка передаётся как-есть.
+ */
+
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { mount, flushPromises } from '@vue/test-utils'
+import SettingsView from '../views/SettingsView.vue'
+
+vi.mock('../api', async (importOriginal) => {
+  const actual = await importOriginal()
+  return {
+    ...actual,
+    api: {
+      projects: vi.fn(),
+      patchProject: vi.fn(),
+      syncObsidian: vi.fn(),
+    },
+  }
+})
+
+import { api } from '../api'
+
+const BASE_PROJECT = {
+  id: 'KIN',
+  name: 'Kin',
+  path: '/projects/kin',
+  status: 'active',
+  priority: 5,
+  tech_stack: ['python'],
+  execution_mode: null,
+  autocommit_enabled: null,
+  auto_test_enabled: 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 mountSettingsWithProject(overrides: Partial = {}) {
+  const project = { ...BASE_PROJECT, ...overrides }
+  vi.mocked(api.projects).mockResolvedValue([project as any])
+  const wrapper = mount(SettingsView)
+  await flushPromises()
+  return wrapper
+}
+
+async function clickSaveDeployConfig(wrapper: ReturnType) {
+  const saveBtn = wrapper.findAll('button').find(b => b.text().includes('Save Deploy Config'))
+  expect(saveBtn).toBeDefined()
+  await saveBtn!.trigger('click')
+  await flushPromises()
+}
+
+// ─────────────────────────────────────────────────────────────
+// 1. Пустая строка передаётся как "" — не теряется как undefined
+// ─────────────────────────────────────────────────────────────
+describe('saveDeployConfig — пустые строки сохраняются в payload', () => {
+  it('deploy_host="" передаётся как "" когда поле пустое (не undefined)', async () => {
+    // null в проекте → инициализируется как '' в компоненте → должно передаться как ""
+    const wrapper = await mountSettingsWithProject({ deploy_host: null })
+    await clickSaveDeployConfig(wrapper)
+
+    const callArgs = vi.mocked(api.patchProject).mock.calls[0]
+    expect(callArgs[1]).toHaveProperty('deploy_host')
+    expect((callArgs[1] as any).deploy_host).toBe('')
+  })
+
+  it('deploy_path="" передаётся как "" когда поле пустое (не undefined)', async () => {
+    const wrapper = await mountSettingsWithProject({ deploy_path: null })
+    await clickSaveDeployConfig(wrapper)
+
+    const callArgs = vi.mocked(api.patchProject).mock.calls[0]
+    expect(callArgs[1]).toHaveProperty('deploy_path')
+    expect((callArgs[1] as any).deploy_path).toBe('')
+  })
+
+  it('deploy_runtime="" передаётся как "" когда поле пустое (не undefined)', async () => {
+    const wrapper = await mountSettingsWithProject({ deploy_runtime: null })
+    await clickSaveDeployConfig(wrapper)
+
+    const callArgs = vi.mocked(api.patchProject).mock.calls[0]
+    expect(callArgs[1]).toHaveProperty('deploy_runtime')
+    expect((callArgs[1] as any).deploy_runtime).toBe('')
+  })
+
+  it('deploy_restart_cmd="" передаётся как "" когда поле пустое (не undefined)', async () => {
+    const wrapper = await mountSettingsWithProject({ deploy_restart_cmd: null })
+    await clickSaveDeployConfig(wrapper)
+
+    const callArgs = vi.mocked(api.patchProject).mock.calls[0]
+    expect(callArgs[1]).toHaveProperty('deploy_restart_cmd')
+    expect((callArgs[1] as any).deploy_restart_cmd).toBe('')
+  })
+})
+
+// ─────────────────────────────────────────────────────────────
+// 2. JSON.stringify не выбрасывает пустые строки — бэкенд их получит
+// ─────────────────────────────────────────────────────────────
+describe('saveDeployConfig — пустые строки выживают JSON.stringify', () => {
+  it('все 4 поля присутствуют в JSON когда все значения пустые', async () => {
+    // Проект с null во всех deploy-полях → инициализируются как ''
+    const wrapper = await mountSettingsWithProject()
+    await clickSaveDeployConfig(wrapper)
+
+    const callArgs = vi.mocked(api.patchProject).mock.calls[0]
+    const payload = callArgs[1] as Record
+
+    // Сериализуем как настоящий PATCH-запрос
+    const jsonBody = JSON.parse(JSON.stringify(payload))
+
+    expect(jsonBody).toHaveProperty('deploy_host', '')
+    expect(jsonBody).toHaveProperty('deploy_path', '')
+    expect(jsonBody).toHaveProperty('deploy_runtime', '')
+    expect(jsonBody).toHaveProperty('deploy_restart_cmd', '')
+  })
+
+  it('пустая строка выживает JSON.stringify (регрессия: undefined исчезает)', async () => {
+    const wrapper = await mountSettingsWithProject({ deploy_host: null })
+    await clickSaveDeployConfig(wrapper)
+
+    const callArgs = vi.mocked(api.patchProject).mock.calls[0]
+    const payload = callArgs[1] as Record
+    const jsonBody = JSON.parse(JSON.stringify(payload))
+
+    // "" должна присутствовать в JSON как пустая строка
+    // undefined здесь отсутствовало бы — это был бы баг
+    expect(Object.keys(jsonBody)).toContain('deploy_host')
+    expect(jsonBody.deploy_host).toBe('')
+    expect(jsonBody.deploy_host).not.toBeUndefined()
+  })
+})
+
+// ─────────────────────────────────────────────────────────────
+// 3. Непустые значения передаются корректно
+// ─────────────────────────────────────────────────────────────
+describe('saveDeployConfig — непустые значения передаются корректно', () => {
+  it('deploy_host с непустым значением передаётся без изменений', async () => {
+    const wrapper = await mountSettingsWithProject({ deploy_host: 'prod.example.com' })
+    await clickSaveDeployConfig(wrapper)
+
+    const callArgs = vi.mocked(api.patchProject).mock.calls[0]
+    expect((callArgs[1] as any).deploy_host).toBe('prod.example.com')
+  })
+
+  it('все 4 поля с непустыми значениями передаются корректно', async () => {
+    const wrapper = await mountSettingsWithProject({
+      deploy_host: 'prod.example.com',
+      deploy_path: '/opt/kin',
+      deploy_runtime: 'docker',
+      deploy_restart_cmd: 'docker compose restart',
+    })
+    await clickSaveDeployConfig(wrapper)
+
+    const callArgs = vi.mocked(api.patchProject).mock.calls[0]
+    const payload = callArgs[1] as any
+    expect(payload.deploy_host).toBe('prod.example.com')
+    expect(payload.deploy_path).toBe('/opt/kin')
+    expect(payload.deploy_runtime).toBe('docker')
+    expect(payload.deploy_restart_cmd).toBe('docker compose restart')
+  })
+})