diff --git a/README.md b/README.md index 1f31ff9..a87f885 100644 --- a/README.md +++ b/README.md @@ -229,9 +229,21 @@ npm run build # List bundled scenarios node dist/cli.js list +# Filter and sort scenarios +node dist/cli.js list --search payment --sort id + +# Show a bundled scenario definition +node dist/cli.js show xlm-payment + # Validate a scenario file node dist/cli.js validate ./my-scenario.yaml +# Validate a scenario from standard input +cat ./my-scenario.json | node dist/cli.js validate --stdin --format json + +# Validate a directory of scenario files +node dist/cli.js validate-directory ./scenarios + # Run a bundled scenario node dist/cli.js run xlm-payment diff --git a/esure-cli/src/cli.ts b/esure-cli/src/cli.ts index 3c84b51..317caf3 100644 --- a/esure-cli/src/cli.ts +++ b/esure-cli/src/cli.ts @@ -1,12 +1,13 @@ #!/usr/bin/env node -import { existsSync, readFileSync } from "node:fs"; -import { readFile } from "node:fs/promises"; -import { dirname, join, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; +import { readFile as readFilePromises } from "node:fs/promises"; +import { readdirSync, statSync } from "node:fs"; + +import { extname, resolve } from "node:path"; import { parseArgs } from "node:util"; +import { validateScenarioDefinition } from "@esure/backend/src/scenario-schema.js"; import { SCENARIO_LIMITS, ScenarioValidationError } from "@esure/backend/src/scenario-schema.js"; -import { parseScenario } from "@esure/backend/src/scenario-loader.js"; +import { parseScenario, prepareScenario, contentHash } 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"; @@ -56,31 +57,17 @@ export const MAX_CLI_TIMEOUT_MS = 600_000; // Shared types // ───────────────────────────────────────────────────────────────────────────── +const supportedExtensions = new Set([".json", ".yaml", ".yml"]); +const maxFilesPerDirectory = 100; + interface CliOptions { output?: "human" | "json"; help?: boolean; version?: boolean; - /** #68 — use ASCII status markers (PASS/FAIL) instead of Unicode glyphs */ - ascii?: boolean; - /** #55 — overall run timeout in milliseconds */ - timeoutMs?: number; -} - -// ───────────────────────────────────────────────────────────────────────────── -// Status markers (#68) -// ───────────────────────────────────────────────────────────────────────────── - -interface Markers { - pass: string; - fail: string; -} - -function markers(ascii: boolean): Markers { - return ascii ? { pass: "PASS", fail: "FAIL" } : { pass: "✓", fail: "✗" }; -} - -function statusMarker(status: "passed" | "failed" | string, m: Markers): string { - return status === "passed" ? m.pass : m.fail; + search?: string; + sort?: string; + stdin?: boolean; + format?: "json" | "yaml"; } // ───────────────────────────────────────────────────────────────────────────── @@ -111,30 +98,32 @@ function showHelp(): void { esure - Stellar Testnet scenario testing toolkit Usage: - esure list List bundled scenarios - esure validate Validate a scenario file - esure report validate Validate an archived JSON run report offline - esure init Scaffold a new scenario file (.json, .yaml, .yml) - esure run Run a bundled scenario - esure run Run a scenario from file - esure run --file Run a scenario from file explicitly - esure run-suite [...] Run multiple bundled scenarios sequentially - esure completions Generate shell completions (bash|zsh) - esure doctor Diagnose the local installation + esure list List bundled scenarios + esure list --search Filter scenarios by ID, name, or description + esure list --sort id Sort scenarios by ID + esure show Show a bundled scenario definition + esure validate Validate a scenario file + esure validate --stdin --format Validate a scenario from standard input + esure validate-directory Validate scenario files in a directory + esure run Run a bundled scenario + esure run Run a scenario from file Options: - --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 + --output Output format: human (default) or json + --search Filter scenarios by ID, name, or description (case-insensitive) + --sort Sort results: id + --stdin Read the scenario definition from standard input + --format Input format for standard input: json or yaml (required with --stdin) + --help Show this help message + --version Show version information Examples: esure list - esure init ./my-scenario.yaml + esure list --search payment --sort id --output json + esure show xlm-payment esure validate ./my-scenario.yaml - esure report validate ./report.json --output json + cat ./my-scenario.json | esure validate --stdin --format json + esure validate-directory ./scenarios esure run xlm-payment esure run ./my-scenario.yaml --output json esure run xlm-payment --timeout-ms 30000 @@ -148,101 +137,20 @@ function showVersion(): void { console.log(`esure v${VERSION}`); } -// ───────────────────────────────────────────────────────────────────────────── -// Argument parsing -// ───────────────────────────────────────────────────────────────────────────── - -function parseCliArgs(): { - command?: string; - args: string[]; - options: CliOptions; - failFast: boolean; - parseError?: { message: string; usage?: string }; -} { - // 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; - } - - const rawJunit = - typeof values.junit === "string" - ? values.junit - : typeof values["junit-file"] === "string" - ? (values["junit-file"] as string) - : undefined; +function parseCliArgs(): { command?: string; args: string[]; options: CliOptions } { + const { values, positionals } = parseArgs({ + options: { + output: { type: "string" }, + help: { type: "boolean", short: "h" }, + version: { type: "boolean", short: "v" }, + search: { type: "string" }, + sort: { type: "string" }, + stdin: { type: "boolean" }, + format: { type: "string" }, + }, + allowPositionals: true, + strict: false, + }); return { command: positionals[0], @@ -251,8 +159,10 @@ function parseCliArgs(): { output, help: Boolean(values.help), version: Boolean(values.version), - ascii: Boolean(values.ascii), - timeoutMs, + search: typeof values.search === "string" ? values.search : undefined, + sort: typeof values.sort === "string" ? values.sort : undefined, + stdin: Boolean(values.stdin), + format: values.format === "json" || values.format === "yaml" ? values.format : undefined, }, failFast: Boolean(values["fail-fast"]), }; @@ -274,23 +184,7 @@ async function loadScenarioFile(filePath: string): Promise { } const absolutePath = resolve(filePath); - let fileStat; - try { - fileStat = await stat(absolutePath); - } catch (err: unknown) { - if (err && typeof err === "object" && "code" in err && (err as { code: string }).code === "ENOENT") { - throw new Error(`Scenario file not found: "${filePath}"`); - } - throw err; - } - - if (fileStat.size > SCENARIO_LIMITS.definitionBytes) { - throw new ScenarioValidationError([`definition exceeds ${SCENARIO_LIMITS.definitionBytes} bytes`]); - } - - const content = await readFile(absolutePath, "utf-8"); - return parseScenario(content, format); -} + const content = await readFilePromises(absolutePath, "utf-8"); // ───────────────────────────────────────────────────────────────────────────── // Execution helpers @@ -355,144 +249,66 @@ function buildRunReport( }; } -// ───────────────────────────────────────────────────────────────────────────── -// Signal & timeout execution runner (#55, #56) -// ───────────────────────────────────────────────────────────────────────────── - -interface ExecutionRunnerResult { - result?: T; - exitCode?: number; +function renderValidationSuccess(scenario: ValidatedScenario, options: CliOptions): void { + if (options.output === "json") { + console.log(JSON.stringify({ + valid: true, + scenarioId: scenario.id, + version: scenario.version, + schemaVersion: scenario.schemaVersion, + contentHash: scenario.contentHash, + }, null, 2)); + } else { + console.log(`✓ Scenario is valid`); + console.log(` ID: ${scenario.id}`); + console.log(` Version: ${scenario.version}`); + console.log(` Name: ${scenario.name}`); + console.log(` Schema: v${scenario.schemaVersion}`); + console.log(` Hash: ${scenario.contentHash}`); + } } -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 }; +function renderValidationError(error: unknown, options: CliOptions): void { + if (options.output === "json") { + const errorObj: { message: string; issues?: string[] } = { + message: error instanceof Error ? error.message : String(error), + }; + if (error && typeof error === "object" && "issues" in error && Array.isArray(error.issues)) { + errorObj.issues = error.issues as string[]; } - - // 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({ + valid: false, + error: errorObj, + }, null, 2)); + } else { + console.error(`✗ Validation failed: ${error instanceof Error ? error.message : String(error)}`); + if (error && typeof error === "object" && "issues" in error && Array.isArray(error.issues)) { + for (const issue of error.issues) { + console.error(` - ${issue}`); } - 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 -// ───────────────────────────────────────────────────────────────────────────── - async function listCommand(options: CliOptions): Promise { try { const registry = createScenarioRegistry(); - const scenarios = registry.list(); + let scenarios = registry.list(); + + if (options.search) { + const term = options.search.trim().toLowerCase(); + if (term) { + scenarios = scenarios.filter((scenario) => + scenario.id.toLowerCase().includes(term) || + scenario.name.toLowerCase().includes(term) || + scenario.description.toLowerCase().includes(term) + ); + } + } + + if (options.sort === "id") { + scenarios = [...scenarios].sort((left, right) => left.id.localeCompare(right.id)); + } if (options.output === "json") { console.log( @@ -520,9 +336,7 @@ async function listCommand(options: CliOptions): Promise { return 0; } catch (error) { if (options.output === "json") { - console.error( - JSON.stringify({ error: { message: error instanceof Error ? error.message : String(error) } }), - ); + console.error(JSON.stringify({ error: { message: error instanceof Error ? error.message : String(error) } }, null, 2)); } else { console.error(`Error: ${error instanceof Error ? error.message : String(error)}`); } @@ -530,53 +344,188 @@ async function listCommand(options: CliOptions): Promise { } } -async function validateCommand(filePath: string, options: CliOptions): Promise { +async function showCommand(scenarioId: string, options: CliOptions): Promise { try { - const scenario = await loadScenarioFile(filePath); + const registry = createScenarioRegistry(); + const found = registry.find(scenarioId); + if (!found) { + throw new Error(`Bundled scenario "${scenarioId}" not found. Run "esure list" to see available scenarios.`); + } + + const payload = { + id: found.id, + version: found.version, + name: found.name, + description: found.description, + contentHash: found.contentHash, + definition: { + schemaVersion: found.schemaVersion, + network: found.network, + accounts: found.accounts, + assets: found.assets, + steps: found.steps, + assertions: found.assertions, + }, + }; if (options.output === "json") { - console.log( - JSON.stringify( - { - valid: true, - scenarioId: scenario.id, - version: scenario.version, - schemaVersion: scenario.schemaVersion, - contentHash: scenario.contentHash, - }, - null, - 2, - ), - ); + console.log(JSON.stringify(payload, null, 2)); } else { - const m = markers(options.ascii ?? false); - console.log(`${m.pass} Scenario is valid`); - console.log(` ID: ${scenario.id}`); - console.log(` Version: ${scenario.version}`); - console.log(` Name: ${scenario.name}`); - console.log(` Schema: v${scenario.schemaVersion}`); - console.log(` Hash: ${scenario.contentHash}`); + console.log(`Scenario: ${found.id} (v${found.version})`); + console.log(`Hash: ${found.contentHash}`); + console.log(`Name: ${found.name}`); + console.log(`Description: ${found.description}`); + console.log(`Definition:`); + console.log(JSON.stringify(payload.definition, null, 2)); } return 0; } catch (error) { if (options.output === "json") { - const errorObj: { message: string; issues?: string[] } = { - message: error instanceof Error ? error.message : String(error), - }; - if (error && typeof error === "object" && "issues" in error && Array.isArray(error.issues)) { - errorObj.issues = error.issues as string[]; + console.error(JSON.stringify({ error: { message: error instanceof Error ? error.message : String(error) } }, null, 2)); + } else { + console.error(`Error: ${error instanceof Error ? error.message : String(error)}`); + } + return 1; + } +} + +async function validateFileCommand(filePath: string, options: CliOptions): Promise { + try { + const scenario = await loadScenarioFile(filePath); + renderValidationSuccess(scenario, options); + return 0; + } catch (error) { + renderValidationError(error, options); + return 1; + } +} + +export async function readStdinInput(limit: number, stdin: NodeJS.ReadableStream = process.stdin): Promise { + const chunks: Buffer[] = []; + let total = 0; + for await (const chunk of stdin) { + const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as string); + total += buf.length; + if (total > limit) { + throw new ScenarioValidationError([`stdin input exceeds ${limit} bytes`]); + } + chunks.push(buf); + } + return Buffer.concat(chunks).toString("utf-8"); +} + +export async function validateStdinCommand(options: CliOptions, stdin: NodeJS.ReadStream = process.stdin): Promise { + try { + if (!options.format) { + throw new Error("Missing required --format option. Usage: esure validate --stdin --format "); + } + if (stdin.isTTY) { + throw new Error("stdin is a terminal; pipe a scenario definition, e.g. cat scenario.json | esure validate --stdin --format json"); + } + + const content = await readStdinInput(SCENARIO_LIMITS.definitionBytes, stdin); + if (content.trim().length === 0) { + throw new ScenarioValidationError(["stdin input is empty"]); + } + + const scenario = parseScenario(content, options.format); + renderValidationSuccess(scenario, options); + return 0; + } catch (error) { + renderValidationError(error, options); + return 1; + } +} + +async function validateDirectoryCommand(dirPath: string, options: CliOptions): Promise { + try { + const absolute = resolve(dirPath); + const entries = readdirSync(absolute, { withFileTypes: true }) + .filter((entry) => entry.isFile() && supportedExtensions.has(extname(entry.name).toLowerCase())) + .sort((left, right) => left.name.localeCompare(right.name)); + + if (entries.length > maxFilesPerDirectory) { + throw new Error(`scenario directory ${absolute} exceeds ${maxFilesPerDirectory} files`); + } + + type DirectoryResult = { + file: string; + valid: boolean; + scenario?: ValidatedScenario; + issues?: string[]; + }; + + const results: DirectoryResult[] = []; + + for (const entry of entries) { + const filePath = resolve(absolute, entry.name); + const size = statSync(filePath).size; + if (size > SCENARIO_LIMITS.definitionBytes) { + results.push({ file: entry.name, valid: false, issues: [`exceeds ${SCENARIO_LIMITS.definitionBytes} bytes`] }); + continue; } - console.error(JSON.stringify({ valid: false, error: errorObj }, null, 2)); + + const content = await readFilePromises(filePath, "utf-8"); + const format = extname(entry.name).toLowerCase() === ".json" ? "json" : "yaml"; + try { + const scenario = parseScenario(content, format); + results.push({ file: entry.name, valid: true, scenario }); + } catch (error) { + const issues = error instanceof ScenarioValidationError + ? error.issues + : [error instanceof Error ? error.message : String(error)]; + results.push({ file: entry.name, valid: false, issues }); + } + } + + const seenIds = new Set(); + for (const result of results) { + if (result.valid && result.scenario) { + if (seenIds.has(result.scenario.id)) { + result.valid = false; + result.issues = [...(result.issues ?? []), `duplicate scenario id ${result.scenario.id}`]; + } else { + seenIds.add(result.scenario.id); + } + } + } + + const total = results.length; + const passed = results.filter((result) => result.valid).length; + const failed = total - passed; + const ok = failed === 0; + + if (options.output === "json") { + console.log(JSON.stringify({ + directory: absolute, + files: results.map((result) => ({ + file: result.file, + valid: result.valid, + ...(result.scenario ? { scenarioId: result.scenario.id, version: result.scenario.version, contentHash: result.scenario.contentHash } : {}), + ...(result.issues ? { issues: result.issues } : {}), + })), + summary: { total, passed, failed }, + valid: ok, + }, null, 2)); } else { - const m = markers(options.ascii ?? false); - console.error( - `${m.fail} Validation failed: ${error instanceof Error ? error.message : String(error)}`, - ); - if (error && typeof error === "object" && "issues" in error && Array.isArray(error.issues)) { - for (const issue of error.issues) { - console.error(` - ${issue}`); + console.log(`\nValidate directory: ${absolute}\n`); + for (const result of results) { + const icon = result.valid ? "✓" : "✗"; + console.log(` ${icon} ${result.file}`); + if (!result.valid && result.issues) { + for (const issue of result.issues) { + console.log(` - ${issue}`); + } } } + console.log(`\n${total} file(s) processed: ${passed} valid, ${failed} failed`); + } + return ok ? 0 : 1; + } catch (error) { + if (options.output === "json") { + console.error(JSON.stringify({ error: { message: error instanceof Error ? error.message : String(error) } }, null, 2)); + } else { + console.error(`Error: ${error instanceof Error ? error.message : String(error)}`); } return 1; } @@ -1010,79 +959,8 @@ async function completionsCommand(shell: string): Promise { return 0; } -// ───────────────────────────────────────────────────────────────────────────── -// #159 — local installation doctor -// ───────────────────────────────────────────────────────────────────────────── - -async function doctorCommand(options: CliOptions): Promise { - const m = markers(options.ascii ?? false); - const pass = m.pass; - const fail = m.fail; - - const checks: { name: string; ok: boolean; detail: string }[] = []; - - // CLI build artifact - const cliDist = resolve("dist/cli.js"); - let cliDistOk = false; - try { - await access(cliDist); - cliDistOk = true; - } catch {} - checks.push({ - name: "CLI build artifact (dist/cli.js)", - ok: cliDistOk, - detail: cliDistOk - ? "present" - : "missing — run `npm run build` in esure-cli", - }); - - // Backend package - const backendPkg = resolve("../esure-backend/package.json"); - let backendPkgOk = false; - try { - await access(backendPkg); - backendPkgOk = true; - } catch {} - checks.push({ - name: "Backend package (@esure/backend)", - ok: backendPkgOk, - detail: backendPkgOk ? "present" : "missing — esure-backend not found", - }); - - // Node version - const nodeVersion = process.version; - const nodeOk = /^v(20|24)\./.test(nodeVersion); - checks.push({ - name: "Node.js version", - ok: nodeOk, - detail: `${nodeVersion} (>=20 required)`, - }); - - let failed = 0; - console.log("esure installation doctor\n"); - for (const check of checks) { - const mark = check.ok ? pass : fail; - console.log(`${mark} ${check.name}: ${check.detail}`); - if (!check.ok) failed += 1; - } - - console.log( - `\n${failed === 0 ? "All checks passed." : `${failed} check(s) failed — installation may be incomplete.`}`, - ); - return failed === 0 ? 0 : 1; -} - -// ───────────────────────────────────────────────────────────────────────────── -// Entry point -// ───────────────────────────────────────────────────────────────────────────── - -async function main(): Promise { - const { command, args, options, failFast, parseError } = parseCliArgs(); - - if (parseError) { - reportArgumentError(parseError.message, parseError.usage, options.output); - return 1; - } +export async function main(): Promise { + const { command, args, options } = parseCliArgs(); if (options.version) { showVersion(); @@ -1102,34 +980,30 @@ async function main(): Promise { return 1; } return listCommand(options); - case "validate": + case "show": if (args.length === 0) { - reportArgumentError( - "Missing file path argument", - "Usage: esure validate ", - options.output, - ); + console.error("Error: Missing scenario ID"); + console.error('Usage: esure show '); return 1; } - if (args.length > 1) { - console.error("Error: Unexpected argument(s) for validate command"); - console.error("Usage: esure validate "); - return 1; + return showCommand(args[0], options); + case "validate": + if (options.stdin) { + return validateStdinCommand(options); } - return validateCommand(args[0], options); - case "report": - if (args[0] !== "validate" || !args[1] || args.length > 2) { - console.error("Usage: esure report validate [--output human|json]"); + if (args.length === 0) { + console.error("Error: Missing file path argument"); + console.error("Usage: esure validate or esure validate --stdin --format "); return 1; } - return validateReportCommand(args[1], options); - case "init": + return validateFileCommand(args[0], options); + case "validate-directory": if (args.length === 0) { - console.error("Error: Missing file path argument"); - console.error("Usage: esure init "); + console.error("Error: Missing directory path"); + console.error('Usage: esure validate-directory '); return 1; } - return initCommand(args[0], options); + return validateDirectoryCommand(args[0], options); case "run": if (options.file !== undefined && args.length > 0) { console.error("Error: Cannot specify both --file and a positional target"); diff --git a/esure-cli/test/cli.test.ts b/esure-cli/test/cli.test.ts index 9ba4d8c..281d1cb 100644 --- a/esure-cli/test/cli.test.ts +++ b/esure-cli/test/cli.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { ValidatedScenario } from "@esure/backend/domain.js"; +import type { ValidatedScenario } from "@esure/backend/src/domain.js"; +import { SCENARIO_LIMITS } from "@esure/backend/src/scenario-schema.js"; const mocks = vi.hoisted(() => ({ execute: vi.fn(), @@ -7,12 +8,14 @@ const mocks = vi.hoisted(() => ({ list: vi.fn(), createRegistry: vi.fn(), readFile: vi.fn(), - stat: vi.fn(), + fsReaddirSync: vi.fn(), + fsStatSync: vi.fn(), })); -vi.mock("node:fs/promises", () => ({ - readFile: mocks.readFile, - stat: mocks.stat, +vi.mock("node:fs/promises", () => ({ readFile: mocks.readFile })); +vi.mock("node:fs", () => ({ + readdirSync: mocks.fsReaddirSync, + statSync: mocks.fsStatSync, })); vi.mock("@esure/backend/src/scenarios.js", () => ({ createScenarioRegistry: mocks.createRegistry, @@ -48,6 +51,12 @@ const catalog = [{ description: scenario.description, }]; +const multiCatalog = [ + { id: "xlm-payment", version: 1, name: "XLM Payment", description: "Send XLM between accounts" }, + { id: "missing-trustline", version: 1, name: "Missing Trustline", description: "Payment without a trustline" }, + { id: "issued-asset-payment", version: 1, name: "Issued Asset Payment", description: "Issue and send TESTUSD" }, +]; + const validJson = JSON.stringify(definition); const validYaml = [ "schemaVersion: 1", @@ -78,13 +87,76 @@ let stdout: string[]; let stderr: string[]; let originalArgv: string[]; let originalExitCode: number | undefined; +let originalStdin: NodeJS.ReadStream; + +async function waitForExitCode(timeoutMs = 5000): Promise { + const start = Date.now(); + while (process.exitCode === undefined && Date.now() - start < timeoutMs) { + await new Promise((resolve) => setImmediate(resolve)); + } + return process.exitCode; +} async function invokeCli(...args: string[]): Promise { process.argv = ["node", "cli.ts", ...args]; vi.resetModules(); await import("../src/cli.js"); - await new Promise((resolve) => setImmediate(resolve)); - return process.exitCode; + return waitForExitCode(); +} + +const originalStdinDescriptor = Object.getOwnPropertyDescriptor(process, "stdin") ?? { + configurable: true, + writable: true, + value: process.stdin, +}; + +function mockStdinInput(content: string, isTTY = false): void { + const chunks = [Buffer.from(content, "utf-8")]; + let ended = false; + const mockStream = { + isTTY, + [Symbol.asyncIterator]() { + return { + next: () => { + if (ended) return Promise.resolve({ value: undefined, done: true }); + if (chunks.length === 0) { + ended = true; + return Promise.resolve({ value: undefined, done: true }); + } + return Promise.resolve({ value: chunks.shift()!, done: false }); + }, + return: () => { + ended = true; + return Promise.resolve({ value: undefined, done: true }); + }, + }; + }, + } as unknown as NodeJS.ReadStream; + + Object.defineProperty(process, "stdin", { + value: mockStream, + configurable: true, + writable: true, + }); +} + +function mockDirectory(files: { name: string; size: number; content: string }[]): void { + const ordered = [...files].sort((left, right) => left.name.localeCompare(right.name)); + mocks.fsReaddirSync.mockReturnValue( + ordered.map((file) => ({ name: file.name, isFile: () => true })) + ); + const sizes: Record = {}; + for (const file of files) sizes[file.name] = file.size; + mocks.fsStatSync.mockImplementation((_path: string) => { + const name = String(_path).split(/[/\\]/).pop()!; + return { size: sizes[name] ?? 1 }; + }); + const contents: Record = {}; + for (const file of files) contents[file.name] = file.content; + mocks.readFile.mockImplementation(async (_path: string) => { + const name = String(_path).split(/[/\\]/).pop()!; + return contents[name]; + }); } async function withFile(content: string, _filePath?: string): Promise { @@ -97,6 +169,7 @@ describe("CLI commands", () => { stderr = []; originalArgv = process.argv; originalExitCode = process.exitCode; + originalStdin = process.stdin; process.exitCode = undefined; vi.spyOn(console, "log").mockImplementation((...values: unknown[]) => { stdout.push(values.join(" ")); @@ -110,17 +183,20 @@ describe("CLI commands", () => { mocks.createRegistry.mockReturnValue({ list: mocks.list, find: mocks.find }); mocks.execute.mockResolvedValue(passedExecution); mocks.readFile.mockReset(); - mocks.stat.mockReset(); - mocks.stat.mockResolvedValue({ size: 100, isFile: () => true }); + mocks.fsReaddirSync.mockReturnValue([]); + mocks.fsStatSync.mockReturnValue({ size: 1 }); mocks.list.mockClear(); mocks.find.mockClear(); mocks.createRegistry.mockClear(); mocks.execute.mockClear(); + mocks.fsReaddirSync.mockClear(); + mocks.fsStatSync.mockClear(); }); afterEach(() => { process.argv = originalArgv; process.exitCode = originalExitCode; + Object.defineProperty(process, "stdin", originalStdinDescriptor); vi.restoreAllMocks(); }); @@ -148,6 +224,38 @@ describe("CLI commands", () => { } }); + it("filters list by search term case-insensitively", async () => { + mocks.list.mockReturnValue(multiCatalog); + expect(await invokeCli("list", "--search", "XLM", "--output", "json")).toBe(0); + const result = JSON.parse(stdout.join("\n")); + expect(result.scenarios).toHaveLength(1); + expect(result.scenarios[0].id).toBe("xlm-payment"); + }); + + it("trims whitespace in the search term", async () => { + mocks.list.mockReturnValue(multiCatalog); + expect(await invokeCli("list", "--search", " xlm ", "--output", "json")).toBe(0); + const result = JSON.parse(stdout.join("\n")); + expect(result.scenarios).toHaveLength(1); + expect(result.scenarios[0].id).toBe("xlm-payment"); + }); + + it("returns an empty collection for zero search matches", async () => { + mocks.list.mockReturnValue(multiCatalog); + expect(await invokeCli("list", "--search", "zzz-no-match", "--output", "json")).toBe(0); + const result = JSON.parse(stdout.join("\n")); + expect(result.scenarios).toEqual([]); + }); + + it("sorts list by id", async () => { + mocks.list.mockReturnValue(multiCatalog); + expect(await invokeCli("list", "--sort", "id", "--output", "json")).toBe(0); + const result = JSON.parse(stdout.join("\n")); + const ids = result.scenarios.map((s: { id: string }) => s.id); + expect(ids).toEqual([...ids].sort()); + expect(ids).toEqual(["issued-asset-payment", "missing-trustline", "xlm-payment"]); + }); + it.each([ ["JSON", "scenario.json", validJson], ["YAML", "scenario.yaml", validYaml], @@ -202,6 +310,171 @@ describe("CLI commands", () => { }); }); + it("validates JSON from standard input", async () => { + mockStdinInput(validJson); + expect(await invokeCli("validate", "--stdin", "--format", "json")).toBe(0); + expect(stdout.join("\n")).toContain("Scenario is valid"); + expect(stderr).toEqual([]); + }); + + it("validates YAML from standard input", async () => { + mockStdinInput(validYaml); + expect(await invokeCli("validate", "--stdin", "--format", "yaml")).toBe(0); + expect(stdout.join("\n")).toContain("Scenario is valid"); + expect(stderr).toEqual([]); + }); + + it("rejects empty standard input", async () => { + mockStdinInput(""); + expect(await invokeCli("validate", "--stdin", "--format", "json", "--output", "json")).toBe(1); + const result = JSON.parse(stderr.join("\n")); + expect(result.valid).toBe(false); + expect(result.error.message).toBeTruthy(); + }); + + it("rejects malformed standard input", async () => { + mockStdinInput("{"); + expect(await invokeCli("validate", "--stdin", "--format", "json", "--output", "json")).toBe(1); + const result = JSON.parse(stderr.join("\n")); + expect(result.valid).toBe(false); + }); + + it("rejects oversized standard input", async () => { + const oversized = "x".repeat(SCENARIO_LIMITS.definitionBytes + 1); + mockStdinInput(oversized); + expect(await invokeCli("validate", "--stdin", "--format", "json", "--output", "json")).toBe(1); + const result = JSON.parse(stderr.join("\n")); + expect(result.valid).toBe(false); + }); + + it("requires --format with --stdin", async () => { + mockStdinInput(validJson); + expect(await invokeCli("validate", "--stdin")).toBe(1); + expect(stderr.join("\n")).toContain("--format"); + }); + + it("does not hang on a terminal without piped input", async () => { + mockStdinInput(validJson, true); + expect(await invokeCli("validate", "--stdin", "--format", "json")).toBe(1); + expect(stderr.join("\n")).toContain("terminal"); + }); + + it("shows a bundled scenario in human-readable format", async () => { + mocks.find.mockImplementation((id: string) => id === scenario.id ? structuredClone(scenario) : undefined); + expect(await invokeCli("show", scenario.id)).toBe(0); + const out = stdout.join("\n"); + expect(out).toContain(`Scenario: ${scenario.id} (v${scenario.version})`); + expect(out).toContain(`Hash: ${scenario.contentHash}`); + expect(out).toContain("Definition:"); + expect(stderr).toEqual([]); + }); + + it("shows a bundled scenario as JSON", async () => { + mocks.find.mockImplementation((id: string) => id === scenario.id ? structuredClone(scenario) : undefined); + expect(await invokeCli("show", scenario.id, "--output", "json")).toBe(0); + const result = JSON.parse(stdout.join("\n")); + expect(result).toMatchObject({ + id: scenario.id, + version: scenario.version, + contentHash: scenario.contentHash, + }); + expect(result.definition).toBeDefined(); + expect(result.definition.accounts).toBeDefined(); + }); + + it("reports an unknown scenario id for show", async () => { + mocks.find.mockReturnValue(undefined); + expect(await invokeCli("show", "missing-scenario", "--output", "json")).toBe(1); + const result = JSON.parse(stderr.join("\n")); + expect(result.error.message).toContain("not found"); + }); + + it("validates all files in a directory and reports per-file results", async () => { + mockDirectory([ + { name: "good.json", size: 50, content: validJson }, + { name: "bad.yaml", size: 50, content: "{" }, + ]); + expect(await invokeCli("validate-directory", "./scenarios", "--output", "json")).toBe(1); + const result = JSON.parse(stdout.join("\n")); + expect(result.summary).toEqual({ total: 2, passed: 1, failed: 1 }); + expect(result.valid).toBe(false); + expect(result.files).toHaveLength(2); + const good = result.files.find((f: { file: string }) => f.file === "good.json"); + const bad = result.files.find((f: { file: string }) => f.file === "bad.yaml"); + expect(good.valid).toBe(true); + expect(bad.valid).toBe(false); + expect(bad.issues).toBeDefined(); + }); + + it("processes directory files in stable filename order", async () => { + mockDirectory([ + { name: "zeta.json", size: 50, content: JSON.stringify({ ...definition, id: "zeta-scenario" }) }, + { name: "alpha.json", size: 50, content: JSON.stringify({ ...definition, id: "alpha-scenario" }) }, + ]); + expect(await invokeCli("validate-directory", "./scenarios", "--output", "json")).toBe(0); + const result = JSON.parse(stdout.join("\n")); + expect(result.files.map((f: { file: string }) => f.file)).toEqual(["alpha.json", "zeta.json"]); + }); + + it("skips unsupported files in a directory", async () => { + mockDirectory([ + { name: "notes.txt", size: 5, content: "hello" }, + { name: "good.json", size: 50, content: validJson }, + ]); + expect(await invokeCli("validate-directory", "./scenarios", "--output", "json")).toBe(0); + const result = JSON.parse(stdout.join("\n")); + expect(result.summary).toEqual({ total: 1, passed: 1, failed: 0 }); + expect(result.files.map((f: { file: string }) => f.file)).toEqual(["good.json"]); + }); + + it("handles an empty directory as valid", async () => { + mockDirectory([]); + expect(await invokeCli("validate-directory", "./empty", "--output", "json")).toBe(0); + const result = JSON.parse(stdout.join("\n")); + expect(result.summary).toEqual({ total: 0, passed: 0, failed: 0 }); + expect(result.valid).toBe(true); + }); + + it("reports an oversized file without stopping the directory scan", async () => { + mockDirectory([ + { name: "good.json", size: 50, content: validJson }, + { name: "big.json", size: SCENARIO_LIMITS.definitionBytes + 1, content: validJson }, + ]); + expect(await invokeCli("validate-directory", "./scenarios", "--output", "json")).toBe(1); + const result = JSON.parse(stdout.join("\n")); + expect(result.valid).toBe(false); + expect(result.files.map((f: { file: string }) => f.file)).toEqual(["big.json", "good.json"]); + const big = result.files.find((f: { file: string }) => f.file === "big.json"); + expect(big.valid).toBe(false); + expect(big.issues).toContain(`exceeds ${SCENARIO_LIMITS.definitionBytes} bytes`); + }); + + it("returns nonzero and reports duplicate scenario ids", async () => { + const dup = { ...definition, id: "test-scenario" }; + mockDirectory([ + { name: "a.json", size: 50, content: JSON.stringify(dup) }, + { name: "b.json", size: 50, content: JSON.stringify(dup) }, + ]); + expect(await invokeCli("validate-directory", "./scenarios", "--output", "json")).toBe(1); + const result = JSON.parse(stdout.join("\n")); + expect(result.valid).toBe(false); + expect(result.files[0].valid).toBe(true); + expect(result.files[1].valid).toBe(false); + expect(result.files[1].issues).toContain("duplicate scenario id test-scenario"); + }); + + it("reports when a directory exceeds the file count bound", async () => { + const files = Array.from({ length: 101 }, (_, i) => ({ + name: `file-${i}.json`, + size: 50, + content: validJson, + })); + mockDirectory(files); + expect(await invokeCli("validate-directory", "./scenarios", "--output", "json")).toBe(1); + const result = JSON.parse(stderr.join("\n")); + expect(result.error.message).toContain("exceeds 100 files"); + }); + it("runs a bundled scenario and returns a passing JSON report", async () => { expect(await invokeCli("run", scenario.id, "--output", "json")).toBe(0); const result = JSON.parse(stdout.join("\n"));