kin: auto-commit after pipeline

This commit is contained in:
Gros Frumos 2026-03-18 15:22:17 +02:00
parent 12fed3e31f
commit 49ea6542b8
8 changed files with 720 additions and 35 deletions

View file

@ -0,0 +1,200 @@
/**
* KIN-120: Тесты ProjectView вкладка Settings
*
* Проверяет:
* 1. Вкладка Settings активируется при route.query.tab=settings
* 2. Вкладка Settings не показывается по умолчанию (tasks активен)
* 3. Форма настроек заполняется данными из проекта
* 4. Поля deploy_host, ssh_host присутствуют в Settings
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { createRouter, createMemoryHistory } from 'vue-router'
import ProjectView from '../ProjectView.vue'
vi.mock('../../api', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../api')>()
return {
...actual,
api: {
project: vi.fn(),
projects: vi.fn(),
getPhases: vi.fn(),
environments: vi.fn(),
projectLinks: vi.fn(),
patchProject: vi.fn(),
syncObsidian: vi.fn(),
},
}
})
import { api } from '../../api'
// localStorage mock (required: ProjectView calls localStorage synchronously in setup)
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 })
const BASE_PROJECT_DETAIL = {
id: 'proj-1',
name: 'Test Project',
path: '/projects/test',
status: 'active',
priority: 5,
tech_stack: ['python'],
execution_mode: 'review',
autocommit_enabled: 0,
auto_test_enabled: 0,
worktrees_enabled: 0,
obsidian_vault_path: '/vault/test',
deploy_command: 'git push',
test_command: 'make test',
deploy_host: 'vdp-prod',
deploy_path: '/srv/proj',
deploy_runtime: 'python',
deploy_restart_cmd: '',
created_at: '2024-01-01',
total_tasks: 0,
done_tasks: 0,
active_tasks: 0,
blocked_tasks: 0,
review_tasks: 0,
project_type: 'development',
ssh_host: 'my-ssh-server',
ssh_user: 'root',
ssh_key_path: '~/.ssh/id_rsa',
ssh_proxy_jump: 'jumpt',
description: null,
tasks: [],
modules: [],
decisions: [],
}
function makeRouter() {
return createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/project/:id', component: ProjectView, props: true },
],
})
}
beforeEach(() => {
localStorageMock.clear()
vi.clearAllMocks()
vi.mocked(api.project).mockResolvedValue(BASE_PROJECT_DETAIL as any)
vi.mocked(api.projects).mockResolvedValue([])
vi.mocked(api.getPhases).mockResolvedValue([])
vi.mocked(api.environments).mockResolvedValue([])
vi.mocked(api.projectLinks).mockResolvedValue([])
vi.mocked(api.patchProject).mockResolvedValue(BASE_PROJECT_DETAIL as any)
})
describe('ProjectView — вкладка Settings', () => {
it('вкладка settings активируется при route.query.tab=settings', async () => {
const router = makeRouter()
await router.push('/project/proj-1?tab=settings')
const wrapper = mount(ProjectView, {
props: { id: 'proj-1' },
global: { plugins: [router] },
})
await flushPromises()
// execution_mode select с опциями review/auto_complete — только в settings tab
const selects = wrapper.findAll('select')
const modeSelect = selects.find(s =>
s.findAll('option').some(o => o.attributes('value') === 'auto_complete')
)
expect(modeSelect?.exists()).toBe(true)
})
it('вкладка settings не открывается без query tab=settings', async () => {
const router = makeRouter()
await router.push('/project/proj-1')
const wrapper = mount(ProjectView, {
props: { id: 'proj-1' },
global: { plugins: [router] },
})
await flushPromises()
// settings form должна быть скрыта (tasks tab по умолчанию)
const selects = wrapper.findAll('select')
const modeSelect = selects.find(s =>
s.findAll('option').some(o => o.attributes('value') === 'auto_complete')
)
expect(modeSelect).toBeUndefined()
})
it('форма settings заполняется test_command из проекта', async () => {
const router = makeRouter()
await router.push('/project/proj-1?tab=settings')
const wrapper = mount(ProjectView, {
props: { id: 'proj-1' },
global: { plugins: [router] },
})
await flushPromises()
const testCommandInput = wrapper.find('input[placeholder="make test"]')
expect(testCommandInput.exists()).toBe(true)
expect((testCommandInput.element as HTMLInputElement).value).toBe('make test')
})
it('форма settings заполняется deploy_host из проекта', async () => {
const router = makeRouter()
await router.push('/project/proj-1?tab=settings')
const wrapper = mount(ProjectView, {
props: { id: 'proj-1' },
global: { plugins: [router] },
})
await flushPromises()
const deployHostInput = wrapper.find('input[placeholder="vdp-prod"]')
expect(deployHostInput.exists()).toBe(true)
expect((deployHostInput.element as HTMLInputElement).value).toBe('vdp-prod')
})
it('форма settings показывает и заполняет ssh_key_path из проекта', async () => {
const router = makeRouter()
await router.push('/project/proj-1?tab=settings')
const wrapper = mount(ProjectView, {
props: { id: 'proj-1' },
global: { plugins: [router] },
})
await flushPromises()
// ssh_key_path имеет уникальный placeholder, это надёжный способ найти SSH секцию
const sshKeyInput = wrapper.find('input[placeholder="~/.ssh/id_rsa"]')
expect(sshKeyInput.exists()).toBe(true)
expect((sshKeyInput.element as HTMLInputElement).value).toBe('~/.ssh/id_rsa')
})
it('форма settings заполняет execution_mode из проекта', async () => {
vi.mocked(api.project).mockResolvedValue({
...BASE_PROJECT_DETAIL,
execution_mode: 'auto_complete',
} as any)
const router = makeRouter()
await router.push('/project/proj-1?tab=settings')
const wrapper = mount(ProjectView, {
props: { id: 'proj-1' },
global: { plugins: [router] },
})
await flushPromises()
const selects = wrapper.findAll('select')
const modeSelect = selects.find(s =>
s.findAll('option').some(o => o.attributes('value') === 'auto_complete')
)
expect(modeSelect?.exists()).toBe(true)
expect((modeSelect!.element as HTMLSelectElement).value).toBe('auto_complete')
})
})

View file

@ -23,6 +23,8 @@ vi.mock('../../api', async (importOriginal) => {
...actual,
api: {
projects: vi.fn(),
projectLinks: vi.fn(),
patchProject: vi.fn(),
},
}
})
@ -73,6 +75,8 @@ function makeRouter() {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(api.projectLinks).mockResolvedValue([])
vi.mocked(api.patchProject).mockResolvedValue({} as any)
})
async function mountSettings(overrides: Partial<typeof BASE_PROJECT> = {}) {
@ -123,3 +127,76 @@ describe('SettingsView — навигатор', () => {
expect(wrapper.text()).not.toContain('auto_complete')
})
})
// --- KIN-120: Isolation and field presence tests ---
async function mountSettingsMultiple(projects: Partial<typeof BASE_PROJECT>[]) {
vi.mocked(api.projects).mockResolvedValue(projects as any[])
const router = makeRouter()
await router.push('/settings')
const wrapper = mount(SettingsView, { global: { plugins: [router] } })
await flushPromises()
return wrapper
}
describe('SettingsView — изоляция настроек проектов', () => {
it('obsidian_vault_path proj-1 и proj-2 независимы', async () => {
const proj1 = { ...BASE_PROJECT, id: 'proj-1', obsidian_vault_path: '/vault/proj1' }
const proj2 = { ...BASE_PROJECT, id: 'proj-2', name: 'Second Project', obsidian_vault_path: '/vault/proj2' }
const wrapper = await mountSettingsMultiple([proj1, proj2])
const inputs = wrapper.findAll('input[placeholder="/path/to/obsidian/vault"]')
expect(inputs).toHaveLength(2)
expect((inputs[0].element as HTMLInputElement).value).toBe('/vault/proj1')
expect((inputs[1].element as HTMLInputElement).value).toBe('/vault/proj2')
})
it('test_command proj-1 не перекрывает test_command proj-2', async () => {
const proj1 = { ...BASE_PROJECT, id: 'proj-1', test_command: 'make test' }
const proj2 = { ...BASE_PROJECT, id: 'proj-2', name: 'Second Project', test_command: 'npm test' }
const wrapper = await mountSettingsMultiple([proj1, proj2])
const inputs = wrapper.findAll('input[placeholder="make test"]')
expect(inputs).toHaveLength(2)
expect((inputs[0].element as HTMLInputElement).value).toBe('make test')
expect((inputs[1].element as HTMLInputElement).value).toBe('npm test')
})
it('deploy_host proj-1 не перекрывает deploy_host proj-2', async () => {
const proj1 = { ...BASE_PROJECT, id: 'proj-1', deploy_host: 'server-a' }
const proj2 = { ...BASE_PROJECT, id: 'proj-2', name: 'Second Project', deploy_host: 'server-b' }
const wrapper = await mountSettingsMultiple([proj1, proj2])
const inputs = wrapper.findAll('input[placeholder="server host (e.g. vdp-prod)"]')
expect(inputs).toHaveLength(2)
expect((inputs[0].element as HTMLInputElement).value).toBe('server-a')
expect((inputs[1].element as HTMLInputElement).value).toBe('server-b')
})
})
describe('SettingsView — наличие полей настроек', () => {
it('показывает поле obsidian_vault_path', async () => {
const wrapper = await mountSettings({ obsidian_vault_path: '/vault/test' })
const input = wrapper.find('input[placeholder="/path/to/obsidian/vault"]')
expect(input.exists()).toBe(true)
expect((input.element as HTMLInputElement).value).toBe('/vault/test')
})
it('показывает поле test_command с корректным значением', async () => {
const wrapper = await mountSettings({ test_command: 'pytest tests/' })
const input = wrapper.find('input[placeholder="make test"]')
expect(input.exists()).toBe(true)
expect((input.element as HTMLInputElement).value).toBe('pytest tests/')
})
it('показывает поле deploy_host', async () => {
const wrapper = await mountSettings({ deploy_host: 'my-server' })
const input = wrapper.find('input[placeholder="server host (e.g. vdp-prod)"]')
expect(input.exists()).toBe(true)
expect((input.element as HTMLInputElement).value).toBe('my-server')
})
it('показывает поле deploy_path', async () => {
const wrapper = await mountSettings({ deploy_path: '/srv/app' })
const input = wrapper.find('input[placeholder="/srv/myproject"]')
expect(input.exists()).toBe(true)
expect((input.element as HTMLInputElement).value).toBe('/srv/app')
})
})