kin: auto-commit after pipeline

This commit is contained in:
Gros Frumos 2026-03-17 17:53:14 +02:00
parent 4144c521be
commit 3c902eaeab
6 changed files with 1354 additions and 7 deletions

View file

@ -0,0 +1,204 @@
/**
* KIN-079: API функции deploy unit тесты с fetch mock
* Тестируют fetch-вызовы напрямую без мока api-модуля.
*
* Проверяет:
* - api.projectLinks(id) GET /api/projects/{id}/links
* - api.createProjectLink(data) POST /api/project-links с телом
* - api.deleteProjectLink(id) DELETE /api/project-links/{id}
* - api.patchProject с deploy_host/path/runtime/restart_cmd
* - api.deployProject structured и legacy ответ
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { api } from '../api'
function mockFetch(body: unknown, status = 200) {
return vi.spyOn(globalThis, 'fetch').mockResolvedValue({
ok: status >= 200 && status < 300,
status,
statusText: status < 300 ? 'OK' : 'Error',
json: () => Promise.resolve(body),
} as Response)
}
beforeEach(() => {
vi.restoreAllMocks()
})
// ─────────────────────────────────────────────────────────────
// api.projectLinks
// ─────────────────────────────────────────────────────────────
describe('api.projectLinks', () => {
it('делает GET /api/projects/{id}/links', async () => {
const spy = mockFetch([])
await api.projectLinks('KIN')
expect(spy).toHaveBeenCalledWith('/api/projects/KIN/links')
})
it('возвращает массив ProjectLink', async () => {
const links = [
{ id: 1, from_project: 'KIN', to_project: 'BRS', link_type: 'depends_on', description: null, created_at: '2026-01-01' },
]
mockFetch(links)
const result = await api.projectLinks('KIN')
expect(result).toHaveLength(1)
expect(result[0].from_project).toBe('KIN')
expect(result[0].to_project).toBe('BRS')
})
})
// ─────────────────────────────────────────────────────────────
// api.createProjectLink
// ─────────────────────────────────────────────────────────────
describe('api.createProjectLink', () => {
it('делает POST /api/project-links', async () => {
const spy = mockFetch({ id: 1, from_project: 'KIN', to_project: 'BRS', link_type: 'depends_on', description: null, created_at: '' })
await api.createProjectLink({ from_project: 'KIN', to_project: 'BRS', link_type: 'depends_on' })
expect(spy).toHaveBeenCalledWith('/api/project-links', expect.objectContaining({ method: 'POST' }))
})
it('передаёт from_project, to_project, link_type, description в теле', async () => {
const spy = mockFetch({ id: 1 })
const data = { from_project: 'KIN', to_project: 'BRS', link_type: 'depends_on', description: 'API used by frontend' }
await api.createProjectLink(data)
const body = JSON.parse((spy.mock.calls[0][1] as RequestInit).body as string)
expect(body).toMatchObject(data)
})
it('передаёт запрос без description когда она не указана', async () => {
const spy = mockFetch({ id: 1 })
await api.createProjectLink({ from_project: 'KIN', to_project: 'BRS', link_type: 'triggers' })
const body = JSON.parse((spy.mock.calls[0][1] as RequestInit).body as string)
expect(body.from_project).toBe('KIN')
expect(body.link_type).toBe('triggers')
})
})
// ─────────────────────────────────────────────────────────────
// api.deleteProjectLink
// ─────────────────────────────────────────────────────────────
describe('api.deleteProjectLink', () => {
it('делает DELETE /api/project-links/{id}', async () => {
const spy = vi.spyOn(globalThis, 'fetch').mockResolvedValue({
ok: true,
status: 204,
statusText: 'No Content',
json: () => Promise.reject(new Error('no body')),
} as Response)
await api.deleteProjectLink(42)
expect(spy).toHaveBeenCalledWith('/api/project-links/42', expect.objectContaining({ method: 'DELETE' }))
})
it('использует числовой id в URL', async () => {
const spy = vi.spyOn(globalThis, 'fetch').mockResolvedValue({
ok: true,
status: 204,
statusText: 'No Content',
json: () => Promise.reject(new Error('no body')),
} as Response)
await api.deleteProjectLink(99)
expect(spy.mock.calls[0][0]).toBe('/api/project-links/99')
})
})
// ─────────────────────────────────────────────────────────────
// api.patchProject — deploy поля
// ─────────────────────────────────────────────────────────────
describe('api.patchProject — deploy поля', () => {
it('все 4 deploy поля передаются в теле PATCH запроса', async () => {
const spy = mockFetch({ id: 'KIN' })
await api.patchProject('KIN', {
deploy_host: 'myserver.com',
deploy_path: '/opt/app',
deploy_runtime: 'docker',
deploy_restart_cmd: 'docker compose up -d',
})
const [url, opts] = spy.mock.calls[0] as [string, RequestInit]
expect(url).toBe('/api/projects/KIN')
expect((opts as RequestInit).method).toBe('PATCH')
const body = JSON.parse((opts as RequestInit).body as string)
expect(body).toMatchObject({
deploy_host: 'myserver.com',
deploy_path: '/opt/app',
deploy_runtime: 'docker',
deploy_restart_cmd: 'docker compose up -d',
})
})
it('можно передать только deploy_runtime без остальных полей', async () => {
const spy = mockFetch({ id: 'KIN' })
await api.patchProject('KIN', { deploy_runtime: 'node' })
const body = JSON.parse((spy.mock.calls[0][1] as RequestInit).body as string)
expect(body.deploy_runtime).toBe('node')
expect(body.deploy_host).toBeUndefined()
expect(body.deploy_path).toBeUndefined()
})
it('deploy_runtime=python передаётся корректно', async () => {
const spy = mockFetch({ id: 'KIN' })
await api.patchProject('KIN', { deploy_runtime: 'python' })
const body = JSON.parse((spy.mock.calls[0][1] as RequestInit).body as string)
expect(body.deploy_runtime).toBe('python')
})
it('deploy_runtime=static передаётся корректно', async () => {
const spy = mockFetch({ id: 'KIN' })
await api.patchProject('KIN', { deploy_runtime: 'static' })
const body = JSON.parse((spy.mock.calls[0][1] as RequestInit).body as string)
expect(body.deploy_runtime).toBe('static')
})
})
// ─────────────────────────────────────────────────────────────
// api.deployProject
// ─────────────────────────────────────────────────────────────
describe('api.deployProject', () => {
it('делает POST /api/projects/{id}/deploy', async () => {
const spy = mockFetch({ success: true, exit_code: 0, stdout: '', stderr: '', duration_seconds: 2 })
await api.deployProject('KIN')
expect(spy).toHaveBeenCalledWith('/api/projects/KIN/deploy', expect.objectContaining({ method: 'POST' }))
})
it('возвращает structured ответ со steps/results/dependents_deployed/overall_success', async () => {
const structured = {
success: true,
exit_code: 0,
stdout: '',
stderr: '',
duration_seconds: 5,
steps: ['git pull', 'docker compose up -d'],
results: [
{ 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: [] },
],
overall_success: true,
}
mockFetch(structured)
const result = await api.deployProject('KIN')
expect(result.steps).toHaveLength(2)
expect(result.results).toHaveLength(2)
expect(result.dependents_deployed).toHaveLength(1)
expect(result.overall_success).toBe(true)
})
it('legacy формат поддерживается — нет steps/results', async () => {
const legacy = { success: true, exit_code: 0, stdout: 'Deploy complete!', stderr: '', duration_seconds: 3 }
mockFetch(legacy)
const result = await api.deployProject('KIN')
expect(result.success).toBe(true)
expect(result.stdout).toBe('Deploy complete!')
expect(result.steps).toBeUndefined()
expect(result.results).toBeUndefined()
})
it('failed deploy возвращает success=false', async () => {
mockFetch({ success: false, exit_code: 1, stdout: '', stderr: 'error', duration_seconds: 1 })
const result = await api.deployProject('KIN')
expect(result.success).toBe(false)
expect(result.exit_code).toBe(1)
})
})

View file

@ -0,0 +1,691 @@
/**
* KIN-079: Стандартизированный deploy компонентные тесты
*
* Проверяет:
* 1. SettingsView deploy config рендерится, Save Deploy Config, runtime select
* 2. ProjectView Deploy кнопка (видимость, disabled, спиннер)
* 3. ProjectView Deploy результат (structured, legacy, dependents)
* 4. ProjectView Links таб (список, add link модал, delete link)
* 5. Граничные кейсы (пустые links, deploy без dependents, overall_success=false)
*/
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 SettingsView from '../views/SettingsView.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(),
},
}
})
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 },
],
})
}
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,
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,
tasks: [],
decisions: [],
modules: [],
}
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: 2,
} as any)
vi.mocked(api.createProjectLink).mockResolvedValue({
id: 1, from_project: 'KIN', to_project: 'BRS', link_type: 'depends_on', description: null, created_at: '2026-01-01',
} as any)
vi.mocked(api.deleteProjectLink).mockResolvedValue(undefined as any)
})
async function mountProjectView(project = BASE_PROJECT) {
vi.mocked(api.project).mockResolvedValue(project as any)
const router = makeRouter()
await router.push('/project/KIN')
const wrapper = mount(ProjectView, {
props: { id: 'KIN' },
global: { plugins: [router] },
})
await flushPromises()
return wrapper
}
async function switchToLinksTab(wrapper: ReturnType<typeof mount>) {
const allBtns = wrapper.findAll('button')
for (const btn of allBtns) {
if (btn.element.className.includes('border-b-2') && btn.text().includes('Links')) {
await btn.trigger('click')
await flushPromises()
return
}
}
}
// ─────────────────────────────────────────────────────────────
// 1. SettingsView — Deploy Config
// ─────────────────────────────────────────────────────────────
describe('SettingsView — Deploy Config', () => {
async function mountSettings(deployFields: Partial<typeof BASE_PROJECT> = {}) {
const project = { ...BASE_PROJECT, ...deployFields }
vi.mocked(api.projects).mockResolvedValue([project as any])
const wrapper = mount(SettingsView)
await flushPromises()
return wrapper
}
it('раздел Deploy Config рендерится для каждого проекта', async () => {
const wrapper = await mountSettings()
expect(wrapper.text()).toContain('Deploy Config')
})
it('поле Server host рендерится', async () => {
const wrapper = await mountSettings()
expect(wrapper.text()).toContain('Server host')
})
it('поле Project path on server рендерится', async () => {
const wrapper = await mountSettings()
expect(wrapper.text()).toContain('Project path on server')
})
it('поле Runtime рендерится', async () => {
const wrapper = await mountSettings()
expect(wrapper.text()).toContain('Runtime')
})
it('поле Restart command рендерится', async () => {
const wrapper = await mountSettings()
expect(wrapper.text()).toContain('Restart command')
})
it('runtime select содержит опции docker, node, python, static', async () => {
const wrapper = await mountSettings()
// Ищем select для runtime (первый select в Deploy Config)
const selects = wrapper.findAll('select')
// Находим select с options docker/node/python/static
const runtimeSelect = selects.find(s => {
const opts = s.findAll('option')
const values = opts.map(o => o.element.value)
return values.includes('docker') && values.includes('node')
})
expect(runtimeSelect).toBeDefined()
const values = runtimeSelect!.findAll('option').map(o => o.element.value)
expect(values).toContain('docker')
expect(values).toContain('node')
expect(values).toContain('python')
expect(values).toContain('static')
})
it('Save Deploy Config вызывает patchProject', async () => {
const wrapper = await mountSettings()
const saveBtn = wrapper.findAll('button').find(b => b.text().includes('Save Deploy Config'))
expect(saveBtn).toBeDefined()
await saveBtn!.trigger('click')
await flushPromises()
expect(vi.mocked(api.patchProject)).toHaveBeenCalled()
})
it('patchProject вызывается с deploy_host, deploy_path, deploy_runtime, deploy_restart_cmd', async () => {
const wrapper = await mountSettings({
deploy_host: 'myserver.com',
deploy_path: '/opt/app',
deploy_runtime: 'docker',
deploy_restart_cmd: 'docker compose up -d',
})
const saveBtn = wrapper.findAll('button').find(b => b.text().includes('Save Deploy Config'))
await saveBtn!.trigger('click')
await flushPromises()
const callArgs = vi.mocked(api.patchProject).mock.calls[0]
expect(callArgs[0]).toBe('KIN')
// Все 4 ключа присутствуют в объекте
expect(callArgs[1]).toHaveProperty('deploy_host')
expect(callArgs[1]).toHaveProperty('deploy_path')
expect(callArgs[1]).toHaveProperty('deploy_runtime')
expect(callArgs[1]).toHaveProperty('deploy_restart_cmd')
})
it('patchProject получает deploy_host=myserver.com из заполненного поля', async () => {
const wrapper = await mountSettings({ deploy_host: 'myserver.com' })
const saveBtn = wrapper.findAll('button').find(b => b.text().includes('Save Deploy Config'))
await saveBtn!.trigger('click')
await flushPromises()
const callArgs = vi.mocked(api.patchProject).mock.calls[0]
expect((callArgs[1] as any).deploy_host).toBe('myserver.com')
})
it('статус "Saved" отображается после успешного сохранения', async () => {
const wrapper = await mountSettings()
const saveBtn = wrapper.findAll('button').find(b => b.text().includes('Save Deploy Config'))
await saveBtn!.trigger('click')
await flushPromises()
expect(wrapper.text()).toContain('Saved')
})
})
// ─────────────────────────────────────────────────────────────
// 2. ProjectView — Deploy кнопка
// ─────────────────────────────────────────────────────────────
describe('ProjectView — Deploy кнопка', () => {
it('кнопка Deploy присутствует в header проекта', async () => {
const wrapper = await mountProjectView()
const deployBtn = wrapper.findAll('button').find(b => b.text().includes('Deploy'))
expect(deployBtn).toBeDefined()
})
it('кнопка disabled когда deploy-параметры не заполнены', async () => {
const wrapper = await mountProjectView()
const deployBtn = wrapper.findAll('button').find(b => b.text().includes('Deploy'))
expect(deployBtn!.element.disabled).toBe(true)
})
it('кнопка активна когда deploy_host + deploy_path + deploy_runtime заполнены', async () => {
const project = { ...BASE_PROJECT, deploy_host: 'myserver', deploy_path: '/opt/app', deploy_runtime: 'docker' }
const wrapper = await mountProjectView(project)
const deployBtn = wrapper.findAll('button').find(b =>
(b.text().trim() === 'Deploy' || b.text().includes('Deploy')) && b.text().includes('Deploy')
)
expect(deployBtn!.element.disabled).toBe(false)
})
it('кнопка активна когда есть legacy deploy_command', async () => {
const project = { ...BASE_PROJECT, deploy_command: 'ssh prod ./deploy.sh' }
const wrapper = await mountProjectView(project)
const deployBtn = wrapper.findAll('button').find(b => b.text().includes('Deploy'))
expect(deployBtn!.element.disabled).toBe(false)
})
it('tooltip содержит "Settings" когда deploy не настроен', async () => {
const wrapper = await mountProjectView()
const deployBtn = wrapper.findAll('button').find(b => b.text().includes('Deploy'))
expect(deployBtn!.attributes('title')).toContain('Settings')
})
it('tooltip содержит "Deploy project" когда deploy настроен', async () => {
const project = { ...BASE_PROJECT, deploy_host: 'srv', deploy_path: '/app', deploy_runtime: 'node' }
const wrapper = await mountProjectView(project)
const deployBtn = wrapper.findAll('button').find(b => b.text().includes('Deploy'))
expect(deployBtn!.attributes('title')).toContain('Deploy project')
})
it('клик вызывает api.deployProject с id проекта', async () => {
const project = { ...BASE_PROJECT, deploy_host: 'srv', deploy_path: '/app', deploy_runtime: 'docker' }
const wrapper = await mountProjectView(project)
const deployBtn = wrapper.findAll('button').find(b => b.text().includes('Deploy'))
await deployBtn!.trigger('click')
await flushPromises()
expect(vi.mocked(api.deployProject)).toHaveBeenCalledWith('KIN')
})
it('спиннер animate-spin виден пока deploying=true', async () => {
const project = { ...BASE_PROJECT, deploy_host: 'srv', deploy_path: '/app', deploy_runtime: 'docker' }
let resolveDeployment!: (v: any) => void
vi.mocked(api.deployProject).mockReturnValue(new Promise(r => { resolveDeployment = r }))
const wrapper = await mountProjectView(project)
const deployBtn = wrapper.findAll('button').find(b => b.text().includes('Deploy'))
await deployBtn!.trigger('click')
await wrapper.vm.$nextTick()
expect(wrapper.find('.animate-spin').exists()).toBe(true)
// Cleanup
resolveDeployment({ success: true, exit_code: 0, stdout: '', stderr: '', duration_seconds: 1 })
await flushPromises()
})
it('кнопка disabled во время deploying', async () => {
const project = { ...BASE_PROJECT, deploy_host: 'srv', deploy_path: '/app', deploy_runtime: 'docker' }
let resolveDeployment!: (v: any) => void
vi.mocked(api.deployProject).mockReturnValue(new Promise(r => { resolveDeployment = r }))
const wrapper = await mountProjectView(project)
const deployBtn = wrapper.findAll('button').find(b => b.text().includes('Deploy'))
await deployBtn!.trigger('click')
await wrapper.vm.$nextTick()
// Во время загрузки кнопка должна быть disabled
const updatedBtn = wrapper.findAll('button').find(b =>
b.text().includes('Deploy') || b.text().includes('Deploying')
)
expect(updatedBtn!.element.disabled).toBe(true)
// Cleanup
resolveDeployment({ success: true, exit_code: 0, stdout: '', stderr: '', duration_seconds: 1 })
await flushPromises()
})
})
// ─────────────────────────────────────────────────────────────
// 3. ProjectView — Deploy результат
// ─────────────────────────────────────────────────────────────
describe('ProjectView — Deploy результат', () => {
const DEPLOY_PROJECT = { ...BASE_PROJECT, deploy_host: 'srv', deploy_path: '/app', deploy_runtime: 'docker' }
async function clickDeploy(result: any) {
vi.mocked(api.deployProject).mockResolvedValue(result)
const wrapper = await mountProjectView(DEPLOY_PROJECT)
const deployBtn = wrapper.findAll('button').find(b => b.text().includes('Deploy'))
await deployBtn!.trigger('click')
await flushPromises()
return wrapper
}
it('блок результата не показывается до первого деплоя', async () => {
const wrapper = await mountProjectView()
expect(wrapper.text()).not.toContain('Deploy succeeded')
expect(wrapper.text()).not.toContain('Deploy failed')
})
it('success=true → текст "Deploy succeeded"', async () => {
const wrapper = await clickDeploy({ success: true, exit_code: 0, stdout: '', stderr: '', duration_seconds: 1 })
expect(wrapper.text()).toContain('Deploy succeeded')
})
it('success=false → текст "Deploy failed"', async () => {
const wrapper = await clickDeploy({ success: false, exit_code: 1, stdout: '', stderr: 'error', duration_seconds: 1 })
expect(wrapper.text()).toContain('Deploy failed')
})
it('structured результат показывает имя step в <details>', async () => {
const result = {
success: true,
exit_code: 0,
stdout: '',
stderr: '',
duration_seconds: 3,
results: [
{ step: 'git pull', stdout: 'done', stderr: '', exit_code: 0 },
{ step: 'docker compose up -d', stdout: 'started', stderr: '', exit_code: 0 },
],
overall_success: true,
}
const wrapper = await clickDeploy(result)
expect(wrapper.findAll('details').length).toBeGreaterThanOrEqual(2)
expect(wrapper.text()).toContain('git pull')
expect(wrapper.text()).toContain('docker compose up -d')
})
it('structured step с exit_code=0 показывает "ok"', async () => {
const result = {
success: true,
exit_code: 0,
stdout: '',
stderr: '',
duration_seconds: 1,
results: [{ step: 'git pull', stdout: 'done', stderr: '', exit_code: 0 }],
overall_success: true,
}
const wrapper = await clickDeploy(result)
expect(wrapper.text()).toContain('ok')
})
it('structured step с exit_code!=0 показывает "fail"', async () => {
const result = {
success: false,
exit_code: 1,
stdout: '',
stderr: '',
duration_seconds: 1,
results: [{ step: 'git pull', stdout: '', stderr: 'error', exit_code: 1 }],
overall_success: false,
}
const wrapper = await clickDeploy(result)
expect(wrapper.text()).toContain('fail')
})
it('legacy формат — stdout рендерится через <pre>', async () => {
const result = { success: true, exit_code: 0, stdout: 'Deploy complete!', stderr: '', duration_seconds: 2 }
const wrapper = await clickDeploy(result)
expect(wrapper.text()).toContain('Deploy complete!')
})
it('legacy формат — stderr рендерится при наличии', async () => {
const result = { success: false, exit_code: 1, stdout: '', stderr: 'Error occurred', duration_seconds: 1 }
const wrapper = await clickDeploy(result)
expect(wrapper.text()).toContain('Error occurred')
})
it('секция "Зависимые проекты" видна когда есть dependents_deployed', async () => {
const result = {
success: true,
exit_code: 0,
stdout: '',
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: [] },
],
overall_success: true,
}
const wrapper = await clickDeploy(result)
expect(wrapper.text()).toContain('Зависимые проекты')
expect(wrapper.text()).toContain('Frontend App')
})
it('overall_success=false → Deploy failed даже если success=true', async () => {
const result = {
success: true,
exit_code: 0,
stdout: '',
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: [] },
],
overall_success: false,
}
const wrapper = await clickDeploy(result)
expect(wrapper.text()).toContain('Deploy failed')
})
it('dependents показывают статус "ok" или "fail" для каждого проекта', async () => {
const result = {
success: true,
exit_code: 0,
stdout: '',
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,
}
const wrapper = await clickDeploy(result)
expect(wrapper.text()).toContain('FrontendOK')
expect(wrapper.text()).toContain('ServiceFail')
})
})
// ─────────────────────────────────────────────────────────────
// 4. ProjectView — Links таб
// ─────────────────────────────────────────────────────────────
describe('ProjectView — Links таб', () => {
it('таб Links присутствует в списке табов', async () => {
const wrapper = await mountProjectView()
const tabBtns = wrapper.findAll('button').filter(b => b.element.className.includes('border-b-2'))
const hasLinks = tabBtns.some(b => b.text().includes('Links'))
expect(hasLinks).toBe(true)
})
it('api.projectLinks вызывается при монтировании', async () => {
await mountProjectView()
expect(vi.mocked(api.projectLinks)).toHaveBeenCalledWith('KIN')
})
it('пустое состояние — "Нет связей" при links=[]', async () => {
vi.mocked(api.projectLinks).mockResolvedValue([])
const wrapper = await mountProjectView()
await switchToLinksTab(wrapper)
expect(wrapper.text()).toContain('Нет связей')
})
it('связи отображаются при links.length > 0', async () => {
const links = [
{ id: 1, from_project: 'KIN', to_project: 'BRS', link_type: 'depends_on', description: 'test', created_at: '2026-01-01' },
]
vi.mocked(api.projectLinks).mockResolvedValue(links as any)
const wrapper = await mountProjectView()
await switchToLinksTab(wrapper)
expect(wrapper.text()).toContain('BRS')
expect(wrapper.text()).toContain('depends_on')
})
it('link_type и description отображаются для каждой связи', async () => {
const links = [
{ id: 2, from_project: 'KIN', to_project: 'API', link_type: 'triggers', description: 'API call', created_at: '2026-01-01' },
]
vi.mocked(api.projectLinks).mockResolvedValue(links as any)
const wrapper = await mountProjectView()
await switchToLinksTab(wrapper)
expect(wrapper.text()).toContain('triggers')
expect(wrapper.text()).toContain('API call')
})
it('кнопка "+ Add Link" присутствует в Links табе', async () => {
const wrapper = await mountProjectView()
await switchToLinksTab(wrapper)
const addBtn = wrapper.findAll('button').find(b => b.text().includes('Add Link'))
expect(addBtn).toBeDefined()
})
it('клик "+ Add Link" показывает форму добавления связи', async () => {
const wrapper = await mountProjectView()
await switchToLinksTab(wrapper)
const addBtn = wrapper.findAll('button').find(b => b.text().includes('Add Link') && !b.text().includes('Saving'))
// Ищем кнопку с "+" в тексте
const plusBtn = wrapper.findAll('button').find(b => b.text().includes('+') && b.text().includes('Link'))
await (plusBtn ?? addBtn)!.trigger('click')
await flushPromises()
expect(wrapper.text()).toContain('From (current project)')
expect(wrapper.text()).toContain('To project')
})
it('форма Add Link содержит from_project readonly и selects', async () => {
const wrapper = await mountProjectView()
await switchToLinksTab(wrapper)
const plusBtn = wrapper.findAll('button').find(b => b.text().includes('+') && b.text().includes('Link'))
await plusBtn!.trigger('click')
await flushPromises()
// from_project — disabled input с id 'KIN'
const disabledInputs = wrapper.findAll('input[disabled]')
const fromInput = disabledInputs.find(i => (i.element as HTMLInputElement).value === 'KIN')
expect(fromInput).toBeDefined()
// to_project и link_type — select элементы
const selects = wrapper.findAll('select')
expect(selects.length).toBeGreaterThanOrEqual(1)
})
it('форма link_type select содержит depends_on, triggers, related_to', async () => {
const wrapper = await mountProjectView()
await switchToLinksTab(wrapper)
const plusBtn = wrapper.findAll('button').find(b => b.text().includes('+') && b.text().includes('Link'))
await plusBtn!.trigger('click')
await flushPromises()
const selects = wrapper.findAll('select')
const linkTypeSelect = selects.find(s => {
const opts = s.findAll('option')
return opts.some(o => o.element.value === 'depends_on')
})
expect(linkTypeSelect).toBeDefined()
const optValues = linkTypeSelect!.findAll('option').map(o => o.element.value)
expect(optValues).toContain('depends_on')
expect(optValues).toContain('triggers')
expect(optValues).toContain('related_to')
})
it('Add Link вызывает api.createProjectLink с from_project=KIN и to_project', async () => {
vi.mocked(api.projects).mockResolvedValue([
{ ...BASE_PROJECT, id: 'BRS', name: 'Barsik' } as any,
])
const wrapper = await mountProjectView()
await switchToLinksTab(wrapper)
const plusBtn = wrapper.findAll('button').find(b => b.text().includes('+') && b.text().includes('Link'))
await plusBtn!.trigger('click')
await flushPromises()
// Выбираем to_project — select с опцией BRS
const selects = wrapper.findAll('select')
const toProjectSelect = selects.find(s => {
const opts = s.findAll('option')
return opts.some(o => o.text().includes('BRS'))
})
if (toProjectSelect) {
await toProjectSelect.setValue('BRS')
}
// Сабмитим форму
const form = wrapper.find('form')
await form.trigger('submit')
await flushPromises()
expect(vi.mocked(api.createProjectLink)).toHaveBeenCalledWith(expect.objectContaining({
from_project: 'KIN',
to_project: 'BRS',
}))
})
it('Delete вызывает api.deleteProjectLink с id связи', async () => {
const links = [
{ id: 7, from_project: 'KIN', to_project: 'BRS', link_type: 'depends_on', description: null, created_at: '2026-01-01' },
]
vi.mocked(api.projectLinks).mockResolvedValue(links as any)
vi.spyOn(window, 'confirm').mockReturnValue(true)
const wrapper = await mountProjectView()
await switchToLinksTab(wrapper)
// Кнопка удаления имеет class text-red-500
const deleteBtn = wrapper.find('button.text-red-500')
expect(deleteBtn.exists()).toBe(true)
await deleteBtn.trigger('click')
await flushPromises()
expect(vi.mocked(api.deleteProjectLink)).toHaveBeenCalledWith(7)
vi.restoreAllMocks()
})
})
// ─────────────────────────────────────────────────────────────
// 5. Граничные кейсы
// ─────────────────────────────────────────────────────────────
describe('Граничные кейсы', () => {
it('Deploy без dependents — секция "Зависимые проекты" не показывается', async () => {
const project = { ...BASE_PROJECT, deploy_host: 'srv', deploy_path: '/app', deploy_runtime: 'node' }
vi.mocked(api.deployProject).mockResolvedValue({
success: true,
exit_code: 0,
stdout: 'done',
stderr: '',
duration_seconds: 2,
} as any)
const wrapper = await mountProjectView(project)
const deployBtn = wrapper.findAll('button').find(b => b.text().includes('Deploy'))
await deployBtn!.trigger('click')
await flushPromises()
expect(wrapper.text()).not.toContain('Зависимые проекты')
})
it('пустой список links — нет ошибок в рендере', async () => {
vi.mocked(api.projectLinks).mockResolvedValue([])
const wrapper = await mountProjectView()
await switchToLinksTab(wrapper)
// Нет ошибок — компонент рендерится нормально
expect(wrapper.text()).toContain('Нет связей')
expect(wrapper.find('[class*="text-red"]').exists()).toBe(false)
})
it('overall_success=false с одним failed dependent → "Deploy failed" и dependent виден', async () => {
const project = { ...BASE_PROJECT, deploy_host: 'srv', deploy_path: '/app', deploy_runtime: 'docker' }
vi.mocked(api.deployProject).mockResolvedValue({
success: true,
exit_code: 0,
stdout: '',
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: [] },
],
overall_success: false,
} as any)
const wrapper = await mountProjectView(project)
const deployBtn = wrapper.findAll('button').find(b => b.text().includes('Deploy'))
await deployBtn!.trigger('click')
await flushPromises()
expect(wrapper.text()).toContain('Deploy failed')
expect(wrapper.text()).toContain('FrontendFailed')
})
it('только deploy_host без deploy_path/runtime — кнопка Deploy disabled', async () => {
const project = { ...BASE_PROJECT, deploy_host: 'myserver' }
const wrapper = await mountProjectView(project)
const deployBtn = wrapper.findAll('button').find(b => b.text().includes('Deploy'))
expect(deployBtn!.element.disabled).toBe(true)
})
it('все три structured deploy поля нужны — только host+path без runtime → disabled', async () => {
const project = { ...BASE_PROJECT, deploy_host: 'srv', deploy_path: '/app' }
const wrapper = await mountProjectView(project)
const deployBtn = wrapper.findAll('button').find(b => b.text().includes('Deploy'))
expect(deployBtn!.element.disabled).toBe(true)
})
})

