253 lines
8.1 KiB
TypeScript
253 lines
8.1 KiB
TypeScript
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>", "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<void>((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();
|
|
});
|
|
});
|