Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion lib/setup/__tests__/plan.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ const readyExec: ExecScript = (argv) => {
/** `readyExec` plus a fast-browser on PATH whose doctor reports the extension not loaded. */
const fastBrowserNotLoadedExec: ExecScript = (argv) => {
if (argv[0] === "/opt/tools/fast-browser" && argv[1] === "doctor") {
return ok(JSON.stringify({ schemaVersion: 1, ok: false, checks: [{ id: "runtime-checksum", status: "pass" }, { id: "extension-loaded", status: "fail" }, { id: "pairing", status: "pass" }] }));
return ok(JSON.stringify({ schemaVersion: 1, ok: false, checks: [{ id: "runtime-checksum", status: "pass" }, { id: "extension-installed", status: "pass" }, { id: "extension-loaded", status: "fail" }, { id: "pairing", status: "pass" }] }));
}
return readyExec(argv);
};
Expand Down
80 changes: 75 additions & 5 deletions lib/setup/__tests__/validators-tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,10 @@ function withCheckStatus(doctor: RealDoctor, id: string, status: string): RealDo
return { ...doctor, checks: doctor.checks.map((c) => (c.id === id ? { ...c, status } : c)) };
}

function withCheck(doctor: RealDoctor, id: string, patch: Record<string, unknown>): RealDoctor {
return { ...doctor, checks: doctor.checks.map((c) => (c.id === id ? { ...c, ...patch } : c)) };
}