View file

@ -1348,6 +1348,79 @@ async function addDecision() {
</div>
</div>
<!-- Links Tab -->
<div v-if="activeTab === 'links'">
<div class="flex items-center justify-between mb-3">
<span class="text-xs text-gray-500">Связи между проектами</span>
<button @click="showAddLink = true"
class="px-3 py-1 text-xs bg-gray-800 text-gray-300 border border-gray-700 rounded hover:bg-gray-700">
+ Add Link
</button>
</div>
<p v-if="linksLoading" class="text-gray-500 text-sm">Загрузка...</p>
<p v-else-if="linksError" class="text-red-400 text-sm">{{ linksError }}</p>
<div v-else-if="links.length === 0" class="text-gray-600 text-sm">Нет связей. Добавьте зависимости между проектами.</div>
<div v-else class="space-y-2">
<div v-for="link in links" :key="link.id"
class="px-4 py-3 border border-gray-800 rounded hover:border-gray-700 flex items-center justify-between gap-3">
<div class="flex items-center gap-2 text-sm flex-wrap">
<span class="text-gray-400 font-mono text-xs">{{ link.from_project }}</span>
<span class="text-gray-600">-></span>
<span class="text-gray-400 font-mono text-xs">{{ link.to_project }}</span>
<span class="px-1.5 py-0.5 text-[10px] bg-indigo-900/30 text-indigo-400 border border-indigo-800 rounded">{{ link.link_type }}</span>
<span v-if="link.description" class="text-gray-500 text-xs">{{ link.description }}</span>
</div>
<button @click="deleteLink(link.id)"
class="px-2 py-0.5 text-xs bg-gray-800 text-red-500 border border-gray-700 rounded hover:bg-red-950/30 hover:border-red-800 shrink-0">
x
</button>
</div>
</div>
<!-- Add Link Modal -->
<Modal v-if="showAddLink" title="Add Link" @close="showAddLink = false; linkForm = { to_project: '', link_type: 'depends_on', description: '' }; linkFormError = ''">
<form @submit.prevent="addLink" class="space-y-3">
<div>
<label class="block text-xs text-gray-500 mb-1">From (current project)</label>
<input :value="props.id" disabled
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-500 font-mono" />
</div>
<div>
<label class="block text-xs text-gray-500 mb-1">To project</label>
<select v-model="linkForm.to_project" required
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-300">
<option value=""> выберите проект </option>
<option v-for="p in allProjects.filter(p => p.id !== props.id)" :key="p.id" :value="p.id">{{ p.id }} {{ p.name }}</option>
</select>
</div>
<div>
<label class="block text-xs text-gray-500 mb-1">Link type</label>
<select v-model="linkForm.link_type"
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-300">
<option value="depends_on">depends_on</option>
<option value="triggers">triggers</option>
<option value="related_to">related_to</option>
</select>
</div>
<div>
<label class="block text-xs text-gray-500 mb-1">Description (optional)</label>
<input v-model="linkForm.description" placeholder="e.g. API used by frontend"
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600" />
</div>
<p v-if="linkFormError" class="text-red-400 text-xs">{{ linkFormError }}</p>
<div class="flex gap-2 justify-end">
<button type="button" @click="showAddLink = false; linkFormError = ''"
class="px-3 py-1.5 text-sm text-gray-400 hover:text-gray-200">Отмена</button>
<button type="submit" :disabled="linkSaving"
class="px-4 py-1.5 text-sm bg-blue-900/50 text-blue-400 border border-blue-800 rounded hover:bg-blue-900 disabled:opacity-50">
{{ linkSaving ? 'Saving...' : 'Add Link' }}
</button>
</div>
</form>
</Modal>
</div>
<!-- Add Task Modal -->
<Modal v-if="showAddTask" title="Add Task" @close="closeAddTaskModal">
<form @submit.prevent="addTask" class="space-y-3">

View file

@ -581,16 +581,55 @@ async function saveEdit() {
<!-- 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'">
:class="deployResult.overall_success !== false && deployResult.success ? 'border-teal-800 bg-teal-950/30 text-teal-300' : 'border-red-800 bg-red-950/30 text-red-300'">
<div class="flex items-center gap-2 mb-1">
<span :class="deployResult.success ? 'text-teal-400' : 'text-red-400'" class="font-semibold">
{{ deployResult.success ? 'Deploy succeeded' : 'Deploy failed' }}
<span :class="deployResult.overall_success !== false && deployResult.success ? 'text-teal-400' : 'text-red-400'" class="font-semibold">
{{ deployResult.overall_success !== false && deployResult.success ? 'Deploy succeeded' : 'Deploy failed' }}
</span>
<span class="text-gray-500">exit {{ deployResult.exit_code }} · {{ deployResult.duration_seconds }}s</span>
<button @click.stop="deployResult = null" class="ml-auto text-gray-600 hover:text-gray-400 bg-transparent border-none cursor-pointer text-xs"></button>
<span class="text-gray-500">{{ deployResult.duration_seconds }}s</span>
<button @click.stop="deployResult = null" class="ml-auto text-gray-600 hover:text-gray-400 bg-transparent border-none cursor-pointer text-xs">x</button>
</div>
<!-- Structured steps -->
<div v-if="deployResult.results?.length" class="space-y-1 mt-1">
<details v-for="step in deployResult.results" :key="step.step" class="border border-gray-700 rounded">
<summary class="flex items-center gap-2 px-2 py-1 cursor-pointer list-none">
<span :class="step.exit_code === 0 ? 'text-teal-400' : 'text-red-400'" class="font-semibold text-[10px]">{{ step.exit_code === 0 ? 'ok' : 'fail' }}</span>
<span class="text-gray-300 text-[11px]">{{ step.step }}</span>
<span class="text-gray-600 text-[10px] ml-auto">exit {{ step.exit_code }}</span>
</summary>
<div class="px-2 pb-2">
<pre v-if="step.stdout" class="whitespace-pre-wrap text-gray-300 max-h-32 overflow-y-auto text-[10px]">{{ step.stdout }}</pre>
<pre v-if="step.stderr" class="whitespace-pre-wrap text-red-400/80 max-h-32 overflow-y-auto text-[10px] mt-1">{{ step.stderr }}</pre>
</div>
</details>
</div>
<!-- Legacy output -->
<template v-else>
<pre v-if="deployResult.stdout" class="whitespace-pre-wrap text-gray-300 max-h-40 overflow-y-auto">{{ deployResult.stdout }}</pre>
<pre v-if="deployResult.stderr" class="whitespace-pre-wrap text-red-400/80 max-h-40 overflow-y-auto mt-1">{{ deployResult.stderr }}</pre>
</template>
<!-- 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>
<pre v-if="deployResult.stdout" class="whitespace-pre-wrap text-gray-300 max-h-40 overflow-y-auto">{{ deployResult.stdout }}</pre>
<pre v-if="deployResult.stderr" class="whitespace-pre-wrap text-red-400/80 max-h-40 overflow-y-auto mt-1">{{ deployResult.stderr }}</pre>
</div>
<!-- Approve Modal -->