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)
+ })
+})