mirror of
https://github.com/morluto/rea
synced 2026-10-27 06:33:32 -04:00
70cd4cbfb8
Replace fixed Linux Hopper release pins with current vendor feed metadata, complete detached-PGP package verification under the stable publisher primary key, and authenticated package evidence for offline admission. Share parsed publisher facts across approved setup, doctor and the pre-execution guard. Preserve existing ownership, cancellation, private-display and cleanup rules. Fixes #3577 Normalize empty XDG settings, preserve backups during approved cache repair, and retain publisher cleanup through cancellation and shutdown. Exercise cold offline verification without reusing process-local proof state. Serialize approved cache publication across processes, restore old evidence on failure, and share the existing lease owner from process without changing its identities. Validation: 438 focused tests (six platform-related skips), full static/documentation checks, real Ubuntu 24.04 official Hopper 6.6.0 setup/CLI/MCP, offline ordinary-user admission, launcher mutation rejection, and cold/warm verification measurements. Official RPM metadata mismatch is tracked separately in #3578 and fails closed. BREAKING CHANGE: Older Linux Hopper builds admitted by the fixed allowlist now require retained publisher-signed package evidence or an approved update to the current public demo. Update explicit HOPPER_LAUNCHER_PATH values when migrating; the live feed exposes only its current release. Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com> Co-authored-by: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com>
773 lines
23 KiB
TypeScript
773 lines
23 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";
|
|
import { err } from "../domain/result.js";
|
|
import { HopperReleaseError } from "./HopperReleaseFeed.js";
|
|
|
|
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);
|
|
});
|
|
});
|