Files
suaveplan/packages/devtools/tooling/electrobun/src/cli/cli.test.ts
2026-05-27 23:29:53 +01:00

906 lines
27 KiB
TypeScript

import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
ELECTROBUN_CODES,
ElectrobunError,
} from "../error-codes/error-codes.js";
import type {
CommandInput,
CommandResult,
CommandRunner,
Prompter,
} from "../types/electrobun.types.js";
import {
buildCommand,
createCommand,
devCommand,
packageCommand,
runCli,
signCommand,
} from "./cli.js";
interface Recording {
runner: CommandRunner;
calls: CommandInput[];
}
function recordingRunner(
results: ReadonlyArray<Partial<CommandResult>>
): Recording {
const calls: CommandInput[] = [];
let i = 0;
const fn: CommandRunner = async (input) => {
calls.push(input);
const r = results[i] ?? results[results.length - 1] ?? {};
i += 1;
return {
exitCode: r.exitCode ?? 0,
stdout: r.stdout ?? "1.0.0",
stderr: r.stderr ?? "",
durationMs: r.durationMs ?? 1,
};
};
return { runner: fn, calls };
}
function probeOk(stdout = "1.0.0"): Partial<CommandResult> {
return { exitCode: 0, stdout };
}
function silentPrompter(): Prompter {
return {
text: async ({ default: d }) => d ?? "demo",
confirm: async () => true,
select: async <T extends string>({
default: d,
choices,
}: {
default?: T;
choices: ReadonlyArray<{ value: T }>;
}): Promise<T> => (d ?? choices[0]!.value) as T,
multiselect: async <T extends string>(): Promise<readonly T[]> =>
[] as readonly T[],
};
}
describe("devCommand / buildCommand / packageCommand / signCommand", () => {
it("devCommand validates options", { timeout: 5000 }, async () => {
try {
await devCommand({ cwd: "" });
throw new Error("expected throw");
} catch (err) {
expect((err as ElectrobunError).code).toBe(
ELECTROBUN_CODES.INVALID_OPTIONS
);
}
});
it("buildCommand passes --update when options.update is true", {
timeout: 5000,
}, async () => {
const r = recordingRunner([probeOk(), { exitCode: 0 }]);
await buildCommand(
{ cwd: "/tmp", target: "mac", update: true },
{ runner: r.runner }
);
// first call = probe, second = build
const build = r.calls[1];
expect(build?.args).toContain("--update");
});
it("buildCommand omits --update by default", {
timeout: 5000,
}, async () => {
const r = recordingRunner([probeOk(), { exitCode: 0 }]);
await buildCommand(
{ cwd: "/tmp", target: "mac" },
{ runner: r.runner }
);
const build = r.calls[1];
expect(build?.args.includes("--update")).toBe(false);
});
it("packageCommand emits the expected argv", {
timeout: 5000,
}, async () => {
const r = recordingRunner([probeOk(), { exitCode: 0 }]);
await packageCommand(
{ cwd: "/tmp", target: "linux", extraArgs: ["--verbose"] },
{ runner: r.runner }
);
expect(r.calls[1]?.args).toEqual([
"electrobun",
"package",
"--target",
"linux",
"--verbose",
]);
});
it("signCommand forwards identity, entitlements, and notarize", {
timeout: 5000,
}, async () => {
const r = recordingRunner([probeOk(), { exitCode: 0 }]);
await signCommand(
{
cwd: "/tmp",
identity: "Dev ID",
entitlements: "/e.plist",
notarize: true,
},
{ runner: r.runner }
);
const args = r.calls[1]?.args ?? [];
expect(args).toContain("--identity");
expect(args).toContain("Dev ID");
expect(args).toContain("--entitlements");
expect(args).toContain("/e.plist");
expect(args).toContain("--notarize");
});
it("subcommands use nodeCommandRunner default when no runner supplied", {
timeout: 30000,
}, async () => {
// No deps.runner → real spawn path. Outcome depends on whether
// bunx electrobun resolves on this host; either branch (success
// or ElectrobunError) is acceptable — the test only exercises
// the default-runner fallback.
const settle = async <T>(p: Promise<T>): Promise<unknown> => {
try {
return await p;
} catch (err) {
return err;
}
};
await settle(signCommand({ cwd: "/tmp", identity: "Dev ID" }));
await settle(devCommand({ cwd: "/tmp" }));
await settle(buildCommand({ cwd: "/tmp", target: "mac" }));
await settle(packageCommand({ cwd: "/tmp", target: "mac" }));
});
it("signCommand forwards AbortSignal to runner", {
timeout: 5000,
}, async () => {
const r = recordingRunner([probeOk(), { exitCode: 0 }]);
const ctl = new AbortController();
await signCommand(
{ cwd: "/tmp", identity: "Dev ID", signal: ctl.signal },
{ runner: r.runner }
);
expect(r.calls[1]?.signal).toBe(ctl.signal);
});
it("buildCommand forwards AbortSignal to runner", {
timeout: 5000,
}, async () => {
const r = recordingRunner([probeOk(), { exitCode: 0 }]);
const ctl = new AbortController();
await buildCommand(
{ cwd: "/tmp", target: "mac", signal: ctl.signal },
{ runner: r.runner }
);
expect(r.calls[1]?.signal).toBe(ctl.signal);
});
it("packageCommand forwards AbortSignal to runner", {
timeout: 5000,
}, async () => {
const r = recordingRunner([probeOk(), { exitCode: 0 }]);
const ctl = new AbortController();
await packageCommand(
{ cwd: "/tmp", target: "mac", signal: ctl.signal },
{ runner: r.runner }
);
expect(r.calls[1]?.signal).toBe(ctl.signal);
});
it("devCommand forwards AbortSignal to runner", {
timeout: 5000,
}, async () => {
const r = recordingRunner([probeOk(), { exitCode: 0 }]);
const ctl = new AbortController();
await devCommand(
{ cwd: "/tmp", signal: ctl.signal },
{ runner: r.runner }
);
expect(r.calls[1]?.signal).toBe(ctl.signal);
});
it("signCommand without entitlements does not pass the flag", {
timeout: 5000,
}, async () => {
const r = recordingRunner([probeOk(), { exitCode: 0 }]);
await signCommand(
{ cwd: "/tmp", identity: "Dev ID" },
{ runner: r.runner }
);
const args = r.calls[1]?.args ?? [];
expect(args.includes("--entitlements")).toBe(false);
});
it("non-zero exit raises RUNNER_FAILED", { timeout: 5000 }, async () => {
const r = recordingRunner([probeOk(), { exitCode: 2, stderr: "fail" }]);
try {
await devCommand({ cwd: "/tmp" }, { runner: r.runner });
throw new Error("expected throw");
} catch (err) {
expect((err as ElectrobunError).code).toBe(
ELECTROBUN_CODES.RUNNER_FAILED
);
expect((err as ElectrobunError).details["exitCode"]).toBe(2);
}
});
it("missing binary raises BINARY_UNAVAILABLE", {
timeout: 5000,
}, async () => {
const r = recordingRunner([{ exitCode: 127 }]);
try {
await buildCommand(
{ cwd: "/tmp", target: "mac" },
{ runner: r.runner }
);
throw new Error("expected throw");
} catch (err) {
expect((err as ElectrobunError).code).toBe(
ELECTROBUN_CODES.BINARY_UNAVAILABLE
);
}
});
it("devCommand forwards extraArgs", { timeout: 5000 }, async () => {
const r = recordingRunner([probeOk(), { exitCode: 0 }]);
await devCommand(
{ cwd: "/tmp", extraArgs: ["--watch"] },
{ runner: r.runner }
);
expect(r.calls[1]?.args).toContain("--watch");
});
it("devCommand records duration even on failure", {
timeout: 5000,
}, async () => {
const r = recordingRunner([probeOk(), { exitCode: 5, stderr: "boom" }]);
await expect(
devCommand({ cwd: "/tmp" }, { runner: r.runner })
).rejects.toBeInstanceOf(ElectrobunError);
});
it("non-Electrobun error inside timeRunner is re-thrown", {
timeout: 5000,
}, async () => {
const r: CommandRunner = async () => {
throw new Error("network down");
};
await expect(
devCommand({ cwd: "/tmp" }, { runner: r })
).rejects.toBeInstanceOf(Error);
});
});
describe("runCli", () => {
it("--help returns code 0", { timeout: 5000 }, async () => {
const captured: string[] = [];
const result = await runCli(["--help"], {
out: (l) => captured.push(l),
});
expect(result).toEqual({ code: 0 });
expect(captured[0]).toContain("Usage");
});
it("runCli with default `out` argument still resolves", {
timeout: 5000,
}, async () => {
// Capture the console.log fallback by stubbing it for one call.
const orig = console.log;
const captured: string[] = [];
console.log = (line: string) => captured.push(String(line));
try {
const result = await runCli(["--help"]);
expect(result.code).toBe(0);
expect(captured.join("\n")).toContain("Usage");
} finally {
console.log = orig;
}
});
it("-h short flag returns code 0", { timeout: 5000 }, async () => {
const captured: string[] = [];
const result = await runCli(["-h"], {
out: (l) => captured.push(l),
});
expect(result).toEqual({ code: 0 });
});
it("no subcommand prints help and returns code 0", {
timeout: 5000,
}, async () => {
const captured: string[] = [];
const result = await runCli([], {
out: (l) => captured.push(l),
});
expect(result).toEqual({ code: 0 });
expect(captured[0]).toContain("Usage");
});
it("unknown subcommand raises UNKNOWN_FLAG", {
timeout: 5000,
}, async () => {
const captured: string[] = [];
const result = await runCli(["zzz"], {
out: (l) => captured.push(l),
});
expect(result).toEqual({ code: 1 });
expect(captured.join("\n")).toContain("ELECTROBUN.UNKNOWN_FLAG");
});
it("unknown long flag raises UNKNOWN_FLAG", { timeout: 5000 }, async () => {
const captured: string[] = [];
const result = await runCli(["dev", "--zzz"], {
out: (l) => captured.push(l),
});
expect(result).toEqual({ code: 1 });
expect(captured.join("\n")).toContain("ELECTROBUN.UNKNOWN_FLAG");
});
it("unknown short flag raises UNKNOWN_FLAG", {
timeout: 5000,
}, async () => {
const captured: string[] = [];
const result = await runCli(["dev", "-z"], {
out: (l) => captured.push(l),
});
expect(result).toEqual({ code: 1 });
});
it("dev subcommand dispatches and reports", { timeout: 5000 }, async () => {
const r = recordingRunner([probeOk(), { exitCode: 0 }]);
const captured: string[] = [];
const result = await runCli(["dev", "--cwd", "/tmp"], {
out: (l) => captured.push(l),
runner: r.runner,
});
expect(result).toEqual({ code: 0 });
expect(captured.join("\n")).toContain("dev exited 0");
});
it("build subcommand parses --target= and --update", {
timeout: 5000,
}, async () => {
const r = recordingRunner([probeOk(), { exitCode: 0 }]);
const captured: string[] = [];
const result = await runCli(
["build", "--cwd=/tmp", "--target=mac", "--update"],
{ out: (l) => captured.push(l), runner: r.runner }
);
expect(result).toEqual({ code: 0 });
expect(r.calls[1]?.args).toContain("--update");
});
it("package subcommand dispatches", { timeout: 5000 }, async () => {
const r = recordingRunner([probeOk(), { exitCode: 0 }]);
const result = await runCli(
["package", "--cwd", "/tmp", "--target", "windows"],
{ runner: r.runner, out: () => undefined }
);
expect(result.code).toBe(0);
expect(r.calls[1]?.args).toContain("windows");
});
it("sign subcommand dispatches with entitlements", {
timeout: 5000,
}, async () => {
const r = recordingRunner([probeOk(), { exitCode: 0 }]);
const result = await runCli(
[
"sign",
"--cwd",
"/tmp",
"--identity",
"Dev ID",
"--entitlements",
"/e.plist",
"--notarize",
],
{ runner: r.runner, out: () => undefined }
);
expect(result.code).toBe(0);
});
it("missing --cwd raises INVALID_OPTIONS", { timeout: 5000 }, async () => {
const captured: string[] = [];
const result = await runCli(["dev"], {
out: (l) => captured.push(l),
runner: recordingRunner([probeOk()]).runner,
});
expect(result.code).toBe(1);
expect(captured.join("\n")).toContain("ELECTROBUN.INVALID_OPTIONS");
});
it("build without --target raises INVALID_OPTIONS", {
timeout: 5000,
}, async () => {
const captured: string[] = [];
const result = await runCli(["build", "--cwd", "/tmp"], {
out: (l) => captured.push(l),
runner: recordingRunner([probeOk()]).runner,
});
expect(result.code).toBe(1);
});
it("create requires a prompter when not skipping prompts", {
timeout: 5000,
}, async () => {
const captured: string[] = [];
const result = await runCli(["create", "/tmp"], {
out: (l) => captured.push(l),
});
expect(result.code).toBe(1);
expect(captured.join("\n")).toContain("ELECTROBUN.INVALID_OPTIONS");
});
it("create subcommand dispatches with skip-prompts + overrides", {
timeout: 5000,
}, async () => {
const writes: string[] = [];
const fs = {
async readFile() {
return "";
},
async writeFile(path: string) {
writes.push(path);
},
async mkdir() {
return undefined;
},
async stat() {
return { isDirectory: () => true };
},
async readdir() {
return [];
},
};
const captured: string[] = [];
const result = await runCli(
[
"create",
"/scratch/dest",
"--skip-prompts",
"--name",
"x",
"--id",
"com.x.y",
"--app-version",
"1.0.0",
],
{
out: (l) => captured.push(l),
prompter: silentPrompter(),
fs,
}
);
expect(result.code).toBe(0);
expect(writes.length).toBeGreaterThan(0);
expect(captured.join("\n")).toContain("Created project");
});
it("create with --icon forwards iconOverride", {
timeout: 5000,
}, async () => {
const fs = {
async readFile() {
return "";
},
async writeFile() {
return undefined;
},
async mkdir() {
return undefined;
},
async stat() {
return { isDirectory: () => true };
},
async readdir() {
return [];
},
};
const renderer = {
async render() {
return { files: ["app.icns"] };
},
};
const result = await runCli(
[
"create",
"/scratch/dest",
"--skip-prompts",
"--name",
"x",
"--id",
"com.x.y",
"--icon",
"/icons/source.png",
],
{
out: () => undefined,
prompter: silentPrompter(),
fs,
renderer,
fileExists: () => true,
}
);
expect(result.code).toBe(0);
});
it("runCli catches ElectrobunError from a subcommand and folds to code 1", {
timeout: 5000,
}, async () => {
const r = recordingRunner([probeOk(), { exitCode: 9, stderr: "boom" }]);
const captured: string[] = [];
const result = await runCli(["dev", "--cwd", "/tmp"], {
out: (l) => captured.push(l),
runner: r.runner,
});
expect(result.code).toBe(1);
expect(captured.join("\n")).toContain("ELECTROBUN.RUNNER_FAILED");
});
it("parseArgs surfaces UNKNOWN_FLAG out to stdout", {
timeout: 5000,
}, async () => {
const captured: string[] = [];
const result = await runCli(["create", "/tmp", "--zzz"], {
out: (l) => captured.push(l),
});
expect(result.code).toBe(1);
expect(captured.join("\n")).toContain("ELECTROBUN.UNKNOWN_FLAG");
});
it("create without positional directory uses defaults", {
timeout: 5000,
}, async () => {
const fs = {
async readFile() {
return "";
},
async writeFile() {
return undefined;
},
async mkdir() {
return undefined;
},
async stat() {
return { isDirectory: () => true };
},
async readdir() {
return [];
},
};
const result = await runCli(["create", "--skip-prompts"], {
out: () => undefined,
prompter: silentPrompter(),
fs,
});
expect(result.code).toBe(0);
});
it("create through runCli without fs override succeeds", {
timeout: 30000,
}, async () => {
// Real fs path — write into a real tmp dir.
const real = mkdtempSync(join(tmpdir(), "electrobun-runcli-create-"));
try {
const result = await runCli(
[
"create",
join(real, "out"),
"--skip-prompts",
"--name",
"x",
"--id",
"com.x.y",
],
{
out: () => undefined,
prompter: silentPrompter(),
}
);
expect(result.code).toBe(0);
} finally {
rmSync(real, { recursive: true, force: true });
}
});
it("sign with notarize-only (no entitlements) covers branch", {
timeout: 5000,
}, async () => {
const r = recordingRunner([probeOk(), { exitCode: 0 }]);
const result = await runCli(
["sign", "--cwd", "/tmp", "--identity", "Dev ID", "--notarize"],
{ runner: r.runner, out: () => undefined }
);
expect(result.code).toBe(0);
});
it("runCli re-throws non-ElectrobunError from dispatch", {
timeout: 5000,
}, async () => {
const failingRunner: CommandRunner = async () => {
throw new Error("network");
};
await expect(
runCli(["dev", "--cwd", "/tmp"], {
runner: failingRunner,
out: () => undefined,
})
).rejects.toBeInstanceOf(Error);
});
});
describe("createCommand", () => {
it("skip-prompts uses overrides and defaults", {
timeout: 5000,
}, async () => {
const written: string[] = [];
const fs = {
async readFile() {
return "";
},
async writeFile(path: string) {
written.push(path);
},
async mkdir() {
return undefined;
},
async stat() {
return { isDirectory: () => true };
},
async readdir() {
return [];
},
};
const renderer = {
async render() {
return { files: ["app.icns", "app.ico"] };
},
};
const report = await createCommand(
{
directory: "/scratch/dest",
skipPrompts: true,
nameOverride: "skip-app",
identifierOverride: "com.skip.app",
iconOverride: "/icons/skip.png",
versionOverride: "2.3.4",
},
{
fs,
prompter: silentPrompter(),
renderer,
fileExists: () => true,
}
);
expect(report.outputDir).toBe("/scratch/dest");
expect(written.length).toBeGreaterThan(0);
});
it("interactive path runs the prompt flow", { timeout: 5000 }, async () => {
const fs = {
async readFile() {
return "";
},
async writeFile() {
return undefined;
},
async mkdir() {
return undefined;
},
async stat() {
return { isDirectory: () => true };
},
async readdir() {
return [];
},
};
const report = await createCommand(
{
directory: "/scratch/dest",
nameOverride: "x",
identifierOverride: "com.x.y",
versionOverride: "1.1.1",
},
{ fs, prompter: silentPrompter() }
);
expect(report.outputDir).toBeDefined();
});
it("skip-prompts honours a config file with full set of fields", {
timeout: 5000,
}, async () => {
const fsAdapter = {
async readFile() {
return "";
},
async writeFile() {
return undefined;
},
async mkdir() {
return undefined;
},
async stat() {
return { isDirectory: () => true };
},
async readdir() {
return [];
},
};
const cfg = {
name: "cfg-name",
identifier: "com.cfg.id",
description: "cfg-desc",
version: "9.9.9",
features: ["i18n"],
networkingComplexity: "full",
r3fModules: ["camera"],
};
const discovery = {
cwd: () => "/cwd",
homedir: () => "/home",
exists: (p: string) => p === "/cwd/.suaveplan-electrobun.json",
readFile: () => JSON.stringify(cfg),
};
const report = await createCommand(
{ skipPrompts: true, discovery },
{ fs: fsAdapter, prompter: silentPrompter() }
);
expect(report.outputDir).toBe("cfg-name");
});
it("interactive path with all overrides set", {
timeout: 5000,
}, async () => {
const fs = {
async readFile() {
return "";
},
async writeFile() {
return undefined;
},
async mkdir() {
return undefined;
},
async stat() {
return { isDirectory: () => true };
},
async readdir() {
return [];
},
};
const renderer = {
async render() {
return { files: ["app.icns"] };
},
};
const report = await createCommand(
{
directory: "/scratch/y",
nameOverride: "n",
identifierOverride: "com.n.y",
iconOverride: "/icons/x.png",
versionOverride: "3.2.1",
},
{
fs,
prompter: silentPrompter(),
renderer,
fileExists: () => true,
}
);
expect(report.outputDir).toBeDefined();
});
it("interactive path with no overrides", {
timeout: 5000,
}, async () => {
const fs = {
async readFile() {
return "";
},
async writeFile() {
return undefined;
},
async mkdir() {
return undefined;
},
async stat() {
return { isDirectory: () => true };
},
async readdir() {
return [];
},
};
const report = await createCommand(
{ directory: "/scratch/y" },
{ fs, prompter: silentPrompter() }
);
expect(report.outputDir).toBeDefined();
});
it("skip-prompts with a full config carries every field through ?? lefts", {
timeout: 5000,
}, async () => {
const fs = {
async readFile() {
return "";
},
async writeFile() {
return undefined;
},
async mkdir() {
return undefined;
},
async stat() {
return { isDirectory: () => true };
},
async readdir() {
return [];
},
};
const cfg = {
name: "cfg-skip-name",
identifier: "com.cfg.skip",
description: "cfg-skip-desc",
version: "8.8.8",
iconSource: "/cfg/icon.png",
features: ["i18n"],
networkingComplexity: "full",
r3fModules: ["camera"],
};
const discovery = {
cwd: () => "/cwd",
homedir: () => "/home",
exists: (p: string) => p === "/cwd/.suaveplan-electrobun.json",
readFile: () => JSON.stringify(cfg),
};
const renderer = {
async render() {
return { files: ["app.icns"] };
},
};
const report = await createCommand(
{ skipPrompts: true, discovery },
{ fs, prompter: silentPrompter(), renderer, fileExists: () => true }
);
expect(report.outputDir).toBe("cfg-skip-name");
});
it("skip-prompts uses bare default outputDir when none supplied", {
timeout: 5000,
}, async () => {
const fs = {
async readFile() {
return "";
},
async writeFile() {
return undefined;
},
async mkdir() {
return undefined;
},
async stat() {
return { isDirectory: () => true };
},
async readdir() {
return [];
},
};
const report = await createCommand(
{ skipPrompts: true },
{ fs, prompter: silentPrompter() }
);
expect(report.outputDir).toBe("my-electrobun-app");
});
});