578 lines
18 KiB
TypeScript
578 lines
18 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
|
|
import { SUPPORTED_NODE_VERSION_PROSE } from "../domain/runtimeVersion.js";
|
|
import type { ClientRegistrationStatus } from "./ClientRegistrationStatus.js";
|
|
import { createDoctorHostFixture as host } from "./Doctor.fixture.js";
|
|
import { runDoctor } from "./Doctor.js";
|
|
|
|
describe("doctor", () => {
|
|
it("returns exact recovery for every failed diagnostic", async () => {
|
|
const result = await runDoctor(
|
|
"/missing/app",
|
|
host({
|
|
nodeVersion: "20.0.0",
|
|
macosVersion: () => Promise.resolve("11.7"),
|
|
executable: () => Promise.resolve(false),
|
|
validTarget: () => Promise.resolve(false),
|
|
}),
|
|
);
|
|
expect(
|
|
Object.fromEntries(
|
|
result.checks.map(({ name, remediation }) => [name, remediation]),
|
|
),
|
|
).toEqual({
|
|
node: `Install ${SUPPORTED_NODE_VERSION_PROSE}.`,
|
|
host: "REA supports macOS 12+, Ubuntu 24.04+, Fedora 41+, Nobara 44+, 64-bit Arch Linux, CachyOS, Omarchy, and the experimental Windows x64 Ghidra P0 boundary.",
|
|
hopper: "Run rea setup to install Hopper, or set HOPPER_LAUNCHER_PATH.",
|
|
target: "Supply a readable local app or program path.",
|
|
});
|
|
});
|
|
|
|
it("reports Node 25 as unsupported", async () => {
|
|
const result = await runDoctor(undefined, host({ nodeVersion: "25.1.0" }));
|
|
|
|
expect(result.healthy).toBe(false);
|
|
expect(result.checks).toContainEqual(
|
|
expect.objectContaining({
|
|
name: "node",
|
|
ok: false,
|
|
detail: "25.1.0",
|
|
classification: "missing_dependency",
|
|
}),
|
|
);
|
|
});
|
|
|
|
it.each(["11.7", undefined])(
|
|
"rejects unsupported macOS version %s",
|
|
async (version) => {
|
|
const result = await runDoctor(
|
|
undefined,
|
|
host({ macosVersion: () => Promise.resolve(version) }),
|
|
);
|
|
expect(result.checks).toContainEqual(
|
|
expect.objectContaining({
|
|
name: "host",
|
|
ok: false,
|
|
classification: "unsupported_host",
|
|
}),
|
|
);
|
|
},
|
|
);
|
|
|
|
it("admits only the experimental Windows x64 host boundary", async () => {
|
|
const windows = await runDoctor(
|
|
undefined,
|
|
host({
|
|
platform: "win32",
|
|
architecture: "x64",
|
|
macosVersion: () => Promise.resolve(undefined),
|
|
executable: () => Promise.resolve(false),
|
|
}),
|
|
);
|
|
const arm = await runDoctor(
|
|
undefined,
|
|
host({
|
|
platform: "win32",
|
|
architecture: "arm64",
|
|
macosVersion: () => Promise.resolve(undefined),
|
|
executable: () => Promise.resolve(false),
|
|
}),
|
|
);
|
|
|
|
expect(windows.checks.find(({ name }) => name === "host")).toMatchObject({
|
|
ok: true,
|
|
detail: "win32 x64",
|
|
});
|
|
expect(arm.checks.find(({ name }) => name === "host")).toMatchObject({
|
|
ok: false,
|
|
detail: "win32 arm64",
|
|
});
|
|
});
|
|
it("detects a manual configured Hopper before Homebrew", async () => {
|
|
let brewProbes = 0;
|
|
const result = await runDoctor(
|
|
undefined,
|
|
host({
|
|
configuredHopperPath: "/manual/Hopper",
|
|
executable: (path) => Promise.resolve(path === "/manual/Hopper"),
|
|
brewHopperPath: () => {
|
|
brewProbes += 1;
|
|
return Promise.resolve(undefined);
|
|
},
|
|
}),
|
|
);
|
|
expect(result.hopperPath).toBe("/manual/Hopper");
|
|
expect(result.healthy).toBe(true);
|
|
expect(brewProbes).toBe(0);
|
|
});
|
|
|
|
it("reports optional BYO ilspycmd diagnostics only when configured", async () => {
|
|
const result = await runDoctor(
|
|
undefined,
|
|
host({
|
|
configuredIlspyCmdPath: "/tools/ilspycmd",
|
|
ilspyCmdVersion: (path) =>
|
|
Promise.resolve(
|
|
path === "/tools/ilspycmd" ? "ilspycmd: 9.1.0.7988" : undefined,
|
|
),
|
|
}),
|
|
);
|
|
expect(result.healthy).toBe(true);
|
|
expect(result.checks).toContainEqual({
|
|
name: "ilspycmd",
|
|
ok: true,
|
|
classification: "healthy",
|
|
detail: "/tools/ilspycmd (ilspycmd: 9.1.0.7988)",
|
|
});
|
|
});
|
|
|
|
it("reports configured ilspycmd drift without installing it", async () => {
|
|
const result = await runDoctor(
|
|
undefined,
|
|
host({
|
|
configuredIlspyCmdPath: "/missing/ilspycmd",
|
|
ilspyCmdVersion: () => Promise.resolve(undefined),
|
|
}),
|
|
);
|
|
expect(result.healthy).toBe(false);
|
|
expect(result.checks).toContainEqual({
|
|
name: "ilspycmd",
|
|
ok: false,
|
|
classification: "config_drift",
|
|
detail: "/missing/ilspycmd",
|
|
remediation:
|
|
"Unset REA_ILSPY_CMD_PATH or point it at a runnable ilspycmd executable.",
|
|
});
|
|
});
|
|
|
|
it("does not fall back when the configured Hopper launcher is invalid", async () => {
|
|
const configuredPath = "/invalid/custom/hopper";
|
|
const result = await runDoctor(
|
|
undefined,
|
|
host({ configuredHopperPath: configuredPath }),
|
|
);
|
|
|
|
expect(result.hopperPath).toBeUndefined();
|
|
expect(result.healthy).toBe(false);
|
|
expect(result.checks.find(({ name }) => name === "hopper")).toEqual({
|
|
name: "hopper",
|
|
ok: false,
|
|
classification: "config_drift",
|
|
detail: configuredPath,
|
|
remediation:
|
|
"Unset or update HOPPER_LAUNCHER_PATH to an executable Hopper launcher.",
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("doctor installation identity", () => {
|
|
it("distinguishes stale skills, path shadowing, and unobservable live state", async () => {
|
|
const result = await runDoctor(
|
|
undefined,
|
|
host({
|
|
installationPaths: () =>
|
|
Promise.resolve(["/usr/local/bin/rea", "/home/user/bin/rea"]),
|
|
installedSkillIdentity: () =>
|
|
Promise.resolve({
|
|
version: "10",
|
|
toolCount: null,
|
|
canonical: false,
|
|
}),
|
|
}),
|
|
);
|
|
|
|
expect(result.identity).toMatchObject({
|
|
live_server: { state: "unknown" },
|
|
installations: { state: "multiple" },
|
|
skill: {
|
|
installed_version: "10",
|
|
state: "stale",
|
|
remediation: "Run rea setup to update the installed REA skill.",
|
|
},
|
|
});
|
|
});
|
|
it("reports a missing installed skill as unhealthy", async () => {
|
|
const result = await runDoctor(
|
|
undefined,
|
|
host({ installedSkillIdentity: () => Promise.resolve(undefined) }),
|
|
);
|
|
|
|
expect(result.healthy).toBe(false);
|
|
expect(result.identity?.skill).toMatchObject({
|
|
state: "missing",
|
|
remediation: "Run rea setup to update the installed REA skill.",
|
|
});
|
|
expect(result.checks).toContainEqual({
|
|
name: "skill:identity",
|
|
ok: false,
|
|
classification: "config_drift",
|
|
detail: "Installed REA skill identity is missing.",
|
|
remediation: "Run rea setup to update the installed REA skill.",
|
|
});
|
|
});
|
|
it("reports stale client registration paths without claiming live state", async () => {
|
|
const result = await runDoctor(
|
|
undefined,
|
|
host({
|
|
clientRegistrations: () =>
|
|
Promise.resolve([
|
|
{
|
|
client: "codex",
|
|
config_path: "/home/user/.codex/config.toml",
|
|
command: ["/old/rea", "mcp"],
|
|
state: "stale",
|
|
remediation:
|
|
"Run rea setup to refresh this registration, then restart the client.",
|
|
},
|
|
]),
|
|
}),
|
|
);
|
|
|
|
expect(result.healthy).toBe(false);
|
|
expect(result.identity).toMatchObject({
|
|
live_server: { state: "unknown" },
|
|
registrations: [{ client: "codex", state: "stale" }],
|
|
});
|
|
expect(result.checks).toContainEqual(
|
|
expect.objectContaining({
|
|
name: "registration:codex",
|
|
classification: "config_drift",
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("doctor scoped readiness", () => {
|
|
it("scopes effective readiness to selected clients without hiding drift", async () => {
|
|
const registrations: readonly ClientRegistrationStatus[] = [
|
|
{
|
|
client: "codex",
|
|
config_path: "/home/user/.codex/config.toml",
|
|
command: ["/current/rea", "mcp"],
|
|
state: "aligned",
|
|
remediation: null,
|
|
},
|
|
{
|
|
client: "cursor",
|
|
config_path: "/home/user/.cursor/mcp.json",
|
|
command: ["/old/rea", "mcp"],
|
|
state: "stale",
|
|
remediation: "Run rea setup to refresh this registration.",
|
|
},
|
|
];
|
|
const scoped = await runDoctor(
|
|
undefined,
|
|
host({ clientRegistrations: () => Promise.resolve(registrations) }),
|
|
{ clients: ["codex"] },
|
|
);
|
|
|
|
expect(scoped).toMatchObject({
|
|
healthy: true,
|
|
environment_healthy: false,
|
|
scope: { mode: "explicit", clients: ["codex"] },
|
|
});
|
|
expect(scoped.scope_checks).toContainEqual(
|
|
expect.objectContaining({ name: "registration:codex", ok: true }),
|
|
);
|
|
expect(scoped.informational_checks).toContainEqual(
|
|
expect.objectContaining({ name: "registration:cursor", ok: false }),
|
|
);
|
|
|
|
const staleSelected = await runDoctor(
|
|
undefined,
|
|
host({ clientRegistrations: () => Promise.resolve(registrations) }),
|
|
{ clients: ["cursor"] },
|
|
);
|
|
expect(staleSelected.healthy).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("Grok Bot readiness", () => {
|
|
it("does not treat a Grok Bot data directory as an aligned registration", async () => {
|
|
const registrations: readonly ClientRegistrationStatus[] = [
|
|
{
|
|
client: "grok_bot",
|
|
config_path: "/home/user/.grokbot",
|
|
command: [],
|
|
state: "manual",
|
|
remediation: "Add the connector from the Grok Bot chat.",
|
|
},
|
|
];
|
|
const audit = await runDoctor(
|
|
undefined,
|
|
host({ clientRegistrations: () => Promise.resolve(registrations) }),
|
|
);
|
|
expect(audit.checks.map(({ name }) => name)).not.toContain(
|
|
"registration:grok_bot",
|
|
);
|
|
expect(audit.identity?.registrations).toEqual(registrations);
|
|
|
|
const scoped = await runDoctor(
|
|
undefined,
|
|
host({ clientRegistrations: () => Promise.resolve(registrations) }),
|
|
{ clients: ["grok_bot"] },
|
|
);
|
|
expect(scoped.healthy).toBe(false);
|
|
expect(scoped.scope_checks).toContainEqual(
|
|
expect.objectContaining({
|
|
name: "registration:grok_bot",
|
|
ok: false,
|
|
remediation: "Add the connector from the Grok Bot chat.",
|
|
}),
|
|
);
|
|
|
|
const absent = await runDoctor(
|
|
undefined,
|
|
host({ clientRegistrations: () => Promise.resolve([]) }),
|
|
{ clients: ["grok_bot"] },
|
|
);
|
|
expect(absent.scope_checks).toContainEqual(
|
|
expect.objectContaining({
|
|
name: "registration:grok_bot",
|
|
ok: false,
|
|
remediation: expect.stringContaining("hosted computer"),
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("doctor scoped readiness", () => {
|
|
it("keeps agent readiness independent of Hopper host requirements", async () => {
|
|
const result = await runDoctor(
|
|
undefined,
|
|
host({
|
|
platform: "linux",
|
|
linuxDistribution: () =>
|
|
Promise.resolve({
|
|
id: "debian",
|
|
versionId: "13",
|
|
packageFamily: "deb",
|
|
supported: false,
|
|
}),
|
|
clientRegistrations: () =>
|
|
Promise.resolve([
|
|
{
|
|
client: "codex",
|
|
config_path: "/home/user/.codex/config.toml",
|
|
command: ["rea", "mcp"],
|
|
state: "aligned",
|
|
remediation: null,
|
|
},
|
|
]),
|
|
}),
|
|
{ clients: ["codex"] },
|
|
);
|
|
|
|
expect(result).toMatchObject({
|
|
healthy: true,
|
|
environment_healthy: false,
|
|
scope: { mode: "explicit", clients: ["codex"], providers: [] },
|
|
});
|
|
expect(result.scope_checks.map(({ name }) => name)).toEqual([
|
|
"node",
|
|
"registration:codex",
|
|
]);
|
|
expect(result.informational_checks).toContainEqual(
|
|
expect.objectContaining({ name: "host", ok: false }),
|
|
);
|
|
});
|
|
|
|
it("requires explicitly selected providers instead of any available provider", async () => {
|
|
const ghidra = {
|
|
id: "ghidra",
|
|
configured: true,
|
|
available: true,
|
|
providerVersion: "12.1.4",
|
|
registrationEnvironment: { GHIDRA_INSTALL_DIR: "/tools/ghidra" },
|
|
checks: [
|
|
{
|
|
name: "configuration",
|
|
ok: true,
|
|
code: null,
|
|
detail: "/tools/ghidra",
|
|
remediation: null,
|
|
classification: "config_drift" as const,
|
|
},
|
|
],
|
|
};
|
|
const noHopper = host({
|
|
executable: () => Promise.resolve(false),
|
|
providerInspections: () => Promise.resolve([ghidra]),
|
|
});
|
|
|
|
const hopper = await runDoctor(undefined, noHopper, {
|
|
providers: ["hopper"],
|
|
});
|
|
expect(hopper).toMatchObject({
|
|
healthy: false,
|
|
environment_healthy: true,
|
|
});
|
|
|
|
const selectedGhidra = await runDoctor(undefined, noHopper, {
|
|
providers: ["ghidra"],
|
|
});
|
|
expect(selectedGhidra.healthy).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("doctor Linux and Hopper discovery", () => {
|
|
it("accepts an officially supported Linux distribution", async () => {
|
|
const path = "/home/user/.local/share/rea/hopper/bin/Hopper";
|
|
const result = await runDoctor(
|
|
undefined,
|
|
host({
|
|
platform: "linux",
|
|
macosVersion: () => Promise.resolve(undefined),
|
|
linuxDistribution: () =>
|
|
Promise.resolve({
|
|
id: "ubuntu",
|
|
versionId: "24.04",
|
|
packageFamily: "deb",
|
|
supported: true,
|
|
}),
|
|
configuredHopperPath: path,
|
|
executable: (candidate) => Promise.resolve(candidate === path),
|
|
}),
|
|
);
|
|
expect(result.healthy).toBe(true);
|
|
expect(result.hopperPath).toBe(path);
|
|
});
|
|
it("reports missing Linux demo-session dependencies", async () => {
|
|
const result = await runDoctor(
|
|
undefined,
|
|
host({
|
|
platform: "linux",
|
|
macosVersion: () => Promise.resolve(undefined),
|
|
linuxDistribution: () =>
|
|
Promise.resolve({
|
|
id: "ubuntu",
|
|
versionId: "24.04",
|
|
packageFamily: "deb",
|
|
supported: true,
|
|
}),
|
|
linuxDemoRuntimeCheck: () =>
|
|
Promise.resolve({
|
|
name: "hopper-demo-runtime",
|
|
ok: false,
|
|
classification: "missing_dependency",
|
|
remediation: "Install Xvfb, xauth, Python 3, libX11, and libXtst.",
|
|
}),
|
|
}),
|
|
);
|
|
expect(result.healthy).toBe(false);
|
|
expect(
|
|
result.checks.find(({ name }) => name === "hopper-demo-runtime"),
|
|
).toMatchObject({
|
|
ok: false,
|
|
classification: "missing_dependency",
|
|
remediation: expect.stringContaining("xauth"),
|
|
});
|
|
});
|
|
it("explains how to recover from an unsupported configured launcher", async () => {
|
|
const path = "/custom/Hopper";
|
|
const result = await runDoctor(
|
|
undefined,
|
|
host({
|
|
platform: "linux",
|
|
configuredHopperPath: path,
|
|
linuxDistribution: () =>
|
|
Promise.resolve({
|
|
id: "ubuntu",
|
|
versionId: "24.04",
|
|
packageFamily: "deb",
|
|
supported: true,
|
|
}),
|
|
executable: (candidate) => Promise.resolve(candidate === path),
|
|
supportedLinuxHopper: () => Promise.resolve(false),
|
|
}),
|
|
);
|
|
expect(
|
|
result.checks.find(({ name }) => name === "hopper-version")?.remediation,
|
|
).toContain("Unset or update HOPPER_LAUNCHER_PATH");
|
|
});
|
|
it("detects a Homebrew cask installed outside /Applications", async () => {
|
|
const path =
|
|
"/opt/homebrew/Caskroom/hopper/Hopper Disassembler.app/Contents/MacOS/hopper";
|
|
const result = await runDoctor(
|
|
undefined,
|
|
host({
|
|
executable: (candidate) => Promise.resolve(candidate === path),
|
|
brewHopperPath: () => Promise.resolve(path),
|
|
}),
|
|
);
|
|
expect(result.hopperPath).toBe(path);
|
|
});
|
|
|
|
it("detects a manually installed Hopper application", async () => {
|
|
const path = "/Applications/Hopper v6.app/Contents/MacOS/hopper";
|
|
const result = await runDoctor(
|
|
undefined,
|
|
host({
|
|
executable: (candidate) => Promise.resolve(candidate === path),
|
|
manualHopperPaths: () => Promise.resolve([path]),
|
|
}),
|
|
);
|
|
expect(result.hopperPath).toBe(path);
|
|
});
|
|
|
|
it("uses shared app target validation when a target is supplied", async () => {
|
|
const result = await runDoctor(
|
|
"/Applications/Notes.app",
|
|
host({
|
|
validTarget: (path) =>
|
|
Promise.resolve(path === "/Applications/Notes.app"),
|
|
}),
|
|
);
|
|
expect(result.checks).toContainEqual(
|
|
expect.objectContaining({
|
|
name: "target",
|
|
ok: true,
|
|
classification: "healthy",
|
|
detail: "/Applications/Notes.app",
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("rejects a readable Hopper launcher without execute permission", async () => {
|
|
const path = "/manual/Hopper";
|
|
const result = await runDoctor(
|
|
undefined,
|
|
host({
|
|
configuredHopperPath: path,
|
|
validTarget: () => Promise.resolve(true),
|
|
executable: () => Promise.resolve(false),
|
|
}),
|
|
);
|
|
expect(result.hopperPath).toBeUndefined();
|
|
expect(result.checks).toContainEqual(
|
|
expect.objectContaining({
|
|
name: "hopper",
|
|
ok: false,
|
|
classification: "config_drift",
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("doctor target admission diagnostics", () => {
|
|
it("preserves route-specific target diagnostics from the host", async () => {
|
|
const targetCheck = {
|
|
name: "target",
|
|
ok: false,
|
|
classification: "unsupported_target" as const,
|
|
detail: "/fixture/javascript-app",
|
|
remediation: "Use analyze_javascript_application with input_path.",
|
|
};
|
|
const result = await runDoctor(
|
|
targetCheck.detail,
|
|
host({
|
|
inspectTarget: () => Promise.resolve(targetCheck),
|
|
validTarget: () => {
|
|
throw new Error("the detailed target check should be used");
|
|
},
|
|
}),
|
|
);
|
|
expect(result.checks).toContainEqual(targetCheck);
|
|
});
|
|
});
|