Compare commits
3 commits
e3a286ef6f
...
49ea6542b8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
49ea6542b8 | ||
|
|
12fed3e31f | ||
|
|
a202210b9f |
8 changed files with 603 additions and 145 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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}",
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
200
web/frontend/src/views/__tests__/ProjectView.settings.test.ts
Normal file
200
web/frontend/src/views/__tests__/ProjectView.settings.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
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/')
|
||||
})
|
||||
|
||||
const payload = vi.mocked(api.patchProject).mock.calls[0][1] as any
|
||||
expect(payload.worktrees_enabled).toBe(false)
|
||||
expect(payload.worktrees_enabled).not.toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// 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('ошибка при выключении: 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_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')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue