/** * CLIResult - Rich Assertion API for CLI Command Results * * Wraps command output with fluent assertion methods and rich error context. * Inspired by execa's error objects with duration, command, and timeout tracking. */ import type { z } from 'zod'; /** * CLIResult interface - Public API */ export interface CLIResult { /** Full command that was executed (execa-inspired) */ readonly command: string; /** Standard output */ readonly stdout: string; /** Standard error */ readonly stderr: string; /** Exit code (0 = success) */ readonly exitCode: number; /** Execution duration in milliseconds (execa-inspired) */ readonly duration: number; /** Whether command timed out (execa-inspired) */ readonly timedOut: boolean; // Fluent assertions expectSuccess(): this; expectFailure(): this; expectStdout(pattern: string | RegExp): this; expectStderr(pattern: string | RegExp): this; expectJson(schema: z.ZodSchema): T; expectNoOutput(): this; expectDuration(maxMs: number): this; // Line utilities (execa-inspired) lines(): string[]; [Symbol.asyncIterator](): AsyncIterator; // Debugging (execa-inspired) toJSON(): object; } /** * CLIResultImpl - Implementation * * @example * ```typescript * const result = await cli.run('module list'); * * // Fluent assertions * result * .expectSuccess() * .expectStdout(/homebridge/) * .expectDuration(2000); * * // Line iteration * const modules = result.lines().filter(l => l.startsWith('- ')); * * // Error context * console.log(result.toJSON()); // Full details for debugging * ``` */ export class CLIResultImpl implements CLIResult { constructor( public readonly command: string, public readonly stdout: string, public readonly stderr: string, public readonly exitCode: number, public readonly duration: number, public readonly timedOut: boolean, ) {} /** * Assert command succeeded (exit code 0) */ expectSuccess(): this { if (this.exitCode !== 0) { throw new Error( `Expected command to succeed but got exit code ${this.exitCode}\n` + `Command: ${this.command}\n` + `Duration: ${this.duration}ms\n` + `Stdout: ${this.stdout}\n` + `Stderr: ${this.stderr}`, ); } return this; } /** * Assert command failed (non-zero exit code) */ expectFailure(): this { if (this.exitCode === 0) { throw new Error( `Expected command to fail but it succeeded\nCommand: ${this.command}\nDuration: ${this.duration}ms\nStdout: ${this.stdout}`, ); } return this; } /** * Assert stdout matches pattern */ expectStdout(pattern: string | RegExp): this { const matches = typeof pattern === 'string' ? this.stdout.includes(pattern) : pattern.test(this.stdout); if (!matches) { throw new Error( `Expected stdout to match ${pattern}\n` + `Command: ${this.command}\n` + `Stdout: ${this.stdout}`, ); } return this; } /** * Assert stderr matches pattern */ expectStderr(pattern: string | RegExp): this { const matches = typeof pattern === 'string' ? this.stderr.includes(pattern) : pattern.test(this.stderr); if (!matches) { throw new Error( `Expected stderr to match ${pattern}\n` + `Command: ${this.command}\n` + `Stderr: ${this.stderr}`, ); } return this; } /** * Parse stdout as JSON and validate with Zod schema * * @example * ```typescript * const schema = z.object({ modules: z.array(z.string()) }); * const data = result.expectJson(schema); * expect(data.modules).toContain('homebridge'); * ``` */ expectJson(schema: z.ZodSchema): T { try { const data = JSON.parse(this.stdout); return schema.parse(data); } catch (error) { throw new Error( `Failed to parse JSON from stdout\nCommand: ${this.command}\nStdout: ${this.stdout}\nError: ${error}`, ); } } /** * Assert no output on stdout or stderr */ expectNoOutput(): this { if (this.stdout.trim() !== '' || this.stderr.trim() !== '') { throw new Error( `Expected no output but got:\nCommand: ${this.command}\nStdout: ${this.stdout}\nStderr: ${this.stderr}`, ); } return this; } /** * Assert command completed within time limit * * @param maxMs - Maximum duration in milliseconds */ expectDuration(maxMs: number): this { if (this.duration > maxMs) { throw new Error( `Expected command to complete in ${maxMs}ms but took ${this.duration}ms\n` + `Command: ${this.command}`, ); } return this; } /** * Split output into lines (execa-inspired) * Useful for processing list output * * @example * ```typescript * const modules = result.lines() * .filter(line => line.startsWith('- ')) * .map(line => line.slice(2)); * ``` */ lines(): string[] { return this.stdout .split('\n') .map((line) => line.trimEnd()) .filter((line) => line.length > 0); } /** * Async iterator for line-by-line processing (execa-inspired) * * @example * ```typescript * for await (const line of result) { * if (line.includes('ERROR')) { * throw new Error(line); * } * } * ``` */ async *[Symbol.asyncIterator](): AsyncIterator { for (const line of this.lines()) { yield line; } } /** * Export full error context for debugging (execa-inspired) * * @example * ```typescript * catch (error) { * console.error(result.toJSON()); * // Shows: command, duration, exitCode, timedOut, stdout, stderr * } * ``` */ toJSON(): object { return { command: this.command, exitCode: this.exitCode, duration: this.duration, timedOut: this.timedOut, stdout: this.stdout, stderr: this.stderr, }; } }