import { describe, it, expect, vi, beforeEach } from 'vitest'; import { createCli } from './index.js'; const mockClient = { errand: { create: { mutate: vi.fn() }, list: { query: vi.fn() }, get: { query: vi.fn() }, diff: { query: vi.fn() }, complete: { mutate: vi.fn() }, merge: { mutate: vi.fn() }, delete: { mutate: vi.fn() }, sendMessage: { mutate: vi.fn() }, abandon: { mutate: vi.fn() }, }, }; vi.mock('./trpc-client.js', () => ({ createDefaultTrpcClient: () => mockClient, })); beforeEach(() => { vi.clearAllMocks(); }); async function runCli(args: string[]): Promise<{ stdout: string; stderr: string; exitCode: number }> { const stdoutLines: string[] = []; const stderrLines: string[] = []; let exitCode = 0; vi.spyOn(process.stdout, 'write').mockImplementation((s: any) => { stdoutLines.push(String(s)); return true; }); vi.spyOn(process.stderr, 'write').mockImplementation((s: any) => { stderrLines.push(String(s)); return true; }); vi.spyOn(console, 'log').mockImplementation((...a: any[]) => { stdoutLines.push(a.join(' ')); }); vi.spyOn(console, 'error').mockImplementation((...a: any[]) => { stderrLines.push(a.join(' ')); }); vi.spyOn(process, 'exit').mockImplementation((code?: any) => { exitCode = code ?? 0; throw new Error(`process.exit(${code})`); }); const program = createCli(); try { await program.parseAsync(['node', 'cw', ...args]); } catch (e: any) { if (!e.message?.startsWith('process.exit')) throw e; } vi.restoreAllMocks(); return { stdout: stdoutLines.join('\n'), stderr: stderrLines.join('\n'), exitCode, }; } describe('cw errand start', () => { it('calls errand.create.mutate with correct args and prints output', async () => { mockClient.errand.create.mutate.mockResolvedValueOnce({ id: 'errand-abc123', branch: 'cw/errand/fix-typo-errand-ab', agentId: 'agent-xyz', }); const { stdout, exitCode } = await runCli(['errand', 'start', 'fix typo', '--project', 'proj-1']); expect(mockClient.errand.create.mutate).toHaveBeenCalledWith({ description: 'fix typo', projectId: 'proj-1', baseBranch: undefined, }); expect(stdout).toContain('Errand started'); expect(stdout).toContain('errand-abc123'); expect(exitCode).toBe(0); }); it('exits 1 and prints length error without calling tRPC when description > 200 chars', async () => { const longDesc = 'x'.repeat(201); const { stderr, exitCode } = await runCli(['errand', 'start', longDesc, '--project', 'proj-1']); expect(mockClient.errand.create.mutate).not.toHaveBeenCalled(); expect(stderr).toContain('description must be ≤200 characters (201 given)'); expect(exitCode).toBe(1); }); it('passes --base option as baseBranch', async () => { mockClient.errand.create.mutate.mockResolvedValueOnce({ id: 'e1', branch: 'b', agentId: 'a' }); await runCli(['errand', 'start', 'fix thing', '--project', 'p1', '--base', 'develop']); expect(mockClient.errand.create.mutate).toHaveBeenCalledWith( expect.objectContaining({ baseBranch: 'develop' }) ); }); }); describe('cw errand list', () => { it('prints tab-separated rows for errands', async () => { mockClient.errand.list.query.mockResolvedValueOnce([ { id: 'errand-abc123full', description: 'fix the bug', branch: 'cw/errand/fix-bug-errand-ab', status: 'active', agentAlias: 'my-agent' }, ]); const { stdout } = await runCli(['errand', 'list']); expect(stdout).toContain('errand-a'); // id.slice(0,8) expect(stdout).toContain('fix the bug'); expect(stdout).toContain('active'); expect(stdout).toContain('my-agent'); }); it('prints "No errands found" on empty result', async () => { mockClient.errand.list.query.mockResolvedValueOnce([]); const { stdout } = await runCli(['errand', 'list']); expect(stdout).toContain('No errands found'); }); it('truncates description at 60 chars with ellipsis', async () => { const longDesc = 'a'.repeat(65); mockClient.errand.list.query.mockResolvedValueOnce([ { id: 'x'.repeat(16), description: longDesc, branch: 'b', status: 'active', agentAlias: null }, ]); const { stdout } = await runCli(['errand', 'list']); expect(stdout).toContain('a'.repeat(57) + '...'); }); it('passes --status filter to query', async () => { mockClient.errand.list.query.mockResolvedValueOnce([]); await runCli(['errand', 'list', '--status', 'active']); expect(mockClient.errand.list.query).toHaveBeenCalledWith(expect.objectContaining({ status: 'active' })); }); it('passes --project filter to query', async () => { mockClient.errand.list.query.mockResolvedValueOnce([]); await runCli(['errand', 'list', '--project', 'proj-99']); expect(mockClient.errand.list.query).toHaveBeenCalledWith(expect.objectContaining({ projectId: 'proj-99' })); }); it('shows "-" for null agentAlias', async () => { mockClient.errand.list.query.mockResolvedValueOnce([ { id: 'x'.repeat(16), description: 'test', branch: 'b', status: 'active', agentAlias: null }, ]); const { stdout } = await runCli(['errand', 'list']); expect(stdout).toContain('-'); }); }); describe('cw errand chat', () => { it('calls sendMessage.mutate with no stdout on success', async () => { mockClient.errand.sendMessage.mutate.mockResolvedValueOnce({ success: true }); const { stdout, exitCode } = await runCli(['errand', 'chat', 'e1', 'hello there']); expect(mockClient.errand.sendMessage.mutate).toHaveBeenCalledWith({ id: 'e1', message: 'hello there' }); expect(stdout.trim()).toBe(''); expect(exitCode).toBe(0); }); it('exits 1 and prints error when tRPC throws (agent not running)', async () => { mockClient.errand.sendMessage.mutate.mockRejectedValueOnce(new Error('Agent is not running (status: stopped)')); const { stderr, exitCode } = await runCli(['errand', 'chat', 'e1', 'msg']); expect(stderr).toContain('Agent is not running'); expect(exitCode).toBe(1); }); }); describe('cw errand diff', () => { it('writes raw diff to stdout and exits 0', async () => { mockClient.errand.diff.query.mockResolvedValueOnce({ diff: 'diff --git a/foo.ts b/foo.ts\n+++ change' }); const { stdout, exitCode } = await runCli(['errand', 'diff', 'e1']); expect(stdout).toContain('diff --git'); expect(exitCode).toBe(0); }); it('produces no output on empty diff and exits 0', async () => { mockClient.errand.diff.query.mockResolvedValueOnce({ diff: '' }); const { stdout, exitCode } = await runCli(['errand', 'diff', 'e1']); expect(stdout.trim()).toBe(''); expect(exitCode).toBe(0); }); it('exits 1 with "Errand not found" on NOT_FOUND error', async () => { mockClient.errand.diff.query.mockRejectedValueOnce(new Error('NOT_FOUND: errand not found')); const { stderr, exitCode } = await runCli(['errand', 'diff', 'missing-id']); expect(stderr).toContain('Errand missing-id not found'); expect(exitCode).toBe(1); }); }); describe('cw errand complete', () => { it('prints "Errand marked as ready for review"', async () => { mockClient.errand.complete.mutate.mockResolvedValueOnce({}); const { stdout, exitCode } = await runCli(['errand', 'complete', 'errand-1']); expect(stdout).toContain('Errand errand-1 marked as ready for review'); expect(exitCode).toBe(0); }); }); describe('cw errand merge', () => { it('prints "Merged into " on clean merge', async () => { mockClient.errand.get.query.mockResolvedValueOnce({ id: 'e1', branch: 'cw/errand/fix-bug-e1', baseBranch: 'main', status: 'pending_review', conflictFiles: [], projectPath: '/path/to/repo', }); mockClient.errand.merge.mutate.mockResolvedValueOnce({ status: 'merged' }); const { stdout, exitCode } = await runCli(['errand', 'merge', 'e1']); expect(stdout).toContain('Merged cw/errand/fix-bug-e1 into main'); expect(exitCode).toBe(0); }); it('exits 1 and prints conflicting files on conflict', async () => { mockClient.errand.get.query.mockResolvedValueOnce({ id: 'e1', branch: 'cw/errand/fix-bug-e1', baseBranch: 'main', status: 'pending_review', conflictFiles: [], projectPath: '/repo', }); const conflictError = Object.assign(new Error('Merge conflict'), { data: { conflictFiles: ['src/a.ts', 'src/b.ts'] }, }); mockClient.errand.merge.mutate.mockRejectedValueOnce(conflictError); const { stderr, exitCode } = await runCli(['errand', 'merge', 'e1']); expect(stderr).toContain('Merge conflict in 2 file(s)'); expect(stderr).toContain('src/a.ts'); expect(stderr).toContain('src/b.ts'); expect(stderr).toContain('Run: cw errand resolve e1'); expect(exitCode).toBe(1); }); it('uses --target override instead of baseBranch', async () => { mockClient.errand.get.query.mockResolvedValueOnce({ id: 'e1', branch: 'cw/errand/fix-e1', baseBranch: 'main', status: 'pending_review', conflictFiles: [], projectPath: '/repo', }); mockClient.errand.merge.mutate.mockResolvedValueOnce({ status: 'merged' }); const { stdout } = await runCli(['errand', 'merge', 'e1', '--target', 'develop']); expect(stdout).toContain('Merged cw/errand/fix-e1 into develop'); expect(mockClient.errand.merge.mutate).toHaveBeenCalledWith({ id: 'e1', target: 'develop' }); }); }); describe('cw errand resolve', () => { it('prints worktree path and conflicting files when status is conflict', async () => { mockClient.errand.get.query.mockResolvedValueOnce({ id: 'e1', status: 'conflict', conflictFiles: ['src/a.ts', 'src/b.ts'], projectPath: '/home/user/project', branch: 'cw/errand/fix-e1', baseBranch: 'main', }); const { stdout, exitCode } = await runCli(['errand', 'resolve', 'e1']); expect(stdout).toContain('/home/user/project/.cw-worktrees/e1'); expect(stdout).toContain('src/a.ts'); expect(stdout).toContain('src/b.ts'); expect(stdout).toContain('cw errand merge e1'); expect(exitCode).toBe(0); }); it('exits 1 with status message when errand is not in conflict', async () => { mockClient.errand.get.query.mockResolvedValueOnce({ id: 'e1', status: 'pending_review', conflictFiles: [], projectPath: '/repo', }); const { stderr, exitCode } = await runCli(['errand', 'resolve', 'e1']); expect(stderr).toContain('is not in conflict'); expect(stderr).toContain('pending_review'); expect(exitCode).toBe(1); }); }); describe('cw errand abandon', () => { it('prints "Errand abandoned"', async () => { mockClient.errand.abandon.mutate.mockResolvedValueOnce({}); const { stdout, exitCode } = await runCli(['errand', 'abandon', 'errand-1']); expect(stdout).toContain('Errand errand-1 abandoned'); expect(exitCode).toBe(0); }); }); describe('cw errand delete', () => { it('prints "Errand deleted"', async () => { mockClient.errand.delete.mutate.mockResolvedValueOnce({ success: true }); const { stdout, exitCode } = await runCli(['errand', 'delete', 'errand-1']); expect(stdout).toContain('Errand errand-1 deleted'); expect(exitCode).toBe(0); }); });