kin/web/frontend/src/__tests__/deploy-standardized.test.ts

691 lines
29 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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', 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', 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('type и description отображаются для каждой связи', async () => {
const links = [
{ id: 2, from_project: 'KIN', to_project: 'API', 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)
})
})