kin: KIN-UI-012-frontend_dev
This commit is contained in:
parent
d64f5105f4
commit
e014c58709
5 changed files with 63 additions and 1034 deletions
|
|
@ -1,190 +1,37 @@
|
|||
/**
|
||||
* KIN-INFRA-012: Регрессионный тест — saveDeployConfig не теряет пустые строки
|
||||
* KIN-INFRA-012 / KIN-UI-012: Регрессия — формы deploy из SettingsView удалены
|
||||
*
|
||||
* Проверяет, что пустая строка в deploy-полях:
|
||||
* 1. Передаётся в patchProject как "" (не undefined)
|
||||
* 2. Переживает JSON.stringify — т.е. бэкенд получает ключ со значением ""
|
||||
* 3. Непустые значения тоже передаются корректно
|
||||
* saveDeployConfig и связанные deploy-поля перенесены в ProjectView → Settings tab.
|
||||
* SettingsView теперь только навигатор.
|
||||
*
|
||||
* До фикса: `deploy_host: deployHosts.value[id] || undefined` → пустая строка
|
||||
* превращалась в undefined и JSON.stringify отбрасывал поле. Бэкенд не получал
|
||||
* сигнал очистки. После фикса: пустая строка передаётся как-есть.
|
||||
* Проверяет, что в SettingsView.vue отсутствуют удалённые формы.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import SettingsView from '../views/SettingsView.vue'
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { readFileSync } from 'fs'
|
||||
import { resolve } from 'path'
|
||||
|
||||
vi.mock('../api', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../api')>()
|
||||
return {
|
||||
...actual,
|
||||
api: {
|
||||
projects: vi.fn(),
|
||||
patchProject: vi.fn(),
|
||||
syncObsidian: vi.fn(),
|
||||
},
|
||||
}
|
||||
})
|
||||
describe('SettingsView.vue — формы deploy удалены (KIN-UI-012)', () => {
|
||||
const filePath = resolve(__dirname, '../views/SettingsView.vue')
|
||||
const content = readFileSync(filePath, 'utf-8')
|
||||
|
||||
import { api } from '../api'
|
||||
|
||||
const BASE_PROJECT = {
|
||||
id: 'KIN',
|
||||
name: 'Kin',
|
||||
path: '/projects/kin',
|
||||
status: 'active',
|
||||
priority: 5,
|
||||
tech_stack: ['python'],
|
||||
execution_mode: null,
|
||||
autocommit_enabled: null,
|
||||
auto_test_enabled: null,
|
||||
obsidian_vault_path: null,
|
||||
deploy_command: null as string | null,
|
||||
test_command: null as string | null,
|
||||
deploy_host: null as string | null,
|
||||
deploy_path: null as string | null,
|
||||
deploy_runtime: null as string | null,
|
||||
deploy_restart_cmd: null as string | 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,
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(api.patchProject).mockResolvedValue(BASE_PROJECT as any)
|
||||
})
|
||||
|
||||
async function mountSettingsWithProject(overrides: Partial<typeof BASE_PROJECT> = {}) {
|
||||
const project = { ...BASE_PROJECT, ...overrides }
|
||||
vi.mocked(api.projects).mockResolvedValue([project as any])
|
||||
const wrapper = mount(SettingsView)
|
||||
await flushPromises()
|
||||
return wrapper
|
||||
}
|
||||
|
||||
async function clickSaveDeployConfig(wrapper: ReturnType<typeof mount>) {
|
||||
const saveBtn = wrapper.findAll('button').find(b => b.text().includes('Save Deploy Config'))
|
||||
expect(saveBtn).toBeDefined()
|
||||
await saveBtn!.trigger('click')
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// 1. Пустая строка передаётся как "" — не теряется как undefined
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
describe('saveDeployConfig — пустые строки сохраняются в payload', () => {
|
||||
it('deploy_host="" передаётся как "" когда поле пустое (не undefined)', async () => {
|
||||
// null в проекте → инициализируется как '' в компоненте → должно передаться как ""
|
||||
const wrapper = await mountSettingsWithProject({ deploy_host: null })
|
||||
await clickSaveDeployConfig(wrapper)
|
||||
|
||||
const callArgs = vi.mocked(api.patchProject).mock.calls[0]
|
||||
expect(callArgs[1]).toHaveProperty('deploy_host')
|
||||
expect((callArgs[1] as any).deploy_host).toBe('')
|
||||
it('не содержит saveDeployConfig (форма перенесена в ProjectView)', () => {
|
||||
expect(content).not.toContain('saveDeployConfig')
|
||||
})
|
||||
|
||||
it('deploy_path="" передаётся как "" когда поле пустое (не undefined)', async () => {
|
||||
const wrapper = await mountSettingsWithProject({ deploy_path: null })
|
||||
await clickSaveDeployConfig(wrapper)
|
||||
|
||||
const callArgs = vi.mocked(api.patchProject).mock.calls[0]
|
||||
expect(callArgs[1]).toHaveProperty('deploy_path')
|
||||
expect((callArgs[1] as any).deploy_path).toBe('')
|
||||
it('не содержит deployHosts (реактивный ref удалён)', () => {
|
||||
expect(content).not.toContain('deployHosts')
|
||||
})
|
||||
|
||||
it('deploy_runtime="" передаётся как "" когда поле пустое (не undefined)', async () => {
|
||||
const wrapper = await mountSettingsWithProject({ deploy_runtime: null })
|
||||
await clickSaveDeployConfig(wrapper)
|
||||
|
||||
const callArgs = vi.mocked(api.patchProject).mock.calls[0]
|
||||
expect(callArgs[1]).toHaveProperty('deploy_runtime')
|
||||
expect((callArgs[1] as any).deploy_runtime).toBe('')
|
||||
it('не содержит deploy_host input поле', () => {
|
||||
expect(content).not.toContain('server host (e.g. vdp-prod)')
|
||||
})
|
||||
|
||||
it('deploy_restart_cmd="" передаётся как "" когда поле пустое (не undefined)', async () => {
|
||||
const wrapper = await mountSettingsWithProject({ deploy_restart_cmd: null })
|
||||
await clickSaveDeployConfig(wrapper)
|
||||
it('не содержит test_command input поле', () => {
|
||||
expect(content).not.toContain('placeholder="make test"')
|
||||
})
|
||||
|
||||
const callArgs = vi.mocked(api.patchProject).mock.calls[0]
|
||||
expect(callArgs[1]).toHaveProperty('deploy_restart_cmd')
|
||||
expect((callArgs[1] as any).deploy_restart_cmd).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// 2. JSON.stringify не выбрасывает пустые строки — бэкенд их получит
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
describe('saveDeployConfig — пустые строки выживают JSON.stringify', () => {
|
||||
it('все 4 поля присутствуют в JSON когда все значения пустые', async () => {
|
||||
// Проект с null во всех deploy-полях → инициализируются как ''
|
||||
const wrapper = await mountSettingsWithProject()
|
||||
await clickSaveDeployConfig(wrapper)
|
||||
|
||||
const callArgs = vi.mocked(api.patchProject).mock.calls[0]
|
||||
const payload = callArgs[1] as Record<string, unknown>
|
||||
|
||||
// Сериализуем как настоящий PATCH-запрос
|
||||
const jsonBody = JSON.parse(JSON.stringify(payload))
|
||||
|
||||
expect(jsonBody).toHaveProperty('deploy_host', '')
|
||||
expect(jsonBody).toHaveProperty('deploy_path', '')
|
||||
expect(jsonBody).toHaveProperty('deploy_runtime', '')
|
||||
expect(jsonBody).toHaveProperty('deploy_restart_cmd', '')
|
||||
})
|
||||
|
||||
it('пустая строка выживает JSON.stringify (регрессия: undefined исчезает)', async () => {
|
||||
const wrapper = await mountSettingsWithProject({ deploy_host: null })
|
||||
await clickSaveDeployConfig(wrapper)
|
||||
|
||||
const callArgs = vi.mocked(api.patchProject).mock.calls[0]
|
||||
const payload = callArgs[1] as Record<string, unknown>
|
||||
const jsonBody = JSON.parse(JSON.stringify(payload))
|
||||
|
||||
// "" должна присутствовать в JSON как пустая строка
|
||||
// undefined здесь отсутствовало бы — это был бы баг
|
||||
expect(Object.keys(jsonBody)).toContain('deploy_host')
|
||||
expect(jsonBody.deploy_host).toBe('')
|
||||
expect(jsonBody.deploy_host).not.toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// 3. Непустые значения передаются корректно
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
describe('saveDeployConfig — непустые значения передаются корректно', () => {
|
||||
it('deploy_host с непустым значением передаётся без изменений', async () => {
|
||||
const wrapper = await mountSettingsWithProject({ deploy_host: 'prod.example.com' })
|
||||
await clickSaveDeployConfig(wrapper)
|
||||
|
||||
const callArgs = vi.mocked(api.patchProject).mock.calls[0]
|
||||
expect((callArgs[1] as any).deploy_host).toBe('prod.example.com')
|
||||
})
|
||||
|
||||
it('все 4 поля с непустыми значениями передаются корректно', async () => {
|
||||
const wrapper = await mountSettingsWithProject({
|
||||
deploy_host: 'prod.example.com',
|
||||
deploy_path: '/opt/kin',
|
||||
deploy_runtime: 'docker',
|
||||
deploy_restart_cmd: 'docker compose restart',
|
||||
})
|
||||
await clickSaveDeployConfig(wrapper)
|
||||
|
||||
const callArgs = vi.mocked(api.patchProject).mock.calls[0]
|
||||
const payload = callArgs[1] as any
|
||||
expect(payload.deploy_host).toBe('prod.example.com')
|
||||
expect(payload.deploy_path).toBe('/opt/kin')
|
||||
expect(payload.deploy_runtime).toBe('docker')
|
||||
expect(payload.deploy_restart_cmd).toBe('docker compose restart')
|
||||
it('не содержит obsidian_vault_path input поле', () => {
|
||||
expect(content).not.toContain('path/to/obsidian/vault')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -2,18 +2,19 @@
|
|||
* 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)
|
||||
* 1. ProjectView — Deploy кнопка (видимость, disabled, спиннер)
|
||||
* 2. ProjectView — Deploy результат (structured, legacy, dependents)
|
||||
* 3. ProjectView — Links таб (список, add link модал, delete link)
|
||||
* 4. Граничные кейсы (пустые links, deploy без dependents, overall_success=false)
|
||||
*
|
||||
* Примечание: тесты SettingsView (Deploy Config, Project Links) удалены (KIN-UI-012).
|
||||
* Эти формы перенесены в ProjectView → Settings tab.
|
||||
*/
|
||||
|
||||
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')>()
|
||||
|
|
@ -135,163 +136,7 @@ async function switchToLinksTab(wrapper: ReturnType<typeof mount>) {
|
|||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// 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')
|
||||
})
|
||||
})
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// 1b. SettingsView — Project Links
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
describe('SettingsView — Project Links', () => {
|
||||
it('link.type рендерится корректно в списке связей — не undefined (конвенция #527)', async () => {
|
||||
// Bug #527: шаблон использовал {{ link.link_type }} вместо {{ link.type }} → runtime undefined
|
||||
vi.mocked(api.projectLinks).mockResolvedValue([
|
||||
{ id: 1, from_project: 'KIN', to_project: 'BRS', type: 'triggers', description: null, created_at: '2026-01-01' },
|
||||
] as any)
|
||||
vi.mocked(api.projects).mockResolvedValue([BASE_PROJECT as any])
|
||||
const wrapper = mount(SettingsView)
|
||||
await flushPromises()
|
||||
|
||||
// Тип связи должен отображаться как строка, а не undefined
|
||||
expect(wrapper.text()).toContain('triggers')
|
||||
expect(wrapper.text()).not.toContain('undefined')
|
||||
})
|
||||
|
||||
it('addLink вызывает createProjectLink с полем type (не link_type) из формы', async () => {
|
||||
// Bug #527: addLink() использовал link_type вместо type при вызове api
|
||||
vi.mocked(api.projects).mockResolvedValue([
|
||||
{ ...BASE_PROJECT, id: 'BRS', name: 'Barsik' } as any,
|
||||
BASE_PROJECT as any,
|
||||
])
|
||||
vi.mocked(api.projectLinks).mockResolvedValue([])
|
||||
const wrapper = mount(SettingsView)
|
||||
await flushPromises()
|
||||
|
||||
// Открываем форму добавления связи для первого проекта
|
||||
const addBtns = wrapper.findAll('button').filter(b => b.text().includes('+ Add Link'))
|
||||
if (addBtns.length > 0) {
|
||||
await addBtns[0].trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
// Выбираем to_project (в SettingsView это select без api.projects — используем allProjectList)
|
||||
const selects = wrapper.findAll('select')
|
||||
const toProjectSelect = selects.find(s => s.findAll('option').some(o => o.element.value !== '' && o.element.value !== 'depends_on' && o.element.value !== 'triggers' && o.element.value !== 'related_to'))
|
||||
if (toProjectSelect) {
|
||||
const opts = toProjectSelect.findAll('option').filter(o => o.element.value !== '')
|
||||
if (opts.length > 0) await toProjectSelect.setValue(opts[0].element.value)
|
||||
}
|
||||
|
||||
const form = wrapper.find('form')
|
||||
await form.trigger('submit')
|
||||
await flushPromises()
|
||||
|
||||
if (vi.mocked(api.createProjectLink).mock.calls.length > 0) {
|
||||
const callArg = vi.mocked(api.createProjectLink).mock.calls[0][0]
|
||||
expect(callArg).toHaveProperty('type')
|
||||
expect(callArg).not.toHaveProperty('link_type')
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// 2. ProjectView — Deploy кнопка
|
||||
// ProjectView — Deploy кнопка
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
describe('ProjectView — Deploy кнопка', () => {
|
||||
it('кнопка Deploy присутствует в header проекта', async () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue