import { describe, expect, it } from "@suaveplan/testing/runner"; import { CLI_CODES } from "../errors/errors.js"; import { App, cli } from "./app.js"; function makeApp(writer: (s: string) => void = () => {}): App { return new App({ name: "test-app", version: "1.2.3", writer }); } describe("App.run()", { timeout: 5000 }, () => { it("prints version with --version", { timeout: 5000 }, async () => { const output: string[] = []; const app = makeApp((s) => output.push(s)); await app.run(["--version"]); expect(output.join("")).toContain("1.2.3"); }); it("prints help with --help", { timeout: 5000 }, async () => { const output: string[] = []; const app = makeApp((s) => output.push(s)); await app.run(["--help"]); expect(output.join("")).toContain("Usage"); }); it("routes to registered command", { timeout: 5000 }, async () => { const called: string[] = []; const app = makeApp(); app.command("build").action(async () => { called.push("build"); }); await app.run(["build"]); expect(called).toEqual(["build"]); }); it("throws CliError(ABORTED) when AbortSignal fires before execution", { timeout: 5000, }, async () => { const controller = new AbortController(); controller.abort(); const app = makeApp(); app.action(async () => {}); await expect( app.run([], { signal: controller.signal }) ).rejects.toMatchObject({ code: CLI_CODES.APP.ABORTED, }); }); it("calls onError with CliError on execution failure", { timeout: 5000, }, async () => { const errors: Error[] = []; const app = new App({ name: "app", onError: (e) => errors.push(e), }); app.action(async () => { throw new Error("boom"); }); await app.run([]); expect(errors.length).toBe(1); }); it("OTel span does not throw (no-op tracer)", { timeout: 5000, }, async () => { const app = makeApp(); app.action(async () => {}); await expect(app.run([])).resolves.not.toThrow(); }); it("rethrows error when no onError handler", { timeout: 5000, }, async () => { const app = makeApp(); app.action(async () => { throw new Error("unhandled"); }); await expect(app.run([])).rejects.toThrow("unhandled"); }); it("wraps error in CliError(RUN_FAILED) when passing to onError", { timeout: 5000, }, async () => { const errors: Error[] = []; const app = new App({ name: "app", onError: (e) => errors.push(e), }); app.action(async () => { throw new Error("boom"); }); await app.run([]); expect(errors[0]).toMatchObject({ code: CLI_CODES.APP.RUN_FAILED }); }); }); describe("App constructor edge cases", { timeout: 5000 }, () => { it("uses process.stdout when no writer provided", { timeout: 5000, }, async () => { const app = new App({ name: "test", version: "1.0.0" }); // Should not throw — uses default stdout writer await app.run(["--version"]); }); it("sets description when provided in config", { timeout: 5000 }, () => { const app = new App({ name: "app", description: "My tool" }); expect(app.helpText()).toContain("My tool"); }); it("run() without argv defaults to process.argv.slice(2)", { timeout: 5000, }, async () => { const app = new App({ name: "app", writer: () => {} }); app.action(async () => {}); // process.argv.slice(2) in test env is likely [] or test runner args — just shouldn't throw await expect(app.run()).resolves.not.toThrow(); }); it("--version without version string does not print version", { timeout: 5000, }, async () => { const output: string[] = []; const errors: unknown[] = []; const app = new App({ name: "app", writer: (s) => output.push(s), onError: (e) => errors.push(e), }); app.action(async () => {}); await app.run(["--version"]); expect(output).toHaveLength(0); }); it("enableVersion:false passes --version to action", { timeout: 5000, }, async () => { const output: string[] = []; const errors: unknown[] = []; const app = new App({ name: "app", version: "1.0.0", enableVersion: false, writer: (s) => output.push(s), onError: (e) => errors.push(e), }); app.action(async () => {}); await app.run(["--version"]); expect(output).toHaveLength(0); }); it("enableHelp:false passes --help to action", { timeout: 5000, }, async () => { const output: string[] = []; const errors: unknown[] = []; const app = new App({ name: "app", enableHelp: false, writer: (s) => output.push(s), onError: (e) => errors.push(e), }); app.action(async () => {}); await app.run(["--help"]); expect(output).toHaveLength(0); }); it("onError receives error from failed action", { timeout: 5000, }, async () => { const errors: Error[] = []; const app = new App({ name: "app", onError: (e) => errors.push(e) }); app.action(async () => { throw new Error("action error"); }); await app.run([]); expect(errors.length).toBe(1); expect(errors[0]).toMatchObject({ code: CLI_CODES.APP.RUN_FAILED }); }); it("onError wraps non-Error throw as CliError", { timeout: 5000, }, async () => { const errors: Error[] = []; const app = new App({ name: "app", onError: (e) => errors.push(e) }); (app as unknown as { _root: { _action: unknown } })._root._action = async () => { throw "string error"; }; await app.run([]); expect(errors[0]).toMatchObject({ code: CLI_CODES.APP.RUN_FAILED }); }); }); describe("App builder methods", { timeout: 5000 }, () => { it("description() sets description on root command", { timeout: 5000, }, () => { const app = makeApp(); app.description("My tool"); expect(app.helpText()).toContain("My tool"); }); it("option() adds global option", { timeout: 5000 }, async () => { let captured: unknown; const app = makeApp(); app.option("--dry-run, -d", "dry run mode"); app.action(async (ctx) => { captured = ctx.options["dryRun"]; }); await app.run(["--dry-run"]); expect(captured).toBe(true); }); it("argument() adds positional argument", { timeout: 5000 }, async () => { let captured: unknown; const app = makeApp(); app.argument("", "file to process"); app.action(async (ctx) => { captured = ctx.args["file"]; }); await app.run(["myfile.txt"]); expect(captured).toBe("myfile.txt"); }); it("example() shows up in help text", { timeout: 5000 }, () => { const output: string[] = []; const app = new App({ name: "app", writer: (s) => output.push(s) }); app.example("app build --verbose"); app.run(["--help"]); return new Promise((r) => setTimeout(() => { expect(output.join("")).toContain("app build --verbose"); r(); }, 50) ); }); }); describe("cli() factory", { timeout: 5000 }, () => { it("creates App with name and version", { timeout: 5000 }, () => { const app = cli("myapp", "2.0.0"); expect(app.name).toBe("myapp"); expect(app.version).toBe("2.0.0"); }); it("creates App with name only", { timeout: 5000 }, () => { const app = cli("myapp"); expect(app.name).toBe("myapp"); expect(app.version).toBeUndefined(); }); });