Files
openrig/packages/cli/test/setup-output.test.ts
T
Mike Schwarz f86a80285a fix(install): say the CLI is installed and setup is not complete when rig setup isn't ready (#1547)
When `rig setup` (step 4 of the one-line installer) finishes without being ready, the installer now ends with `INSTALLED, SETUP NOT COMPLETE (exit N).` instead of `FAILED [4/4] … (exit 1)`, which read as "OpenRig didn't install" though the CLI was installed and the compatibility check ran. The summary gives the installed CLI path, the Node/SQLite check result (`passed`, or `SKIPPED (not checked; see above)`), a pointer to setup's own "Some steps need attention" list, with a Fix where setup gives one, or to the error that stopped it, and "Fix those, then run rig setup again; the CLI does not need reinstalling."

- Setup's exit status passes through exactly, and setup's own output stays above the summary.
- Steps 1-3 keep their `FAILED [n/4]` line, and a ready setup keeps today's closing line.
- It names no cause that setup's result doesn't establish: a provider check fails for a missing sign-in but also for a probe that timed out or errored, and the installer sees only setup's exit status, so the summary doesn't say "sign in".
- A skipped compatibility check is never reported as passed. Setup's readiness and exit behaviour, provider selection and every other exit status are unchanged, with no extra probe.

The README and getting-started are unchanged on purpose: they show the pinned release's installer, which still ends with `FAILED [4/4]`. They move together with the pin, at the first release tag that contains this script.

Tests: the wrapper tests cover setup exit 1, 40 and a piped run's 43, unchanged early failures, a skipped check and the ready ending; five setup-boundary cases cover sign-in only, a mixed failure, ready, an unselected provider and an uncertain probe.
2026-10-19 13:16:39 -07:00

173 lines
8.6 KiB
TypeScript

