diff --git a/.gitignore b/.gitignore index 30c4bd744..264c9dbf5 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,7 @@ update-test/ .claude .DS_Store docs/ +openspec/ # Sandbox bind-mount artifacts from user home (not project files). # Root-anchored so legitimate nested files with these names are still tracked. diff --git a/electron/mcp/coordinator-sequence.test.ts b/electron/mcp/coordinator-sequence.test.ts index 8f5e6d331..a09dc282c 100644 --- a/electron/mcp/coordinator-sequence.test.ts +++ b/electron/mcp/coordinator-sequence.test.ts @@ -1,158 +1,16 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; - -// --- fs / child_process mocks (must come before dynamic import) --- -const mockExecFile = vi.fn( - (_cmd: unknown, _args: unknown, _opts: unknown, cb: (...a: unknown[]) => void) => { - cb(null, '', ''); - return { on: vi.fn() }; - }, -); - -vi.mock('child_process', () => ({ - execFile: mockExecFile, - promisify: vi.fn( - (fn: unknown) => - (...args: unknown[]) => - new Promise<{ stdout: string; stderr: string }>((resolve, reject) => { - (fn as (...a: unknown[]) => void)( - ...args, - (err: unknown, stdout: string, stderr: string) => { - if (err) reject(err); - else resolve({ stdout, stderr }); - }, - ); - }), - ), -})); - -const mockWriteFileSync = vi.fn(); -const mockReadFileSync = vi.fn(() => '# existing\n'); -const mockExistsSync = vi.fn(() => false); -const mockUnlinkSync = vi.fn(); -const mockMkdirSync = vi.fn(); - -vi.mock('fs', () => ({ - writeFileSync: mockWriteFileSync, - readFileSync: mockReadFileSync, - existsSync: mockExistsSync, - unlinkSync: mockUnlinkSync, - mkdirSync: mockMkdirSync, -})); - -// createTask writes an agent preamble via fs/promises + atomic.js; mock both so the -// smoke tests stay hermetic (otherwise they perform real I/O against worktree_path). -const mockFsMkdir = vi.fn().mockResolvedValue(undefined); -const mockFsAccess = vi - .fn() - .mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })); -const mockFsReadFile = vi.fn().mockResolvedValue('{}'); -const mockFsWriteFile = vi.fn().mockResolvedValue(undefined); -const mockFsUnlink = vi.fn().mockResolvedValue(undefined); - -vi.mock('fs/promises', () => ({ - readFile: mockFsReadFile, - writeFile: mockFsWriteFile, - unlink: mockFsUnlink, - access: mockFsAccess, - mkdir: mockFsMkdir, -})); - -vi.mock('./atomic.js', () => ({ - atomicWriteFile: vi.fn().mockResolvedValue(undefined), - atomicWriteFileSync: vi.fn(), -})); - -// --- other mocks --- -const mockNotifyRenderer = vi.fn(); -const mockOnPtyEvent = vi.fn(); -const mockSpawnAgent = vi.fn(); -const mockSubscribeToAgent = vi.fn(); -const mockGetAgentScrollback = vi.fn(() => null); -const mockGitMergeTask = vi.fn().mockResolvedValue({ - main_branch: 'main', - lines_added: 10, - lines_removed: 5, -}); -const mockCreateBackendTask = vi.fn().mockResolvedValue({ - id: 'task-1', - branch_name: 'task/test', - worktree_path: '/tmp/test', -}); - -vi.mock('./prompt-detect.js', () => ({ - stripAnsi: (s: string) => s, - AGENT_READY_TAIL_CHARS: 1000, - chunkContainsAgentPrompt: (s: string) => - s - .slice(-1000) - .split(/\r?\n/) - .some((line) => /(?:^|\s)❯\s*$/.test(line.trim())), -})); - -vi.mock('../ipc/pty.js', () => ({ - spawnAgent: mockSpawnAgent, - writeToAgent: vi.fn(), - killAgent: vi.fn(), - subscribeToAgent: mockSubscribeToAgent, - unsubscribeFromAgent: vi.fn(), - getAgentScrollback: mockGetAgentScrollback, - onPtyEvent: mockOnPtyEvent, -})); - -vi.mock('../ipc/git.js', () => ({ - getChangedFiles: vi.fn().mockResolvedValue([]), - getAllFileDiffs: vi.fn().mockResolvedValue(''), - mergeTask: mockGitMergeTask, -})); - -vi.mock('../ipc/tasks.js', () => ({ - createTask: mockCreateBackendTask, - deleteTask: vi.fn().mockResolvedValue(undefined), -})); - -vi.mock('../ipc/channels.js', () => ({ - IPC: { - MCP_TaskCreated: 'mcp_task_created', - MCP_TaskClosed: 'mcp_task_closed', - MCP_TaskStateSync: 'mcp_task_state_sync', - MCP_CoordinatorNotificationStaged: 'mcp_coordinator_notification_staged', - MCP_CoordinatorNotificationCleared: 'mcp_coordinator_notification_cleared', - MCP_CoordinatorOrphanedNotification: 'mcp_coordinator_orphaned_notification', - MCP_CoordinatorDeregistered: 'mcp_coordinator_deregistered', - MCP_CoordinatorNotificationAck: 'mcp_coordinator_notification_ack', - }, -})); - -// Import after mocks -const { Coordinator } = await import('./coordinator.js'); - -// --- helpers --- -function _getExitHandler(): (agentId: string, data: unknown) => void { - const call = mockOnPtyEvent.mock.calls.find((c) => c[0] === 'exit'); - if (!call) throw new Error('exit handler not registered'); - return call[1] as (agentId: string, data: unknown) => void; -} - -function getOutputCb(): (encoded: string) => void { - const call = mockSubscribeToAgent.mock.calls[0]; - if (!call) throw new Error('subscribeToAgent not called'); - return call[1] as (encoded: string) => void; -} - -function _getAgentId(): string { - const call = mockSubscribeToAgent.mock.calls[0]; - if (!call) throw new Error('subscribeToAgent not called'); - return call[0] as string; -} - -function encode(s: string): string { - return Buffer.from(s).toString('base64'); -} - -const mockWin = { - isDestroyed: () => false, - webContents: { send: mockNotifyRenderer }, -} as unknown as import('electron').BrowserWindow; +import { + setupCoordinatorHarness, + resetCoordinatorMocks, + mockNextTask, + registerDefaultCoordinator, + getOutputCb, + encodeAgentOutput as encode, + mockGitMergeTask, + mockNotifyRenderer, +} from './coordinator-test-harness.js'; + +const { Coordinator } = await setupCoordinatorHarness(); // ─── end-to-end tool sequence smoke ────────────────────────────────────────── @@ -160,16 +18,9 @@ describe('Coordinator — end-to-end tool sequence smoke', () => { let coordinator: InstanceType; beforeEach(() => { - vi.clearAllMocks(); - mockExistsSync.mockReturnValue(false); - mockCreateBackendTask.mockResolvedValue({ - id: 'task-1', - branch_name: 'task/test', - worktree_path: '/tmp/test', - }); - coordinator = new Coordinator(); - coordinator.setWindow(mockWin); - coordinator.setDefaultProject('proj-1', '/tmp/project'); + resetCoordinatorMocks(); + mockNextTask(); + coordinator = registerDefaultCoordinator(new Coordinator()); }); it('Test 1: full lifecycle — create → wait_for_idle → signal_done → wait_for_signal_done → close', async () => { diff --git a/electron/mcp/coordinator-test-harness.ts b/electron/mcp/coordinator-test-harness.ts new file mode 100644 index 000000000..34ec61d8d --- /dev/null +++ b/electron/mcp/coordinator-test-harness.ts @@ -0,0 +1,459 @@ +import type { BrowserWindow } from 'electron'; +import { vi } from 'vitest'; + +export type BackendTaskFixture = { + id: string; + branch_name: string; + worktree_path: string; +}; + +export type CoordinatorHarnessOptions = { + coordinatorId?: string; + projectId?: string; + projectPath?: string; + register?: boolean; +}; + +const defaultBackendTask = (): BackendTaskFixture => ({ + id: 'task-1', + branch_name: 'task/test', + worktree_path: '/tmp/test', +}); + +const enoent = () => Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + +const mocks = vi.hoisted(() => { + const mockExecFile = vi.fn(); + const mockWriteFileSync = vi.fn(); + const mockReadFileSync = vi.fn(); + const mockExistsSync = vi.fn(); + const mockUnlinkSync = vi.fn(); + const mockMkdirSync = vi.fn(); + const mockFsWriteFile = vi.fn(); + const mockFsReadFile = vi.fn(); + const mockFsAccess = vi.fn(); + const mockFsUnlink = vi.fn(); + const mockFsMkdir = vi.fn(); + const mockAtomicWriteFileSync = vi.fn(); + const mockAtomicWriteFile = vi.fn(); + const mockNotifyRenderer = vi.fn(); + const mockLogInfo = vi.fn(); + const mockLogWarn = vi.fn(); + const mockOnPtyEvent = vi.fn(); + const mockSpawnAgent = vi.fn(); + const mockWriteToAgent = vi.fn(); + const mockKillAgent = vi.fn(); + const mockSubscribeToAgent = vi.fn(); + const mockUnsubscribeFromAgent = vi.fn(); + const mockGetAgentScrollback = vi.fn(); + const mockGetChangedFiles = vi.fn(); + const mockGetAllFileDiffs = vi.fn(); + const mockGetDiffBaseSha = vi.fn(); + const mockGitMergeTask = vi.fn(); + const mockCreateBackendTask = vi.fn(); + const mockDeleteBackendTask = vi.fn(); + + return { + mockExecFile, + mockWriteFileSync, + mockReadFileSync, + mockExistsSync, + mockUnlinkSync, + mockMkdirSync, + mockFsWriteFile, + mockFsReadFile, + mockFsAccess, + mockFsUnlink, + mockFsMkdir, + mockAtomicWriteFileSync, + mockAtomicWriteFile, + mockNotifyRenderer, + mockLogInfo, + mockLogWarn, + mockOnPtyEvent, + mockSpawnAgent, + mockWriteToAgent, + mockKillAgent, + mockSubscribeToAgent, + mockUnsubscribeFromAgent, + mockGetAgentScrollback, + mockGetChangedFiles, + mockGetAllFileDiffs, + mockGetDiffBaseSha, + mockGitMergeTask, + mockCreateBackendTask, + mockDeleteBackendTask, + }; +}); + +vi.mock('child_process', () => ({ + execFile: mocks.mockExecFile, +})); + +vi.mock('fs', () => ({ + writeFileSync: mocks.mockWriteFileSync, + readFileSync: mocks.mockReadFileSync, + existsSync: mocks.mockExistsSync, + unlinkSync: mocks.mockUnlinkSync, + mkdirSync: mocks.mockMkdirSync, +})); + +vi.mock('fs/promises', () => ({ + writeFile: mocks.mockFsWriteFile, + readFile: mocks.mockFsReadFile, + access: mocks.mockFsAccess, + unlink: mocks.mockFsUnlink, + mkdir: mocks.mockFsMkdir, +})); + +vi.mock('./atomic.js', () => ({ + atomicWriteFileSync: mocks.mockAtomicWriteFileSync, + atomicWriteFile: mocks.mockAtomicWriteFile, +})); + +vi.mock('./prompt-detect.js', () => ({ + stripAnsi: (s: string) => + s.replace( + // eslint-disable-next-line no-control-regex + /[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nq-uy=><~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)?/g, + '', + ), + AGENT_READY_TAIL_CHARS: 1000, + getAgentPromptReadiness: (s: string) => { + const tail = s.slice(-1000); + if ( + /\bDo\s+you\s+trust\b|\bPress\s+enter\s+to\s+continue\b|\bBooting\s+MCP\s+server\b|\bStarting\s+MCP\s+servers?\b/i.test( + tail, + ) + ) { + return { ready: false, reason: 'startup_or_dialog', tail }; + } + if ( + /\bq*Working\s*\(|\bbackground\s+terminal\s+running\b|\besc\s+to\s+interrupt\b|\/stop\s+to\s+close\b/i.test( + tail, + ) + ) { + return { ready: false, reason: 'busy', tail }; + } + const ready = tail + .slice(-1000) + .split(/\r\n?|\n/) + .some((line) => + /(?:^|\s)[❯›]\s*$|^\s*--\s*INSERT\s*--\s*$|^\s*>\s*(?:Type your message|$)/i.test( + line.trim(), + ), + ); + return { ready, reason: ready ? 'ready' : 'no_prompt', tail }; + }, + chunkContainsAgentPrompt: (s: string) => { + const tail = s.slice(-1000); + if ( + /\bDo\s+you\s+trust\b|\bPress\s+enter\s+to\s+continue\b|\bBooting\s+MCP\s+server\b|\bStarting\s+MCP\s+servers?\b/i.test( + tail, + ) + ) { + return false; + } + if ( + /\bq*Working\s*\(|\bbackground\s+terminal\s+running\b|\besc\s+to\s+interrupt\b|\/stop\s+to\s+close\b/i.test( + tail, + ) + ) { + return false; + } + return tail + .split(/\r\n?|\n/) + .some((line) => + /(?:^|\s)[❯›]\s*$|^\s*--\s*INSERT\s*--\s*$|^\s*>\s*(?:Type your message|$)/i.test( + line.trim(), + ), + ); + }, +})); + +vi.mock('../ipc/pty.js', () => ({ + spawnAgent: mocks.mockSpawnAgent, + writeToAgent: mocks.mockWriteToAgent, + killAgent: mocks.mockKillAgent, + subscribeToAgent: mocks.mockSubscribeToAgent, + unsubscribeFromAgent: mocks.mockUnsubscribeFromAgent, + getAgentScrollback: mocks.mockGetAgentScrollback, + onPtyEvent: mocks.mockOnPtyEvent, +})); + +vi.mock('../ipc/git.js', () => ({ + getChangedFiles: mocks.mockGetChangedFiles, + getAllFileDiffs: mocks.mockGetAllFileDiffs, + getDiffBaseSha: mocks.mockGetDiffBaseSha, + mergeTask: mocks.mockGitMergeTask, +})); + +vi.mock('../ipc/tasks.js', () => ({ + createTask: mocks.mockCreateBackendTask, + deleteTask: mocks.mockDeleteBackendTask, +})); + +vi.mock('../ipc/channels.js', () => ({ + IPC: { + MCP_TaskCreated: 'mcp_task_created', + MCP_TaskClosed: 'mcp_task_closed', + MCP_TaskCleanupFailed: 'mcp_task_cleanup_failed', + MCP_TaskStateSync: 'mcp_task_state_sync', + MCP_CoordinatorNotificationStaged: 'mcp_coordinator_notification_staged', + MCP_CoordinatorNotificationCleared: 'mcp_coordinator_notification_cleared', + MCP_CoordinatorOrphanedNotification: 'mcp_coordinator_orphaned_notification', + MCP_CoordinatorDeregistered: 'mcp_coordinator_deregistered', + MCP_CoordinatorNotificationAck: 'mcp_coordinator_notification_ack', + }, +})); + +vi.mock('../log.js', () => ({ + info: mocks.mockLogInfo, + warn: mocks.mockLogWarn, +})); + +export const { + mockExecFile, + mockWriteFileSync, + mockReadFileSync, + mockExistsSync, + mockUnlinkSync, + mockMkdirSync, + mockFsWriteFile, + mockFsReadFile, + mockFsAccess, + mockFsUnlink, + mockFsMkdir, + mockAtomicWriteFileSync, + mockAtomicWriteFile, + mockNotifyRenderer, + mockLogInfo, + mockLogWarn, + mockOnPtyEvent, + mockSpawnAgent, + mockWriteToAgent, + mockKillAgent, + mockSubscribeToAgent, + mockUnsubscribeFromAgent, + mockGetAgentScrollback, + mockGetChangedFiles, + mockGetAllFileDiffs, + mockGetDiffBaseSha, + mockGitMergeTask, + mockCreateBackendTask, + mockDeleteBackendTask, +} = mocks; + +export const mockWin = { + isDestroyed: () => false, + webContents: { send: mockNotifyRenderer }, +} as unknown as BrowserWindow; + +export function createCoordinatorTask( + overrides: Partial = {}, +): BackendTaskFixture { + return { ...defaultBackendTask(), ...overrides }; +} + +export function mockNextTask(overrides: Partial = {}): BackendTaskFixture { + const task = createCoordinatorTask(overrides); + mockCreateBackendTask.mockResolvedValueOnce(task); + return task; +} + +export function resetCoordinatorMocks(): void { + mockExecFile.mockReset(); + mockExecFile.mockImplementation( + ( + _cmd: string, + _args: string[] | ((err: Error | null, stdout: string, stderr: string) => void), + _opts?: unknown, + cb?: (err: Error | null, stdout: string, stderr: string) => void, + ) => { + const callback = + typeof _args === 'function' ? _args : typeof _opts === 'function' ? _opts : cb; + callback?.(null, '', ''); + return { on: vi.fn() }; + }, + ); + + mockWriteFileSync.mockReset(); + mockReadFileSync.mockReset(); + mockReadFileSync.mockReturnValue('# existing\n'); + mockExistsSync.mockReset(); + mockExistsSync.mockReturnValue(false); + mockUnlinkSync.mockReset(); + mockMkdirSync.mockReset(); + + mockFsWriteFile.mockReset(); + mockFsWriteFile.mockResolvedValue(undefined); + mockFsReadFile.mockReset(); + mockFsReadFile.mockResolvedValue('# existing\n'); + mockFsAccess.mockReset(); + mockFsAccess.mockRejectedValue(enoent()); + mockFsUnlink.mockReset(); + mockFsUnlink.mockResolvedValue(undefined); + mockFsMkdir.mockReset(); + mockFsMkdir.mockResolvedValue(undefined); + + mockAtomicWriteFileSync.mockReset(); + mockAtomicWriteFile.mockReset(); + mockAtomicWriteFile.mockResolvedValue(undefined); + + mockNotifyRenderer.mockReset(); + mockLogInfo.mockReset(); + mockLogWarn.mockReset(); + mockOnPtyEvent.mockReset(); + mockSpawnAgent.mockReset(); + mockWriteToAgent.mockReset(); + mockWriteToAgent.mockImplementation((agentId: string, data: string) => ({ id: agentId, data })); + mockKillAgent.mockReset(); + mockSubscribeToAgent.mockReset(); + mockUnsubscribeFromAgent.mockReset(); + mockGetAgentScrollback.mockReset(); + mockGetAgentScrollback.mockReturnValue(null); + + mockGetChangedFiles.mockReset(); + mockGetChangedFiles.mockResolvedValue([]); + mockGetAllFileDiffs.mockReset(); + mockGetAllFileDiffs.mockResolvedValue(''); + mockGetDiffBaseSha.mockReset(); + mockGetDiffBaseSha.mockResolvedValue('abc123sha'); + mockGitMergeTask.mockReset(); + mockGitMergeTask.mockResolvedValue({ main_branch: 'main', lines_added: 10, lines_removed: 5 }); + + mockCreateBackendTask.mockReset(); + mockCreateBackendTask.mockResolvedValue(defaultBackendTask()); + mockDeleteBackendTask.mockReset(); + mockDeleteBackendTask.mockResolvedValue(undefined); +} + +export async function setupCoordinatorHarness(options: CoordinatorHarnessOptions = {}) { + resetCoordinatorMocks(); + const { Coordinator } = await import('./coordinator.js'); + const coordinator = new Coordinator(); + registerDefaultCoordinator(coordinator, options); + return { + Coordinator, + coordinator, + mockWin, + resetCoordinatorMocks, + mockNextTask, + registerDefaultCoordinator, + createCoordinatorTask, + getOutputCb, + emitAgentOutput, + deliverReadyPrompt, + getAgentId, + getSpawnHandler, + getExitHandler, + getAgentTextWrites, + getSubtaskConfigWrites, + getSettingsLocalWrites, + rendererEvents, + ...mocks, + }; +} + +export function registerDefaultCoordinator( + coordinator: InstanceType<(typeof import('./coordinator.js'))['Coordinator']>, + { + coordinatorId = 'coord-1', + projectId = 'proj-1', + projectPath = '/tmp/project', + register = false, + }: CoordinatorHarnessOptions = {}, +) { + coordinator.setWindow(mockWin); + coordinator.setDefaultProject(projectId, projectPath); + if (register) coordinator.registerCoordinator(coordinatorId, projectId); + return coordinator; +} + +export function getOutputCb(index = 0): (encoded: string) => void { + const call = mockSubscribeToAgent.mock.calls[index]; + if (!call) throw new Error('subscribeToAgent not called'); + return call[1] as (encoded: string) => void; +} + +export function getAgentId(index = 0): string { + const call = mockSubscribeToAgent.mock.calls[index]; + if (!call) throw new Error('subscribeToAgent not called'); + return call[0] as string; +} + +export function getSpawnHandler(): (agentId: string) => void { + const call = mockOnPtyEvent.mock.calls.find((c) => c[0] === 'spawn'); + if (!call) throw new Error('spawn handler not registered'); + return call[1] as (agentId: string) => void; +} + +export function getExitHandler(): (agentId: string, data: unknown) => void { + const call = mockOnPtyEvent.mock.calls.find((c) => c[0] === 'exit'); + if (!call) throw new Error('exit handler not registered'); + return call[1] as (agentId: string, data: unknown) => void; +} + +export function encodeAgentOutput(s: string): string { + return Buffer.from(s).toString('base64'); +} + +export function encodeAgentBytes(bytes: Buffer): string { + return bytes.toString('base64'); +} + +export function emitAgentOutput(text: string, index = 0): void { + getOutputCb(index)(encodeAgentOutput(text)); +} + +export function deliverReadyPrompt({ text = 'Done ❯ ', index = 0 } = {}): void { + emitAgentOutput(text, index); +} + +export function emitWorkThenIdle(outputCb: (encoded: string) => void = getOutputCb()): void { + outputCb(encodeAgentOutput('Working...\n')); + outputCb(encodeAgentOutput('Done ❯ ')); +} + +export function getAgentTextWrites(agentId?: string): string[] { + return mockWriteToAgent.mock.calls + .filter(([id, text]) => (!agentId || id === agentId) && text !== '\r' && text !== '\x1b[I') + .map(([, text]) => text as string); +} + +type AtomicWrite = { path: string; content: string; sync: boolean; call: unknown[] }; + +function atomicWrites(): AtomicWrite[] { + return [ + ...mockAtomicWriteFile.mock.calls.map((call) => ({ + path: String(call[0]), + content: String(call[1]), + sync: false, + call, + })), + ...mockAtomicWriteFileSync.mock.calls.map((call) => ({ + path: String(call[0]), + content: String(call[1]), + sync: true, + call, + })), + ]; +} + +export function getSubtaskConfigWrites(): AtomicWrite[] { + return atomicWrites().filter( + ({ path }) => path.includes('parallel-code-subtask-') || path.includes('/subtask-'), + ); +} + +export function getSettingsLocalWrites(): AtomicWrite[] { + return atomicWrites().filter(({ path }) => path.endsWith('settings.local.json')); +} + +export function rendererEvents(channel?: string): unknown[][] { + return channel + ? mockNotifyRenderer.mock.calls.filter(([eventChannel]) => eventChannel === channel) + : mockNotifyRenderer.mock.calls; +} + +resetCoordinatorMocks(); diff --git a/electron/mcp/coordinator.test.ts b/electron/mcp/coordinator.test.ts index 3b7c34312..26324fcc9 100644 --- a/electron/mcp/coordinator.test.ts +++ b/electron/mcp/coordinator.test.ts @@ -1,230 +1,49 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import os from 'os'; import { join, dirname } from 'path'; -import { getChangedFiles, getAllFileDiffs, getDiffBaseSha, mergeTask } from '../ipc/git.js'; import { NOT_READY_AGENT_FRAME_FIXTURES, READY_AGENT_FRAME_FIXTURES, } from './agent-frame-fixtures.js'; import { handleMCPToolCall } from './server.js'; import type { MCPClient } from './client.js'; - -// --- fs / child_process mocks (must come before dynamic import) --- -const mockExecFile = vi.fn( - ( - _cmd: string, - _args: string[], - _opts: unknown, - cb: (err: Error | null, stdout: string, stderr: string) => void, - ) => { - cb(null, '', ''); - }, -); - -vi.mock('child_process', () => ({ - execFile: mockExecFile, -})); - -const mockWriteFileSync = vi.fn(); -const mockReadFileSync = vi.fn(() => '# existing\n'); -const mockExistsSync = vi.fn(() => false); -const mockUnlinkSync = vi.fn(); -const mockMkdirSync = vi.fn(); - -vi.mock('fs', () => ({ - writeFileSync: mockWriteFileSync, - readFileSync: mockReadFileSync, - existsSync: mockExistsSync, - unlinkSync: mockUnlinkSync, - mkdirSync: mockMkdirSync, -})); - -// fs/promises mocks — mirror the sync mocks above -const mockFsWriteFile = vi.fn().mockResolvedValue(undefined); -const mockFsReadFile = vi.fn().mockResolvedValue('# existing\n'); -const mockFsAccess = vi - .fn() - .mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })); -const mockFsUnlink = vi.fn().mockResolvedValue(undefined); -const mockFsMkdir = vi.fn().mockResolvedValue(undefined); - -vi.mock('fs/promises', () => ({ - writeFile: mockFsWriteFile, - readFile: mockFsReadFile, - access: mockFsAccess, - unlink: mockFsUnlink, - mkdir: mockFsMkdir, -})); - -// --- other mocks --- -const mockNotifyRenderer = vi.fn(); -const mockLogInfo = vi.fn(); -const mockOnPtyEvent = vi.fn(); -const mockSpawnAgent = vi.fn(); -const mockWriteToAgent = vi.fn(); -const mockSubscribeToAgent = vi.fn(); -const mockGetAgentScrollback = vi.fn<() => string | null>(() => null); -const mockCreateBackendTask = vi.fn().mockResolvedValue({ - id: 'task-1', - branch_name: 'task/test', - worktree_path: '/tmp/test', -}); - -const mockAtomicWriteFileSync = vi.fn(); -const mockAtomicWriteFile = vi.fn().mockResolvedValue(undefined); - -vi.mock('./atomic.js', () => ({ - atomicWriteFileSync: mockAtomicWriteFileSync, - atomicWriteFile: mockAtomicWriteFile, -})); - -vi.mock('./prompt-detect.js', () => ({ - stripAnsi: (s: string) => - s.replace( - // eslint-disable-next-line no-control-regex - /[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nq-uy=><~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)?/g, - '', - ), - AGENT_READY_TAIL_CHARS: 1000, - getAgentPromptReadiness: (s: string) => { - const tail = s.slice(-1000); - if ( - /\bDo\s+you\s+trust\b|\bPress\s+enter\s+to\s+continue\b|\bBooting\s+MCP\s+server\b|\bStarting\s+MCP\s+servers?\b/i.test( - tail, - ) - ) { - return { ready: false, reason: 'startup_or_dialog', tail }; - } - if ( - /\bq*Working\s*\(|\bbackground\s+terminal\s+running\b|\besc\s+to\s+interrupt\b|\/stop\s+to\s+close\b/i.test( - tail, - ) - ) { - return { ready: false, reason: 'busy', tail }; - } - const ready = tail - .slice(-1000) - .split(/\r\n?|\n/) - .some((line) => - /(?:^|\s)[❯›]\s*$|^\s*--\s*INSERT\s*--\s*$|^\s*>\s*(?:Type your message|$)/i.test( - line.trim(), - ), - ); - return { ready, reason: ready ? 'ready' : 'no_prompt', tail }; - }, - chunkContainsAgentPrompt: (s: string) => { - const tail = s.slice(-1000); - if ( - /\bDo\s+you\s+trust\b|\bPress\s+enter\s+to\s+continue\b|\bBooting\s+MCP\s+server\b|\bStarting\s+MCP\s+servers?\b/i.test( - tail, - ) - ) { - return false; - } - if ( - /\bq*Working\s*\(|\bbackground\s+terminal\s+running\b|\besc\s+to\s+interrupt\b|\/stop\s+to\s+close\b/i.test( - tail, - ) - ) { - return false; - } - return tail - .split(/\r\n?|\n/) - .some((line) => - /(?:^|\s)[❯›]\s*$|^\s*--\s*INSERT\s*--\s*$|^\s*>\s*(?:Type your message|$)/i.test( - line.trim(), - ), - ); - }, -})); - -vi.mock('../ipc/pty.js', () => ({ - spawnAgent: mockSpawnAgent, - writeToAgent: mockWriteToAgent, - killAgent: vi.fn(), - subscribeToAgent: mockSubscribeToAgent, - unsubscribeFromAgent: vi.fn(), - getAgentScrollback: mockGetAgentScrollback, - onPtyEvent: mockOnPtyEvent, -})); - -vi.mock('../ipc/git.js', () => ({ - getChangedFiles: vi.fn().mockResolvedValue([]), - getAllFileDiffs: vi.fn().mockResolvedValue(''), - getDiffBaseSha: vi.fn().mockResolvedValue('abc123sha'), - mergeTask: vi.fn(), -})); - -vi.mock('../ipc/tasks.js', () => ({ - createTask: mockCreateBackendTask, - deleteTask: vi.fn().mockResolvedValue(undefined), -})); - -vi.mock('../ipc/channels.js', () => ({ - IPC: { - MCP_TaskCreated: 'mcp_task_created', - MCP_TaskClosed: 'mcp_task_closed', - MCP_TaskCleanupFailed: 'mcp_task_cleanup_failed', - MCP_TaskStateSync: 'mcp_task_state_sync', - MCP_CoordinatorNotificationStaged: 'mcp_coordinator_notification_staged', - MCP_CoordinatorNotificationCleared: 'mcp_coordinator_notification_cleared', - MCP_CoordinatorOrphanedNotification: 'mcp_coordinator_orphaned_notification', - MCP_CoordinatorDeregistered: 'mcp_coordinator_deregistered', - MCP_CoordinatorNotificationAck: 'mcp_coordinator_notification_ack', - }, -})); - -vi.mock('../log.js', () => ({ - info: mockLogInfo, - warn: vi.fn(), -})); - -// Import after mocks -const { Coordinator } = await import('./coordinator.js'); +import { + setupCoordinatorHarness, + mockExecFile, + mockReadFileSync, + mockExistsSync, + mockUnlinkSync, + mockFsReadFile, + mockFsAccess, + mockAtomicWriteFileSync, + mockAtomicWriteFile, + mockNotifyRenderer, + mockLogInfo, + mockSpawnAgent, + mockWriteToAgent, + mockSubscribeToAgent, + mockGetAgentScrollback, + mockGetChangedFiles, + mockGetAllFileDiffs, + mockGetDiffBaseSha, + mockGitMergeTask, + mockCreateBackendTask, + mockWin, + getExitHandler, + getSpawnHandler, + getOutputCb, + getAgentId, + encodeAgentOutput as encode, + encodeAgentBytes as encodeBytes, + emitWorkThenIdle, +} from './coordinator-test-harness.js'; + +const { Coordinator } = await setupCoordinatorHarness(); const { removePreambleBlock } = await import('./preamble.js'); - -// --- helpers --- -function getExitHandler(): (agentId: string, data: unknown) => void { - const call = mockOnPtyEvent.mock.calls.find((c) => c[0] === 'exit'); - if (!call) throw new Error('exit handler not registered'); - return call[1] as (agentId: string, data: unknown) => void; -} - -function getSpawnHandler(): (agentId: string) => void { - const call = mockOnPtyEvent.mock.calls.find((c) => c[0] === 'spawn'); - if (!call) throw new Error('spawn handler not registered'); - return call[1] as (agentId: string) => void; -} - -function getOutputCb(): (encoded: string) => void { - const call = mockSubscribeToAgent.mock.calls[0]; - if (!call) throw new Error('subscribeToAgent not called'); - return call[1] as (encoded: string) => void; -} - -function getAgentId(): string { - const call = mockSubscribeToAgent.mock.calls[0]; - if (!call) throw new Error('subscribeToAgent not called'); - return call[0] as string; -} - -function encode(s: string): string { - return Buffer.from(s).toString('base64'); -} - -function encodeBytes(bytes: Buffer): string { - return bytes.toString('base64'); -} - -function emitWorkThenIdle(outputCb: (encoded: string) => void): void { - outputCb(encode('Working...\n')); - outputCb(encode('Done ❯ ')); -} - -const mockWin = { - isDestroyed: () => false, - webContents: { send: mockNotifyRenderer }, -} as unknown as import('electron').BrowserWindow; +const getChangedFiles = mockGetChangedFiles; +const getAllFileDiffs = mockGetAllFileDiffs; +const getDiffBaseSha = mockGetDiffBaseSha; +const mergeTask = mockGitMergeTask; // ─── registerCoordinator idempotency and restore path ──────────────────────── diff --git a/screens/best-video.mkv b/screens/best-video.mkv deleted file mode 100644 index 5fb2651f1..000000000 Binary files a/screens/best-video.mkv and /dev/null differ diff --git a/screens/demo.gif b/screens/demo.gif deleted file mode 100644 index d54f05d51..000000000 Binary files a/screens/demo.gif and /dev/null differ diff --git a/screens/demo.mov b/screens/demo.mov deleted file mode 100644 index 0e785f3f8..000000000 Binary files a/screens/demo.mov and /dev/null differ diff --git a/screens/longer-video.mkv b/screens/longer-video.mkv deleted file mode 100644 index 739718502..000000000 Binary files a/screens/longer-video.mkv and /dev/null differ diff --git a/screens/longer-video.mp4 b/screens/longer-video.mp4 deleted file mode 100644 index 2b855704d..000000000 Binary files a/screens/longer-video.mp4 and /dev/null differ diff --git a/src/App.tsx b/src/App.tsx index 0948663f0..df1c9dd65 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -545,7 +545,7 @@ function App() { const offStepsContent = window.electron.ipcRenderer.on(IPC.StepsContent, (data: unknown) => { if (!data || typeof data !== 'object') return; const msg = data as { taskId: string; steps: unknown[] | null }; - console.warn('[steps.recv]', msg.taskId, 'len=', msg.steps?.length ?? 'null'); + log.debug('steps', 'recv', { taskId: msg.taskId, len: msg.steps?.length ?? null }); if (msg.taskId && store.tasks[msg.taskId]) { setStepsContent(msg.taskId, msg.steps); } diff --git a/src/store/agents.test.ts b/src/store/agents.test.ts index 48e237520..693f0fdea 100644 --- a/src/store/agents.test.ts +++ b/src/store/agents.test.ts @@ -1,9 +1,12 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { expectDefined, type MockStoreHarness } from './test-helpers'; -const { mockSetStore, mockMarkAgentSpawned } = vi.hoisted(() => ({ - mockSetStore: vi.fn(), +const { mockMarkAgentSpawned } = vi.hoisted(() => ({ mockMarkAgentSpawned: vi.fn(), })); +const core = vi.hoisted(() => ({ + harness: undefined as MockStoreHarness<{ agents: Record }> | undefined, +})); let mockAgents: Record = {}; @@ -31,21 +34,18 @@ interface AgentDefLike { description: string; } -function applySetStore(...args: unknown[]): void { - if (args.length === 1 && typeof args[0] === 'function') { - (args[0] as (s: { agents: Record }) => void)({ agents: mockAgents }); - } -} - -vi.mock('./core', () => ({ - store: new Proxy({} as Record, { - get(_target, prop) { - if (prop === 'agents') return mockAgents; - return undefined; +vi.mock('./core', async () => { + const { createMockStoreHarness } = await import('./test-helpers'); + core.harness = createMockStoreHarness({ + get agents() { + return mockAgents; }, - }), - setStore: mockSetStore, -})); + set agents(next) { + mockAgents = next; + }, + }); + return core.harness.moduleMock(); +}); vi.mock('./taskStatus', () => ({ markAgentSpawned: mockMarkAgentSpawned, @@ -87,7 +87,8 @@ function exitedAgent(overrides: Partial = {}): AgentLike { beforeEach(() => { vi.clearAllMocks(); - mockSetStore.mockImplementation((...args: unknown[]) => applySetStore(...args)); + const harness = expectDefined(core.harness, 'mock store harness'); + harness.reset(harness.state()); mockAgents = { 'agent-1': exitedAgent() }; }); diff --git a/src/store/appearance-mode.test.ts b/src/store/appearance-mode.test.ts index 9912dd807..ac2cd0502 100644 --- a/src/store/appearance-mode.test.ts +++ b/src/store/appearance-mode.test.ts @@ -6,6 +6,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { AppearanceMode } from '../lib/look'; import type { LookPreset } from '../lib/look'; import type { CustomTheme } from '../lib/custom-theme'; +import { expectDefined, type MockStoreHarness } from './test-helpers'; type MockStore = { appearanceMode: AppearanceMode; @@ -19,46 +20,25 @@ type MockStore = { }; let mockStore: MockStore; +const core = vi.hoisted(() => ({ + harness: undefined as MockStoreHarness | undefined, +})); let mockOsIsDark: boolean; -function setStorePath(...args: unknown[]): void { - const value = args[args.length - 1]; - let target: Record = mockStore as unknown as Record; - for (let i = 0; i < args.length - 2; i++) { - const key = args[i] as string; - if (!target[key] || typeof target[key] !== 'object') target[key] = {}; - target = target[key] as Record; - } - target[args[args.length - 2] as string] = value; -} - vi.mock('solid-js', () => ({ batch: (fn: () => void) => fn(), })); -vi.mock('solid-js/store', () => ({ - produce: (fn: (draft: unknown) => void) => fn, -})); +vi.mock('solid-js/store', async () => { + const { mockSolidStoreProduce } = await import('./test-helpers'); + return mockSolidStoreProduce(); +}); -vi.mock('./core', () => ({ - store: new Proxy( - {}, - { - get(_target, prop) { - return mockStore[prop as keyof MockStore]; - }, - }, - ), - setStore: vi.fn((...args: unknown[]) => { - if (args.length === 2 && typeof args[1] === 'function') { - const key = args[0] as keyof MockStore; - const producer = args[1] as (draft: unknown) => void; - producer(mockStore[key]); - return; - } - setStorePath(...args); - }), -})); +vi.mock('./core', async () => { + const { createMockStoreHarness } = await import('./test-helpers'); + core.harness = createMockStoreHarness({} as MockStore); + return core.harness.moduleMock(); +}); vi.mock('./navigation', () => ({ setActiveTask: vi.fn() })); vi.mock('./focus', () => ({ setTaskFocusedPanel: vi.fn() })); @@ -91,7 +71,8 @@ function makeTheme(id: string): CustomTheme { beforeEach(() => { mockOsIsDark = true; - mockStore = { + const harness = expectDefined(core.harness, 'mock store harness'); + mockStore = harness.reset({ appearanceMode: 'dark', lightThemePreset: 'islands-light', lightThemeCustomId: null, @@ -100,7 +81,7 @@ beforeEach(() => { themePreset: 'islands-dark', activeCustomThemeId: null, customThemes: {}, - }; + }); }); afterEach(() => { diff --git a/src/store/focus.test.ts b/src/store/focus.test.ts index ec7d51da5..defa87c74 100644 --- a/src/store/focus.test.ts +++ b/src/store/focus.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { expectDefined, type MockStoreHarness } from './test-helpers'; type MockStore = { activeTaskId: string | null; @@ -34,36 +35,19 @@ type MockTask = { }; let mockStore: MockStore; - -function setStorePath(...args: unknown[]): void { - const value = args[args.length - 1]; - let target: Record = mockStore as unknown as Record; - for (let i = 0; i < args.length - 2; i++) { - const key = args[i] as string; - const next = target[key] as Record | undefined; - if (!next || typeof next !== 'object') { - target[key] = {}; - } - target = target[key] as Record; - } - target[args[args.length - 2] as string] = value; -} +const core = vi.hoisted(() => ({ + harness: undefined as MockStoreHarness | undefined, +})); vi.mock('solid-js', () => ({ batch: (fn: () => void) => fn(), })); -vi.mock('./core', () => ({ - store: new Proxy( - {}, - { - get(_target, prop) { - return mockStore[prop as keyof MockStore]; - }, - }, - ), - setStore: vi.fn((...args: unknown[]) => setStorePath(...args)), -})); +vi.mock('./core', async () => { + const { createMockStoreHarness } = await import('./test-helpers'); + core.harness = createMockStoreHarness({} as MockStore); + return core.harness.moduleMock(); +}); vi.mock('./navigation', () => ({ setActiveTask: vi.fn((id: string) => { @@ -103,7 +87,8 @@ function setTask(id: string, overrides: Record = {}): void { } beforeEach(() => { - mockStore = { + const harness = expectDefined(core.harness, 'mock store harness'); + mockStore = harness.reset({ activeTaskId: 'task-1', activeAgentId: 'agent-1', tasks: {}, @@ -122,7 +107,7 @@ beforeEach(() => { showPromptInput: true, sidebarVisible: true, taskSplitMode: {}, - }; + }); vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => { cb(0); diff --git a/src/store/navigation.test.ts b/src/store/navigation.test.ts index 8cdb5d916..09015a0f3 100644 --- a/src/store/navigation.test.ts +++ b/src/store/navigation.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { expectDefined, type MockStoreHarness } from './test-helpers'; type MockStore = { activeTaskId: string | null; @@ -15,27 +16,16 @@ type MockStore = { }; let mockStore: MockStore; - -vi.mock('./core', () => ({ - store: new Proxy( - {}, - { - get(_target, prop) { - return mockStore[prop as keyof MockStore]; - }, - }, - ), - setStore: vi.fn((...args: unknown[]) => { - const value = args[args.length - 1]; - let target: Record = mockStore as unknown as Record; - for (let i = 0; i < args.length - 2; i++) { - const key = args[i] as string; - target = target[key] as Record; - } - target[args[args.length - 2] as string] = value; - }), +const core = vi.hoisted(() => ({ + harness: undefined as MockStoreHarness | undefined, })); +vi.mock('./core', async () => { + const { createMockStoreHarness } = await import('./test-helpers'); + core.harness = createMockStoreHarness({} as MockStore); + return core.harness.moduleMock(); +}); + vi.mock('./focus', () => ({})); vi.mock('./notification', () => ({ showNotification: vi.fn() })); vi.mock('./projects', () => ({ pickAndAddProject: vi.fn() })); @@ -44,7 +34,8 @@ vi.mock('./tasks', () => ({ reorderTask: vi.fn() })); import { jumpToTask } from './navigation'; beforeEach(() => { - mockStore = { + const harness = expectDefined(core.harness, 'mock store harness'); + mockStore = harness.reset({ activeTaskId: null, activeAgentId: null, tasks: { @@ -60,7 +51,7 @@ beforeEach(() => { sidebarFocused: false, sidebarFocusedProjectId: null, sidebarFocusedTaskId: null, - }; + }); }); afterEach(() => { diff --git a/src/store/notifications.test.ts b/src/store/notifications.test.ts index 8fd872bfa..9ddb34fb9 100644 --- a/src/store/notifications.test.ts +++ b/src/store/notifications.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { expectDefined, type MockStoreHarness } from './test-helpers'; type StagedNotification = { batchId: string; @@ -17,42 +18,29 @@ type MockTask = { }; let mockTasks: Record = {}; +const core = vi.hoisted(() => ({ + harness: undefined as MockStoreHarness<{ tasks: Record }> | undefined, +})); const ipcHandlers = new Map void>(); const activeHandlerCounts = new Map(); -// Must be a function declaration (hoisted) so vi.mock factories can reference it. -function applySetStore(...args: unknown[]): void { - if (args.length === 1 && typeof args[0] === 'function') { - // produce(fn) pattern: solid-js/store is mocked so produce returns fn directly, - // and setStore receives fn as its only arg. Call it with a plain object that - // mirrors the store shape so mutations land on mockTasks. - (args[0] as (s: { tasks: Record }) => void)({ tasks: mockTasks }); - return; - } - // Path-based pattern: setStore('tasks', taskId, 'stagedNotification', value) - const value = args[args.length - 1]; - let target: Record = { tasks: mockTasks }; - for (let i = 0; i < args.length - 2; i++) { - target = target[args[i] as string] as Record; - } - target[args[args.length - 2] as string] = value; -} - -vi.mock('solid-js/store', () => ({ - // produce(fn) → fn, so setStore(produce(fn)) becomes setStore(fn) - produce: (fn: (s: unknown) => void) => fn, -})); +vi.mock('solid-js/store', async () => { + const { mockSolidStoreProduce } = await import('./test-helpers'); + return mockSolidStoreProduce(); +}); -vi.mock('./core', () => ({ - store: new Proxy({} as Record, { - get(_target, prop) { - if (prop === 'tasks') return mockTasks; - return undefined; +vi.mock('./core', async () => { + const { createMockStoreHarness } = await import('./test-helpers'); + core.harness = createMockStoreHarness({ + get tasks() { + return mockTasks; }, - }), - setStore: vi.fn((...args: unknown[]) => applySetStore(...args)), - cleanupPanelEntries: vi.fn(), -})); + set tasks(next) { + mockTasks = next; + }, + }); + return core.harness.moduleMock({ cleanupPanelEntries: vi.fn() }); +}); vi.mock('../lib/ipc', () => ({ invoke: vi.fn() })); vi.mock('../../electron/ipc/channels', () => ({ @@ -134,6 +122,8 @@ function setTask(id: string, overrides: Partial = {}): void { } beforeEach(() => { + const harness = expectDefined(core.harness, 'mock store harness'); + harness.reset(harness.state()); mockTasks = {}; }); diff --git a/src/store/sidebar-order.test.ts b/src/store/sidebar-order.test.ts index e40296692..c982ca9ee 100644 --- a/src/store/sidebar-order.test.ts +++ b/src/store/sidebar-order.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { expectDefined, type MockStoreHarness } from './test-helpers'; type MockTask = { projectId?: string; @@ -6,19 +7,28 @@ type MockTask = { coordinatedBy?: string; collapsed?: boolean; }; +type MockStore = { + tasks: Record; + taskOrder: string[]; + collapsedTaskOrder: string[]; + projects: Array<{ id: string }>; +}; -const { mockStore } = vi.hoisted(() => ({ - mockStore: { - tasks: {} as Record, - taskOrder: [] as string[], - collapsedTaskOrder: [] as string[], - projects: [] as Array<{ id: string }>, - }, +const core = vi.hoisted(() => ({ + harness: undefined as MockStoreHarness | undefined, })); +let mockStore: MockStore; -vi.mock('./core', () => ({ - store: mockStore, -})); +vi.mock('./core', async () => { + const { createMockStoreHarness } = await import('./test-helpers'); + core.harness = createMockStoreHarness({ + tasks: {}, + taskOrder: [], + collapsedTaskOrder: [], + projects: [], + }); + return core.harness.moduleMock(); +}); import { computeGroupedTasks, @@ -27,10 +37,13 @@ import { } from './sidebar-order'; beforeEach(() => { - mockStore.tasks = {}; - mockStore.taskOrder = []; - mockStore.collapsedTaskOrder = []; - mockStore.projects = []; + const harness = expectDefined(core.harness, 'mock store harness'); + mockStore = harness.reset({ + tasks: {}, + taskOrder: [], + collapsedTaskOrder: [], + projects: [], + }); }); describe('sidebar coordinator ordering', () => { diff --git a/src/store/taskStatus.test.ts b/src/store/taskStatus.test.ts index c1d713043..19cfc4969 100644 --- a/src/store/taskStatus.test.ts +++ b/src/store/taskStatus.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { expectDefined, type MockStoreHarness } from './test-helpers'; // Mock the SolidJS store before importing the module under test. let mockAutoTrustFolders = false; @@ -6,22 +7,53 @@ let mockActiveTaskId: string | null = null; let mockTasks: Record = {}; let mockAgents: Record = {}; let mockTaskGitStatus: Record = {}; -vi.mock('./core', () => ({ - store: new Proxy( - {}, - { - get(_target, prop) { - if (prop === 'autoTrustFolders') return mockAutoTrustFolders; - if (prop === 'activeTaskId') return mockActiveTaskId; - if (prop === 'tasks') return mockTasks; - if (prop === 'agents') return mockAgents; - if (prop === 'taskGitStatus') return mockTaskGitStatus; - return undefined; - }, - }, - ), - setStore: vi.fn(), +const core = vi.hoisted(() => ({ + harness: undefined as + | MockStoreHarness<{ + autoTrustFolders: boolean; + activeTaskId: string | null; + tasks: Record; + agents: Record; + taskGitStatus: Record; + }> + | undefined, })); +vi.mock('./core', async () => { + const { createMockStoreHarness } = await import('./test-helpers'); + core.harness = createMockStoreHarness({ + get autoTrustFolders() { + return mockAutoTrustFolders; + }, + set autoTrustFolders(next) { + mockAutoTrustFolders = next; + }, + get activeTaskId() { + return mockActiveTaskId; + }, + set activeTaskId(next) { + mockActiveTaskId = next; + }, + get tasks() { + return mockTasks; + }, + set tasks(next) { + mockTasks = next; + }, + get agents() { + return mockAgents; + }, + set agents(next) { + mockAgents = next; + }, + get taskGitStatus() { + return mockTaskGitStatus; + }, + set taskGitStatus(next) { + mockTaskGitStatus = next; + }, + }); + return core.harness.moduleMock(); +}); // Mock IPC so tryAutoTrust's invoke call doesn't hit Electron. vi.mock('../lib/ipc', () => ({ @@ -89,6 +121,8 @@ function setMockAgent(agentId: string, overrides: Record = {}): beforeEach(() => { vi.useFakeTimers(); + const harness = expectDefined(core.harness, 'mock store harness'); + harness.reset(harness.state()); vi.mocked(invoke).mockClear(); vi.mocked(setStore).mockClear(); mockAutoTrustFolders = false; diff --git a/src/store/tasks.test.ts b/src/store/tasks.test.ts index fb24f5031..9d4ca6ab1 100644 --- a/src/store/tasks.test.ts +++ b/src/store/tasks.test.ts @@ -1,15 +1,26 @@ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; import { IPC } from '../../electron/ipc/channels'; +import { expectDefined, type MockStoreHarness } from './test-helpers'; // Hoisted so these refs are available both in vi.mock() factories and in test bodies. -const { mockInvoke, mockIsAgentBracketedPasteEnabled, mockSaveState, mockSetStore } = vi.hoisted( - () => ({ - mockInvoke: vi.fn(), - mockIsAgentBracketedPasteEnabled: vi.fn(), - mockSaveState: vi.fn(), - mockSetStore: vi.fn(), - }), -); +const { mockInvoke, mockIsAgentBracketedPasteEnabled, mockSaveState } = vi.hoisted(() => ({ + mockInvoke: vi.fn(), + mockIsAgentBracketedPasteEnabled: vi.fn(), + mockSaveState: vi.fn(), +})); +const core = vi.hoisted(() => ({ + harness: undefined as + | MockStoreHarness<{ + tasks: Record; + agents: Record; + taskOrder: string[]; + collapsedTaskOrder: string[]; + projects: { id: string; path: string }[]; + availableAgents: unknown[]; + defaultStepsEnabled: boolean; + }> + | undefined, +})); // ─── Coordinator test infrastructure ───────────────────────────────────────── @@ -28,66 +39,56 @@ let mockCollapsedTaskOrder: string[] = []; let mockProjects: { id: string; path: string }[] = []; const ipcHandlers = new Map void>(); -function applySetStore(...args: unknown[]): void { - if (args.length === 1 && typeof args[0] === 'function') { - ( - args[0] as (s: { - tasks: Record; - agents: Record; - taskOrder: string[]; - collapsedTaskOrder: string[]; - }) => void - )({ - tasks: mockTasks, - agents: mockAgents, - taskOrder: mockTaskOrder, - collapsedTaskOrder: mockCollapsedTaskOrder, - }); - return; - } - // Path-based: setStore('tasks', taskId, 'field', value) - const value = args[args.length - 1]; - let target: Record = { - tasks: mockTasks, - agents: mockAgents, - taskOrder: mockTaskOrder, - }; - for (let i = 0; i < args.length - 2; i++) { - const next = target[args[i] as string] as Record | undefined; - if (next === undefined || next === null) return; - target = next; - } - target[args[args.length - 2] as string] = value; -} - -// Wire up mockSetStore to apply mutations so coordinator tests can read back state. -// Re-applied in sendPrompt's beforeEach after vi.clearAllMocks(). -mockSetStore.mockImplementation((...args: unknown[]) => applySetStore(...args)); - // ─── Mocks ─────────────────────────────────────────────────────────────────── vi.mock('../lib/ipc', () => ({ Channel: vi.fn(), invoke: mockInvoke })); let mockDefaultStepsEnabled = false; -vi.mock('./core', () => ({ - store: new Proxy({} as Record, { - get(_target, prop) { - if (prop === 'tasks') return mockTasks; - if (prop === 'agents') return mockAgents; - if (prop === 'taskOrder') return mockTaskOrder; - if (prop === 'collapsedTaskOrder') return mockCollapsedTaskOrder; - if (prop === 'availableAgents') return []; - if (prop === 'projects') return mockProjects; - if (prop === 'defaultStepsEnabled') return mockDefaultStepsEnabled; - return undefined; +vi.mock('./core', async () => { + const { createMockStoreHarness } = await import('./test-helpers'); + core.harness = createMockStoreHarness({ + get tasks() { + return mockTasks; }, - }), - setStore: mockSetStore, - cleanupPanelEntries: vi.fn(), -})); + set tasks(next) { + mockTasks = next; + }, + get agents() { + return mockAgents; + }, + set agents(next) { + mockAgents = next; + }, + get taskOrder() { + return mockTaskOrder; + }, + set taskOrder(next) { + mockTaskOrder = next; + }, + get collapsedTaskOrder() { + return mockCollapsedTaskOrder; + }, + set collapsedTaskOrder(next) { + mockCollapsedTaskOrder = next; + }, + get projects() { + return mockProjects; + }, + set projects(next) { + mockProjects = next; + }, + availableAgents: [], + get defaultStepsEnabled() { + return mockDefaultStepsEnabled; + }, + set defaultStepsEnabled(next) { + mockDefaultStepsEnabled = next; + }, + }); + return core.harness.moduleMock({ cleanupPanelEntries: vi.fn() }); +}); -vi.mock('../lib/ipc', () => ({ Channel: vi.fn(), invoke: mockInvoke })); vi.mock('./persistence', () => ({ saveState: mockSaveState })); vi.mock('./focus', () => ({ setTaskFocusedPanel: vi.fn() })); vi.mock('./projects', () => ({ @@ -156,6 +157,7 @@ import { recordMergedLines, recordTaskMerged } from './completion'; import { markAgentSpawned, rescheduleTaskStatusPolling } from './taskStatus'; import { saveState } from './persistence'; import { getProjectBranchPrefix, getProjectPath, isProjectMissing } from './projects'; +const mockSetStore = expectDefined(core.harness, 'mock store harness').setStore; // ─── Coordinator listener setup ─────────────────────────────────────────────── @@ -167,7 +169,8 @@ if (!taskStateSyncHandler) throw new Error('mcp_task_state_sync handler not regi beforeEach(() => { vi.clearAllMocks(); - mockSetStore.mockImplementation((...args: unknown[]) => applySetStore(...args)); + const harness = expectDefined(core.harness, 'mock store harness'); + harness.reset(harness.state()); mockTasks = {}; mockAgents = {}; mockTaskOrder = []; @@ -423,7 +426,8 @@ describe('terminalInputPendingFromQuestion — real typing survives self-resolvi beforeEach(() => { vi.clearAllMocks(); vi.useFakeTimers(); - mockSetStore.mockImplementation((...args: unknown[]) => applySetStore(...args)); + const harness = expectDefined(core.harness, 'mock store harness'); + harness.reset(harness.state()); mockTasks['sub-task-1'] = { agentIds: ['agent-sub-1'], shellAgentIds: [], @@ -542,7 +546,8 @@ describe('hasActiveCoordinator condition — coordinator task removal', () => { describe('MCP startup status transitions', () => { beforeEach(() => { vi.clearAllMocks(); - mockSetStore.mockImplementation((...args: unknown[]) => applySetStore(...args)); + const harness = expectDefined(core.harness, 'mock store harness'); + harness.reset(harness.state()); mockInvoke.mockResolvedValue(undefined); mockProjects = [{ id: 'proj-1', path: '/repo' }]; }); @@ -744,7 +749,8 @@ describe('MCP startup status transitions', () => { describe('createTask coordinator base branch prompt', () => { beforeEach(() => { vi.clearAllMocks(); - mockSetStore.mockImplementation((...args: unknown[]) => applySetStore(...args)); + const harness = expectDefined(core.harness, 'mock store harness'); + harness.reset(harness.state()); mockTasks = {}; mockAgents = {}; mockTaskOrder = []; @@ -817,7 +823,8 @@ describe('createTask does not mutate defaultStepsEnabled', () => { beforeEach(() => { vi.clearAllMocks(); - mockSetStore.mockImplementation((...args: unknown[]) => applySetStore(...args)); + const harness = expectDefined(core.harness, 'mock store harness'); + harness.reset(harness.state()); mockTasks = {}; mockAgents = {}; mockTaskOrder = []; @@ -865,8 +872,8 @@ function writePayloads(): string[] { describe('sendPrompt', () => { beforeEach(() => { vi.clearAllMocks(); - // Re-apply after clearAllMocks() so coordinator store mutations still work. - mockSetStore.mockImplementation((...args: unknown[]) => applySetStore(...args)); + const harness = expectDefined(core.harness, 'mock store harness'); + harness.reset(harness.state()); mockInvoke.mockResolvedValue(undefined); mockIsAgentBracketedPasteEnabled.mockReturnValue(false); mockAgents = { 'agent-1': { status: 'running' } }; @@ -919,7 +926,8 @@ if (!cleanupFailedHandler) throw new Error('mcp_task_cleanup_failed handler not describe('MCP_TaskCleanupFailed IPC handler', () => { beforeEach(() => { vi.clearAllMocks(); - mockSetStore.mockImplementation((...args: unknown[]) => applySetStore(...args)); + const harness = expectDefined(core.harness, 'mock store harness'); + harness.reset(harness.state()); mockTasks = { 'task-1': { agentIds: ['agent-1'], @@ -960,7 +968,8 @@ describe('MCP_TaskCleanupFailed IPC handler', () => { describe('closeTask — IPC cleanup ordering', () => { beforeEach(() => { vi.clearAllMocks(); - mockSetStore.mockImplementation((...args: unknown[]) => applySetStore(...args)); + const harness = expectDefined(core.harness, 'mock store harness'); + harness.reset(harness.state()); mockInvoke.mockResolvedValue(undefined); }); @@ -1078,7 +1087,8 @@ describe('closeTask — IPC cleanup ordering', () => { describe('recordTaskMerged counts merges with cleanup, not closures', () => { beforeEach(() => { vi.clearAllMocks(); - mockSetStore.mockImplementation((...args: unknown[]) => applySetStore(...args)); + const harness = expectDefined(core.harness, 'mock store harness'); + harness.reset(harness.state()); mockInvoke.mockResolvedValue(undefined); vi.mocked(getProjectPath).mockReturnValue('/repo'); }); @@ -1146,7 +1156,8 @@ describe('recordTaskMerged counts merges with cleanup, not closures', () => { describe('MCP_TaskStateSync listener', () => { beforeEach(() => { - mockSetStore.mockImplementation((...args: unknown[]) => applySetStore(...args)); + const harness = expectDefined(core.harness, 'mock store harness'); + harness.reset(harness.state()); mockTasks['task-1'] = { agentIds: [], shellAgentIds: [], diff --git a/src/store/test-helpers.ts b/src/store/test-helpers.ts new file mode 100644 index 000000000..382702580 --- /dev/null +++ b/src/store/test-helpers.ts @@ -0,0 +1,120 @@ +import { vi } from 'vitest'; +import type { Mock } from 'vitest'; + +export type MockStoreExtra = Record; + +export type MockStoreHarness = { + readonly store: TStore; + readonly setStore: Mock<(...args: unknown[]) => void>; + state(): TStore; + reset(next: TStore): TStore; + applySetStore(...args: unknown[]): void; + moduleMock( + extra?: MockStoreExtra, + ): { store: TStore; setStore: Mock<(...args: unknown[]) => void> } & MockStoreExtra; +}; + +type Producer = (draft: T) => unknown; + +export function expectDefined(value: T | null | undefined, label = 'value'): T { + if (value === null || value === undefined) throw new Error(`${label} is not defined`); + return value; +} + +function isObject(value: unknown): value is Record { + return (typeof value === 'object' && value !== null) || typeof value === 'function'; +} + +function getContainer(target: unknown, key: PropertyKey): Record | undefined { + if (!isObject(target)) return undefined; + const next = target[key]; + return isObject(next) ? next : undefined; +} + +function setPath(target: unknown, path: unknown[], value: unknown): void { + if (path.length === 0) return; + let parent = target; + for (const key of path.slice(0, -1)) { + const next = getContainer(parent, key as PropertyKey); + if (!next) return; + parent = next; + } + if (!isObject(parent)) return; + parent[path[path.length - 1] as PropertyKey] = value; +} + +function readPath(target: unknown, path: unknown[]): unknown { + let current = target; + for (const key of path) { + if (!isObject(current)) return undefined; + current = current[key as PropertyKey]; + } + return current; +} + +export function createMockStoreHarness( + initial: TStore, +): MockStoreHarness { + let current = initial; + const store = new Proxy({} as TStore, { + get(_target, prop) { + return current[prop as keyof TStore]; + }, + set(_target, prop, value) { + current[prop as keyof TStore] = value as TStore[keyof TStore]; + return true; + }, + has(_target, prop) { + return prop in current; + }, + ownKeys() { + return Reflect.ownKeys(current); + }, + getOwnPropertyDescriptor(_target, prop) { + if (!(prop in current)) return undefined; + return { configurable: true, enumerable: true, value: current[prop as keyof TStore] }; + }, + }); + + const applySetStore = (...args: unknown[]): void => { + if (args.length === 0) return; + if (args.length === 1 && typeof args[0] === 'function') { + (args[0] as Producer)(current); + return; + } + if (args.length >= 2 && typeof args[args.length - 1] === 'function') { + const path = args.slice(0, -1); + const target = readPath(current, path); + const next = (args[args.length - 1] as Producer)(target); + if (next !== undefined) setPath(current, path, next); + return; + } + setPath(current, args.slice(0, -1), args[args.length - 1]); + }; + + const setStore = vi.fn((...args: unknown[]) => applySetStore(...args)); + + return { + store, + setStore, + state: () => current, + reset(next) { + current = next; + setStore.mockClear(); + setStore.mockImplementation((...args: unknown[]) => applySetStore(...args)); + return current; + }, + applySetStore, + moduleMock(extra = {}) { + return { store, setStore, ...extra }; + }, + }; +} + +export function mockSolidStoreProduce(): { + produce: (producer: TProducer) => TProducer; +} { + return { + produce: (producer) => producer, + }; +} diff --git a/src/store/ui.test.ts b/src/store/ui.test.ts index a61703e18..75591752f 100644 --- a/src/store/ui.test.ts +++ b/src/store/ui.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { expectDefined, type MockStoreHarness } from './test-helpers'; type MockStore = { activeTaskId: string | null; @@ -11,6 +12,9 @@ type MockStore = { }; let mockStore: MockStore; +const core = vi.hoisted(() => ({ + harness: undefined as MockStoreHarness | undefined, +})); const mocks = vi.hoisted(() => ({ setActiveTask: vi.fn((id: string) => { mockStore.activeTaskId = id; @@ -20,50 +24,22 @@ const mocks = vi.hoisted(() => ({ }), })); -function setStorePath(...args: unknown[]): void { - const value = args[args.length - 1]; - let target: Record = mockStore as unknown as Record; - for (let i = 0; i < args.length - 2; i++) { - const key = args[i] as string; - const next = target[key] as Record | undefined; - if (!next || typeof next !== 'object') { - target[key] = {}; - } - target = target[key] as Record; - } - target[args[args.length - 2] as string] = value; -} - vi.mock('solid-js', () => ({ batch: (fn: () => void) => fn(), })); // Real Solid produce uses Proxy mutation tracking; for the mock, a thin // pass-through is enough because our store slices are plain objects. -vi.mock('solid-js/store', () => ({ - produce: (fn: (draft: unknown) => void) => fn, -})); +vi.mock('solid-js/store', async () => { + const { mockSolidStoreProduce } = await import('./test-helpers'); + return mockSolidStoreProduce(); +}); -vi.mock('./core', () => ({ - store: new Proxy( - {}, - { - get(_target, prop) { - return mockStore[prop as keyof MockStore]; - }, - }, - ), - setStore: vi.fn((...args: unknown[]) => { - // Produce-style: setStore('key', produceFn) — run the producer on the slice. - if (args.length === 2 && typeof args[1] === 'function') { - const key = args[0] as keyof MockStore; - const producer = args[1] as (draft: unknown) => void; - producer(mockStore[key]); - return; - } - setStorePath(...args); - }), -})); +vi.mock('./core', async () => { + const { createMockStoreHarness } = await import('./test-helpers'); + core.harness = createMockStoreHarness({} as MockStore); + return core.harness.moduleMock(); +}); vi.mock('./navigation', () => ({ setActiveTask: mocks.setActiveTask, @@ -90,7 +66,8 @@ import { } from './ui'; beforeEach(() => { - mockStore = { + const harness = expectDefined(core.harness, 'mock store harness'); + mockStore = harness.reset({ activeTaskId: 'task-1', focusMode: false, tasks: { @@ -101,7 +78,7 @@ beforeEach(() => { panelUserSize: {}, projectsCollapsed: false, sidebarFocusedProjectId: null, - }; + }); vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => { cb(0);