2026-03-17 17:53:14 +02:00
|
|
|
|
/**
|
|
|
|
|
|
* 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({
|
2026-03-17 18:24:02 +02:00
|
|
|
|
id: 1, from_project: 'KIN', to_project: 'BRS', type: 'depends_on', description: null, created_at: '2026-01-01',
|
2026-03-17 17:53:14 +02:00
|
|
|
|
} 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 = [
|
2026-03-17 18:24:02 +02:00
|
|
|
|
{ id: 1, from_project: 'KIN', to_project: 'BRS', type: 'depends_on', description: 'test', created_at: '2026-01-01' },
|
2026-03-17 17:53:14 +02:00
|
|
|
|
]
|
|
|
|
|
|
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')
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-03-17 18:24:02 +02:00
|
|
|
|
it('type и description отображаются для каждой связи', async () => {
|
2026-03-17 17:53:14 +02:00
|
|
|
|
const links = [
|
2026-03-17 18:24:02 +02:00
|
|
|
|
{ id: 2, from_project: 'KIN', to_project: 'API', type: 'triggers', description: 'API call', created_at: '2026-01-01' },
|
2026-03-17 17:53:14 +02:00
|
|
|
|
]
|
|
|
|
|
|
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)
|
|
|
|
|
|
})
|
|
|
|
|
|
})
|