Files
rea/src/application/Doctor.test.ts
T
morluto b718ad166d fix(doctor): reject unusable Kuna caches and invalid native settings (#5647)
* fix(kuna): reject dangling cache roots in doctor

Treat a cache path as creatable only when no directory entry occupies it.
A dangling symbolic link, including one hidden by a trailing separator,
fails the cache check and clears provider availability while the installation
check and caller-selected registration settings remain.

Fixes #5554
Fixes #5105

Co-authored-by: morluto <morluto@users.noreply.github.com>

* fix(doctor): admit the same settings native analysis rejects

Project parseConfig failures into scoped engine readiness. Invalid provider
preferences, startup timeouts, and other shared settings now fail the
selected provider and the full audit, while a JEB-only timeout stays outside
that admission.

Fixes #5454

Co-authored-by: morluto <morluto@users.noreply.github.com>

* fix(doctor): report an invalid log level from doctor

The dispatcher rejected REA_LOG_LEVEL before every command, so doctor never
emitted its readiness check. Doctor now runs and names that setting; other
commands still stop before execution.

Co-authored-by: morluto <morluto@users.noreply.github.com>

* fix(doctor): require log-level failures in every scope

An invalid REA_LOG_LEVEL stops the executable and MCP server before a client
or skill can run, so those scopes now fail readiness too. Other native
admission failures stay limited to a selected provider.

Co-authored-by: morluto <morluto@users.noreply.github.com>

* fix(kuna): strip only the platform cache separator

On Darwin and Linux a trailing backslash is a filename character. Doctor now
removes only the selected platform separator before inspecting the cache path.

Co-authored-by: morluto <morluto@users.noreply.github.com>

* fix(doctor): fail every provider on an invalid Hopper timeout

REA_HOPPER_STARTUP_TIMEOUT_MS is rejected before any native engine starts.
A selected non-Hopper provider now includes the existing timeout check.

Co-authored-by: morluto <morluto@users.noreply.github.com>

* style(doctor): format the Hopper timeout regression

Co-authored-by: morluto <morluto@users.noreply.github.com>

* fix(doctor): fail client scopes on MCP startup errors

A registered client starts the MCP server, and parseConfig rejects every
native admission failure before that server starts. Client readiness now
includes those failures, not only REA_LOG_LEVEL.

Co-authored-by: morluto <morluto@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: morluto <morluto@users.noreply.github.com>
2026-10-21 03:44:49 +08:00

1135 lines
34 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, systemDoctorHost, type DoctorHost } from "./Doctor.js";
import { err } from "../domain/result.js";
import { HopperReleaseError } from "./HopperReleaseFeed.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_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.3.0", ok: true },
{ 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 additionally requires Ubuntu 24.04+, Fedora 41+, Nobara 44+, 64-bit 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("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);
});
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);
});
});
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);
});
});