mirror of
https://github.com/morluto/rea.git
synced 2026-11-08 15:56:35 +00:00
1259 lines
38 KiB
TypeScript
1259 lines
38 KiB
TypeScript
import { mkdir } from "node:fs/promises";
|
|
import { join, posix } from "node:path";
|
|
import { describe, expect, it } from "vitest";
|
|
|
|
import { defaultHopperLauncherPath } from "../config/parseConfig.js";
|
|
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, systemDoctorHost, type DoctorHost } from "./Doctor.js";
|
|
import { err } from "../domain/result.js";
|
|
import { HopperReleaseError } from "./HopperReleaseFeed.js";
|
|
import { MAC_HOPPER_SYSTEM_LAUNCHER } from "./MacHopperPaths.js";
|
|
import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js";
|
|
|
|
describe("doctor Hopper startup timeout", () => {
|
|
const timeoutHost = (
|
|
value: string | undefined,
|
|
overrides: Partial<DoctorHost> = {},
|
|
): DoctorHost => ({
|
|
...systemDoctorHost({
|
|
environment:
|
|
value === undefined ? {} : { REA_HOPPER_STARTUP_TIMEOUT_MS: value },
|
|
}),
|
|
...host({
|
|
installationPaths: () => Promise.resolve([]),
|
|
clientRegistrations: () => Promise.resolve([]),
|
|
...overrides,
|
|
}),
|
|
});
|
|
|
|
it.each([
|
|
["0", true, false],
|
|
["120000", true, true],
|
|
["0", false, false],
|
|
["120000", false, true],
|
|
[undefined, false, true],
|
|
] as const)(
|
|
"checks timeout %s with Hopper present=%s and an available peer",
|
|
async (value, hopperPresent, healthy) => {
|
|
const selectedHost = timeoutHost(value, {
|
|
executable: () => Promise.resolve(hopperPresent),
|
|
providerInspections: () =>
|
|
Promise.resolve([
|
|
{
|
|
id: "ghidra",
|
|
configured: true,
|
|
available: true,
|
|
providerVersion: null,
|
|
registrationEnvironment: {},
|
|
checks: [],
|
|
},
|
|
]),
|
|
});
|
|
const result = await runDoctor(undefined, selectedHost);
|
|
expect(result.healthy).toBe(healthy);
|
|
expect(result.environment_healthy).toBe(healthy);
|
|
const timeoutCheck = result.checks.find(
|
|
({ name }) => name === "hopper-startup-timeout",
|
|
);
|
|
if (value === undefined) expect(timeoutCheck).toBeUndefined();
|
|
else expect(timeoutCheck).toMatchObject({ ok: value !== "0" });
|
|
},
|
|
);
|
|
|
|
it.each(["", "0", "-1", "1.5", "1e3", "invalid", "2147483648"])(
|
|
"rejects invalid Hopper startup timeout %j in the selected provider scope",
|
|
async (value) => {
|
|
const selectedHost = timeoutHost(value);
|
|
const result = await runDoctor(undefined, selectedHost, {
|
|
providers: ["hopper"],
|
|
});
|
|
|
|
expect(result.healthy).toBe(false);
|
|
expect(result.scope_checks).toContainEqual(
|
|
expect.objectContaining({
|
|
name: "hopper-startup-timeout",
|
|
ok: false,
|
|
classification: "config_drift",
|
|
detail: expect.stringContaining("REA_HOPPER_STARTUP_TIMEOUT_MS"),
|
|
remediation: expect.stringContaining("REA_HOPPER_STARTUP_TIMEOUT_MS"),
|
|
}),
|
|
);
|
|
},
|
|
);
|
|
|
|
it.each([undefined, "1", "120000", "2147483647"])(
|
|
"keeps Hopper ready with the valid startup timeout %j",
|
|
async (value) => {
|
|
const selectedHost = timeoutHost(value);
|
|
expect(
|
|
(await runDoctor(undefined, selectedHost, { providers: ["hopper"] }))
|
|
.healthy,
|
|
).toBe(true);
|
|
},
|
|
);
|
|
|
|
it("keeps an invalid Hopper timeout informational when no provider is selected", async () => {
|
|
const selectedHost = timeoutHost("0");
|
|
const result = await runDoctor(undefined, selectedHost, { providers: [] });
|
|
expect(result.healthy).toBe(true);
|
|
expect(result.informational_checks).toContainEqual(
|
|
expect.objectContaining({
|
|
name: "hopper-startup-timeout",
|
|
ok: false,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("requires an invalid Hopper timeout for a ready non-Hopper provider", async () => {
|
|
const ghidra = {
|
|
providerInspections: () =>
|
|
Promise.resolve([
|
|
{
|
|
id: "ghidra" as const,
|
|
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 blocked = await runDoctor(undefined, timeoutHost("abc", ghidra), {
|
|
providers: ["ghidra"],
|
|
});
|
|
expect(blocked.healthy).toBe(false);
|
|
expect(blocked.scope_checks).toContainEqual(
|
|
expect.objectContaining({
|
|
name: "hopper-startup-timeout",
|
|
ok: false,
|
|
classification: "config_drift",
|
|
}),
|
|
);
|
|
expect(
|
|
blocked.scope_checks.filter(
|
|
({ name }) => name === "hopper-startup-timeout",
|
|
),
|
|
).toHaveLength(1);
|
|
|
|
const ready = await runDoctor(undefined, timeoutHost("120000", ghidra), {
|
|
providers: ["ghidra"],
|
|
});
|
|
expect(ready.healthy).toBe(true);
|
|
expect(ready.scope_checks).toContainEqual(
|
|
expect.objectContaining({ name: "hopper-startup-timeout", ok: true }),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("doctor native analysis configuration", () => {
|
|
const configurationHost = (
|
|
environment: Readonly<NodeJS.ProcessEnv>,
|
|
): DoctorHost => ({
|
|
...systemDoctorHost({ environment: { ...environment } }),
|
|
...host({
|
|
installationPaths: () => Promise.resolve([]),
|
|
clientRegistrations: () => Promise.resolve([]),
|
|
}),
|
|
});
|
|
|
|
it.each([
|
|
["REA_ANALYSIS_PROVIDER", "Hopper"],
|
|
["REA_REGEX_MATCHING_DEADLINE_MS", "0"],
|
|
["REA_BRIDGE_MAX_REQUEST_BYTES", "7463"],
|
|
["REA_CAPTURE_DECODER_OLD_GENERATION_MIB", "7"],
|
|
["REA_REGEX_WORKER_HEAP_REQUEST_MIB", "6"],
|
|
["REA_MCP_INITIALIZE_BUDGET_MS", "0"],
|
|
["REA_MCP_TOOLS_LIST_BUDGET_MS", "0"],
|
|
["REA_MCP_FIRST_CATALOG_BUDGET_MS", "0"],
|
|
["REA_MCP_DOCTOR_DEADLINE_MS", "0"],
|
|
["REA_GHIDRA_STARTUP_TIMEOUT_MS", "abc"],
|
|
["REA_SWIFTC_COMPILE_TIMEOUT_MS", "0"],
|
|
["REA_LOG_LEVEL", "verbose"],
|
|
["REA_GHIDRA_COMPILER_SPEC_ID", "gcc"],
|
|
] as const)(
|
|
"rejects %s before scoped Hopper readiness reports success",
|
|
async (setting, value) => {
|
|
const result = await runDoctor(
|
|
undefined,
|
|
configurationHost({ [setting]: value }),
|
|
{ providers: ["hopper"] },
|
|
);
|
|
|
|
expect(result.healthy).toBe(false);
|
|
expect(result.environment_healthy).toBe(false);
|
|
expect(result.scope_checks).toContainEqual(
|
|
expect.objectContaining({
|
|
name: `runtime-configuration:${setting}`,
|
|
ok: false,
|
|
classification: "config_drift",
|
|
detail: expect.stringContaining(setting),
|
|
remediation: expect.stringContaining(setting),
|
|
details: { setting },
|
|
}),
|
|
);
|
|
},
|
|
);
|
|
|
|
it("keeps a lowercase provider preference ready for the selected engine", async () => {
|
|
const result = await runDoctor(
|
|
undefined,
|
|
configurationHost({ REA_ANALYSIS_PROVIDER: "hopper" }),
|
|
{ providers: ["hopper"] },
|
|
);
|
|
|
|
expect(result.healthy).toBe(true);
|
|
expect(
|
|
result.checks.some(({ name }) =>
|
|
name.startsWith("runtime-configuration:"),
|
|
),
|
|
).toBe(false);
|
|
});
|
|
|
|
it("keeps an invalid native setting informational when nothing starts MCP", async () => {
|
|
const result = await runDoctor(
|
|
undefined,
|
|
configurationHost({ REA_GHIDRA_STARTUP_TIMEOUT_MS: "abc" }),
|
|
{ providers: [] },
|
|
);
|
|
|
|
expect(result.healthy).toBe(true);
|
|
expect(result.environment_healthy).toBe(false);
|
|
expect(result.scope_checks.map(({ name }) => name)).not.toContain(
|
|
"runtime-configuration:REA_GHIDRA_STARTUP_TIMEOUT_MS",
|
|
);
|
|
expect(result.informational_checks).toContainEqual(
|
|
expect.objectContaining({
|
|
name: "runtime-configuration:REA_GHIDRA_STARTUP_TIMEOUT_MS",
|
|
ok: false,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("does not treat a JEB-only timeout as native analysis configuration", async () => {
|
|
const result = await runDoctor(
|
|
undefined,
|
|
configurationHost({ REA_JEB_MCP_TIMEOUT_MS: "abc" }),
|
|
{ providers: ["hopper"] },
|
|
);
|
|
|
|
expect(result.healthy).toBe(true);
|
|
expect(
|
|
result.checks.some(({ name }) =>
|
|
name.startsWith("runtime-configuration:"),
|
|
),
|
|
).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("doctor MCP startup readiness", () => {
|
|
const configurationHost = (
|
|
environment: Readonly<NodeJS.ProcessEnv>,
|
|
): DoctorHost => ({
|
|
...systemDoctorHost({ environment: { ...environment } }),
|
|
...host({
|
|
installationPaths: () => Promise.resolve([]),
|
|
clientRegistrations: () => Promise.resolve([]),
|
|
}),
|
|
});
|
|
const alignedCodex: ClientRegistrationStatus = {
|
|
client: "codex",
|
|
config_path: "/home/user/.codex/config.toml",
|
|
command: ["/current/rea", "mcp"],
|
|
state: "aligned",
|
|
remediation: null,
|
|
};
|
|
const selectedHost = (
|
|
environment: Readonly<NodeJS.ProcessEnv>,
|
|
): DoctorHost => ({
|
|
...configurationHost(environment),
|
|
clientRegistrations: () => Promise.resolve([alignedCodex]),
|
|
});
|
|
|
|
it("requires an invalid log level for a client and for the skill", async () => {
|
|
const client = await runDoctor(
|
|
undefined,
|
|
selectedHost({
|
|
REA_LOG_LEVEL: "verbose",
|
|
REA_GHIDRA_STARTUP_TIMEOUT_MS: "abc",
|
|
}),
|
|
{ clients: ["codex"] },
|
|
);
|
|
expect(client.healthy).toBe(false);
|
|
expect(client.scope_checks).toContainEqual(
|
|
expect.objectContaining({
|
|
name: "runtime-configuration:REA_LOG_LEVEL",
|
|
ok: false,
|
|
details: { setting: "REA_LOG_LEVEL" },
|
|
}),
|
|
);
|
|
expect(client.scope_checks).toContainEqual(
|
|
expect.objectContaining({
|
|
name: "runtime-configuration:REA_GHIDRA_STARTUP_TIMEOUT_MS",
|
|
ok: false,
|
|
}),
|
|
);
|
|
expect(client.scope_checks).toContainEqual(
|
|
expect.objectContaining({ name: "registration:codex", ok: true }),
|
|
);
|
|
|
|
const skill = await runDoctor(
|
|
undefined,
|
|
selectedHost({
|
|
REA_LOG_LEVEL: "verbose",
|
|
REA_GHIDRA_STARTUP_TIMEOUT_MS: "abc",
|
|
}),
|
|
{ skill: true },
|
|
);
|
|
expect(skill.healthy).toBe(false);
|
|
expect(skill.scope_checks).toContainEqual(
|
|
expect.objectContaining({
|
|
name: "runtime-configuration:REA_LOG_LEVEL",
|
|
ok: false,
|
|
}),
|
|
);
|
|
expect(skill.scope_checks.map(({ name }) => name)).not.toContain(
|
|
"runtime-configuration:REA_GHIDRA_STARTUP_TIMEOUT_MS",
|
|
);
|
|
});
|
|
|
|
it("requires every MCP startup failure for an aligned client", async () => {
|
|
const ghidraTimeout = await runDoctor(
|
|
undefined,
|
|
selectedHost({ REA_GHIDRA_STARTUP_TIMEOUT_MS: "abc" }),
|
|
{ clients: ["codex"] },
|
|
);
|
|
expect(ghidraTimeout.healthy).toBe(false);
|
|
expect(ghidraTimeout.scope_checks).toContainEqual(
|
|
expect.objectContaining({
|
|
name: "runtime-configuration:REA_GHIDRA_STARTUP_TIMEOUT_MS",
|
|
ok: false,
|
|
}),
|
|
);
|
|
expect(ghidraTimeout.scope_checks).toContainEqual(
|
|
expect.objectContaining({ name: "registration:codex", ok: true }),
|
|
);
|
|
|
|
const hopperTimeout = await runDoctor(
|
|
undefined,
|
|
selectedHost({ REA_HOPPER_STARTUP_TIMEOUT_MS: "abc" }),
|
|
{ clients: ["codex"] },
|
|
);
|
|
expect(hopperTimeout.healthy).toBe(false);
|
|
expect(
|
|
hopperTimeout.scope_checks.filter(
|
|
({ name }) => name === "hopper-startup-timeout",
|
|
),
|
|
).toHaveLength(1);
|
|
|
|
const hopperAndClient = await runDoctor(
|
|
undefined,
|
|
selectedHost({ REA_HOPPER_STARTUP_TIMEOUT_MS: "abc" }),
|
|
{ clients: ["codex"], providers: ["hopper"] },
|
|
);
|
|
expect(
|
|
hopperAndClient.scope_checks.filter(
|
|
({ name }) => name === "hopper-startup-timeout",
|
|
),
|
|
).toHaveLength(1);
|
|
|
|
const skill = await runDoctor(
|
|
undefined,
|
|
selectedHost({ REA_GHIDRA_STARTUP_TIMEOUT_MS: "abc" }),
|
|
{ skill: true },
|
|
);
|
|
expect(skill.healthy).toBe(true);
|
|
expect(skill.informational_checks).toContainEqual(
|
|
expect.objectContaining({
|
|
name: "runtime-configuration:REA_GHIDRA_STARTUP_TIMEOUT_MS",
|
|
ok: false,
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("doctor runtime check", () => {
|
|
it("reports Node 25 as unsupported", async () => {
|
|
const result = await runDoctor(
|
|
undefined,
|
|
host({ runtime: { id: "node", version: "25.1.0" } }),
|
|
);
|
|
|
|
expect(result.healthy).toBe(false);
|
|
expect(result.checks).toContainEqual(
|
|
expect.objectContaining({
|
|
name: "node",
|
|
ok: false,
|
|
detail: "25.1.0",
|
|
details: { runtime: "node", version: "25.1.0" },
|
|
classification: "missing_dependency",
|
|
}),
|
|
);
|
|
});
|
|
|
|
it.each([
|
|
{ compatibility: "26.12.1", ok: true },
|
|
{ compatibility: "26.12.0", ok: false },
|
|
{ compatibility: "25.1.0", ok: false },
|
|
])(
|
|
"identifies Bun and admits it by Node.js compatibility $compatibility",
|
|
async ({ compatibility, ok }) => {
|
|
const result = await runDoctor(
|
|
undefined,
|
|
host({
|
|
runtime: {
|
|
id: "bun",
|
|
version: "1.4.2",
|
|
nodeCompatibilityVersion: compatibility,
|
|
},
|
|
}),
|
|
);
|
|
|
|
expect(result.checks).toContainEqual(
|
|
expect.objectContaining({
|
|
name: "node",
|
|
ok,
|
|
classification: ok ? "healthy" : "missing_dependency",
|
|
details: {
|
|
runtime: "bun",
|
|
version: "1.4.2",
|
|
node_compatibility_version: compatibility,
|
|
},
|
|
}),
|
|
);
|
|
},
|
|
);
|
|
});
|
|
|
|
describe("doctor", () => {
|
|
it("returns exact recovery for every failed diagnostic", async () => {
|
|
const result = await runDoctor(
|
|
"/missing/app",
|
|
host({
|
|
runtime: { id: "node", version: "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+, Linux distributions with a recognized apt, dnf, or pacman package family, and the experimental Windows x64 Ghidra P0 boundary; automated Hopper setup on Linux additionally requires an x86_64 host running Ubuntu 24.04+, Fedora 41+, Nobara 44+, Arch Linux, CachyOS, or Omarchy.",
|
|
hopper: "Run rea setup to install Hopper, or set HOPPER_LAUNCHER_PATH.",
|
|
target: "Supply a readable local app or program path.",
|
|
});
|
|
});
|
|
|
|
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("names each stale or missing skill destination and its exact repair selection", async () => {
|
|
const result = await runDoctor(
|
|
undefined,
|
|
host({
|
|
installedSkillIdentity: () =>
|
|
Promise.resolve({
|
|
version: "current-first-copy",
|
|
toolCount: 0,
|
|
canonical: false,
|
|
issues: [
|
|
{
|
|
client: "claude_code",
|
|
path: "/home/.claude/skills/rea",
|
|
kind: "stale",
|
|
detail: "Selected skill copy is stale",
|
|
},
|
|
{
|
|
client: "shared",
|
|
path: "/home/.agents/skills/rea",
|
|
kind: "missing",
|
|
detail: "Selected skill copy is missing",
|
|
},
|
|
],
|
|
}),
|
|
}),
|
|
);
|
|
expect(result.identity?.skill).toMatchObject({ state: "stale" });
|
|
expect(result.identity?.skill.remediation).toContain(
|
|
"rea setup --skill-client claude_code --skill",
|
|
);
|
|
expect(result.identity?.skill.remediation).toContain(
|
|
"rea setup --skill-client shared --skill",
|
|
);
|
|
expect(result.identity?.skill.remediation).toContain(
|
|
"/home/.claude/skills/rea",
|
|
);
|
|
expect(result.identity?.skill.remediation).toContain(
|
|
"/home/.agents/skills/rea",
|
|
);
|
|
});
|
|
|
|
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("checks explicitly selected skill destinations independently of registrations", async () => {
|
|
const selected: (readonly string[] | undefined)[] = [];
|
|
const result = await runDoctor(
|
|
undefined,
|
|
host({
|
|
clientRegistrations: () => Promise.resolve([]),
|
|
installedSkillIdentity: (clients) => {
|
|
selected.push(clients);
|
|
return Promise.resolve({
|
|
version: "stale",
|
|
toolCount: 0,
|
|
canonical: false,
|
|
});
|
|
},
|
|
}),
|
|
{
|
|
clients: [],
|
|
skillClients: ["claude_code", "claude_code"],
|
|
skill: true,
|
|
},
|
|
);
|
|
|
|
expect(selected).toEqual([["claude_code"]]);
|
|
expect(result.scope).toMatchObject({
|
|
mode: "explicit",
|
|
clients: [],
|
|
skill_clients: ["claude_code"],
|
|
skill: true,
|
|
});
|
|
expect(result.healthy).toBe(false);
|
|
expect(result.scope_checks.map(({ name }) => name)).toEqual([
|
|
"node",
|
|
"skill:identity",
|
|
]);
|
|
});
|
|
|
|
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: "unknown",
|
|
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,
|
|
classification: "unknown",
|
|
}),
|
|
);
|
|
|
|
const staleSelected = await runDoctor(
|
|
undefined,
|
|
host({ clientRegistrations: () => Promise.resolve(registrations) }),
|
|
{ clients: ["cursor"] },
|
|
);
|
|
expect(staleSelected.healthy).toBe(false);
|
|
});
|
|
|
|
it("keeps a caller-selected short timeout healthy and reports its advisory as informational", async () => {
|
|
const result = await runDoctor(
|
|
undefined,
|
|
host({
|
|
clientRegistrations: () =>
|
|
Promise.resolve([
|
|
{
|
|
client: "kimi",
|
|
config_path: "/home/user/.kimi-code/mcp.json",
|
|
command: ["/current/rea", "mcp"],
|
|
state: "aligned",
|
|
remediation: null,
|
|
advisory: "The configured toolTimeoutMs is shorter than startup.",
|
|
},
|
|
]),
|
|
}),
|
|
{ clients: ["kimi"] },
|
|
);
|
|
|
|
expect(result.healthy).toBe(true);
|
|
expect(result.scope_checks).toContainEqual(
|
|
expect.objectContaining({ name: "registration:kimi", ok: true }),
|
|
);
|
|
expect(result.informational_checks).toContainEqual(
|
|
expect.objectContaining({
|
|
name: "registration_timeout:kimi",
|
|
ok: true,
|
|
detail: "The configured toolTimeoutMs is shorter than startup.",
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
|
|
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: "nixos",
|
|
packageFamily: undefined,
|
|
hopperSetupSupported: 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("accepts a Debian-family host that has no tested Hopper package", async () => {
|
|
const result = await runDoctor(
|
|
undefined,
|
|
host({
|
|
platform: "linux",
|
|
macosVersion: () => Promise.resolve(undefined),
|
|
linuxDistribution: () =>
|
|
Promise.resolve({
|
|
id: "kali",
|
|
versionId: "2026.3",
|
|
packageFamily: "deb",
|
|
hopperSetupSupported: false,
|
|
}),
|
|
}),
|
|
);
|
|
|
|
expect(result.checks).toContainEqual(
|
|
expect.objectContaining({
|
|
name: "host",
|
|
ok: true,
|
|
detail: "kali 2026.3",
|
|
}),
|
|
);
|
|
});
|
|
|
|
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",
|
|
hopperSetupSupported: 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",
|
|
hopperSetupSupported: 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",
|
|
hopperSetupSupported: true,
|
|
}),
|
|
executable: (candidate) => Promise.resolve(candidate === path),
|
|
inspectLinuxHopper: () =>
|
|
Promise.resolve(
|
|
err(
|
|
new HopperReleaseError(
|
|
"unsupported_hopper_build",
|
|
"Unverified fixture build",
|
|
),
|
|
),
|
|
),
|
|
}),
|
|
);
|
|
expect(
|
|
result.checks.find(({ name }) => name === "hopper-version")?.remediation,
|
|
).toContain("Check HOPPER_LAUNCHER_PATH");
|
|
});
|
|
});
|
|
|
|
describe("doctor installed Hopper discovery", () => {
|
|
it("checks the canonical user install after the system launcher", async () => {
|
|
const userPath =
|
|
"/fixture/home/Applications/Hopper Disassembler.app/Contents/MacOS/hopper";
|
|
const checked: string[] = [];
|
|
const result = await runDoctor(
|
|
undefined,
|
|
host({
|
|
homeDirectory: "/fixture/home",
|
|
executable: (candidate) => {
|
|
checked.push(candidate);
|
|
return Promise.resolve(candidate === userPath);
|
|
},
|
|
}),
|
|
);
|
|
|
|
expect(result.hopperPath).toBe(userPath);
|
|
expect(checked.slice(0, 2)).toEqual([
|
|
"/Applications/Hopper Disassembler.app/Contents/MacOS/hopper",
|
|
userPath,
|
|
]);
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|
|
|
|
describe("doctor Hopper discovery matches runtime selection", () => {
|
|
// Model launchers with runtime's POSIX joins; filesystem work stays native.
|
|
const launchers = (home: string) => ({
|
|
macSystem: MAC_HOPPER_SYSTEM_LAUNCHER,
|
|
renamed: posix.join(
|
|
home,
|
|
"Applications/Hopper 5.app/Contents/MacOS/hopper",
|
|
),
|
|
linuxSystem: "/opt/hopper-v4/bin/hopper",
|
|
});
|
|
type Launcher = keyof ReturnType<typeof launchers>;
|
|
|
|
it.each<[NodeJS.Platform, readonly Launcher[], Launcher | undefined]>([
|
|
["darwin", ["linuxSystem"], undefined],
|
|
["darwin", ["linuxSystem", "renamed"], "renamed"],
|
|
["darwin", ["linuxSystem", "macSystem"], "macSystem"],
|
|
["darwin", ["renamed", "macSystem"], "macSystem"],
|
|
["linux", ["macSystem", "renamed"], undefined],
|
|
["linux", ["macSystem", "linuxSystem"], "linuxSystem"],
|
|
["win32", ["macSystem", "linuxSystem", "renamed"], undefined],
|
|
])(
|
|
"on %s with %j executable, selects %s",
|
|
async (platform, executable, expected) => {
|
|
// Runtime enumerates renamed bundles from disk; doctor reads its host scan.
|
|
const home = await createTestTempDirectory("rea-doctor-hopper-");
|
|
await mkdir(join(home, "Applications/Hopper 5.app"), { recursive: true });
|
|
const paths = launchers(home);
|
|
const executables = new Set(executable.map((name) => paths[name]));
|
|
const probed: string[] = [];
|
|
let renamedScans = 0;
|
|
let brewProbes = 0;
|
|
const result = await runDoctor(
|
|
undefined,
|
|
host({
|
|
platform,
|
|
homeDirectory: home,
|
|
executable: (path) => {
|
|
probed.push(path);
|
|
return Promise.resolve(executables.has(path));
|
|
},
|
|
manualHopperPaths: () => {
|
|
renamedScans += 1;
|
|
return Promise.resolve([paths.renamed]);
|
|
},
|
|
brewHopperPath: () => {
|
|
brewProbes += 1;
|
|
return Promise.resolve(undefined);
|
|
},
|
|
}),
|
|
);
|
|
const runtime = defaultHopperLauncherPath(platform, home, (path) =>
|
|
executables.has(path),
|
|
);
|
|
|
|
expect(result.hopperPath).toBe(
|
|
expected === undefined ? undefined : paths[expected],
|
|
);
|
|
// Hopper has no automatic launcher on other platforms; runtime's fixed
|
|
// fallback there is never probed.
|
|
if (platform === "win32") expect(probed).toEqual([]);
|
|
else
|
|
expect(result.hopperPath).toBe(
|
|
executables.has(runtime) ? runtime : undefined,
|
|
);
|
|
expect(renamedScans).toBe(
|
|
platform === "darwin" && expected !== "macSystem" ? 1 : 0,
|
|
);
|
|
expect(brewProbes).toBe(
|
|
platform === "darwin" && expected === undefined ? 1 : 0,
|
|
);
|
|
if (expected === undefined)
|
|
expect(
|
|
result.checks.find(({ name }) => name === "hopper"),
|
|
).toMatchObject({
|
|
ok: false,
|
|
classification: "missing_analysis_engine",
|
|
});
|
|
},
|
|
);
|
|
});
|
|
|
|
describe("doctor target admission on Linux", () => {
|
|
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 standard Linux Hopper paths", () => {
|
|
it.each(["/opt/hopper-v4/bin/hopper", "/usr/bin/hopper"])(
|
|
"detects Hopper at the standard path %s",
|
|
async (path) => {
|
|
const result = await runDoctor(
|
|
undefined,
|
|
host({
|
|
platform: "linux",
|
|
macosVersion: () => Promise.resolve(undefined),
|
|
linuxDistribution: () =>
|
|
Promise.resolve({
|
|
id: "ubuntu",
|
|
versionId: "24.04",
|
|
packageFamily: "deb",
|
|
hopperSetupSupported: true,
|
|
}),
|
|
executable: (candidate) => Promise.resolve(candidate === path),
|
|
}),
|
|
);
|
|
expect(result.hopperPath).toBe(path);
|
|
},
|
|
);
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|