From 49ea6542b8b3dae26eb6a3140144999569efc044 Mon Sep 17 00:00:00 2001 From: Gros Frumos Date: Wed, 18 Mar 2026 15:22:17 +0200 Subject: [PATCH] kin: auto-commit after pipeline --- web/frontend/src/i18n.ts | 4 +- web/frontend/src/locales/en.json | 20 +- web/frontend/src/locales/ru.json | 2 + web/frontend/src/views/ChatView.vue | 2 +- web/frontend/src/views/ProjectView.vue | 4 +- web/frontend/src/views/SettingsView.vue | 446 +++++++++++++++++- .../__tests__/ProjectView.settings.test.ts | 200 ++++++++ .../__tests__/SettingsView.worktrees.test.ts | 77 +++ 8 files changed, 720 insertions(+), 35 deletions(-) create mode 100644 web/frontend/src/views/__tests__/ProjectView.settings.test.ts diff --git a/web/frontend/src/i18n.ts b/web/frontend/src/i18n.ts index a3ed00c..a9dd2c8 100644 --- a/web/frontend/src/i18n.ts +++ b/web/frontend/src/i18n.ts @@ -2,7 +2,9 @@ import { createI18n } from 'vue-i18n' import ru from './locales/ru.json' import en from './locales/en.json' -const savedLocale = localStorage.getItem('kin-locale') || 'ru' +const savedLocale = (typeof localStorage !== 'undefined' && typeof localStorage.getItem === 'function' + ? localStorage.getItem('kin-locale') + : null) || 'en' export const i18n = createI18n({ legacy: false, diff --git a/web/frontend/src/locales/en.json b/web/frontend/src/locales/en.json index 0b2bbdb..2aee55f 100644 --- a/web/frontend/src/locales/en.json +++ b/web/frontend/src/locales/en.json @@ -89,7 +89,7 @@ "back_to_project": "← Project", "chat_label": "— chat", "loading": "Loading...", - "server_unavailable": "Server unavailable. Check your connection.", + "server_unavailable": "Сервер недоступен. Проверьте подключение.", "empty_hint": "Describe a task or ask about the project status", "input_placeholder": "Describe a task or question... (Enter — send, Shift+Enter — newline)", "send": "Send", @@ -159,10 +159,10 @@ "revise_placeholder": "What to revise or clarify...", "autopilot_active": "Autopilot active", "attachments": "Attachments", - "more_details": "↓ more details", + "more_details": "↓ подробнее", "terminal_login_hint": "Open a terminal and run:", "login_after_hint": "After login, retry the pipeline.", - "dependent_projects": "Dependent projects:", + "dependent_projects": "Зависимые проекты:", "decision_title_placeholder": "Decision title (optional)", "description_placeholder": "Description", "brief_label": "Brief", @@ -181,7 +181,8 @@ "kanban_tab": "Kanban", "links_tab": "Links", "add_task": "+ Task", - "audit_backlog": "Audit backlog", + "audit_backlog": "Аудит бэклога", + "kanban_add_task": "+ Тас", "back": "← back", "deploy": "Deploy", "kanban_pending": "Pending", @@ -196,7 +197,8 @@ "worktrees_on": "Worktrees: on", "worktrees_off": "Worktrees: off", "all_statuses": "All", - "search_placeholder": "Search tasks...", + "search_placeholder": "Поиск по задачам...", + "kanban_search_placeholder": "Поиск...", "manual_escalations_warn": "⚠ Require manual resolution", "comment_required": "Comment required", "select_project": "Select project", @@ -225,10 +227,10 @@ "dismiss": "Dismiss" }, "liveConsole": { - "hide_log": "▲ Hide log", - "show_log": "▼ Show log", - "no_records": "No records...", - "error_prefix": "Error:" + "hide_log": "▲ Скрыть лог", + "show_log": "▼ Показать лог", + "no_records": "Нет записей...", + "error_prefix": "Ошибка:" }, "attachments": { "images_only": "Only images are supported", diff --git a/web/frontend/src/locales/ru.json b/web/frontend/src/locales/ru.json index 1027a90..caca3a5 100644 --- a/web/frontend/src/locales/ru.json +++ b/web/frontend/src/locales/ru.json @@ -182,6 +182,7 @@ "links_tab": "Связи", "add_task": "+ Задача", "audit_backlog": "Аудит бэклога", + "kanban_add_task": "+ Тас", "back": "← назад", "deploy": "Деплой", "kanban_pending": "Ожидает", @@ -197,6 +198,7 @@ "worktrees_off": "Worktrees: выкл", "all_statuses": "Все", "search_placeholder": "Поиск по задачам...", + "kanban_search_placeholder": "Поиск...", "manual_escalations_warn": "⚠ Требуют ручного решения", "comment_required": "Комментарий обязателен", "select_project": "Выберите проект", diff --git a/web/frontend/src/views/ChatView.vue b/web/frontend/src/views/ChatView.vue index f4443a2..5007976 100644 --- a/web/frontend/src/views/ChatView.vue +++ b/web/frontend/src/views/ChatView.vue @@ -45,7 +45,7 @@ function checkAndPoll() { if (!hasRunningTasks(updated)) stopPoll() } catch (e: any) { consecutiveErrors.value++ - console.warn('[polling] error #' + consecutiveErrors.value + ':', e) + console.warn(`[polling] ошибка #${consecutiveErrors.value}:`, e) if (consecutiveErrors.value >= 3) { error.value = t('chat.server_unavailable') stopPoll() diff --git a/web/frontend/src/views/ProjectView.vue b/web/frontend/src/views/ProjectView.vue index 4765046..28bb0d0 100644 --- a/web/frontend/src/views/ProjectView.vue +++ b/web/frontend/src/views/ProjectView.vue @@ -1324,7 +1324,7 @@ async function addDecision() {
- @@ -1370,7 +1370,7 @@ async function addDecision() {
diff --git a/web/frontend/src/views/SettingsView.vue b/web/frontend/src/views/SettingsView.vue index 6c677b6..3b59070 100644 --- a/web/frontend/src/views/SettingsView.vue +++ b/web/frontend/src/views/SettingsView.vue @@ -1,48 +1,450 @@ diff --git a/web/frontend/src/views/__tests__/ProjectView.settings.test.ts b/web/frontend/src/views/__tests__/ProjectView.settings.test.ts new file mode 100644 index 0000000..ae59fd3 --- /dev/null +++ b/web/frontend/src/views/__tests__/ProjectView.settings.test.ts @@ -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() + 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 = {} + 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') + }) +}) diff --git a/web/frontend/src/views/__tests__/SettingsView.worktrees.test.ts b/web/frontend/src/views/__tests__/SettingsView.worktrees.test.ts index 4d3fa2e..1b7274b 100644 --- a/web/frontend/src/views/__tests__/SettingsView.worktrees.test.ts +++ b/web/frontend/src/views/__tests__/SettingsView.worktrees.test.ts @@ -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 = {}) { @@ -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[]) { + 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') + }) +})