Compare commits

...

3 commits

Author SHA1 Message Date
Gros Frumos
49ea6542b8 kin: auto-commit after pipeline 2026-03-18 15:22:17 +02:00
Gros Frumos
12fed3e31f Merge branch 'KIN-120-frontend_dev' 2026-03-18 14:30:36 +02:00
Gros Frumos
a202210b9f kin: KIN-120-frontend_dev 2026-03-18 14:30:36 +02:00
8 changed files with 603 additions and 145 deletions

View file

@ -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,

View file

@ -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",
@ -97,6 +97,8 @@
},
"settings": {
"title": "Settings",
"navigate_hint": "Each project's settings are available in its own Settings tab.",
"open_settings": "Open Settings",
"obsidian_vault_path": "Obsidian Vault Path",
"test_command": "Test Command",
"test_command_hint": "Test run command, executed via shell in the project directory.",
@ -157,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",
@ -179,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",
@ -194,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",
@ -206,7 +210,14 @@
"loading_phases": "Loading phases...",
"revise_modal_title": "Revise phase",
"reject_modal_title": "Reject phase",
"add_link_title": "Add link"
"add_link_title": "Add link",
"settings_tab": "Settings",
"settings_agent_section": "Agent Execution",
"settings_deploy_section": "Deploy",
"settings_integrations_section": "Integrations",
"settings_execution_mode": "Execution mode",
"settings_autocommit": "Autocommit",
"settings_autocommit_hint": "— git commit after pipeline"
},
"escalation": {
"watchdog_blocked": "Watchdog: task {task_id} blocked — {reason}",
@ -216,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",

View file

@ -97,6 +97,8 @@
},
"settings": {
"title": "Настройки",
"navigate_hint": "Настройки каждого проекта доступны в его собственной вкладке «Настройки».",
"open_settings": "Открыть настройки",
"obsidian_vault_path": "Путь к Obsidian Vault",
"test_command": "Команда тестирования",
"test_command_hint": "Команда запуска тестов, выполняется через shell в директории проекта.",
@ -180,6 +182,7 @@
"links_tab": "Связи",
"add_task": "+ Задача",
"audit_backlog": "Аудит бэклога",
"kanban_add_task": "+ Тас",
"back": "← назад",
"deploy": "Деплой",
"kanban_pending": "Ожидает",
@ -195,6 +198,7 @@
"worktrees_off": "Worktrees: выкл",
"all_statuses": "Все",
"search_placeholder": "Поиск по задачам...",
"kanban_search_placeholder": "Поиск...",
"manual_escalations_warn": "⚠ Требуют ручного решения",
"comment_required": "Комментарий обязателен",
"select_project": "Выберите проект",
@ -206,7 +210,14 @@
"loading_phases": "Загрузка фаз...",
"revise_modal_title": "Доработать фазу",
"reject_modal_title": "Отклонить фазу",
"add_link_title": "Добавить связь"
"add_link_title": "Добавить связь",
"settings_tab": "Настройки",
"settings_agent_section": "Запуск агентов",
"settings_deploy_section": "Деплой",
"settings_integrations_section": "Интеграции",
"settings_execution_mode": "Режим выполнения",
"settings_autocommit": "Автокоммит",
"settings_autocommit_hint": "— git commit после pipeline"
},
"escalation": {
"watchdog_blocked": "Watchdog: задача {task_id} заблокирована — {reason}",

View file

@ -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()

View file

@ -2,7 +2,7 @@
import { ref, onMounted, onUnmounted, computed, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { api, ApiError, type ProjectDetail, type AuditResult, type Phase, type Task, type ProjectEnvironment, type DeployResult, type ProjectLink } from '../api'
import { api, ApiError, type ProjectDetail, type AuditResult, type Phase, type Task, type ProjectEnvironment, type DeployResult, type ProjectLink, type ObsidianSyncResult } from '../api'
import Badge from '../components/Badge.vue'
import Modal from '../components/Modal.vue'
@ -14,7 +14,7 @@ const { t } = useI18n()
const project = ref<ProjectDetail | null>(null)
const loading = ref(true)
const error = ref('')
const activeTab = ref<'tasks' | 'phases' | 'decisions' | 'modules' | 'kanban' | 'environments' | 'links'>('tasks')
const activeTab = ref<'tasks' | 'phases' | 'decisions' | 'modules' | 'kanban' | 'environments' | 'links' | 'settings'>('tasks')
// Phases
const phases = ref<Phase[]>([])
@ -276,6 +276,99 @@ async function toggleWorktrees() {
}
}
// Settings form
const settingsForm = ref({
execution_mode: 'review',
autocommit_enabled: false,
auto_test_enabled: false,
worktrees_enabled: false,
test_command: '',
deploy_host: '',
deploy_path: '',
deploy_runtime: '',
deploy_restart_cmd: '',
deploy_command: '',
obsidian_vault_path: '',
ssh_host: '',
ssh_user: '',
ssh_key_path: '',
ssh_proxy_jump: '',
})
const settingsSaving = ref(false)
const settingsSaveStatus = ref('')
const syncingObsidian = ref(false)
const obsidianSyncResult = ref<ObsidianSyncResult | null>(null)
function loadSettingsForm() {
if (!project.value) return
settingsForm.value = {
execution_mode: project.value.execution_mode ?? 'review',
autocommit_enabled: !!(project.value.autocommit_enabled),
auto_test_enabled: !!(project.value.auto_test_enabled),
worktrees_enabled: !!(project.value.worktrees_enabled),
test_command: project.value.test_command ?? '',
deploy_host: project.value.deploy_host ?? '',
deploy_path: project.value.deploy_path ?? '',
deploy_runtime: project.value.deploy_runtime ?? '',
deploy_restart_cmd: project.value.deploy_restart_cmd ?? '',
deploy_command: project.value.deploy_command ?? '',
obsidian_vault_path: project.value.obsidian_vault_path ?? '',
ssh_host: project.value.ssh_host ?? '',
ssh_user: project.value.ssh_user ?? '',
ssh_key_path: project.value.ssh_key_path ?? '',
ssh_proxy_jump: project.value.ssh_proxy_jump ?? '',
}
}
async function saveSettings() {
settingsSaving.value = true
settingsSaveStatus.value = ''
try {
const updated = await api.patchProject(props.id, {
execution_mode: settingsForm.value.execution_mode,
autocommit_enabled: settingsForm.value.autocommit_enabled,
auto_test_enabled: settingsForm.value.auto_test_enabled,
worktrees_enabled: settingsForm.value.worktrees_enabled,
test_command: settingsForm.value.test_command,
deploy_host: settingsForm.value.deploy_host,
deploy_path: settingsForm.value.deploy_path,
deploy_runtime: settingsForm.value.deploy_runtime,
deploy_restart_cmd: settingsForm.value.deploy_restart_cmd,
deploy_command: settingsForm.value.deploy_command,
obsidian_vault_path: settingsForm.value.obsidian_vault_path,
ssh_host: settingsForm.value.ssh_host,
ssh_user: settingsForm.value.ssh_user,
ssh_key_path: settingsForm.value.ssh_key_path,
ssh_proxy_jump: settingsForm.value.ssh_proxy_jump,
})
if (project.value) {
project.value = { ...project.value, ...updated }
loadMode()
loadAutocommit()
loadAutoTest()
loadWorktrees()
}
settingsSaveStatus.value = t('common.saved')
} catch (e: any) {
settingsSaveStatus.value = `${t('common.error')}: ${e.message}`
} finally {
settingsSaving.value = false
}
}
async function syncObsidianVault() {
syncingObsidian.value = true
obsidianSyncResult.value = null
try {
await api.patchProject(props.id, { obsidian_vault_path: settingsForm.value.obsidian_vault_path })
obsidianSyncResult.value = await api.syncObsidian(props.id)
} catch (e: any) {
settingsSaveStatus.value = `${t('common.error')}: ${e.message}`
} finally {
syncingObsidian.value = false
}
}
// Audit
const auditLoading = ref(false)
const auditResult = ref<AuditResult | null>(null)
@ -504,6 +597,7 @@ async function load() {
loadAutocommit()
loadAutoTest()
loadWorktrees()
loadSettingsForm()
} catch (e: any) {
error.value = e.message
} finally {
@ -534,6 +628,9 @@ onMounted(async () => {
const all = await api.projects()
allProjects.value = all.map(p => ({ id: p.id, name: p.name }))
} catch {}
if (route.query.tab === 'settings') {
activeTab.value = 'settings'
}
})
onUnmounted(() => {
@ -874,13 +971,13 @@ async function addDecision() {
<!-- Tabs -->
<div class="flex gap-1 mb-4 border-b border-gray-800 flex-wrap">
<button v-for="tab in (['tasks', 'phases', 'decisions', 'modules', 'kanban', 'environments', 'links'] as const)" :key="tab"
<button v-for="tab in (['tasks', 'phases', 'decisions', 'modules', 'kanban', 'environments', 'links', 'settings'] as const)" :key="tab"
@click="activeTab = tab"
class="px-4 py-2 text-sm border-b-2 transition-colors"
:class="activeTab === tab
? 'text-gray-200 border-blue-500'
: 'text-gray-500 border-transparent hover:text-gray-300'">
{{ tab === 'tasks' ? t('projectView.tasks_tab') : tab === 'phases' ? t('projectView.phases_tab') : tab === 'decisions' ? t('projectView.decisions_tab') : tab === 'modules' ? t('projectView.modules_tab') : tab === 'kanban' ? t('projectView.kanban_tab') : tab === 'environments' ? t('projectView.environments') : t('projectView.links_tab') }}
{{ tab === 'tasks' ? t('projectView.tasks_tab') : tab === 'phases' ? t('projectView.phases_tab') : tab === 'decisions' ? t('projectView.decisions_tab') : tab === 'modules' ? t('projectView.modules_tab') : tab === 'kanban' ? t('projectView.kanban_tab') : tab === 'environments' ? t('projectView.environments') : tab === 'links' ? t('projectView.links_tab') : t('projectView.settings_tab') }}
<span class="text-xs text-gray-600 ml-1">
{{ tab === 'tasks' ? project.tasks.length
: tab === 'phases' ? phases.length
@ -888,7 +985,8 @@ async function addDecision() {
: tab === 'modules' ? project.modules.length
: tab === 'environments' ? environments.length
: tab === 'links' ? links.length
: project.tasks.length }}
: tab === 'kanban' ? project.tasks.length
: '' }}
</span>
</button>
</div>
@ -1226,7 +1324,7 @@ async function addDecision() {
<div v-if="activeTab === 'kanban'" class="pb-4">
<div class="flex items-center justify-between gap-2 mb-3">
<div class="flex items-center gap-1">
<input v-model="taskSearch" :placeholder="t('projectView.search_placeholder')"
<input v-model="taskSearch" :placeholder="t('projectView.kanban_search_placeholder')"
class="bg-gray-800 border border-gray-700 rounded px-2 py-1 text-xs text-gray-300 placeholder-gray-600 w-48 focus:border-gray-500 outline-none" />
<button v-if="taskSearch" @click="taskSearch = ''"
class="text-gray-600 hover:text-red-400 text-xs px-1"></button>
@ -1272,7 +1370,7 @@ async function addDecision() {
</button>
<button @click="showAddTask = true"
class="px-3 py-1 text-xs bg-gray-800 text-gray-300 border border-gray-700 rounded hover:bg-gray-700">
{{ t('projectView.add_task') }}
{{ t('projectView.kanban_add_task') }}
</button>
</div>
</div>
@ -1444,6 +1542,138 @@ async function addDecision() {
</Modal>
</div>
<!-- Settings Tab -->
<div v-if="activeTab === 'settings'" class="space-y-6 max-w-2xl">
<!-- Agent Execution Section -->
<div>
<p class="text-xs font-semibold text-gray-400 uppercase tracking-wide mb-3">{{ t('projectView.settings_agent_section') }}</p>
<div class="space-y-3">
<div>
<label class="block text-xs text-gray-500 mb-1">{{ t('projectView.settings_execution_mode') }}</label>
<select v-model="settingsForm.execution_mode"
class="w-full bg-gray-900 border border-gray-700 rounded px-3 py-2 text-sm text-gray-300 focus:outline-none focus:border-gray-500">
<option value="review">review</option>
<option value="auto_complete">auto_complete</option>
</select>
</div>
<label class="flex items-center gap-2 cursor-pointer select-none">
<input type="checkbox" v-model="settingsForm.autocommit_enabled" class="w-4 h-4 rounded border-gray-600 bg-gray-800 accent-blue-500 cursor-pointer" />
<span class="text-sm text-gray-300">{{ t('projectView.settings_autocommit') }}</span>
<span class="text-xs text-gray-500">{{ t('projectView.settings_autocommit_hint') }}</span>
</label>
<label class="flex items-center gap-2 cursor-pointer select-none">
<input type="checkbox" v-model="settingsForm.auto_test_enabled" class="w-4 h-4 rounded border-gray-600 bg-gray-800 accent-blue-500 cursor-pointer" />
<span class="text-sm text-gray-300">{{ t('settings.auto_test') }}</span>
<span class="text-xs text-gray-500">{{ t('settings.auto_test_hint') }}</span>
</label>
<label class="flex items-center gap-2 cursor-pointer select-none">
<input type="checkbox" v-model="settingsForm.worktrees_enabled" class="w-4 h-4 rounded border-gray-600 bg-gray-800 accent-blue-500 cursor-pointer" />
<span class="text-sm text-gray-300">{{ t('settings.worktrees') }}</span>
<span class="text-xs text-gray-500">{{ t('settings.worktrees_hint') }}</span>
</label>
<div>
<label class="block text-xs text-gray-500 mb-1">{{ t('settings.test_command') }}</label>
<input v-model="settingsForm.test_command" type="text" placeholder="make test"
class="w-full bg-gray-900 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 font-mono focus:outline-none focus:border-gray-500" />
<p class="text-xs text-gray-600 mt-1">{{ t('settings.test_command_hint') }}</p>
</div>
</div>
</div>
<!-- Deploy Section -->
<div class="pt-4 border-t border-gray-800">
<p class="text-xs font-semibold text-gray-400 uppercase tracking-wide mb-3">{{ t('projectView.settings_deploy_section') }}</p>
<div class="space-y-3">
<div>
<label class="block text-xs text-gray-500 mb-1">{{ t('settings.server_host') }}</label>
<input v-model="settingsForm.deploy_host" type="text" placeholder="vdp-prod"
class="w-full bg-gray-900 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 font-mono focus:outline-none focus:border-gray-500" />
</div>
<div>
<label class="block text-xs text-gray-500 mb-1">{{ t('settings.project_path_on_server') }}</label>
<input v-model="settingsForm.deploy_path" type="text" placeholder="/srv/myproject"
class="w-full bg-gray-900 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 font-mono focus:outline-none focus:border-gray-500" />
</div>
<div>
<label class="block text-xs text-gray-500 mb-1">{{ t('settings.runtime') }}</label>
<select v-model="settingsForm.deploy_runtime"
class="w-full bg-gray-900 border border-gray-700 rounded px-3 py-2 text-sm text-gray-300 focus:outline-none focus:border-gray-500">
<option value="">{{ t('settings.select_runtime') }}</option>
<option value="docker">docker</option>
<option value="node">node</option>
<option value="python">python</option>
<option value="static">static</option>
</select>
</div>
<div>
<label class="block text-xs text-gray-500 mb-1">{{ t('settings.restart_command') }}</label>
<input v-model="settingsForm.deploy_restart_cmd" type="text" placeholder="optional override command"
class="w-full bg-gray-900 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 font-mono focus:outline-none focus:border-gray-500" />
</div>
<div>
<label class="block text-xs text-gray-500 mb-1">{{ t('settings.fallback_command') }}</label>
<input v-model="settingsForm.deploy_command" type="text" placeholder="git push origin main"
class="w-full bg-gray-900 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 font-mono focus:outline-none focus:border-gray-500" />
</div>
</div>
</div>
<!-- Integrations Section -->
<div class="pt-4 border-t border-gray-800">
<p class="text-xs font-semibold text-gray-400 uppercase tracking-wide mb-3">{{ t('projectView.settings_integrations_section') }}</p>
<div class="space-y-3">
<div>
<label class="block text-xs text-gray-500 mb-1">{{ t('settings.obsidian_vault_path') }}</label>
<div class="flex gap-2">
<input v-model="settingsForm.obsidian_vault_path" type="text" placeholder="/path/to/obsidian/vault"
class="flex-1 bg-gray-900 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 font-mono focus:outline-none focus:border-gray-500" />
<button @click="syncObsidianVault" :disabled="syncingObsidian || !settingsForm.obsidian_vault_path"
class="px-3 py-1.5 text-sm bg-indigo-900/50 text-indigo-400 border border-indigo-800 rounded hover:bg-indigo-900 disabled:opacity-50">
{{ syncingObsidian ? t('settings.syncing') : t('settings.sync_obsidian') }}
</button>
</div>
<div v-if="obsidianSyncResult" class="mt-2 p-2 bg-gray-900 rounded text-xs text-gray-300 flex gap-3">
<span>{{ obsidianSyncResult.exported_decisions }} decisions</span>
<span>{{ obsidianSyncResult.tasks_updated }} tasks</span>
</div>
</div>
<div>
<label class="block text-xs text-gray-500 mb-1">SSH Host</label>
<input v-model="settingsForm.ssh_host" type="text" placeholder="vdp-prod"
class="w-full bg-gray-900 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 font-mono focus:outline-none focus:border-gray-500" />
</div>
<div>
<label class="block text-xs text-gray-500 mb-1">SSH User</label>
<input v-model="settingsForm.ssh_user" type="text" placeholder="root"
class="w-full bg-gray-900 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 font-mono focus:outline-none focus:border-gray-500" />
</div>
<div>
<label class="block text-xs text-gray-500 mb-1">SSH Key Path</label>
<input v-model="settingsForm.ssh_key_path" type="text" placeholder="~/.ssh/id_rsa"
class="w-full bg-gray-900 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 font-mono focus:outline-none focus:border-gray-500" />
</div>
<div>
<label class="block text-xs text-gray-500 mb-1">SSH ProxyJump</label>
<input v-model="settingsForm.ssh_proxy_jump" type="text" placeholder="jumpt"
class="w-full bg-gray-900 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 font-mono focus:outline-none focus:border-gray-500" />
</div>
</div>
</div>
<!-- Save Button -->
<div class="flex items-center gap-3 pt-4 border-t border-gray-800">
<button @click="saveSettings" :disabled="settingsSaving"
class="px-4 py-2 text-sm bg-blue-900/50 text-blue-400 border border-blue-800 rounded hover:bg-blue-900 disabled:opacity-50">
{{ settingsSaving ? t('common.saving') : t('common.save') }}
</button>
<span v-if="settingsSaveStatus" class="text-xs"
:class="settingsSaveStatus.startsWith(t('common.error')) ? 'text-red-400' : 'text-green-400'">
{{ settingsSaveStatus }}
</span>
</div>
</div>
<!-- Add Task Modal -->
<Modal v-if="showAddTask" title="Add Task" @close="closeAddTaskModal">
<form @submit.prevent="addTask" class="space-y-3">

View file

@ -206,6 +206,11 @@ async function deleteLink(projectId: string, linkId: number) {
<div class="flex items-center gap-3 mb-3">
<span class="font-medium text-gray-100">{{ project.name }}</span>
<span class="text-xs text-gray-500 font-mono">{{ project.id }}</span>
<span v-if="project.execution_mode" class="text-xs text-gray-500 shrink-0">{{ project.execution_mode }}</span>
<a :href="'/project/' + project.id + '?tab=settings'"
class="ml-auto px-3 py-1 text-xs bg-gray-800 text-gray-300 border border-gray-700 rounded hover:bg-gray-700 no-underline shrink-0">
{{ t('settings.open_settings') }}
</a>
</div>
<div class="mb-3">

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

@ -1,18 +1,20 @@
/**
* KIN-103: Тесты worktrees_enabled toggle в SettingsView
* KIN-120: Тесты SettingsView навигатор по настройкам проектов
*
* После рефакторинга SettingsView стал навигатором:
* показывает список проектов и ссылки на /project/{id}?tab=settings.
* Детальные настройки каждого проекта переехали в ProjectView вкладка Settings.
*
* Проверяет:
* 1. Интерфейс Project содержит worktrees_enabled: number | null
* 2. patchProject принимает worktrees_enabled?: boolean
* 3. Инициализацию из числовых значений (0, 1, null) !!()
* 4. Toggle вызывает PATCH с true/false
* 5. Откат при ошибке PATCH
* 6. Checkbox disabled пока идёт сохранение
* 7. Статусные сообщения (Saved / Error)
* 1. Загрузка и отображение списка проектов
* 2. Имя и id проекта видны
* 3. Ссылки ведут на /project/{id}?tab=settings
* 4. execution_mode отображается если задан
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { createRouter, createMemoryHistory } from 'vue-router'
import SettingsView from '../SettingsView.vue'
vi.mock('../../api', async (importOriginal) => {
@ -21,9 +23,8 @@ vi.mock('../../api', async (importOriginal) => {
...actual,
api: {
projects: vi.fn(),
projectLinks: vi.fn(),
patchProject: vi.fn(),
syncObsidian: vi.fn(),
projectLinks: vi.fn().mockResolvedValue([]),
},
}
})
@ -37,7 +38,7 @@ const BASE_PROJECT = {
status: 'active',
priority: 5,
tech_stack: ['python'],
execution_mode: null,
execution_mode: null as string | null,
autocommit_enabled: null,
auto_test_enabled: null,
worktrees_enabled: null as number | null,
@ -62,142 +63,140 @@ const BASE_PROJECT = {
description: null,
}
function makeRouter() {
return createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/settings', component: SettingsView },
{ path: '/project/:id', component: { template: '<div />' } },
],
})
}
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(api.patchProject).mockResolvedValue(BASE_PROJECT as any)
vi.mocked(api.projectLinks).mockResolvedValue([])
vi.mocked(api.patchProject).mockResolvedValue({} as any)
})
async function mountSettings(overrides: Partial<typeof BASE_PROJECT> = {}) {
const project = { ...BASE_PROJECT, ...overrides }
vi.mocked(api.projects).mockResolvedValue([project as any])
const wrapper = mount(SettingsView)
const router = makeRouter()
await router.push('/settings')
const wrapper = mount(SettingsView, { global: { plugins: [router] } })
await flushPromises()
return wrapper
}
function findWorktreesCheckbox(wrapper: ReturnType<typeof mount>) {
const labels = wrapper.findAll('label')
const worktreesLabel = labels.find(l => l.text().includes('Worktrees'))
const checkbox = worktreesLabel?.find('input[type="checkbox"]')
// decision #544: assertion безусловная — ложный зелёный недопустим
expect(checkbox?.exists()).toBe(true)
return checkbox!
describe('SettingsView — навигатор', () => {
it('показывает имя проекта', async () => {
const wrapper = await mountSettings()
expect(wrapper.text()).toContain('Test Project')
})
it('показывает id проекта', async () => {
const wrapper = await mountSettings()
expect(wrapper.text()).toContain('proj-1')
})
it('содержит ссылку на страницу настроек проекта', async () => {
const wrapper = await mountSettings()
const links = wrapper.findAll('a')
expect(links.length).toBeGreaterThan(0)
const settingsLink = links.find(l => l.attributes('href')?.includes('proj-1'))
expect(settingsLink?.exists()).toBe(true)
expect(settingsLink?.attributes('href')).toContain('settings')
})
it('ссылка ведёт на /project/{id} с tab=settings', async () => {
const wrapper = await mountSettings()
const link = wrapper.find('a[href*="proj-1"]')
expect(link.exists()).toBe(true)
expect(link.attributes('href')).toMatch(/\/project\/proj-1/)
expect(link.attributes('href')).toContain('settings')
})
it('показывает execution_mode если задан', async () => {
const wrapper = await mountSettings({ execution_mode: 'auto_complete' })
expect(wrapper.text()).toContain('auto_complete')
})
it('не показывает execution_mode если null', async () => {
const wrapper = await mountSettings({ execution_mode: null })
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
}
// ─────────────────────────────────────────────────────────────
// 1. Инициализация из числовых значений
// ─────────────────────────────────────────────────────────────
describe('worktreesEnabled — инициализация', () => {
it('worktrees_enabled=1 → checkbox checked', async () => {
const wrapper = await mountSettings({ worktrees_enabled: 1 })
const checkbox = findWorktreesCheckbox(wrapper)
expect((checkbox.element as HTMLInputElement).checked).toBe(true)
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('worktrees_enabled=0 → checkbox unchecked', async () => {
const wrapper = await mountSettings({ worktrees_enabled: 0 })
const checkbox = findWorktreesCheckbox(wrapper)
expect((checkbox.element as HTMLInputElement).checked).toBe(false)
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('worktrees_enabled=null → checkbox unchecked', async () => {
const wrapper = await mountSettings({ worktrees_enabled: null })
const checkbox = findWorktreesCheckbox(wrapper)
expect((checkbox.element as HTMLInputElement).checked).toBe(false)
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')
})
})
// ─────────────────────────────────────────────────────────────
// 2. Toggle → patchProject вызывается с корректным значением
// ─────────────────────────────────────────────────────────────
describe('toggleWorktrees — вызов patchProject', () => {
it('toggle с unchecked → patchProject({ worktrees_enabled: true })', async () => {
const wrapper = await mountSettings({ worktrees_enabled: 0 })
await findWorktreesCheckbox(wrapper).trigger('change')
await flushPromises()
expect(vi.mocked(api.patchProject)).toHaveBeenCalledWith(
'proj-1',
expect.objectContaining({ worktrees_enabled: true }),
)
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('toggle с checked → patchProject({ worktrees_enabled: false }), не undefined (decision #524)', async () => {
const wrapper = await mountSettings({ worktrees_enabled: 1 })
await findWorktreesCheckbox(wrapper).trigger('change')
await flushPromises()
const payload = vi.mocked(api.patchProject).mock.calls[0][1] as any
expect(payload.worktrees_enabled).toBe(false)
expect(payload.worktrees_enabled).not.toBeUndefined()
})
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/')
})
// ─────────────────────────────────────────────────────────────
// 3. Откат при ошибке PATCH
// ─────────────────────────────────────────────────────────────
describe('toggleWorktrees — откат при ошибке PATCH', () => {
it('ошибка при включении: checkbox откатывается обратно к unchecked', async () => {
vi.mocked(api.patchProject).mockRejectedValueOnce(new Error('network error'))
const wrapper = await mountSettings({ worktrees_enabled: 0 })
await findWorktreesCheckbox(wrapper).trigger('change')
await flushPromises()
expect((findWorktreesCheckbox(wrapper).element as HTMLInputElement).checked).toBe(false)
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('ошибка при выключении: checkbox откатывается обратно к checked', async () => {
vi.mocked(api.patchProject).mockRejectedValueOnce(new Error('server error'))
const wrapper = await mountSettings({ worktrees_enabled: 1 })
await findWorktreesCheckbox(wrapper).trigger('change')
await flushPromises()
expect((findWorktreesCheckbox(wrapper).element as HTMLInputElement).checked).toBe(true)
})
})
// ─────────────────────────────────────────────────────────────
// 4. Disabled во время сохранения
// ─────────────────────────────────────────────────────────────
describe('toggleWorktrees — disabled во время сохранения', () => {
it('checkbox disabled пока идёт PATCH, enabled после завершения', async () => {
let resolveRequest!: (v: any) => void
vi.mocked(api.patchProject).mockImplementationOnce(
() => new Promise(resolve => { resolveRequest = resolve }),
)
const wrapper = await mountSettings({ worktrees_enabled: 0 })
const checkbox = findWorktreesCheckbox(wrapper)
await checkbox.trigger('change')
// savingWorktrees = true → checkbox должен быть disabled
expect((checkbox.element as HTMLInputElement).disabled).toBe(true)
resolveRequest(BASE_PROJECT)
await flushPromises()
expect((checkbox.element as HTMLInputElement).disabled).toBe(false)
})
})
// ─────────────────────────────────────────────────────────────
// 5. Статусные сообщения
// ─────────────────────────────────────────────────────────────
describe('toggleWorktrees — статусное сообщение', () => {
it('успех → показывает "Saved"', async () => {
const wrapper = await mountSettings({ worktrees_enabled: 0 })
await findWorktreesCheckbox(wrapper).trigger('change')
await flushPromises()
expect(wrapper.text()).toContain('Saved')
})
it('ошибка → показывает "Error: <message>"', async () => {
vi.mocked(api.patchProject).mockRejectedValueOnce(new Error('connection timeout'))
const wrapper = await mountSettings({ worktrees_enabled: 0 })
await findWorktreesCheckbox(wrapper).trigger('change')
await flushPromises()
expect(wrapper.text()).toContain('Error: connection timeout')
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')
})
})