From e8adb199a1661ef34c000e175c921ae5cfcc097c Mon Sep 17 00:00:00 2001 From: linesofcodes0 <306641211+linesofcodes0-cloud@users.noreply.github.com> Date: Fri, 16 Oct 2026 13:11:19 +0100 Subject: [PATCH] feat(cli): handle argument errors, timeouts, signals, and version resolution Resolve issues #54, #55, #56, and #57 in the ESURE CLI: - Return structured JSON error envelopes on stderr for CLI argument errors - Add bounded --timeout-ms option wired to gateway execution via AbortSignal - Gracefully handle SIGINT and SIGTERM with documented exit codes 130 and 143 - Dynamically resolve package version from package.json metadata --- esure-cli/README.md | 10 +- esure-cli/src/cli.ts | 565 +++++++++++++++++++++++++++++-------- esure-cli/test/cli.test.ts | 287 ++++++++++++++++++- 3 files changed, 734 insertions(+), 128 deletions(-) diff --git a/esure-cli/README.md b/esure-cli/README.md index f5aa4dd..e43a02c 100644 --- a/esure-cli/README.md +++ b/esure-cli/README.md @@ -121,11 +121,17 @@ Executes a scenario on Stellar Testnet. - ``: Bundled scenario ID (e.g., `xlm-payment`) or path to scenario file **Options:** -- `--output json`: Output execution report in JSON format +- `--output, -o json`: Output execution report in JSON format +- `--timeout-ms `: Overall run timeout in milliseconds (default: 120000) +- `--ascii`: Use ASCII status markers (PASS/FAIL) instead of Unicode glyphs **Exit codes:** - `0`: All steps and assertions passed -- `1`: One or more steps or assertions failed, or execution error +- `1`: One or more steps or assertions failed, execution error, or run timeout +- `130`: Interrupted by SIGINT +- `143`: Terminated by SIGTERM + +When `--output json` is requested, argument errors and cancellation responses are emitted to `stderr` in a structured JSON error envelope (`{"error": {"code": "...", "message": "..."}}`). ## Examples diff --git a/esure-cli/src/cli.ts b/esure-cli/src/cli.ts index 1a02829..a4126b8 100644 --- a/esure-cli/src/cli.ts +++ b/esure-cli/src/cli.ts @@ -1,19 +1,56 @@ #!/usr/bin/env node +import { existsSync, readFileSync } from "node:fs"; import { readFile } from "node:fs/promises"; -import { resolve } from "node:path"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; import { parseArgs } from "node:util"; import { validateScenarioDefinition } from "@esure/backend/src/scenario-schema.js"; import { prepareScenario } from "@esure/backend/src/scenario-loader.js"; import { createScenarioRegistry } from "@esure/backend/src/scenarios.js"; import { StellarTestnetGateway } from "@esure/backend/src/stellar-gateway.js"; +import { SafeRunError } from "@esure/backend/src/errors.js"; import { parse as parseYaml } from "yaml"; import type { ValidatedScenario } from "@esure/backend/src/domain.js"; import type { LedgerExecution, LedgerExecutionOptions } from "@esure/backend/src/domain.js"; -const VERSION = "0.1.0"; +// ───────────────────────────────────────────────────────────────────────────── +// Version resolution (#57) +// ───────────────────────────────────────────────────────────────────────────── + +export function resolveVersion(): string { + try { + let dir = dirname(fileURLToPath(import.meta.url)); + for (let i = 0; i < 4; i++) { + const candidate = join(dir, "package.json"); + if (existsSync(candidate)) { + try { + const pkg = JSON.parse(readFileSync(candidate, "utf8")); + if (pkg.name === "@esure/cli" && typeof pkg.version === "string") { + return pkg.version; + } + } catch { + // ignore JSON parse error and keep walking up + } + } + const parent = dirname(dir); + if (parent === dir) break; + dir = parent; + } + } catch { + // fallback if import.meta.url is unavailable + } + return "0.1.0"; +} + +const VERSION = resolveVersion(); const BUNDLED_SCENARIO_DIR_TIMEOUT_MS = 30_000; +/** Default overall run timeout in milliseconds (#55). */ +export const DEFAULT_CLI_TIMEOUT_MS = 120_000; +export const MIN_CLI_TIMEOUT_MS = 1; +export const MAX_CLI_TIMEOUT_MS = 600_000; + // ───────────────────────────────────────────────────────────────────────────── // Shared types // ───────────────────────────────────────────────────────────────────────────── @@ -24,6 +61,8 @@ interface CliOptions { version?: boolean; /** #68 — use ASCII status markers (PASS/FAIL) instead of Unicode glyphs */ ascii?: boolean; + /** #55 — overall run timeout in milliseconds */ + timeoutMs?: number; } // ───────────────────────────────────────────────────────────────────────────── @@ -43,6 +82,25 @@ function statusMarker(status: "passed" | "failed" | string, m: Markers): string return status === "passed" ? m.pass : m.fail; } +// ───────────────────────────────────────────────────────────────────────────── +// Output and error helpers (#54, #55, #56) +// ───────────────────────────────────────────────────────────────────────────── + +function reportArgumentError( + message: string, + usageHint: string | undefined, + output: "human" | "json" | undefined, +): void { + if (output === "json") { + console.error(JSON.stringify({ error: { code: "INVALID_ARGUMENT", message } })); + } else { + console.error(`Error: ${message}`); + if (usageHint) { + console.error(usageHint); + } + } +} + // ───────────────────────────────────────────────────────────────────────────── // Help & version // ───────────────────────────────────────────────────────────────────────────── @@ -59,17 +117,19 @@ Usage: esure run-suite [...] Run multiple bundled scenarios sequentially Options: - --output Output format: human (default) or json - --ascii Use ASCII status markers (PASS/FAIL) instead of Unicode glyphs - --fail-fast (run-suite) Stop after first failure - --help Show this help message - --version Show version information + --output, -o Output format: human (default) or json + --timeout-ms Overall run timeout in milliseconds (default: 120000) + --ascii Use ASCII status markers (PASS/FAIL) instead of Unicode glyphs + --fail-fast (run-suite) Stop after first failure + --help, -h Show this help message + --version, -v Show version information Examples: esure list esure validate ./my-scenario.yaml esure run xlm-payment esure run ./my-scenario.yaml --output json + esure run xlm-payment --timeout-ms 30000 esure run-suite xlm-payment path-payment --fail-fast Documentation: https://github.com/Esureorg/Esure @@ -89,27 +149,95 @@ function parseCliArgs(): { args: string[]; options: CliOptions; failFast: boolean; + parseError?: { message: string; usage?: string }; } { - const { values, positionals } = parseArgs({ - options: { - output: { type: "string" }, - help: { type: "boolean", short: "h" }, - version: { type: "boolean", short: "v" }, - ascii: { type: "boolean" }, - "fail-fast": { type: "boolean" }, - }, - allowPositionals: true, - strict: false, - }); + // Pre-scan for requested output mode so parse errors can be formatted as JSON (#54) + const argv = process.argv.slice(2); + let requestedOutput: "human" | "json" = "human"; + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + if (arg === "--output=json" || arg === "-o=json") { + requestedOutput = "json"; + } else if ((arg === "--output" || arg === "-o") && argv[i + 1] === "json") { + requestedOutput = "json"; + } + } + + let parsed: ReturnType; + try { + parsed = parseArgs({ + options: { + output: { type: "string", short: "o" }, + help: { type: "boolean", short: "h" }, + version: { type: "boolean", short: "v" }, + ascii: { type: "boolean" }, + "fail-fast": { type: "boolean" }, + "timeout-ms": { type: "string" }, + }, + allowPositionals: true, + strict: true, + }); + } catch (error) { + return { + args: [], + options: { output: requestedOutput }, + failFast: false, + parseError: { + message: error instanceof Error ? error.message : String(error), + usage: 'Run "esure --help" for usage information', + }, + }; + } + + const { values, positionals } = parsed; + + let output: "human" | "json" = "human"; + if (values.output !== undefined) { + if (values.output === "json") { + output = "json"; + } else if (values.output === "human") { + output = "human"; + } else { + return { + args: [], + options: { output: requestedOutput }, + failFast: false, + parseError: { + message: `Invalid output format "${values.output}". Supported formats are "human" and "json".`, + }, + }; + } + } + + let timeoutMs: number | undefined; + if (values["timeout-ms"] !== undefined) { + const parsedTimeout = Number(values["timeout-ms"]); + if ( + !Number.isInteger(parsedTimeout) || + parsedTimeout < MIN_CLI_TIMEOUT_MS || + parsedTimeout > MAX_CLI_TIMEOUT_MS + ) { + return { + args: [], + options: { output }, + failFast: false, + parseError: { + message: `--timeout-ms must be an integer between ${MIN_CLI_TIMEOUT_MS} and ${MAX_CLI_TIMEOUT_MS}`, + }, + }; + } + timeoutMs = parsedTimeout; + } return { command: positionals[0], args: positionals.slice(1), options: { - output: values.output === "json" ? "json" : "human", + output, help: Boolean(values.help), version: Boolean(values.version), ascii: Boolean(values.ascii), + timeoutMs, }, failFast: Boolean(values["fail-fast"]), }; @@ -141,15 +269,23 @@ async function loadScenarioFile(filePath: string): Promise { // ───────────────────────────────────────────────────────────────────────────── /** Build a LedgerExecutionOptions object that prints progress in human mode (#66). */ -function buildExecutionOptions(options: CliOptions): LedgerExecutionOptions { - const execOptions: LedgerExecutionOptions = { stepTimeoutMs: BUNDLED_SCENARIO_DIR_TIMEOUT_MS }; +function buildExecutionOptions( + options: CliOptions, + signal?: AbortSignal, +): LedgerExecutionOptions { + const execOptions: LedgerExecutionOptions = { + stepTimeoutMs: BUNDLED_SCENARIO_DIR_TIMEOUT_MS, + signal, + }; if (options.output === "human") { const m = markers(options.ascii ?? false); execOptions.onStep = (step) => { console.log(` ${statusMarker(step.status, m)} step ${step.id}: ${step.message}`); }; execOptions.onAssertion = (assertion) => { - console.log(` ${statusMarker(assertion.status, m)} check ${assertion.type}: ${assertion.message}`); + console.log( + ` ${statusMarker(assertion.status, m)} check ${assertion.type}: ${assertion.message}`, + ); }; } return execOptions; @@ -191,6 +327,136 @@ function buildRunReport( }; } +// ───────────────────────────────────────────────────────────────────────────── +// Signal & timeout execution runner (#55, #56) +// ───────────────────────────────────────────────────────────────────────────── + +interface ExecutionRunnerResult { + result?: T; + exitCode?: number; +} + +async function runWithSignalsAndTimeout( + timeoutMs: number, + options: CliOptions, + action: (signal: AbortSignal) => Promise, +): Promise> { + const controller = new AbortController(); + let timedOut = false; + let receivedSignal: "SIGINT" | "SIGTERM" | undefined; + let timer: NodeJS.Timeout | undefined; + + const onSigint = () => { + if (receivedSignal === "SIGINT") { + process.exit(130); + } + receivedSignal = "SIGINT"; + controller.abort(); + }; + + const onSigterm = () => { + if (receivedSignal === "SIGTERM") { + process.exit(143); + } + receivedSignal = "SIGTERM"; + controller.abort(); + }; + + process.on("SIGINT", onSigint); + process.on("SIGTERM", onSigterm); + + timer = setTimeout(() => { + timedOut = true; + controller.abort(); + }, timeoutMs); + timer.unref?.(); + + try { + const result = await action(controller.signal); + return { result }; + } catch (error) { + if (receivedSignal === "SIGINT") { + if (options.output === "json") { + console.error( + JSON.stringify({ + status: "cancelled", + error: { + code: "SIGINT", + message: "Execution interrupted by SIGINT.", + }, + }), + ); + } else { + const m = markers(options.ascii ?? false); + console.error(`\n${m.fail} Execution interrupted by SIGINT.`); + } + return { exitCode: 130 }; + } + + if (receivedSignal === "SIGTERM") { + if (options.output === "json") { + console.error( + JSON.stringify({ + status: "cancelled", + error: { + code: "SIGTERM", + message: "Execution terminated by SIGTERM.", + }, + }), + ); + } else { + const m = markers(options.ascii ?? false); + console.error(`\n${m.fail} Execution terminated by SIGTERM.`); + } + return { exitCode: 143 }; + } + + if (timedOut) { + const timeoutError = { + code: "RUN_TIMEOUT", + message: `The run exceeded its ${timeoutMs}ms execution limit.`, + }; + if (options.output === "json") { + console.error( + JSON.stringify( + { + status: "error", + error: timeoutError, + }, + null, + 2, + ), + ); + } else { + const m = markers(options.ascii ?? false); + console.error(`\n${m.fail} Run timed out: ${timeoutError.message}`); + } + return { exitCode: 1 }; + } + + // Normal execution error + if (options.output === "json") { + const errorObj: { message: string; report?: unknown } = { + message: error instanceof Error ? error.message : String(error), + }; + if (error && typeof error === "object" && "report" in error) { + errorObj.report = (error as { report: unknown }).report; + } + console.error(JSON.stringify({ status: "error", error: errorObj }, null, 2)); + } else { + const m = markers(options.ascii ?? false); + console.error( + `\n${m.fail} Run failed: ${error instanceof Error ? error.message : String(error)}`, + ); + } + return { exitCode: 1 }; + } finally { + if (timer) clearTimeout(timer); + process.removeListener("SIGINT", onSigint); + process.removeListener("SIGTERM", onSigterm); + } +} + // ───────────────────────────────────────────────────────────────────────────── // Commands // ───────────────────────────────────────────────────────────────────────────── @@ -289,9 +555,8 @@ async function validateCommand(filePath: string, options: CliOptions): Promise { + let scenario: ValidatedScenario; try { - let scenario: ValidatedScenario; - // Determine if target is a bundled scenario ID or file path if (target.includes("/") || target.includes("\\") || target.includes(".")) { scenario = await loadScenarioFile(target); @@ -305,52 +570,11 @@ async function runCommand(target: string, options: CliOptions): Promise } scenario = found; } - - // Execute scenario on Stellar Testnet - const gateway = new StellarTestnetGateway( - "https://horizon-testnet.stellar.org", - "https://friendbot.stellar.org", - ); - - if (options.output === "human") { - console.log(`Running scenario: ${scenario.name}`); - console.log(`ID: ${scenario.id} (v${scenario.version})\n`); - } - - // #66 — attach onStep / onAssertion for real-time progress in human mode - const execOptions = buildExecutionOptions(options); - const execution = await gateway.execute(scenario, execOptions); - const report = buildRunReport(scenario, execution); - - if (options.output === "json") { - console.log(JSON.stringify(report, null, 2)); - } else { - // In human mode the steps/assertions were already printed via callbacks. - // Only print the final summary — do not reprint the full step list. - const m = markers(options.ascii ?? false); - const passed = report.status === "passed"; - console.log(`\nResult: ${passed ? "PASSED" : "FAILED"}`); - console.log( - ` Steps: ${report.summary.stepsPassed}/${execution.steps.length} passed`, - ); - console.log( - ` Assertions: ${report.summary.assertionsPassed}/${execution.assertions.length} passed`, - ); - if (!passed) { - console.log(`\n${m.fail} One or more checks failed.`); - } - } - - return report.status === "passed" ? 0 : 1; } catch (error) { if (options.output === "json") { - const errorObj: { message: string; report?: unknown } = { - message: error instanceof Error ? error.message : String(error), - }; - if (error && typeof error === "object" && "report" in error) { - errorObj.report = (error as { report: unknown }).report; - } - console.error(JSON.stringify({ status: "error", error: errorObj }, null, 2)); + console.error( + JSON.stringify({ error: { message: error instanceof Error ? error.message : String(error) } }), + ); } else { const m = markers(options.ascii ?? false); console.error( @@ -359,6 +583,56 @@ async function runCommand(target: string, options: CliOptions): Promise } return 1; } + + const effectiveTimeout = options.timeoutMs ?? DEFAULT_CLI_TIMEOUT_MS; + + const executionResult = await runWithSignalsAndTimeout( + effectiveTimeout, + options, + async (signal) => { + // Execute scenario on Stellar Testnet + const gateway = new StellarTestnetGateway( + "https://horizon-testnet.stellar.org", + "https://friendbot.stellar.org", + ); + + if (options.output === "human") { + console.log(`Running scenario: ${scenario.name}`); + console.log(`ID: ${scenario.id} (v${scenario.version})\n`); + } + + const execOptions = buildExecutionOptions(options, signal); + return gateway.execute(scenario, execOptions); + }, + ); + + if (executionResult.exitCode !== undefined) { + return executionResult.exitCode; + } + + const execution = executionResult.result!; + const report = buildRunReport(scenario, execution); + + if (options.output === "json") { + console.log(JSON.stringify(report, null, 2)); + } else { + // In human mode the steps/assertions were already printed via callbacks. + // Only print the final summary — do not reprint the full step list. + const m = markers(options.ascii ?? false); + const passed = report.status === "passed"; + console.log(`\nResult: ${passed ? "PASSED" : "FAILED"}`); + console.log( + ` Steps: ${report.summary.stepsPassed}/${execution.steps.length} passed`, + ); + console.log( + ` Assertions: ${report.summary.assertionsPassed}/${execution.assertions.length} passed`, + ); + if (!passed) { + console.log(`\n${m.fail} One or more checks failed.`); + } + } + + return report.status === "passed" ? 0 : 1; } // ───────────────────────────────────────────────────────────────────────────── @@ -435,61 +709,89 @@ async function runSuiteCommand( return 1; } - // 2. Execute sequentially with isolated gateway per scenario - const results: SuiteScenarioResult[] = []; - let suitePassed = true; + // 2. Execute sequentially with isolated gateway per scenario and overall signal/timeout wiring + const effectiveTimeout = options.timeoutMs ?? DEFAULT_CLI_TIMEOUT_MS; - if (options.output === "human") { - console.log(`\nRun suite: ${ids.length} scenario(s)\n`); - } + const suiteRun = await runWithSignalsAndTimeout( + effectiveTimeout, + options, + async (signal) => { + const results: SuiteScenarioResult[] = []; + let suitePassed = true; - for (const scenario of scenarios) { - if (options.output === "human") { - console.log(`Running: ${scenario.id} — ${scenario.name}`); - } + if (options.output === "human") { + console.log(`\nRun suite: ${ids.length} scenario(s)\n`); + } - let result: SuiteScenarioResult; - try { - const gateway = new StellarTestnetGateway( - "https://horizon-testnet.stellar.org", - "https://friendbot.stellar.org", - ); - // Per-scenario isolated execution with progress callbacks in human mode - const execOptions = buildExecutionOptions(options); - const execution = await gateway.execute(scenario, execOptions); - const report = buildRunReport(scenario, execution); - result = { - scenarioId: scenario.id, - status: report.status, - summary: report.summary, - }; - } catch (error) { - result = { - scenarioId: scenario.id, - status: "error", - error: { message: error instanceof Error ? error.message : String(error) }, - }; - } + for (const scenario of scenarios) { + if (signal.aborted) { + throw new SafeRunError({ + code: "RUN_TIMEOUT", + message: "The run was cancelled after reaching its execution limit.", + category: "timeout", + retryable: true, + }); + } - results.push(result); - if (result.status !== "passed") suitePassed = false; + if (options.output === "human") { + console.log(`Running: ${scenario.id} — ${scenario.name}`); + } - if (options.output === "human") { - const m = markers(options.ascii ?? false); - const marker = result.status === "passed" ? m.pass : m.fail; - if (result.status === "error") { - console.log(` ${marker} ${scenario.id}: error — ${result.error?.message ?? "unknown"}\n`); - } else { - const s = result.summary!; - console.log( - ` ${marker} ${scenario.id}: ${result.status} (steps ${s.stepsPassed}/${s.stepsPassed + s.stepsFailed}, checks ${s.assertionsPassed}/${s.assertionsPassed + s.assertionsFailed})\n`, - ); + let result: SuiteScenarioResult; + try { + const gateway = new StellarTestnetGateway( + "https://horizon-testnet.stellar.org", + "https://friendbot.stellar.org", + ); + // Per-scenario isolated execution with progress callbacks in human mode + const execOptions = buildExecutionOptions(options, signal); + const execution = await gateway.execute(scenario, execOptions); + const report = buildRunReport(scenario, execution); + result = { + scenarioId: scenario.id, + status: report.status, + summary: report.summary, + }; + } catch (error) { + if (signal.aborted) { + throw error; + } + result = { + scenarioId: scenario.id, + status: "error", + error: { message: error instanceof Error ? error.message : String(error) }, + }; + } + + results.push(result); + if (result.status !== "passed") suitePassed = false; + + if (options.output === "human") { + const m = markers(options.ascii ?? false); + const marker = result.status === "passed" ? m.pass : m.fail; + if (result.status === "error") { + console.log(` ${marker} ${scenario.id}: error — ${result.error?.message ?? "unknown"}\n`); + } else { + const s = result.summary!; + console.log( + ` ${marker} ${scenario.id}: ${result.status} (steps ${s.stepsPassed}/${s.stepsPassed + s.stepsFailed}, checks ${s.assertionsPassed}/${s.assertionsPassed + s.assertionsFailed})\n`, + ); + } + } + + if (!suitePassed && failFast) break; } - } - if (!suitePassed && failFast) break; + return { results, suitePassed }; + }, + ); + + if (suiteRun.exitCode !== undefined) { + return suiteRun.exitCode; } + const { results, suitePassed } = suiteRun.result!; + // 3. Output final summary const passedCount = results.filter((r) => r.status === "passed").length; const failedCount = results.length - passedCount; @@ -526,7 +828,12 @@ async function runSuiteCommand( // ───────────────────────────────────────────────────────────────────────────── async function main(): Promise { - const { command, args, options, failFast } = parseCliArgs(); + const { command, args, options, failFast, parseError } = parseCliArgs(); + + if (parseError) { + reportArgumentError(parseError.message, parseError.usage, options.output); + return 1; + } if (options.version) { showVersion(); @@ -543,28 +850,40 @@ async function main(): Promise { return listCommand(options); case "validate": if (args.length === 0) { - console.error("Error: Missing file path argument"); - console.error("Usage: esure validate "); + reportArgumentError( + "Missing file path argument", + "Usage: esure validate ", + options.output, + ); return 1; } return validateCommand(args[0], options); case "run": if (args.length === 0) { - console.error("Error: Missing scenario ID or file path"); - console.error("Usage: esure run or esure run "); + reportArgumentError( + "Missing scenario ID or file path", + "Usage: esure run or esure run ", + options.output, + ); return 1; } return runCommand(args[0], options); case "run-suite": if (args.length === 0) { - console.error("Error: Missing scenario IDs"); - console.error("Usage: esure run-suite [...]"); + reportArgumentError( + "Missing scenario IDs", + "Usage: esure run-suite [...]", + options.output, + ); return 1; } return runSuiteCommand(args, options, failFast); default: - console.error(`Error: Unknown command "${command}"`); - console.error('Run "esure --help" for usage information'); + reportArgumentError( + `Unknown command "${command}"`, + 'Run "esure --help" for usage information', + options.output, + ); return 1; } } diff --git a/esure-cli/test/cli.test.ts b/esure-cli/test/cli.test.ts index d41f50b..84afb46 100644 --- a/esure-cli/test/cli.test.ts +++ b/esure-cli/test/cli.test.ts @@ -205,9 +205,10 @@ describe("CLI commands", () => { status: "passed", summary: { stepsPassed: 1, stepsFailed: 0, assertionsPassed: 1, assertionsFailed: 0 }, }); - expect(mocks.execute).toHaveBeenCalledWith(expect.objectContaining({ id: scenario.id }), { - stepTimeoutMs: 30000, - }); + expect(mocks.execute).toHaveBeenCalledWith( + expect.objectContaining({ id: scenario.id }), + expect.objectContaining({ stepTimeoutMs: 30000 }), + ); }); it("runs a scenario file and prints a human-readable passing report", async () => { @@ -575,3 +576,283 @@ describe("#68 ASCII status markers", () => { expect(stdout.join("\n")).toContain("--ascii"); }); }); + +// ───────────────────────────────────────────────────────────────────────────── +// #54 — Structured JSON argument errors +// ───────────────────────────────────────────────────────────────────────────── + +describe("#54 CLI argument errors conform to JSON mode", () => { + beforeEach(() => { + stdout = []; + stderr = []; + originalArgv = process.argv; + originalExitCode = process.exitCode; + process.exitCode = undefined; + vi.spyOn(console, "log").mockImplementation((...values: unknown[]) => { + stdout.push(values.join(" ")); + }); + vi.spyOn(console, "error").mockImplementation((...values: unknown[]) => { + stderr.push(values.join(" ")); + }); + mocks.list.mockReturnValue(catalog); + mocks.find.mockImplementation((id: string) => id === scenario.id ? structuredClone(scenario) : undefined); + mocks.createRegistry.mockReturnValue({ list: mocks.list, find: mocks.find }); + mocks.execute.mockResolvedValue(passedExecution); + }); + + afterEach(() => { + process.argv = originalArgv; + process.exitCode = originalExitCode; + vi.restoreAllMocks(); + }); + + it("returns JSON error envelope for unknown command with --output json", async () => { + expect(await invokeCli("unknown-cmd", "--output", "json")).toBe(1); + expect(stdout).toEqual([]); + const err = JSON.parse(stderr.join("\n")); + expect(err).toEqual({ + error: { + code: "INVALID_ARGUMENT", + message: 'Unknown command "unknown-cmd"', + }, + }); + }); + + it("returns JSON error envelope for missing validate file target with -o json", async () => { + expect(await invokeCli("validate", "-o", "json")).toBe(1); + expect(stdout).toEqual([]); + const err = JSON.parse(stderr.join("\n")); + expect(err).toEqual({ + error: { + code: "INVALID_ARGUMENT", + message: "Missing file path argument", + }, + }); + }); + + it("returns JSON error envelope for missing run scenario target with --output json", async () => { + expect(await invokeCli("run", "--output", "json")).toBe(1); + expect(stdout).toEqual([]); + const err = JSON.parse(stderr.join("\n")); + expect(err).toEqual({ + error: { + code: "INVALID_ARGUMENT", + message: "Missing scenario ID or file path", + }, + }); + }); + + it("returns JSON error envelope for missing run-suite target with --output json", async () => { + expect(await invokeCli("run-suite", "--output", "json")).toBe(1); + expect(stdout).toEqual([]); + const err = JSON.parse(stderr.join("\n")); + expect(err).toEqual({ + error: { + code: "INVALID_ARGUMENT", + message: "Missing scenario IDs", + }, + }); + }); + + it("returns JSON error envelope for invalid output format with --output=unknown", async () => { + expect(await invokeCli("list", "--output", "xml")).toBe(1); + expect(stdout).toEqual([]); + expect(stderr.join("\n")).toContain("Invalid output format"); + }); + + it("returns JSON error envelope for unknown option when --output json is specified", async () => { + expect(await invokeCli("list", "--bogus-flag", "--output", "json")).toBe(1); + expect(stdout).toEqual([]); + const err = JSON.parse(stderr.join("\n")); + expect(err.error.code).toBe("INVALID_ARGUMENT"); + expect(err.error.message).toContain("Unknown option"); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// #55 — Bounded overall run timeout +// ───────────────────────────────────────────────────────────────────────────── + +describe("#55 bounded overall run timeout", () => { + beforeEach(() => { + stdout = []; + stderr = []; + originalArgv = process.argv; + originalExitCode = process.exitCode; + process.exitCode = undefined; + vi.spyOn(console, "log").mockImplementation((...values: unknown[]) => { + stdout.push(values.join(" ")); + }); + vi.spyOn(console, "error").mockImplementation((...values: unknown[]) => { + stderr.push(values.join(" ")); + }); + mocks.list.mockReturnValue(catalog); + mocks.find.mockImplementation((id: string) => id === scenario.id ? structuredClone(scenario) : undefined); + mocks.createRegistry.mockReturnValue({ list: mocks.list, find: mocks.find }); + }); + + afterEach(() => { + process.argv = originalArgv; + process.exitCode = originalExitCode; + vi.restoreAllMocks(); + }); + + it("rejects non-integer or out-of-range timeout values in JSON mode", async () => { + expect(await invokeCli("run", scenario.id, "--timeout-ms", "0", "--output", "json")).toBe(1); + const err = JSON.parse(stderr.join("\n")); + expect(err.error.code).toBe("INVALID_ARGUMENT"); + expect(err.error.message).toContain("--timeout-ms must be an integer between 1 and 600000"); + + stderr = []; + expect(await invokeCli("run", scenario.id, "--timeout-ms", "abc", "--output", "json")).toBe(1); + const err2 = JSON.parse(stderr.join("\n")); + expect(err2.error.code).toBe("INVALID_ARGUMENT"); + expect(err2.error.message).toContain("--timeout-ms must be an integer between 1 and 600000"); + }); + + it("aborts execution and returns stable timeout error in JSON mode", async () => { + mocks.execute.mockImplementation((_sc: unknown, opts: { signal?: AbortSignal }) => { + return new Promise((_resolve, reject) => { + opts.signal?.addEventListener("abort", () => { + reject(new Error("aborted")); + }); + }); + }); + + const exitCode = await invokeCli("run", scenario.id, "--timeout-ms", "10", "--output", "json"); + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(process.exitCode).toBe(1); + const err = JSON.parse(stderr.join("\n")); + expect(err.status).toBe("error"); + expect(err.error.code).toBe("RUN_TIMEOUT"); + expect(err.error.message).toContain("10ms"); + }); + + it("aborts execution and reports timeout in human mode", async () => { + mocks.execute.mockImplementation((_sc: unknown, opts: { signal?: AbortSignal }) => { + return new Promise((_resolve, reject) => { + opts.signal?.addEventListener("abort", () => { + reject(new Error("aborted")); + }); + }); + }); + + await invokeCli("run", scenario.id, "--timeout-ms", "10"); + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(process.exitCode).toBe(1); + expect(stderr.join("\n")).toContain("Run timed out: The run exceeded its 10ms execution limit."); + }); + + it("clears timeout handle when execution completes before deadline", async () => { + mocks.execute.mockResolvedValueOnce(passedExecution); + expect(await invokeCli("run", scenario.id, "--timeout-ms", "10000", "--output", "json")).toBe(0); + const out = JSON.parse(stdout.join("\n")); + expect(out.status).toBe("passed"); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// #56 — SIGINT and SIGTERM graceful cancellation +// ───────────────────────────────────────────────────────────────────────────── + +describe("#56 graceful SIGINT and SIGTERM cancellation", () => { + beforeEach(() => { + stdout = []; + stderr = []; + originalArgv = process.argv; + originalExitCode = process.exitCode; + process.exitCode = undefined; + vi.spyOn(console, "log").mockImplementation((...values: unknown[]) => { + stdout.push(values.join(" ")); + }); + vi.spyOn(console, "error").mockImplementation((...values: unknown[]) => { + stderr.push(values.join(" ")); + }); + mocks.list.mockReturnValue(catalog); + mocks.find.mockImplementation((id: string) => id === scenario.id ? structuredClone(scenario) : undefined); + mocks.createRegistry.mockReturnValue({ list: mocks.list, find: mocks.find }); + }); + + afterEach(() => { + process.argv = originalArgv; + process.exitCode = originalExitCode; + vi.restoreAllMocks(); + }); + + it("handles SIGINT with exit code 130 and concise JSON output", async () => { + const initialSigintListeners = process.listenerCount("SIGINT"); + const initialSigtermListeners = process.listenerCount("SIGTERM"); + + mocks.execute.mockImplementation((_sc: unknown, opts: { signal?: AbortSignal }) => { + return new Promise((_resolve, reject) => { + opts.signal?.addEventListener("abort", () => { + reject(new Error("operation aborted")); + }); + setImmediate(() => { + process.emit("SIGINT"); + }); + }); + }); + + const exitCode = await invokeCli("run", scenario.id, "--output", "json"); + expect(exitCode).toBe(130); + const err = JSON.parse(stderr.join("\n")); + expect(err).toEqual({ + status: "cancelled", + error: { + code: "SIGINT", + message: "Execution interrupted by SIGINT.", + }, + }); + + // Verify listeners are cleaned up + expect(process.listenerCount("SIGINT")).toBe(initialSigintListeners); + expect(process.listenerCount("SIGTERM")).toBe(initialSigtermListeners); + }); + + it("handles SIGTERM with exit code 143 and concise human output", async () => { + mocks.execute.mockImplementation((_sc: unknown, opts: { signal?: AbortSignal }) => { + return new Promise((_resolve, reject) => { + opts.signal?.addEventListener("abort", () => { + reject(new Error("operation aborted")); + }); + setImmediate(() => { + process.emit("SIGTERM"); + }); + }); + }); + + const exitCode = await invokeCli("run", scenario.id); + expect(exitCode).toBe(143); + expect(stderr.join("\n")).toContain("Execution terminated by SIGTERM."); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// #57 — Dynamic version from package metadata +// ───────────────────────────────────────────────────────────────────────────── + +describe("#57 derive --version from package metadata", () => { + beforeEach(() => { + stdout = []; + stderr = []; + originalArgv = process.argv; + originalExitCode = process.exitCode; + process.exitCode = undefined; + vi.spyOn(console, "log").mockImplementation((...values: unknown[]) => { + stdout.push(values.join(" ")); + }); + }); + + afterEach(() => { + process.argv = originalArgv; + process.exitCode = originalExitCode; + vi.restoreAllMocks(); + }); + + it("displays version matching package.json", async () => { + expect(await invokeCli("--version")).toBe(0); + expect(stdout.join("\n")).toBe("esure v0.1.0"); + }); +}); +