import { afterEach, describe, expect, it, vi } from "vitest";
import { Command } from "commander";
import { setupCommand, type SetupDeps, type SetupResult } from "../src/commands/setup.js";
// Exercise the real command/result path without probing or changing this host.
vi.mock("../src/commands/doctor.js", () => ({
runDoctorChecks: () => ({ checks: [], asyncChecks: [] }),
}));
vi.mock("../src/config-store.js", () => ({
ConfigStore: class {
resolve() { return { daemon: { host: "127.0.0.1", port: 1 } }; }
},
}));
const originalExitCode = process.exitCode;
afterEach(() => {
process.exitCode = originalExitCode;
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
function fixture(failed: boolean): SetupDeps {
return {
platform: "darwin",
env: {},
exec: vi.fn((cmd: string) => {
if (cmd === "claude auth status" && failed) throw new Error("fixture login missing");
if (cmd === "cmux capabilities --json") return '{"capabilities":[]}';
return "available";
}),
readFile: () => null,
exists: () => false,
writeFile: vi.fn(),
mkdirp: vi.fn(),
};
}
function expectConversationRoute(text: string): void {
expect(text).toContain("Show the person OpenRig: open the OpenRig TUI and operator for them now");
expect(text).toContain("On a Mac, offer Ghostty once");
expect(text).toContain("rig setup --ghostty");
expect(text).toContain("--no-ghostty");
expect(text).toContain("preserve earlier choices such as --no-herdr");
expect(text).toContain("rig terminal open saved:kernel");
expect(text).toContain("rig terminal open saved:kernel --window --json");
expect(text).toContain("On a desktop, no copying or typing for the person; check the result notes, not just CLI success");
expect(text).toContain("Over headless SSH: ask the person to open a new terminal/tab and give the exact connection/attach command");
expect(text).toContain("then hand them to the ready operator");
expect(text).toContain("Do not implement the person's project yourself");
expect(text).toContain("Started is not ready");
expect(text).toContain("rig context get reference/getting-started.md#incomplete-setup-and-restart");
}
describe("setup conversation handoff output", () => {
for (const mode of ["dry-run", "failed", "ready"] as const) {
for (const json of [false, true]) {
it(`${mode} retains its outcome and offers the operator route in ${json ? "JSON" : "human output"}`, async () => {
process.exitCode = undefined;
vi.stubGlobal("fetch", vi.fn(async () => new Response(null, { status: 503 })));
const logs: string[] = [];
vi.spyOn(console, "log").mockImplementation((...args) => { logs.push(args.map(String).join(" ")); });
const deps = fixture(mode === "failed");
const program = new Command().addCommand(setupCommand(deps));
await program.parseAsync(["node", "rig", "setup", ...(mode === "dry-run" ? ["--dry-run"] : []), ...(json ? ["--json"] : [])]);
const output = logs.join("\n");
expect(process.exitCode).toBe(mode === "failed" ? 1 : undefined);
if (json) {
expect(logs).toHaveLength(1);
const result = JSON.parse(output) as SetupResult & { nextSteps: string[] };
expect(result.ready).toBe(mode === "ready");
expect(result.profile).toBe("core");
expect(result.platform).toBe("darwin");
expect(result.runtimeConfig.length).toBeGreaterThan(0);
expectConversationRoute(result.nextSteps?.join("\n") ?? "");
if (mode === "failed") {
expect(result.steps).toContainEqual(expect.objectContaining({ id: "claude_auth", status: "fail", message: expect.stringContaining("fixture login missing") }));
} else if (mode === "dry-run") {
expect(result.steps.every((step) => step.status === "skipped")).toBe(true);
}
} else {
expectConversationRoute(output);
expect(output.includes("Setup complete.")).toBe(mode === "ready");
expect(output).toContain("Permission policy (optional");
if (mode === "failed") expect(output).toContain("fixture login missing");
if (mode === "dry-run") {
expect(output).toContain("Plan only: no setup changes were made; readiness was not checked.");
// A short installer read must still reach the conversation handoff.
expectConversationRoute(output.split("\n").slice(0, 80).join("\n"));
}
}
if (mode === "dry-run") {
expect(deps.exec).not.toHaveBeenCalled();
expect(deps.writeFile).not.toHaveBeenCalled();
expect(deps.mkdirp).not.toHaveBeenCalled();
expect(fetch).not.toHaveBeenCalled();
}
});
}
}
});
it.each([
[[], "offers Ghostty once"],
[["--ghostty"], "installation would be attempted"],
[["--no-ghostty"], "declined"],
] as const)("keeps the Ghostty choice in dry-run JSON: %j", async (flags, expected) => {
const logs: string[] = [];
vi.spyOn(console, "log").mockImplementation((...args) => logs.push(args.map(String).join(" ")));
const program = new Command().exitOverride().addCommand(setupCommand(fixture(false)));
await program.parseAsync(["node", "rig", "setup", "--dry-run", "--json", ...flags]);
const result = JSON.parse(logs[0]!) as SetupResult;
expect(result.steps.find(step => step.id === "ghostty_install")?.message).toContain(expected);
});
// The installer's step 4 consumes only `rig setup`'s exit status and prints setup's own output. These five
// cases pin that boundary: an incomplete setup exits 1 and lists what is left (each with its Fix) under
// "Some steps need attention"; a ready one exits 0 with "Setup complete.". A failed provider probe is listed
// as "not ready to launch" with the probe's own error, whatever the cause, so the installer names no sign-in.
describe("the installer's setup boundary", () => {
const run = async (exec: (cmd: string) => string, flags: string[] = []) => {
process.exitCode = undefined;
vi.stubGlobal("fetch", vi.fn(async () => new Response(null, { status: 503 })));
const logs: string[] = [];
vi.spyOn(console, "log").mockImplementation((...args) => { logs.push(args.map(String).join(" ")); });
const deps: SetupDeps = { platform: "darwin", env: {}, exec: vi.fn(exec), readFile: () => null, exists: () => false, writeFile: vi.fn(), mkdirp: vi.fn() };
await new Command().addCommand(setupCommand(deps)).parseAsync(["node", "rig", "setup", ...flags]);
const output = logs.join("\n");
return { exitCode: process.exitCode, output, attention: output.slice(output.indexOf("Some steps need attention:")) };
};
const ok = (cmd: string) => cmd === "cmux capabilities --json" ? '{"capabilities":[]}' : "available";
it("a sign-in only: exit 1, the provider listed with its Fix", async () => {
const r = await run((cmd) => { if (cmd === "claude auth status") throw new Error("Not logged in"); return ok(cmd); });
expect(r.exitCode).toBe(1);
expect(r.attention).toContain("claude_auth: Claude Code is installed but not ready to launch: Not logged in");
expect(r.attention).toContain("Fix: Run `claude auth login`");
});
it("a real failure beside a sign-in: exit 1, both listed", async () => {
const r = await run((cmd) => {
if (cmd === "claude auth status") throw new Error("Not logged in");
if (cmd === "codex --version" || cmd === "npm install -g @openai/codex") throw new Error("npm ERR! network");
return ok(cmd);
});
expect(r.exitCode).toBe(1);
expect(r.attention).toContain("claude_auth:");
expect(r.attention).toContain("codex_install:");
});
it("ready: exit 0 and Setup complete", async () => {
const r = await run(ok);
expect(r.exitCode).toBeUndefined();
expect(r.output).toContain("Setup complete.");
expect(r.output).not.toContain("Some steps need attention:");
});
it("an unselected provider is not a failure: exit 0 with only the chosen provider checked", async () => {
const r = await run((cmd) => { if (cmd.startsWith("codex")) throw new Error("codex absent"); return ok(cmd); }, ["--providers", "claude"]);
expect(r.exitCode).toBeUndefined();
expect(r.output).toContain("Setup complete.");
expect(r.output).toContain("codex_auth: Not selected");
});
it("an uncertain probe is listed with its own error, not as a confirmed missing login", async () => {
const r = await run((cmd) => { if (cmd === "claude auth status") throw new Error("Command timed out after 10000ms"); return ok(cmd); });
expect(r.exitCode).toBe(1);
expect(r.attention).toContain("claude_auth: Claude Code is installed but not ready to launch: Command timed out after 10000ms");
expect(r.attention).not.toMatch(/not logged in/i);
});
});