/** * 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() 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: '
' } const localStorageMock = (() => { let store: Record = {} 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) { 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 = {}) { 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 в
', 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 рендерится через
', 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)
  })
})