From b9afd5a57ec7b3cc9a70ec975fcbfc8c3e4ed8df Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sun, 16 Aug 2026 18:23:56 +0200 Subject: [PATCH] test: derive the CLI inventory from the real commander program The file asserted against a hand-written fixture array with its own argument parser, so it could not see a command being renamed, losing an alias or disappearing, and it described a `tui` command that does not exist. It now walks program.commands: names, aliases, subcommands, option flags, operands, descriptions, and a guard against registering a name or alias twice at one level. Assertions are "at least this exists", so a new command (including the tui one this plan adds later) passes without editing the test. Co-Authored-By: Claude Fable 5 --- test/cli-commands.test.ts | 975 +++++++------------------------------- 1 file changed, 170 insertions(+), 805 deletions(-) diff --git a/test/cli-commands.test.ts b/test/cli-commands.test.ts index ae65fb5d..93a010ed 100644 --- a/test/cli-commands.test.ts +++ b/test/cli-commands.test.ts @@ -1,836 +1,201 @@ /** - * @fileoverview Tests for CLI command parsing and validation + * @fileoverview Inventory tests for the real commander program in `src/cli.ts`. * - * Tests command argument parsing, validation, and help text generation. + * Everything here walks the actual `program` object. The previous version of + * this file asserted against a hand-written fixture array (with its own arg + * parser), so it could not see a command being renamed, losing an alias or + * disappearing entirely, and it happily described a `tui` command that did not + * exist. Assertions are deliberately "at least this exists", so adding a new + * command does not fail the suite. */ import { describe, it, expect } from 'vitest'; +import type { Command } from 'commander'; import { program } from '../src/cli.js'; -describe('CLI Command Parsing', () => { - describe('Command Structure', () => { - interface Command { - name: string; - aliases: string[]; - description: string; - subcommands?: Command[]; +/** Resolve a subcommand the way commander does, by name or alias. */ +function find(parent: Command, name: string): Command | undefined { + return parent.commands.find((c) => c.name() === name || c.aliases().includes(name)); +} + +/** Every `-x` / `--long` flag a command registers. */ +function flagsOf(cmd: Command): string[] { + return cmd.options.flatMap((option) => [option.short, option.long].filter((f): f is string => Boolean(f))); +} + +/** Depth-first walk over the whole command tree, including the root. */ +function walk(cmd: Command, path: string[] = []): Array<{ path: string[]; cmd: Command }> { + const here = [...path, cmd.name()]; + return [{ path: here, cmd }, ...cmd.commands.flatMap((child) => walk(child, here))]; +} + +/** Top-level commands and the aliases they must keep. */ +const TOP_LEVEL: Record = { + attach: [], + skill: [], + session: ['s'], + task: ['t'], + ralph: ['r'], + status: [], + reset: [], + start: [], + list: ['ls'], + web: [], + service: [], + users: [], + doctor: ['check-deps'], +}; + +/** Subcommands per parent, with their aliases. */ +const SUBCOMMANDS: Record> = { + session: { start: [], stop: [], list: ['ls'], logs: [] }, + task: { add: [], list: ['ls'], status: [], remove: ['rm'], clear: [] }, + ralph: { start: [], stop: [], status: [] }, + skill: { install: [], uninstall: [] }, + service: { install: [], uninstall: [], status: [] }, + users: { add: [], passwd: [], list: ['ls'], rm: [] }, +}; + +describe('registered commands', () => { + it('is the codeman program', () => { + expect(program.name()).toBe('codeman'); + }); + + it.each(Object.entries(TOP_LEVEL))('registers `%s` with its aliases', (name, aliases) => { + const cmd = find(program, name); + expect(cmd, `top-level command "${name}" is missing`).toBeDefined(); + expect(cmd!.name()).toBe(name); + expect(cmd!.aliases()).toEqual(expect.arrayContaining(aliases)); + }); + + it.each(Object.entries(SUBCOMMANDS))('registers the `%s` subcommands', (parentName, children) => { + const parent = find(program, parentName)!; + for (const [name, aliases] of Object.entries(children)) { + const child = find(parent, name); + expect(child, `${parentName} ${name} is missing`).toBeDefined(); + expect(child!.name()).toBe(name); + expect(child!.aliases()).toEqual(expect.arrayContaining(aliases)); } - - const commands: Command[] = [ - { - name: 'session', - aliases: ['s'], - description: 'Manage Claude sessions', - subcommands: [ - { name: 'start', aliases: [], description: 'Start new session' }, - { name: 'stop', aliases: [], description: 'Stop session' }, - { name: 'list', aliases: ['ls'], description: 'List all sessions' }, - { name: 'logs', aliases: [], description: 'View session output' }, - ], - }, - { - name: 'task', - aliases: ['t'], - description: 'Manage tasks', - subcommands: [ - { name: 'add', aliases: [], description: 'Add task' }, - { name: 'list', aliases: ['ls'], description: 'List tasks' }, - { name: 'status', aliases: [], description: 'Task details' }, - { name: 'remove', aliases: ['rm'], description: 'Remove task' }, - { name: 'clear', aliases: [], description: 'Clear completed' }, - ], - }, - { - name: 'ralph', - aliases: ['r'], - description: 'Control Ralph loop', - subcommands: [ - { name: 'start', aliases: [], description: 'Start loop' }, - { name: 'stop', aliases: [], description: 'Stop loop' }, - { name: 'status', aliases: [], description: 'Show status' }, - ], - }, - { - name: 'web', - aliases: [], - description: 'Start web interface', - }, - { - name: 'tui', - aliases: [], - description: 'Start TUI', - }, - { - name: 'status', - aliases: [], - description: 'Overall status', - }, - { - name: 'reset', - aliases: [], - description: 'Reset all state', - }, - ]; - - const findCommand = (name: string): Command | undefined => { - return commands.find((c) => c.name === name || c.aliases.includes(name)); - }; - - const findSubcommand = (parent: Command, name: string): Command | undefined => { - return parent.subcommands?.find((c) => c.name === name || c.aliases.includes(name)); - }; - - it('should find commands by name', () => { - expect(findCommand('session')?.name).toBe('session'); - expect(findCommand('task')?.name).toBe('task'); - expect(findCommand('ralph')?.name).toBe('ralph'); - }); - - it('should find commands by alias', () => { - expect(findCommand('s')?.name).toBe('session'); - expect(findCommand('t')?.name).toBe('task'); - expect(findCommand('r')?.name).toBe('ralph'); - }); - - it('should find subcommands', () => { - const session = findCommand('session')!; - expect(findSubcommand(session, 'start')?.name).toBe('start'); - expect(findSubcommand(session, 'list')?.name).toBe('list'); - expect(findSubcommand(session, 'ls')?.name).toBe('list'); - }); - - it('should return undefined for unknown commands', () => { - expect(findCommand('unknown')).toBeUndefined(); - }); - - it('should have descriptions for all commands', () => { - commands.forEach((cmd) => { - expect(cmd.description).toBeTruthy(); - }); - }); }); - describe('Argument Parsing', () => { - interface ParsedArgs { - command?: string; - subcommand?: string; - args: string[]; - flags: Record; + it('gives every command a description', () => { + const missing = walk(program) + .slice(1) + .filter(({ cmd }) => !cmd.description()) + .map(({ path }) => path.join(' ')); + expect(missing).toEqual([]); + }); + + it('never registers the same name or alias twice at one level', () => { + for (const { path, cmd } of walk(program)) { + const taken = cmd.commands.flatMap((child) => [child.name(), ...child.aliases()]); + expect(new Set(taken).size, `duplicate command name/alias under "${path.join(' ')}"`).toBe(taken.length); } - - const parseArgs = (argv: string[]): ParsedArgs => { - const result: ParsedArgs = { args: [], flags: {} }; - let i = 0; - - // Skip node and script name if present - while (i < argv.length && (argv[i].includes('node') || argv[i].endsWith('.js'))) { - i++; - } - - // Parse remaining args - while (i < argv.length) { - const arg = argv[i]; - - if (arg.startsWith('--')) { - const key = arg.slice(2); - const nextArg = argv[i + 1]; - if (nextArg && !nextArg.startsWith('-')) { - result.flags[key] = nextArg; - i++; - } else { - result.flags[key] = true; - } - } else if (arg.startsWith('-')) { - const key = arg.slice(1); - const nextArg = argv[i + 1]; - if (nextArg && !nextArg.startsWith('-')) { - result.flags[key] = nextArg; - i++; - } else { - result.flags[key] = true; - } - } else if (!result.command) { - result.command = arg; - } else if (!result.subcommand) { - result.subcommand = arg; - } else { - result.args.push(arg); - } - i++; - } - - return result; - }; - - it('should parse simple command', () => { - const parsed = parseArgs(['status']); - expect(parsed.command).toBe('status'); - }); - - it('should parse command with subcommand', () => { - const parsed = parseArgs(['session', 'start']); - expect(parsed.command).toBe('session'); - expect(parsed.subcommand).toBe('start'); - }); - - it('should parse boolean flags', () => { - const parsed = parseArgs(['web', '--verbose']); - expect(parsed.flags.verbose).toBe(true); - }); - - it('should parse flags with values', () => { - const parsed = parseArgs(['web', '-p', '8080']); - expect(parsed.flags.p).toBe('8080'); - }); - - it('should parse long flags with values', () => { - const parsed = parseArgs(['web', '--port', '8080']); - expect(parsed.flags.port).toBe('8080'); - }); - - it('should parse positional arguments', () => { - const parsed = parseArgs(['session', 'stop', 'session-123']); - expect(parsed.args).toEqual(['session-123']); - }); - - it('should handle multiple flags', () => { - const parsed = parseArgs(['tui', '--with-web', '-p', '3000']); - expect(parsed.flags['with-web']).toBe(true); - expect(parsed.flags.p).toBe('3000'); - }); - - it('should handle empty input', () => { - const parsed = parseArgs([]); - expect(parsed.command).toBeUndefined(); - expect(parsed.args).toEqual([]); - }); }); - describe('Flag Validation', () => { - interface FlagDef { - name: string; - short?: string; - type: 'boolean' | 'string' | 'number'; - required?: boolean; - default?: unknown; - } - - const webFlags: FlagDef[] = [ - { name: 'port', short: 'p', type: 'number', default: 3000 }, - { name: 'host', short: 'h', type: 'string', default: '0.0.0.0' }, - ]; - - const tuiFlags: FlagDef[] = [ - { name: 'port', short: 'p', type: 'number', default: 3000 }, - { name: 'with-web', type: 'boolean', default: false }, - { name: 'no-web', type: 'boolean', default: false }, - ]; - - const validateFlag = (value: unknown, def: FlagDef): boolean => { - if (value === undefined) return !def.required; - - switch (def.type) { - case 'boolean': - return typeof value === 'boolean'; - case 'string': - return typeof value === 'string' && value.length > 0; - case 'number': - return typeof value === 'number' || /^\d+$/.test(String(value)); - default: - return false; - } - }; - - it('should validate boolean flags', () => { - expect(validateFlag(true, { name: 'verbose', type: 'boolean' })).toBe(true); - expect(validateFlag(false, { name: 'verbose', type: 'boolean' })).toBe(true); - expect(validateFlag('true', { name: 'verbose', type: 'boolean' })).toBe(false); - }); - - it('should validate string flags', () => { - expect(validateFlag('value', { name: 'host', type: 'string' })).toBe(true); - expect(validateFlag('', { name: 'host', type: 'string' })).toBe(false); - expect(validateFlag(123, { name: 'host', type: 'string' })).toBe(false); - }); - - it('should validate number flags', () => { - expect(validateFlag(8080, { name: 'port', type: 'number' })).toBe(true); - expect(validateFlag('8080', { name: 'port', type: 'number' })).toBe(true); - expect(validateFlag('abc', { name: 'port', type: 'number' })).toBe(false); - }); - - it('should handle missing optional flags', () => { - expect(validateFlag(undefined, { name: 'port', type: 'number' })).toBe(true); - }); - - it('should reject missing required flags', () => { - expect(validateFlag(undefined, { name: 'port', type: 'number', required: true })).toBe(false); - }); - - it('should have defaults for web flags', () => { - webFlags.forEach((flag) => { - expect(flag.default).toBeDefined(); - }); - }); - - it('should have defaults for tui flags', () => { - tuiFlags.forEach((flag) => { - expect(flag.default).toBeDefined(); - }); - }); + it('resolves commands by alias, not just by name', () => { + expect(find(program, 's')?.name()).toBe('session'); + expect(find(program, 't')?.name()).toBe('task'); + expect(find(program, 'r')?.name()).toBe('ralph'); + expect(find(program, 'ls')?.name()).toBe('list'); + expect(find(program, 'check-deps')?.name()).toBe('doctor'); + expect(find(find(program, 'session')!, 'ls')?.name()).toBe('list'); }); - describe('Port Validation', () => { - const isValidPort = (port: number): boolean => { - return Number.isInteger(port) && port >= 1 && port <= 65535; - }; - - const isPrivilegedPort = (port: number): boolean => { - return port < 1024; - }; - - it('should accept valid ports', () => { - expect(isValidPort(80)).toBe(true); - expect(isValidPort(3000)).toBe(true); - expect(isValidPort(8080)).toBe(true); - expect(isValidPort(65535)).toBe(true); - }); - - it('should reject invalid ports', () => { - expect(isValidPort(0)).toBe(false); - expect(isValidPort(-1)).toBe(false); - expect(isValidPort(65536)).toBe(false); - expect(isValidPort(100000)).toBe(false); - }); - - it('should reject non-integer ports', () => { - expect(isValidPort(3000.5)).toBe(false); - expect(isValidPort(NaN)).toBe(false); - }); - - it('should detect privileged ports', () => { - expect(isPrivilegedPort(80)).toBe(true); - expect(isPrivilegedPort(443)).toBe(true); - expect(isPrivilegedPort(1023)).toBe(true); - expect(isPrivilegedPort(1024)).toBe(false); - expect(isPrivilegedPort(3000)).toBe(false); - }); - }); - - describe('Help Text Generation', () => { - const generateHelp = (command: string, description: string, options: string[]): string => { - let help = `Usage: codeman ${command}\n\n`; - help += `${description}\n`; - if (options.length > 0) { - help += '\nOptions:\n'; - options.forEach((opt) => { - help += ` ${opt}\n`; - }); - } - return help; - }; - - it('should generate basic help', () => { - const help = generateHelp('status', 'Show overall status', []); - expect(help).toContain('Usage: codeman status'); - expect(help).toContain('Show overall status'); - }); - - it('should include options', () => { - const help = generateHelp('web', 'Start web interface', [ - '-p, --port Server port (default: 3000)', - '-h, --host Server host (default: 0.0.0.0)', - ]); - expect(help).toContain('Options:'); - expect(help).toContain('--port'); - expect(help).toContain('--host'); - }); - - it('documents the unauthenticated network override in real web command help', () => { - const webCommand = program.commands.find((command) => command.name() === 'web'); - expect(webCommand).toBeDefined(); - - const help = webCommand!.helpInformation(); - expect(help).toContain('--allow-unauthenticated-network'); - expect(help).toMatch(/without\s+CODEMAN_PASSWORD/); - }); - - it('should format properly', () => { - const help = generateHelp('test', 'Test command', ['--flag']); - const lines = help.split('\n'); - expect(lines[0]).toMatch(/^Usage:/); - }); - }); - - describe('Session ID Validation', () => { - const isValidSessionId = (id: string): boolean => { - // UUID format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx - return /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/.test(id); - }; - - const isValidShortId = (id: string): boolean => { - // Short format: session-timestamp-random - return /^session-\d+-[a-z0-9]+$/.test(id); - }; - - it('should validate UUID session IDs', () => { - expect(isValidSessionId('550e8400-e29b-41d4-a716-446655440000')).toBe(true); - expect(isValidSessionId('123e4567-e89b-12d3-a456-426614174000')).toBe(true); - }); - - it('should reject invalid UUIDs', () => { - expect(isValidSessionId('not-a-uuid')).toBe(false); - expect(isValidSessionId('550e8400-e29b-41d4-a716')).toBe(false); - expect(isValidSessionId('')).toBe(false); - }); - - it('should validate short session IDs', () => { - expect(isValidShortId('session-1234567890-abc123')).toBe(true); - }); - - it('should reject invalid short IDs', () => { - expect(isValidShortId('session-abc-123')).toBe(false); - expect(isValidShortId('not-session-123-abc')).toBe(false); - }); - }); - - describe('Case Name Validation', () => { - const isValidCaseName = (name: string): boolean => { - if (name.length === 0 || name.length > 100) return false; - // Allow alphanumeric, hyphens, underscores - return /^[a-zA-Z0-9_-]+$/.test(name); - }; - - const sanitizeCaseName = (name: string): string => { - return name - .toLowerCase() - .replace(/[^a-z0-9_-]/g, '-') - .replace(/-+/g, '-') - .replace(/^-|-$/g, '') - .substring(0, 100); - }; - - it('should accept valid case names', () => { - expect(isValidCaseName('my-project')).toBe(true); - expect(isValidCaseName('project_v2')).toBe(true); - expect(isValidCaseName('Test123')).toBe(true); - }); - - it('should reject invalid case names', () => { - expect(isValidCaseName('')).toBe(false); - expect(isValidCaseName('my project')).toBe(false); - expect(isValidCaseName('project@v2')).toBe(false); - }); - - it('should reject too long names', () => { - expect(isValidCaseName('x'.repeat(101))).toBe(false); - }); - - it('should sanitize names', () => { - expect(sanitizeCaseName('My Project!')).toBe('my-project'); - expect(sanitizeCaseName('test@#$123')).toBe('test-123'); - expect(sanitizeCaseName(' spaces ')).toBe('spaces'); - }); - - it('should handle consecutive special chars', () => { - expect(sanitizeCaseName('a!!!b')).toBe('a-b'); - }); - }); - - describe('Prompt Validation', () => { - const MAX_PROMPT_LENGTH = 100000; - - const isValidPrompt = (prompt: string): boolean => { - return prompt.length > 0 && prompt.length <= MAX_PROMPT_LENGTH; - }; - - const truncatePrompt = (prompt: string, maxLength: number = MAX_PROMPT_LENGTH): string => { - if (prompt.length <= maxLength) return prompt; - return prompt.substring(0, maxLength - 3) + '...'; - }; - - it('should accept valid prompts', () => { - expect(isValidPrompt('Hello')).toBe(true); - expect(isValidPrompt('A'.repeat(1000))).toBe(true); - }); - - it('should reject empty prompts', () => { - expect(isValidPrompt('')).toBe(false); - }); - - it('should reject too long prompts', () => { - expect(isValidPrompt('A'.repeat(MAX_PROMPT_LENGTH + 1))).toBe(false); - }); - - it('should truncate long prompts', () => { - const longPrompt = 'A'.repeat(100); - const truncated = truncatePrompt(longPrompt, 50); - expect(truncated.length).toBe(50); - expect(truncated.endsWith('...')).toBe(true); - }); - - it('should not truncate short prompts', () => { - const shortPrompt = 'Hello'; - expect(truncatePrompt(shortPrompt, 50)).toBe(shortPrompt); - }); + it('has no command for an unknown name', () => { + expect(find(program, 'unknown')).toBeUndefined(); }); }); -describe('CLI Output Formatting', () => { - describe('Table Formatting', () => { - interface Column { - header: string; - width: number; +describe('registered options', () => { + it('exposes the web launch flags', () => { + const flags = flagsOf(find(program, 'web')!); + expect(flags).toEqual( + expect.arrayContaining([ + '-H', + '--host', + '-p', + '--port', + '--https', + '--title-hostname', + '--allow-unauthenticated-network', + '--multiuser', + '-d', + '--daemon', + '--stop', + '--status', + ]) + ); + }); + + it('gives `service install` the same launch flags as `web`', () => { + const install = flagsOf(find(find(program, 'service')!, 'install')!); + expect(install).toEqual(expect.arrayContaining(['-H', '--host', '-p', '--port', '--https', '--multiuser'])); + }); + + it('keeps `doctor --json` and `--category`', () => { + expect(flagsOf(find(program, 'doctor')!)).toEqual(expect.arrayContaining(['--json', '--category'])); + }); + + it('keeps the escape hatches that scripts depend on', () => { + expect(flagsOf(find(program, 'reset')!)).toEqual(expect.arrayContaining(['-f', '--force'])); + expect(flagsOf(find(program, 'status')!)).toEqual(expect.arrayContaining(['--url'])); + expect(flagsOf(find(program, 'attach')!)).toEqual(expect.arrayContaining(['-s', '--session', '--url'])); + const users = find(program, 'users')!; + expect(flagsOf(find(users, 'add')!)).toEqual(expect.arrayContaining(['--admin', '--password-stdin'])); + expect(flagsOf(find(users, 'passwd')!)).toEqual(expect.arrayContaining(['--password-stdin'])); + expect(flagsOf(find(users, 'rm')!)).toEqual(expect.arrayContaining(['--delete-space'])); + }); + + it('takes a working directory for the session commands', () => { + expect(flagsOf(find(find(program, 'session')!, 'start')!)).toEqual(expect.arrayContaining(['-d', '--dir'])); + expect(flagsOf(find(program, 'start')!)).toEqual(expect.arrayContaining(['-d', '--dir'])); + }); + + it('scopes skill install/uninstall to a case', () => { + const skill = find(program, 'skill')!; + for (const name of ['install', 'uninstall']) { + expect(flagsOf(find(skill, name)!)).toEqual(expect.arrayContaining(['-g', '--global', '-c', '--case'])); } - - const formatRow = (values: string[], columns: Column[]): string => { - return values - .map((val, i) => { - const width = columns[i]?.width || 10; - return val.padEnd(width).substring(0, width); - }) - .join(' '); - }; - - const formatTable = (headers: string[], rows: string[][], widths: number[]): string => { - const columns = headers.map((h, i) => ({ header: h, width: widths[i] })); - const headerRow = formatRow(headers, columns); - const separator = columns.map((c) => '-'.repeat(c.width)).join(' '); - const dataRows = rows.map((row) => formatRow(row, columns)); - return [headerRow, separator, ...dataRows].join('\n'); - }; - - it('should format single row', () => { - const columns = [ - { header: 'ID', width: 10 }, - { header: 'Status', width: 8 }, - ]; - const row = formatRow(['123', 'active'], columns); - expect(row).toBe('123 active '); - }); - - it('should truncate long values', () => { - const columns = [{ header: 'Name', width: 5 }]; - const row = formatRow(['verylongname'], columns); - expect(row).toBe('veryl'); - }); - - it('should format complete table', () => { - const table = formatTable( - ['ID', 'Status'], - [ - ['1', 'active'], - ['2', 'idle'], - ], - [5, 8] - ); - expect(table).toContain('ID'); - expect(table).toContain('Status'); - expect(table).toContain('-----'); - expect(table).toContain('active'); - }); - }); - - describe('Status Formatting', () => { - const formatStatus = (status: string): string => { - const statusMap: Record = { - active: '● active', - idle: '○ idle', - working: '◐ working', - error: '✗ error', - stopped: '□ stopped', - }; - return statusMap[status] || status; - }; - - it('should format active status', () => { - expect(formatStatus('active')).toBe('● active'); - }); - - it('should format idle status', () => { - expect(formatStatus('idle')).toBe('○ idle'); - }); - - it('should format working status', () => { - expect(formatStatus('working')).toBe('◐ working'); - }); - - it('should format error status', () => { - expect(formatStatus('error')).toBe('✗ error'); - }); - - it('should return unknown status as-is', () => { - expect(formatStatus('unknown')).toBe('unknown'); - }); - }); - - describe('Token Formatting', () => { - const formatTokens = (tokens: number): string => { - if (tokens >= 1_000_000) { - return `${(tokens / 1_000_000).toFixed(1)}M`; - } - if (tokens >= 1_000) { - return `${(tokens / 1_000).toFixed(1)}k`; - } - return tokens.toString(); - }; - - it('should format millions', () => { - expect(formatTokens(1_500_000)).toBe('1.5M'); - expect(formatTokens(2_000_000)).toBe('2.0M'); - }); - - it('should format thousands', () => { - expect(formatTokens(1_500)).toBe('1.5k'); - expect(formatTokens(100_000)).toBe('100.0k'); - }); - - it('should format small numbers', () => { - expect(formatTokens(500)).toBe('500'); - expect(formatTokens(0)).toBe('0'); - }); - }); - - describe('Cost Formatting', () => { - const formatCost = (cost: number): string => { - if (cost < 0.01) { - return `$${cost.toFixed(4)}`; - } - return `$${cost.toFixed(2)}`; - }; - - it('should format small costs with 4 decimals', () => { - expect(formatCost(0.0015)).toBe('$0.0015'); - expect(formatCost(0.0001)).toBe('$0.0001'); - }); - - it('should format normal costs with 2 decimals', () => { - expect(formatCost(1.5)).toBe('$1.50'); - expect(formatCost(0.05)).toBe('$0.05'); - }); - - it('should handle zero', () => { - expect(formatCost(0)).toBe('$0.0000'); - }); - }); - - describe('Duration Formatting', () => { - const formatDuration = (ms: number): string => { - const seconds = Math.floor(ms / 1000); - const minutes = Math.floor(seconds / 60); - const hours = Math.floor(minutes / 60); - - if (hours > 0) { - return `${hours}h ${minutes % 60}m`; - } - if (minutes > 0) { - return `${minutes}m ${seconds % 60}s`; - } - return `${seconds}s`; - }; - - it('should format seconds', () => { - expect(formatDuration(5000)).toBe('5s'); - expect(formatDuration(45000)).toBe('45s'); - }); - - it('should format minutes', () => { - expect(formatDuration(90000)).toBe('1m 30s'); - expect(formatDuration(300000)).toBe('5m 0s'); - }); - - it('should format hours', () => { - expect(formatDuration(3600000)).toBe('1h 0m'); - expect(formatDuration(5400000)).toBe('1h 30m'); - }); - - it('should handle zero', () => { - expect(formatDuration(0)).toBe('0s'); - }); - }); - - describe('List Formatting', () => { - const formatList = (items: string[], bullet: string = '-'): string => { - return items.map((item) => `${bullet} ${item}`).join('\n'); - }; - - const formatNumberedList = (items: string[]): string => { - return items.map((item, i) => `${i + 1}. ${item}`).join('\n'); - }; - - it('should format bulleted list', () => { - const list = formatList(['item1', 'item2', 'item3']); - expect(list).toBe('- item1\n- item2\n- item3'); - }); - - it('should format with custom bullet', () => { - const list = formatList(['item1', 'item2'], '*'); - expect(list).toBe('* item1\n* item2'); - }); - - it('should format numbered list', () => { - const list = formatNumberedList(['item1', 'item2', 'item3']); - expect(list).toBe('1. item1\n2. item2\n3. item3'); - }); - - it('should handle empty list', () => { - expect(formatList([])).toBe(''); - expect(formatNumberedList([])).toBe(''); - }); - }); - - describe('Error Message Formatting', () => { - const formatError = (message: string, code?: string): string => { - if (code) { - return `Error [${code}]: ${message}`; - } - return `Error: ${message}`; - }; - - it('should format error with code', () => { - const error = formatError('Session not found', 'NOT_FOUND'); - expect(error).toBe('Error [NOT_FOUND]: Session not found'); - }); - - it('should format error without code', () => { - const error = formatError('Something went wrong'); - expect(error).toBe('Error: Something went wrong'); - }); - }); - - describe('Progress Formatting', () => { - const formatProgress = (current: number, total: number, width: number = 20): string => { - const percent = Math.round((current / total) * 100); - const filled = Math.round((current / total) * width); - const empty = width - filled; - return `[${'='.repeat(filled)}${' '.repeat(empty)}] ${percent}%`; - }; - - it('should format progress at 0%', () => { - const progress = formatProgress(0, 100, 10); - expect(progress).toBe('[ ] 0%'); - }); - - it('should format progress at 50%', () => { - const progress = formatProgress(50, 100, 10); - expect(progress).toBe('[===== ] 50%'); - }); - - it('should format progress at 100%', () => { - const progress = formatProgress(100, 100, 10); - expect(progress).toBe('[==========] 100%'); - }); - - it('should handle non-round percentages', () => { - const progress = formatProgress(33, 100, 10); - expect(progress).toBe('[=== ] 33%'); - }); }); }); -describe('CLI Configuration', () => { - describe('Default Configuration', () => { - interface CLIConfig { - maxConcurrentSessions: number; - defaultPort: number; - defaultHost: string; - casesDirectory: string; - stateDirectory: string; - } - - const defaultConfig: CLIConfig = { - maxConcurrentSessions: 5, - defaultPort: 3000, - defaultHost: '0.0.0.0', - casesDirectory: '~/codeman-cases', - stateDirectory: '~/.codeman', - }; - - const expandPath = (path: string): string => { - if (path.startsWith('~/')) { - return `/home/user${path.slice(1)}`; - } - return path; - }; - - it('should have sensible defaults', () => { - expect(defaultConfig.maxConcurrentSessions).toBe(5); - expect(defaultConfig.defaultPort).toBe(3000); - expect(defaultConfig.defaultHost).toBe('0.0.0.0'); - }); - - it('should expand home directory paths', () => { - expect(expandPath('~/codeman-cases')).toBe('/home/user/codeman-cases'); - expect(expandPath('~/.codeman')).toBe('/home/user/.codeman'); - }); - - it('should not modify absolute paths', () => { - expect(expandPath('/var/data')).toBe('/var/data'); - }); +describe('registered arguments', () => { + it.each([ + ['attach', ['path']], + ['start', []], + ])('declares the operands of `%s`', (name, expected) => { + const args = find(program, name)!.registeredArguments.map((arg) => arg.name()); + expect(args).toEqual(expected); }); - describe('Environment Variable Parsing', () => { - const parseEnvInt = (value: string | undefined, defaultValue: number): number => { - if (value === undefined) return defaultValue; - const parsed = parseInt(value, 10); - return isNaN(parsed) ? defaultValue : parsed; - }; + it('requires an id for the commands that act on one session or task', () => { + const session = find(program, 'session')!; + expect(find(session, 'stop')!.registeredArguments.map((a) => a.name())).toEqual(['id']); + expect(find(session, 'logs')!.registeredArguments.map((a) => a.name())).toEqual(['id']); + const task = find(program, 'task')!; + expect(find(task, 'status')!.registeredArguments.map((a) => a.name())).toEqual(['id']); + expect(find(task, 'remove')!.registeredArguments.map((a) => a.name())).toEqual(['id']); + }); +}); - const parseEnvBool = (value: string | undefined, defaultValue: boolean): boolean => { - if (value === undefined) return defaultValue; - return value.toLowerCase() === 'true' || value === '1'; - }; +describe('help text', () => { + it('documents the unauthenticated network override in the real web command help', () => { + const help = find(program, 'web')!.helpInformation(); + expect(help).toContain('--allow-unauthenticated-network'); + expect(help).toMatch(/without\s+CODEMAN_PASSWORD/); + }); - it('should parse integer env vars', () => { - expect(parseEnvInt('8080', 3000)).toBe(8080); - expect(parseEnvInt(undefined, 3000)).toBe(3000); - expect(parseEnvInt('invalid', 3000)).toBe(3000); - }); - - it('should parse boolean env vars', () => { - expect(parseEnvBool('true', false)).toBe(true); - expect(parseEnvBool('1', false)).toBe(true); - expect(parseEnvBool('false', true)).toBe(false); - expect(parseEnvBool(undefined, true)).toBe(true); - }); + it('describes `attach` as the attachment card command, not a hook context', () => { + const help = find(program, 'attach')!.helpInformation(); + expect(help).toContain('attachment card'); + expect(help).not.toContain('hook context'); }); - describe('Config File Parsing', () => { - interface ConfigFile { - port?: number; - host?: string; - maxSessions?: number; + it('lists every top-level command in the root help', () => { + const help = program.helpInformation(); + for (const name of Object.keys(TOP_LEVEL)) { + expect(help).toContain(name); } - - const parseConfigFile = (content: string): ConfigFile => { - try { - return JSON.parse(content); - } catch { - return {}; - } - }; - - const mergeConfigs = (defaults: ConfigFile, file: ConfigFile, env: ConfigFile): ConfigFile => { - return { ...defaults, ...file, ...env }; - }; - - it('should parse valid JSON config', () => { - const config = parseConfigFile('{"port": 8080}'); - expect(config.port).toBe(8080); - }); - - it('should handle invalid JSON', () => { - const config = parseConfigFile('invalid json'); - expect(config).toEqual({}); - }); - - it('should merge configs with priority', () => { - const defaults = { port: 3000, host: 'localhost' }; - const file = { port: 8080 }; - const env = { host: '0.0.0.0' }; - const merged = mergeConfigs(defaults, file, env); - expect(merged.port).toBe(8080); - expect(merged.host).toBe('0.0.0.0'); - }); }); });