691 lines
29 KiB
TypeScript
691 lines
29 KiB
TypeScript
/**
|
||
* 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)
|
||
})
|
||
})
|