Files
Codewalkers/apps/server/cli/errand.test.ts
Lukas May e86a743c0b feat: Add all 9 cw errand CLI subcommands with tests
Wires errand command group into CLI with start, list, chat, diff,
complete, merge, resolve, abandon, and delete subcommands. All
commands call tRPC procedures via createDefaultTrpcClient(). The
start command validates description length client-side (≤200 chars)
before making any network calls.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 16:26:15 +01:00

267 lines
11 KiB
TypeScript

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 <id> 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 <id> 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 <branch> into <baseBranch>" 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 <id> 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 <id> 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);
});
});