From ea35e9586ded27668348f4d5e2a158e8eac71692 Mon Sep 17 00:00:00 2001 From: serverlessdomain-hash <279776963+serverlessdomain-hash@users.noreply.github.com> Date: Fri, 16 Oct 2026 01:33:27 +0100 Subject: [PATCH] feat(cli): add show command, stdin validation, directory validation, and list filtering - Add 'show ' command to inspect bundled scenarios offline - Add '--search' and '--sort id' flags to 'list' command for deterministic filtering - Add '--stdin' option with required '--format' to 'validate' command with byte bounds - Add 'validate-directory ' command with per-file summaries and duplicate detection - Document new CLI commands and options in README.md and '--help' - Add comprehensive test coverage in esure-cli/test/cli.test.ts Closes #61, Closes #63, Closes #64, Closes #65 --- README.md | 12 ++ esure-cli/src/cli.ts | 326 ++++++++++++++++++++++++++++++++----- esure-cli/test/cli.test.ts | 283 +++++++++++++++++++++++++++++++- 3 files changed, 579 insertions(+), 42 deletions(-) diff --git a/README.md b/README.md index 8ad88bc..9c1cebd 100644 --- a/README.md +++ b/README.md @@ -227,9 +227,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 74f3927..2d72d75 100644 --- a/esure-cli/src/cli.ts +++ b/esure-cli/src/cli.ts @@ -1,10 +1,13 @@ #!/usr/bin/env node -import { readFile } from "node:fs/promises"; -import { resolve } from "node:path"; +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 { prepareScenario } from "@esure/backend/src/scenario-loader.js"; +import { SCENARIO_LIMITS, ScenarioValidationError } from "@esure/backend/src/scenario-schema.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 { parse as parseYaml } from "yaml"; @@ -12,10 +15,17 @@ import type { ValidatedScenario } from "@esure/backend/src/domain.js"; const VERSION = "0.1.0"; +const supportedExtensions = new Set([".json", ".yaml", ".yml"]); +const maxFilesPerDirectory = 100; + interface CliOptions { output?: "human" | "json"; help?: boolean; version?: boolean; + search?: string; + sort?: string; + stdin?: boolean; + format?: "json" | "yaml"; } function showHelp(): void { @@ -23,19 +33,32 @@ function showHelp(): void { esure - Stellar Testnet scenario testing toolkit Usage: - esure list List bundled scenarios - esure validate Validate a scenario file - esure run Run a bundled scenario - esure run Run a scenario from file + 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 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 list --search payment --sort id --output json + esure show xlm-payment esure validate ./my-scenario.yaml + cat ./my-scenario.json | esure validate --stdin --format json + esure validate-directory ./scenarios esure run xlm-payment esure run ./my-scenario.yaml --output json @@ -53,6 +76,10 @@ function parseCliArgs(): { command?: string; args: string[]; options: CliOptions 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, @@ -65,13 +92,17 @@ function parseCliArgs(): { command?: string; args: string[]; options: CliOptions output: values.output === "json" ? "json" : "human", help: Boolean(values.help), version: Boolean(values.version), + 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, }, }; } async function loadScenarioFile(filePath: string): Promise { const absolutePath = resolve(filePath); - const content = await readFile(absolutePath, "utf-8"); + const content = await readFilePromises(absolutePath, "utf-8"); let parsed: unknown; if (filePath.endsWith(".json")) { @@ -86,10 +117,66 @@ async function loadScenarioFile(filePath: string): Promise { return prepareScenario(validated); } +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}`); + } +} + +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[]; + } + 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}`); + } + } + } +} + 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(JSON.stringify({ scenarios: scenarios.map(s => ({ id: s.id, version: s.version, name: s.name, description: s.description })) }, null, 2)); @@ -104,7 +191,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)}`); } @@ -112,46 +199,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 { - 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}`); + 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, + + 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 { - 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.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; } @@ -247,7 +476,7 @@ async function runCommand(target: string, options: CliOptions): Promise } } -async function main(): Promise { +export async function main(): Promise { const { command, args, options } = parseCliArgs(); if (options.version) { @@ -263,13 +492,30 @@ async function main(): Promise { switch (command) { case "list": return listCommand(options); + case "show": + if (args.length === 0) { + console.error("Error: Missing scenario ID"); + console.error('Usage: esure show '); + return 1; + } + return showCommand(args[0], options); case "validate": + if (options.stdin) { + return validateStdinCommand(options); + } if (args.length === 0) { console.error("Error: Missing file path argument"); - console.error("Usage: esure validate "); + console.error("Usage: esure validate or esure validate --stdin --format "); + return 1; + } + return validateFileCommand(args[0], options); + case "validate-directory": + if (args.length === 0) { + console.error("Error: Missing directory path"); + console.error('Usage: esure validate-directory '); return 1; } - return validateCommand(args[0], options); + return validateDirectoryCommand(args[0], options); case "run": if (args.length === 0) { console.error("Error: Missing scenario ID or file path"); diff --git a/esure-cli/test/cli.test.ts b/esure-cli/test/cli.test.ts index 3b75758..62b315d 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/src/domain.js"; +import { SCENARIO_LIMITS } from "@esure/backend/src/scenario-schema.js"; const mocks = vi.hoisted(() => ({ execute: vi.fn(), @@ -7,9 +8,15 @@ const mocks = vi.hoisted(() => ({ list: vi.fn(), createRegistry: vi.fn(), readFile: vi.fn(), + fsReaddirSync: vi.fn(), + fsStatSync: vi.fn(), })); 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, })); @@ -44,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", @@ -74,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 { @@ -93,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(" ")); @@ -106,15 +183,20 @@ describe("CLI commands", () => { mocks.createRegistry.mockReturnValue({ list: mocks.list, find: mocks.find }); mocks.execute.mockResolvedValue(passedExecution); mocks.readFile.mockReset(); + 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(); }); @@ -142,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], @@ -196,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"));