function withoutCheck(doctor: RealDoctor, id: string): RealDoctor {
return { ...doctor, checks: doctor.checks.filter((c) => c.id !== id) };
}
Expand Down Expand Up @@ -296,12 +300,24 @@ describe("toolRows — tool.fast-browser", () => {

/** Feeds `report` back as doctor's JSON envelope. */
function doctorExec(report: unknown, code = 0): ExecScript {
return (argv) => (argv[2] === "doctor" && argv[3] === "--json" ? { code, stdout: JSON.stringify(report), stderr: "" } : ok());
return (argv) => (argv[2] === "doctor" ? { code, stdout: JSON.stringify(report), stderr: "" } : ok());
}

test("real fully healthy envelope -> ready, and doctor ran through the resolved exec (C2)", async () => {
const p = fakeProbes({ exec: doctorExec(REAL_DOCTOR) });
const r = await pickRow(toolRows(p, [], { hasBrew: true, secrets: NO_SECRETS }, fastBrowserSeams()), "tool.fast-browser");
expect(p.calls.exec).toContainEqual(["node", "fast-browser.mjs", "doctor", "--checks", "runtime-checksum,extension-installed,extension-loaded,pairing", "--json"]);
expect(r.status).toBe("ready");
});

test("a fast-browser without --checks refuses it as a usage error -> rerun as a full doctor", async () => {
const exec: ExecScript = (argv) => {
if (argv[2] !== "doctor") return ok();
if (argv.includes("--checks")) return { code: 2, stdout: "", stderr: "UsageError: unsupported argument: --checks" };
return ok(JSON.stringify(REAL_DOCTOR));
};
const p = fakeProbes({ exec });
const r = await pickRow(toolRows(p, [], { hasBrew: true, secrets: NO_SECRETS }, fastBrowserSeams()), "tool.fast-browser");
expect(p.calls.exec).toContainEqual(["node", "fast-browser.mjs", "doctor", "--json"]);
expect(r.status).toBe("ready");
});
Expand Down Expand Up @@ -366,7 +382,7 @@ describe("toolRows — tool.fast-browser", () => {
test("doctor exec is issued with a longer timeout than every other probe (RT-138)", async () => {
const seenTimeouts: Record<string, number | undefined> = {};
const exec: ExecScript = (argv, execOpts) => {
if (argv[2] === "doctor" && argv[3] === "--json") {
if (argv[2] === "doctor") {
seenTimeouts.doctor = execOpts?.timeoutMs;
return doctorExec(REAL_DOCTOR)(argv);
}
Expand All @@ -384,7 +400,7 @@ describe("toolRows - tool.fast-browser-extension", () => {
return { ...NOOP_SEAMS, resolveTool: (_p, tool) => (tool === "fast-browser" ? { tool, bundled: "node", exec: ["node", "fast-browser.mjs"], userCopy: null, linked: false, chosen: "node" } : noopResolution(tool)) };
}
function doctorExec(report: unknown): ExecScript {
return (argv) => (argv[2] === "doctor" && argv[3] === "--json" ? ok(JSON.stringify(report)) : ok());
return (argv) => (argv[2] === "doctor" ? ok(JSON.stringify(report)) : ok());
}
function withChrome(exec: ExecScript) {
const p = fakeProbes({ exec });
Expand All @@ -410,6 +426,58 @@ describe("toolRows - tool.fast-browser-extension", () => {
expect(steps.join(" ")).toContain("reconnect token");
});

// extension-loaded passes when no managed extension is loaded at all and
// leaves that to extension-installed, so reading loaded alone called a Mac
// with no extension ready.
test("extension-installed fails while extension-loaded passes -> needs-you with doctor's message and the load steps", async () => {
const report = withCheck(REAL_DOCTOR, "extension-installed", { status: "fail", message: "The pinned Chrome extension is not installed." });
const p = withChrome(doctorExec(withCheck(report, "extension-loaded", { message: "No managed Chrome extension load to verify." })));
const r = await pickRow(toolRows(p, [], { hasBrew: true, secrets: NO_SECRETS }, fastBrowserSeams()), "tool.fast-browser-extension");
expect(r.status).toBe("needs-you");
expect(r.detail).toBe("The pinned Chrome extension is not installed.");
const steps = (r.action as { steps: string[] }).steps;
expect(steps[0]).toContain("chrome://extensions");
});

// doctor distinguishes a missing extension from a store copy on another
// version, and only its own remedy fits each; the load steps would swap a
// store copy for one that never auto-updates.
test("extension-installed fails with a remedy -> the row's steps are doctor's remedy", async () => {
const remediation = "Chrome updates it once the store has 0.2.12. To check now, open chrome://extensions, turn on Developer mode, and click Update.";
const report = withCheck(REAL_DOCTOR, "extension-installed", { status: "fail", message: "The Chrome Web Store copy is at 0.2.11; this Fast Browser pins 0.2.12.", remediation });
const r = await pickRow(toolRows(withChrome(doctorExec(report)), [], { hasBrew: true, secrets: NO_SECRETS }, fastBrowserSeams()), "tool.fast-browser-extension");
expect(r.status).toBe("needs-you");
expect(r.action).toEqual({ type: "steps", label: "Show steps…", steps: [remediation] });
});

// Swift decodes steps as [String] and detail as String; one wrong-typed
// field fails the whole plan and the checklist never loads.
test("a non-string remedy or message from doctor falls back instead of reaching the row", async () => {
const report = withCheck(REAL_DOCTOR, "extension-installed", { status: "fail", message: 42, remediation: { text: "x" } });
const r = await pickRow(toolRows(withChrome(doctorExec(report)), [], { hasBrew: true, secrets: NO_SECRETS }, fastBrowserSeams()), "tool.fast-browser-extension");
expect(r.status).toBe("needs-you");
expect(typeof r.detail).toBe("string");
expect((r.action as { steps: string[] }).steps[0]).toContain("chrome://extensions");
});

// A stale unpacked load needs Chrome's reload arrow; loading unpacked again
// wipes the reconnect token and forces a re-pair.
test("extension-loaded fails with a remedy -> the row's steps are doctor's remedy", async () => {
const remediation = "Open chrome://extensions and click the reload arrow on Fast Browser.";
const report = withCheck(REAL_DOCTOR, "extension-loaded", { status: "fail", remediation });
const r = await pickRow(toolRows(withChrome(doctorExec(report)), [], { hasBrew: true, secrets: NO_SECRETS }, fastBrowserSeams()), "tool.fast-browser-extension");
expect(r.status).toBe("needs-you");
expect(r.action).toEqual({ type: "steps", label: "Show steps…", steps: [remediation] });
});

test("extension-installed check absent from the report -> error naming the remedy", async () => {
const p = withChrome(doctorExec(withoutCheck(REAL_DOCTOR, "extension-installed")));
const r = await pickRow(toolRows(p, [], { hasBrew: true, secrets: NO_SECRETS }, fastBrowserSeams()), "tool.fast-browser-extension");
expect(r.status).toBe("error");
expect(r.detail).toContain("extension-installed");
expect(r.action).toEqual({ type: "run", label: "Re-check", verb: ["setup", "status"] });
});

// doctor's own "pairing" check already passes whenever the connection mode
// isn't auto (the documented default). Manual connection is the documented
// default, so a manually connected machine is healthy. This row reports the
Expand Down Expand Up @@ -471,6 +539,8 @@ describe("toolRows - tool.fast-browser-extension", () => {
doctorExec(withCheckStatus(REAL_DOCTOR, "pairing", "fail")),
doctorExec(withoutCheck(REAL_DOCTOR, "extension-loaded")),
doctorExec(withoutCheck(REAL_DOCTOR, "pairing")),
doctorExec(withCheckStatus(REAL_DOCTOR, "extension-installed", "fail")),
doctorExec(withoutCheck(REAL_DOCTOR, "extension-installed")),
];
for (const exec of scenarios) {
const r = await pickRow(toolRows(withChrome(exec), [], { hasBrew: true, secrets: NO_SECRETS }, fastBrowserSeams()), "tool.fast-browser-extension");
Expand All @@ -485,7 +555,7 @@ describe("toolRows — well-formed-JSON-but-wrong-shape doctor payloads: no thro
return { ...NOOP_SEAMS, resolveTool: (_p, tool) => (tool === "fast-browser" ? { tool, bundled: "node", exec: ["node", "fast-browser.mjs"], userCopy: null, linked: false, chosen: "node" } : noopResolution(tool)) };
}
function doctorExec(report: unknown): ExecScript {
return (argv) => (argv[2] === "doctor" && argv[3] === "--json" ? ok(JSON.stringify(report)) : ok());
return (argv) => (argv[2] === "doctor" ? ok(JSON.stringify(report)) : ok());
}
function withChrome(exec: ExecScript) {
const p = fakeProbes({ exec });
Expand Down Expand Up @@ -1084,7 +1154,7 @@ describe("Done-screen contract: optional rows with a manual action", () => {

test("fast-browser extension not loaded and a pack declaring chrome sign-in: both are the allowlisted manual steps", async () => {
const reqs: PackRequirements[] = [{ pack: "somepack", integrations: [], tools: [], chrome: { required: true, signedIntoApp: "work@example.com" } }];
const exec: ExecScript = (argv) => (argv[2] === "doctor" && argv[3] === "--json" ? ok(JSON.stringify(withCheckStatus(REAL_DOCTOR, "extension-loaded", "fail"))) : ok());
const exec: ExecScript = (argv) => (argv[2] === "doctor" ? ok(JSON.stringify(withCheckStatus(REAL_DOCTOR, "extension-loaded", "fail"))) : ok());
const p = fakeProbes({ exec });
p.mkdirp("/Applications/Google Chrome.app");
const rows = await toolRows(p, reqs, { hasBrew: true, secrets: NO_SECRETS }, fastBrowserSeams());
Expand Down
28 changes: 26 additions & 2 deletions lib/setup/validators/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -229,12 +229,17 @@ interface FastBrowserProbe {
failure: string | null;
}

/** The checks the two rows read. The full doctor adds a live Codex agent smoke that costs most of its runtime. */
const DOCTOR_CHECKS = ["runtime-checksum", "extension-installed", "extension-loaded", "pairing"];

/** One `doctor` run feeds both rows: they read different fields of the same report, and a second spawn would double the bounded wait on every plan. */
async function probeFastBrowser(p: Probes, seams: ToolsSeams): Promise<FastBrowserProbe> {
const resolved = seams.resolveTool(p, "fast-browser");
if (!resolved.exec) return { resolvable: false, doctor: null, failure: null };

const res = await exec(p, [...resolved.exec, "doctor", "--json"], DOCTOR_TIMEOUT_MS);
let res = await exec(p, [...resolved.exec, "doctor", "--checks", DOCTOR_CHECKS.join(","), "--json"], DOCTOR_TIMEOUT_MS);
// fast-browser before 0.1.4 has no --checks and refuses it as a usage error.
if (res.code === 2 && res.stdout.trim() === "") res = await exec(p, [...resolved.exec, "doctor", "--json"], DOCTOR_TIMEOUT_MS);
if (res.code === 124) return { resolvable: true, doctor: null, failure: "fast-browser doctor timed out" };

// `doctor` is a health check: it commonly exits non-zero BECAUSE it found a
Expand Down Expand Up @@ -287,6 +292,16 @@ const FAST_BROWSER_PAIR_STEPS: Action = { type: "steps", label: "Show steps…",
const FAST_BROWSER_RECHECK: Action = { type: "run", label: "Re-check", verb: ["setup", "status"] };
const DOCTOR_CHECK_MISSING_REMEDY = "update Fast Browser, then Re-check";

/** Swift decodes detail as String and steps as [String]; one wrong-typed field from doctor would fail the whole plan. */
function doctorText(value: unknown): string | null {
return typeof value === "string" && value.trim() !== "" ? value : null;
}

function doctorRemedy(check: FastBrowserCheck | undefined): Action {
const remedy = doctorText(check?.remediation);
return remedy ? { type: "steps", label: "Show steps…", steps: [remedy] } : FAST_BROWSER_LOAD_STEPS;
}

/**
* Never gates Install in any Chrome state: loading an unpacked extension is a
* Chrome step rt cannot perform, and nothing on the checklist can create the
Expand All @@ -310,9 +325,18 @@ function fastBrowserExtensionRow(p: Probes, probe: FastBrowserProbe): Row {
// would be two rows for one fact.
if (!probe.doctor) return row({ ...base, status: "skipped", detail: "fast-browser doctor could not be read (see Fast Browser)" });

const installed = probe.doctor.checks?.find((c) => c.id === "extension-installed");
if (!installed) return row({ ...base, status: "error", detail: `fast-browser doctor report has no extension-installed check; ${DOCTOR_CHECK_MISSING_REMEDY}`, action: FAST_BROWSER_RECHECK });
// doctor tells a missing extension from a store copy on another version;
// only its own remedy fits each, and the load steps would trade a store
// copy for one that never auto-updates.
if (installed.status !== "pass") return row({ ...base, status: "needs-you", detail: doctorText(installed.message) ?? "not installed in Chrome", action: doctorRemedy(installed) });

const extension = checkState(probe.doctor, "extension-loaded");
if (extension === "absent") return row({ ...base, status: "error", detail: `fast-browser doctor report has no extension-loaded check; ${DOCTOR_CHECK_MISSING_REMEDY}`, action: FAST_BROWSER_RECHECK });
if (extension === "fail") return row({ ...base, status: "needs-you", detail: "not loaded in Chrome", action: FAST_BROWSER_LOAD_STEPS });
// A stale unpacked load needs Chrome's reload arrow; loading unpacked again
// wipes the reconnect token and forces a re-pair.
if (extension === "fail") return row({ ...base, status: "needs-you", detail: "not loaded in Chrome", action: doctorRemedy(probe.doctor.checks?.find((c) => c.id === "extension-loaded")) });

// Trust doctor's own pairing check rather than a separate rule: pairing
// passes whenever the connection mode isn't auto, and manual is the
Expand Down
6 changes: 3 additions & 3 deletions rt-tray/deps.lock
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,9 @@
"sha256": "8294b7aa9b03997481c06babf1e8b270c859358f27da57a11509afe537ac381d",
"archive": "tar.gz", "extract": "node-v24.19.0-darwin-arm64", "bundlePath": "Contents/Helpers/node", "exec": ["Contents/Helpers/node/bin/node"],
"exposeByDefault": false, "entitlements": "jit", "status": "bundled", "kind": "helper" },
{ "name": "fast-browser", "version": "0.1.3", "license": "MIT",
"url": "https://registry.npmjs.org/@mattstack/fast-browser/-/fast-browser-0.1.3.tgz",
"sha256": "1405a724db620e69f7176b82e6ede891bc46d6d4a07556ee7f99aa5c7a42499a",
{ "name": "fast-browser", "version": "0.1.5", "license": "MIT",
"url": "https://registry.npmjs.org/@mattstack/fast-browser/-/fast-browser-0.1.5.tgz",
"sha256": "6407e95a99393b5a8957afa9fd193cd32da8623644f303a5762723117a60b78a",
"archive": "npm", "extract": "package", "bundlePath": "Contents/Helpers/fast-browser",
"exec": ["Contents/Helpers/node/bin/node", "Contents/Helpers/fast-browser/bin/fast-browser.mjs"],
"exposeByDefault": true, "entitlements": "none", "status": "bundled", "kind": "helper" },
Expand Down
Loading