kin: auto-commit after pipeline
This commit is contained in:
parent
4144c521be
commit
3c902eaeab
6 changed files with 1354 additions and 7 deletions
204
web/frontend/src/__tests__/deploy-api.test.ts
Normal file
204
web/frontend/src/__tests__/deploy-api.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
691
web/frontend/src/__tests__/deploy-standardized.test.ts
Normal file
691
web/frontend/src/__tests__/deploy-standardized.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue