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() {
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 @@
-
{{ t('settings.title') }}
-
{{ t('settings.navigate_hint') }}
+
{{ t('settings.title') }}
-
{{ t('common.loading') }}
-
{{ error }}
-
-
-
- {{ project.name }}
- {{ project.id }}
-
- {{ project.execution_mode }}
-
-
+ {{ error }}
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ t('settings.test_command_hint') }}
+
+
+
+
+
+ {{ saveTestStatus[project.id] }}
+
+
+
+
+
+
{{ t('settings.deploy_config') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ saveDeployConfigStatus[project.id] }}
+
+
+
+
+
+
+
+
{{ t('settings.project_links') }}
+
+
+
{{ t('settings.links_loading') }}
+
{{ linkError[project.id] }}
+
{{ t('settings.no_links') }}
+
+
+ {{ link.from_project }}
+ →
+ {{ link.to_project }}
+ {{ link.type }}
+ {{ link.description }}
+
+
+
+
+
+
+
+
+
+ {{ saveAutoTestStatus[project.id] }}
+
+
+
+
+
+
+ {{ saveWorktreesStatus[project.id] }}
+
+
+
+
+
+
+
+
+
+ {{ saveStatus[project.id] }}
+
+
+
+
+
Exported: {{ syncResults[project.id]!.exported_decisions }} decisions
+
Updated: {{ syncResults[project.id]!.tasks_updated }} tasks
+
+
+
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')
+ })
+})