diff --git a/tests/test_kin_096_regression.py b/tests/test_kin_096_regression.py new file mode 100644 index 0000000..40b5c82 --- /dev/null +++ b/tests/test_kin_096_regression.py @@ -0,0 +1,177 @@ +""" +Regression tests for KIN-096: + Pipeline из веб-интерфейса не передаёт SSH_AUTH_SOCK в subprocess агентов. + +Root causes fixed: + (1) agents/runner.py _build_claude_env() — уже содержит glob-детекцию + SSH_AUTH_SOCK из /private/tmp/com.apple.launchd.*/Listeners. + (2) agents/runner.py _build_claude_env() — SSH_AGENT_PID detection (строки 74-77) + был логическим no-op (env = os.environ.copy(), поэтому проверка + 'SSH_AGENT_PID not in env' == 'SSH_AGENT_PID not in os.environ'). + (3) web/api.py _launch_pipeline_subprocess() — должна добавить аналогичную + glob-детекцию SSH_AUTH_SOCK, чтобы промежуточный subprocess получал + SSH env при старте из launchd. + +Acceptance criteria: + - При наличии SSH_AUTH_SOCK в os.environ subprocess получает его в env. + - При отсутствии SSH_AUTH_SOCK и наличии macOS-сокета — детектируется через glob. + - При отсутствии SSH_AUTH_SOCK и пустом glob — subprocess стартует без него (graceful). +""" + +import os +from unittest.mock import patch, MagicMock + +import pytest + +from agents.runner import _build_claude_env + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _make_popen_mock(): + proc = MagicMock() + proc.pid = 12345 + return proc + + +# --------------------------------------------------------------------------- +# _build_claude_env: SSH_AUTH_SOCK detection (agents/runner.py) +# --------------------------------------------------------------------------- + +class TestBuildClaudeEnvSSH: + """Тесты SSH env в _build_claude_env (agents/runner.py).""" + + def test_ssh_auth_sock_preserved_when_present_in_environ(self): + """Если SSH_AUTH_SOCK уже есть в os.environ — он попадает в env без изменений.""" + sock_path = "/tmp/ssh-agent-test.sock" + with patch.dict("os.environ", {"SSH_AUTH_SOCK": sock_path}, clear=False): + env = _build_claude_env() + assert env.get("SSH_AUTH_SOCK") == sock_path + + def test_ssh_auth_sock_detected_via_glob_when_absent(self): + """Если SSH_AUTH_SOCK отсутствует в os.environ — детектируется через glob. + + Примечание: в runner.py glob импортируется локально внутри if-блока, + поэтому патчим glob.glob напрямую, а не agents.runner.glob. + """ + fake_sock = "/private/tmp/com.apple.launchd.ABCDEF/Listeners" + env_without_sock = {k: v for k, v in os.environ.items() if k != "SSH_AUTH_SOCK"} + with patch.dict("os.environ", env_without_sock, clear=True): + with patch("glob.glob", return_value=[fake_sock]) as mock_glob: + env = _build_claude_env() + assert env.get("SSH_AUTH_SOCK") == fake_sock + mock_glob.assert_called_with("/private/tmp/com.apple.launchd.*/Listeners") + + def test_ssh_auth_sock_absent_when_glob_finds_nothing(self): + """Graceful degradation: если glob ничего не нашёл — SSH_AUTH_SOCK не устанавливается.""" + env_without_sock = {k: v for k, v in os.environ.items() if k != "SSH_AUTH_SOCK"} + with patch.dict("os.environ", env_without_sock, clear=True): + with patch("glob.glob", return_value=[]): + env = _build_claude_env() + assert "SSH_AUTH_SOCK" not in env + + def test_ssh_agent_pid_is_inherited_via_environ_copy(self): + """SSH_AGENT_PID попадает в env через os.environ.copy() — без специального кода. + + Проверяет что корректный путь работает: env=os.environ.copy() + автоматически включает SSH_AGENT_PID, если он был в окружении. + """ + with patch.dict("os.environ", {"SSH_AGENT_PID": "9999"}, clear=False): + env = _build_claude_env() + assert env.get("SSH_AGENT_PID") == "9999" + + def test_ssh_agent_pid_absent_when_not_in_environ(self): + """Если SSH_AGENT_PID нет в os.environ, он не появляется в env.""" + env_without_pid = {k: v for k, v in os.environ.items() if k != "SSH_AGENT_PID"} + with patch.dict("os.environ", env_without_pid, clear=True): + with patch("glob.glob", return_value=[]): + env = _build_claude_env() + assert "SSH_AGENT_PID" not in env + + +# --------------------------------------------------------------------------- +# _launch_pipeline_subprocess: SSH_AUTH_SOCK передаётся в Popen (web/api.py) +# --------------------------------------------------------------------------- + +class TestLaunchPipelineSubprocessSSH: + """Тесты передачи SSH-переменных в subprocess из _launch_pipeline_subprocess.""" + + def test_ssh_auth_sock_passed_to_subprocess_when_in_environ(self): + """KIN-096: SSH_AUTH_SOCK из os.environ попадает в env Popen через copy().""" + from web.api import _launch_pipeline_subprocess + + sock_path = "/tmp/ssh-TEST-known.sock" + with patch.dict("os.environ", {"SSH_AUTH_SOCK": sock_path}, clear=False): + with patch("web.api.subprocess.Popen", return_value=_make_popen_mock()) as mock_popen: + _launch_pipeline_subprocess("P1-001") + + assert mock_popen.called + kwargs = mock_popen.call_args[1] + env_passed = kwargs.get("env", {}) + assert env_passed.get("SSH_AUTH_SOCK") == sock_path, ( + "KIN-096: SSH_AUTH_SOCK должен передаваться в Popen env через os.environ.copy()" + ) + + def test_kin_noninteractive_always_set_in_subprocess_env(self): + """KIN_NONINTERACTIVE=1 всегда присутствует в env Popen — независимо от SSH.""" + from web.api import _launch_pipeline_subprocess + + with patch("web.api.subprocess.Popen", return_value=_make_popen_mock()) as mock_popen: + _launch_pipeline_subprocess("P1-001") + + kwargs = mock_popen.call_args[1] + env_passed = kwargs.get("env", {}) + assert env_passed.get("KIN_NONINTERACTIVE") == "1" + + def test_popen_failure_does_not_propagate_exception(self): + """Если Popen бросает исключение, _launch_pipeline_subprocess не пробрасывает его.""" + from web.api import _launch_pipeline_subprocess + + with patch("web.api.subprocess.Popen", side_effect=OSError("no such file")): + # не должно бросать + _launch_pipeline_subprocess("P1-001") + + def test_subprocess_launched_without_ssh_auth_sock_does_not_raise(self): + """Graceful degradation: при отсутствии SSH_AUTH_SOCK Popen вызывается без ошибок. + + Тест воспроизводит сценарий launchd-среды без SSH_AUTH_SOCK. + После применения фикса (glob-детекция в _launch_pipeline_subprocess) + macOS-сокет будет добавлен; до применения — subprocess стартует без него. + В обоих случаях исключений быть не должно. + """ + from web.api import _launch_pipeline_subprocess + + env_without_sock = {k: v for k, v in os.environ.items() if k != "SSH_AUTH_SOCK"} + with patch.dict("os.environ", env_without_sock, clear=True): + with patch("web.api.subprocess.Popen", return_value=_make_popen_mock()) as mock_popen: + _launch_pipeline_subprocess("P1-001") + + assert mock_popen.called, "Popen должен быть вызван даже без SSH_AUTH_SOCK в окружении" + + def test_ssh_auth_sock_detected_via_glob_in_launch_subprocess(self): + """KIN-096 FIX VERIFICATION: glob-детекция SSH_AUTH_SOCK в _launch_pipeline_subprocess. + + После применения фикса (добавление import glob + glob.glob детекции в + _launch_pipeline_subprocess), SSH_AUTH_SOCK должен определяться + автоматически из macOS launchd-сокета. + + ВНИМАНИЕ: этот тест проверяет фикс, который ещё НЕ применён в web/api.py. + При текущем коде (без фикса) — тест упадёт, т.к. glob-детекция отсутствует. + """ + from web.api import _launch_pipeline_subprocess + + fake_sock = "/private/tmp/com.apple.launchd.XYZ123/Listeners" + env_without_sock = {k: v for k, v in os.environ.items() if k != "SSH_AUTH_SOCK"} + with patch.dict("os.environ", env_without_sock, clear=True): + with patch("glob.glob", return_value=[fake_sock]): + with patch("web.api.subprocess.Popen", return_value=_make_popen_mock()) as mock_popen: + _launch_pipeline_subprocess("P1-001") + + kwargs = mock_popen.call_args[1] + env_passed = kwargs.get("env", {}) + assert env_passed.get("SSH_AUTH_SOCK") == fake_sock, ( + "KIN-096: после фикса SSH_AUTH_SOCK должен детектироваться через glob " + "в _launch_pipeline_subprocess когда его нет в os.environ" + ) diff --git a/web/frontend/src/__tests__/blocked-reason-list.test.ts b/web/frontend/src/__tests__/blocked-reason-list.test.ts new file mode 100644 index 0000000..a041e70 --- /dev/null +++ b/web/frontend/src/__tests__/blocked-reason-list.test.ts @@ -0,0 +1,225 @@ +/** + * KIN-099: blocked_reason в списке задач (ProjectView) + * + * Проверяет: + * 1. Задача со статусом 'blocked' и blocked_reason — reason отображается в карточке + * 2. Задача со статусом 'blocked' без blocked_reason — дополнительный текст НЕ отображается + * 3. Задача с другим статусом — blocked_reason НЕ отображается + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { mount, flushPromises } from '@vue/test-utils' +import { createRouter, createMemoryHistory } from 'vue-router' +import ProjectView from '../views/ProjectView.vue' + +vi.mock('../api', () => ({ + api: { + project: vi.fn(), + taskFull: vi.fn(), + runTask: vi.fn(), + auditProject: vi.fn(), + createTask: vi.fn(), + patchTask: vi.fn(), + patchProject: vi.fn(), + deployProject: vi.fn(), + getPhases: vi.fn(), + uploadAttachment: vi.fn(), + environments: vi.fn(), + reviseTask: vi.fn(), + }, +})) + +import { api } from '../api' + +const Stub = { template: '
' } + +function makeTask(id: string, status: string, blocked_reason: string | null = null) { + return { + id, + project_id: 'KIN', + title: `Task ${id}`, + status, + priority: 5, + assigned_role: null, + parent_task_id: null, + brief: null, + spec: null, + execution_mode: null, + blocked_reason, + dangerously_skipped: null, + category: null, + acceptance_criteria: null, + created_at: '2024-01-01', + updated_at: '2024-01-01', + } +} + +function makeMockProject(tasks: ReturnType[]) { + return { + id: 'KIN', + name: 'Kin', + path: '/projects/kin', + status: 'active', + priority: 5, + tech_stack: ['python', 'vue'], + execution_mode: 'review', + autocommit_enabled: 0, + auto_test_enabled: 0, + test_command: null, + obsidian_vault_path: null, + deploy_command: null, + created_at: '2024-01-01', + total_tasks: tasks.length, + done_tasks: 0, + active_tasks: 0, + blocked_tasks: 0, + review_tasks: 0, + project_type: 'development', + ssh_host: null, + ssh_user: null, + ssh_key_path: null, + ssh_proxy_jump: null, + description: null, + tasks, + decisions: [], + modules: [], + } +} + +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 }) + +function makeRouter() { + return createRouter({ + history: createMemoryHistory(), + routes: [ + { path: '/', component: Stub }, + { path: '/project/:id', component: ProjectView, props: true }, + { path: '/task/:id', component: Stub }, + ], + }) +} + +async function mountProjectView(tasks: ReturnType[]) { + vi.mocked(api.project).mockResolvedValue(makeMockProject(tasks) as any) + const router = makeRouter() + await router.push('/project/KIN') + const wrapper = mount(ProjectView, { + props: { id: 'KIN' }, + global: { plugins: [router] }, + }) + await flushPromises() + return wrapper +} + +beforeEach(() => { + localStorageMock.clear() + vi.clearAllMocks() + vi.mocked(api.getPhases).mockResolvedValue([]) + vi.mocked(api.environments).mockResolvedValue([]) +}) + +afterEach(() => { + vi.restoreAllMocks() +}) + +// ───────────────────────────────────────────────────────────── +// Критерий 1: blocked + blocked_reason → reason отображается +// ───────────────────────────────────────────────────────────── + +describe('KIN-099: blocked_reason отображается для задач со статусом blocked', () => { + it('Задача со статусом blocked и blocked_reason — reason виден в тексте карточки', async () => { + const wrapper = await mountProjectView([ + makeTask('KIN-001', 'blocked', 'Process died unexpectedly (PID 12345)'), + ]) + + expect(wrapper.text()).toContain('Process died unexpectedly (PID 12345)') + }) + + it('blocked_reason отображается в div с классами text-red-400 truncate', async () => { + const wrapper = await mountProjectView([ + makeTask('KIN-002', 'blocked', 'Agent needs human decision'), + ]) + + // Ищем div с blocked_reason — он имеет класс text-xs text-red-400 truncate + const reasonDiv = wrapper.find('.text-red-400.truncate') + expect(reasonDiv.exists()).toBe(true) + expect(reasonDiv.text()).toContain('Agent needs human decision') + }) + + it('Сам task_id и title также присутствуют вместе с reason', async () => { + const wrapper = await mountProjectView([ + makeTask('KIN-003', 'blocked', 'Process died unexpectedly (PID 99)'), + ]) + + const text = wrapper.text() + expect(text).toContain('KIN-003') + expect(text).toContain('Task KIN-003') + expect(text).toContain('Process died unexpectedly (PID 99)') + }) +}) + +// ───────────────────────────────────────────────────────────── +// Критерий 2: blocked без blocked_reason → div с reason НЕ рендерится +// ───────────────────────────────────────────────────────────── + +describe('KIN-099: blocked_reason НЕ отображается если поле пустое (null)', () => { + it('Задача со статусом blocked без blocked_reason — div text-red-400.truncate отсутствует', async () => { + const wrapper = await mountProjectView([ + makeTask('KIN-004', 'blocked', null), + ]) + + // Задача отображается + expect(wrapper.text()).toContain('KIN-004') + + // Но div для blocked_reason отсутствует + expect(wrapper.find('.text-red-400.truncate').exists()).toBe(false) + }) +}) + +// ───────────────────────────────────────────────────────────── +// Критерий 3: другой статус → blocked_reason НЕ отображается +// ───────────────────────────────────────────────────────────── + +describe('KIN-099: blocked_reason НЕ отображается для не-blocked статусов', () => { + it('Задача со статусом pending и blocked_reason — reason НЕ отображается', async () => { + const wrapper = await mountProjectView([ + makeTask('KIN-005', 'pending', 'Should not be visible'), + ]) + + expect(wrapper.text()).toContain('KIN-005') + expect(wrapper.text()).not.toContain('Should not be visible') + }) + + it('Задача со статусом in_progress и blocked_reason — reason НЕ отображается', async () => { + const wrapper = await mountProjectView([ + makeTask('KIN-006', 'in_progress', 'Hidden in progress reason'), + ]) + + expect(wrapper.text()).not.toContain('Hidden in progress reason') + }) + + it('Задача со статусом done и blocked_reason — reason НЕ отображается', async () => { + const wrapper = await mountProjectView([ + makeTask('KIN-007', 'done', 'Past blocked reason'), + ]) + + expect(wrapper.text()).not.toContain('Past blocked reason') + }) + + it('div text-red-400.truncate отсутствует для non-blocked задачи с blocked_reason', async () => { + const wrapper = await mountProjectView([ + makeTask('KIN-008', 'review', 'Hidden review reason'), + ]) + + expect(wrapper.find('.text-red-400.truncate').exists()).toBe(false) + }) +}) diff --git a/web/frontend/src/__tests__/watchdog-toast.test.ts b/web/frontend/src/__tests__/watchdog-toast.test.ts new file mode 100644 index 0000000..c47d6f6 --- /dev/null +++ b/web/frontend/src/__tests__/watchdog-toast.test.ts @@ -0,0 +1,214 @@ +/** + * KIN-099: Watchdog toast-уведомления в EscalationBanner + * + * Проверяет: + * 1. Toast появляется когда reason содержит 'Process died' — task_id и reason в тексте + * 2. Toast НЕ появляется для обычной (не-watchdog) эскалации + * 3. После dismiss — тот же toast не появляется снова при следующем polling (localStorage) + * 4. Toast исчезает автоматически через 8 секунд (vi.useFakeTimers) + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { mount, flushPromises } from '@vue/test-utils' +import EscalationBanner from '../components/EscalationBanner.vue' + +vi.mock('../api', () => ({ + api: { + notifications: vi.fn(), + }, +})) + +import { api } from '../api' + +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 }) + +function makeNotification(taskId: string, reason: string) { + return { + task_id: taskId, + project_id: 'KIN', + agent_role: 'developer', + reason, + pipeline_step: null, + blocked_at: '2026-03-17T10:00:00', + telegram_sent: false, + } +} + +beforeEach(() => { + localStorageMock.clear() + vi.clearAllMocks() + vi.useFakeTimers() +}) + +afterEach(() => { + vi.useRealTimers() + vi.restoreAllMocks() +}) + +// ───────────────────────────────────────────────────────────── +// Критерий 1: Toast появляется при "Process died" +// ───────────────────────────────────────────────────────────── + +describe('KIN-099: watchdog toast появляется при "Process died"', () => { + it('Toast с task_id и reason отображается когда reason содержит "Process died unexpectedly (PID XXXX)"', async () => { + vi.mocked(api.notifications).mockResolvedValue([ + makeNotification('KIN-042', 'Process died unexpectedly (PID 12345)'), + ]) + + const wrapper = mount(EscalationBanner) + await flushPromises() + + expect(wrapper.text()).toContain('KIN-042') + expect(wrapper.text()).toContain('Process died unexpectedly (PID 12345)') + }) + + it('Toast отображается для "Parent process died" reason', async () => { + vi.mocked(api.notifications).mockResolvedValue([ + makeNotification('KIN-043', 'Parent process died unexpectedly'), + ]) + + const wrapper = mount(EscalationBanner) + await flushPromises() + + expect(wrapper.text()).toContain('KIN-043') + expect(wrapper.text()).toContain('Parent process died') + }) + + it('Toast рендерится с красной рамкой border-red-700', async () => { + vi.mocked(api.notifications).mockResolvedValue([ + makeNotification('KIN-044', 'Process died unexpectedly (PID 9999)'), + ]) + + const wrapper = mount(EscalationBanner) + await flushPromises() + + expect(wrapper.find('.border-red-700').exists()).toBe(true) + }) +}) + +// ───────────────────────────────────────────────────────────── +// Критерий 2: Toast НЕ появляется при обычной эскалации +// ───────────────────────────────────────────────────────────── + +describe('KIN-099: watchdog toast НЕ появляется при обычной эскалации', () => { + it('Toast НЕ появляется когда reason не содержит "Process died"', async () => { + vi.mocked(api.notifications).mockResolvedValue([ + makeNotification('KIN-045', 'Agent requested human input: unclear requirements'), + ]) + + const wrapper = mount(EscalationBanner) + await flushPromises() + + expect(wrapper.find('.border-red-700').exists()).toBe(false) + expect(wrapper.text()).not.toContain('Watchdog:') + }) + + it('При пустом списке уведомлений toast отсутствует', async () => { + vi.mocked(api.notifications).mockResolvedValue([]) + + const wrapper = mount(EscalationBanner) + await flushPromises() + + expect(wrapper.find('.border-red-700').exists()).toBe(false) + }) +}) + +// ───────────────────────────────────────────────────────────── +// Критерий 3: dismiss сохраняется — toast не появляется при следующем polling +// ───────────────────────────────────────────────────────────── + +describe('KIN-099: dismiss сохраняет состояние в localStorage', () => { + it('После нажатия × toast исчезает', async () => { + vi.mocked(api.notifications).mockResolvedValue([ + makeNotification('KIN-046', 'Process died unexpectedly (PID 8888)'), + ]) + + const wrapper = mount(EscalationBanner) + await flushPromises() + + expect(wrapper.find('.border-red-700').exists()).toBe(true) + + await wrapper.find('.border-red-700 button').trigger('click') + await flushPromises() + + expect(wrapper.find('.border-red-700').exists()).toBe(false) + }) + + it('После dismiss — при следующем polling тот же toast не появляется снова', async () => { + vi.mocked(api.notifications).mockResolvedValue([ + makeNotification('KIN-046', 'Process died unexpectedly (PID 8888)'), + ]) + + const wrapper = mount(EscalationBanner) + await flushPromises() + + await wrapper.find('.border-red-700 button').trigger('click') + await flushPromises() + + // Следующий polling через 10 сек + vi.advanceTimersByTime(10000) + await flushPromises() + + expect(wrapper.find('.border-red-700').exists()).toBe(false) + }) + + it('task_id сохраняется в localStorage (kin_dismissed_watchdog_toasts) после dismiss', async () => { + vi.mocked(api.notifications).mockResolvedValue([ + makeNotification('KIN-047', 'Process died unexpectedly (PID 7777)'), + ]) + + const wrapper = mount(EscalationBanner) + await flushPromises() + + await wrapper.find('.border-red-700 button').trigger('click') + + const stored = localStorageMock.getItem('kin_dismissed_watchdog_toasts') + expect(stored).toBeTruthy() + expect(JSON.parse(stored!)).toContain('KIN-047') + }) +}) + +// ───────────────────────────────────────────────────────────── +// Критерий 4: auto-dismiss через 8 секунд +// ───────────────────────────────────────────────────────────── + +describe('KIN-099: toast auto-dismiss через 8 секунд', () => { + it('Toast исчезает автоматически ровно через 8 секунд', async () => { + vi.mocked(api.notifications).mockResolvedValue([ + makeNotification('KIN-048', 'Process died unexpectedly (PID 6666)'), + ]) + + const wrapper = mount(EscalationBanner) + await flushPromises() + + expect(wrapper.find('.border-red-700').exists()).toBe(true) + + vi.advanceTimersByTime(8000) + await flushPromises() + + expect(wrapper.find('.border-red-700').exists()).toBe(false) + }) + + it('Toast ещё виден через 7.9 секунд (до истечения таймера)', async () => { + vi.mocked(api.notifications).mockResolvedValue([ + makeNotification('KIN-049', 'Process died unexpectedly (PID 5555)'), + ]) + + const wrapper = mount(EscalationBanner) + await flushPromises() + + vi.advanceTimersByTime(7999) + await flushPromises() + + expect(wrapper.find('.border-red-700').exists()).toBe(true) + }) +})