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> ): 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 { return { exitCode: 0, stdout }; } function silentPrompter(): Prompter { return { text: async ({ default: d }) => d ?? "demo", confirm: async () => true, select: async ({ default: d, choices, }: { default?: T; choices: ReadonlyArray<{ value: T }>; }): Promise => (d ?? choices[0]!.value) as T, multiselect: async (): Promise => [] 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 (p: Promise): Promise => { 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"); }); });