726 lines
24 KiB
TypeScript
726 lines
24 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
|
|
import {
|
|
registrationPermissionEnvironment,
|
|
runSetup,
|
|
type ClientConfigurationInspection,
|
|
type ClientConfigurationResult,
|
|
type SetupClient,
|
|
type SetupHost,
|
|
type SetupOptions,
|
|
type SetupHopperInstallResult,
|
|
type SetupProgressEvent,
|
|
type SetupProviderEnvironment,
|
|
} from "../src/application/Setup.js";
|
|
import type {
|
|
DoctorCheck,
|
|
DoctorReport,
|
|
DoctorScope,
|
|
} from "../src/application/Doctor.js";
|
|
import type { LinuxDistribution } from "../src/application/LinuxHopper.js";
|
|
|
|
class FakeSetupHost implements SetupHost {
|
|
readonly platform: NodeJS.Platform;
|
|
nodeVersion = "25.1.0";
|
|
version: string | undefined = "14.5";
|
|
distribution: LinuxDistribution | undefined;
|
|
hopper: string | undefined;
|
|
ghidra: string | undefined;
|
|
javaHome: string | undefined;
|
|
hopperInstallSucceeds = true;
|
|
skill: "installed" | "unchanged" | "failed" = "installed";
|
|
clients: readonly SetupClient[] = [];
|
|
clientResults = new Map<string, ClientConfigurationResult>();
|
|
clientInspections = new Map<string, ClientConfigurationInspection>();
|
|
hopperInstalls = 0;
|
|
hopperReplaceRequests: boolean[] = [];
|
|
configurations = 0;
|
|
skillInstalls = 0;
|
|
doctorCalls = 0;
|
|
doctorScopes: Array<DoctorScope | undefined> = [];
|
|
checkedHopperPaths: Array<string | undefined> = [];
|
|
configuredProviderEnvironments: SetupProviderEnvironment[] = [];
|
|
doctorHealthy: boolean | undefined;
|
|
scopedDoctorHealthy: boolean | undefined;
|
|
linuxDemoRuntimeMissing = false;
|
|
unsupportedHopperVersion = false;
|
|
|
|
constructor(platform: NodeJS.Platform = "darwin") {
|
|
this.platform = platform;
|
|
}
|
|
|
|
macosVersion = (): Promise<string | undefined> =>
|
|
Promise.resolve(this.version);
|
|
linuxDistribution = (): Promise<LinuxDistribution | undefined> =>
|
|
Promise.resolve(this.distribution);
|
|
hopperPath = (): Promise<string | undefined> => Promise.resolve(this.hopper);
|
|
providerEnvironment = (): Promise<SetupProviderEnvironment> =>
|
|
Promise.resolve({
|
|
...(this.hopper === undefined
|
|
? {}
|
|
: { HOPPER_LAUNCHER_PATH: this.hopper }),
|
|
...(this.ghidra === undefined ? {} : { GHIDRA_INSTALL_DIR: this.ghidra }),
|
|
...(this.javaHome === undefined ? {} : { JAVA_HOME: this.javaHome }),
|
|
});
|
|
installHopper = (
|
|
replaceExisting: boolean,
|
|
): Promise<SetupHopperInstallResult> => {
|
|
this.hopperInstalls += 1;
|
|
this.hopperReplaceRequests.push(replaceExisting);
|
|
this.linuxDemoRuntimeMissing = false;
|
|
if (this.hopperInstallSucceeds) {
|
|
this.hopper = "/manual/Hopper";
|
|
return Promise.resolve({
|
|
status: "installed",
|
|
launcherPath: this.hopper,
|
|
});
|
|
}
|
|
return Promise.resolve({
|
|
status: "failed",
|
|
code: "download_failed",
|
|
remediation: "Download failed.",
|
|
});
|
|
};
|
|
detectedClients = (): Promise<readonly SetupClient[]> =>
|
|
Promise.resolve(this.clients);
|
|
configureClient = (
|
|
client: SetupClient,
|
|
providerEnvironment: SetupProviderEnvironment,
|
|
): Promise<ClientConfigurationResult> => {
|
|
this.configurations += 1;
|
|
this.configuredProviderEnvironments.push(providerEnvironment);
|
|
return Promise.resolve(
|
|
this.clientResults.get(client.name) ?? { status: "configured" },
|
|
);
|
|
};
|
|
clientNeedsConfigure = (
|
|
client: SetupClient,
|
|
providerEnvironment: SetupProviderEnvironment,
|
|
): Promise<boolean> => {
|
|
this.checkedHopperPaths.push(providerEnvironment.HOPPER_LAUNCHER_PATH);
|
|
return Promise.resolve(
|
|
this.clientResults.get(client.name)?.status !== "unchanged",
|
|
);
|
|
};
|
|
inspectClientConfiguration = (
|
|
client: SetupClient,
|
|
): Promise<ClientConfigurationInspection> =>
|
|
Promise.resolve(
|
|
this.clientInspections.get(client.name) ?? { status: "update" },
|
|
);
|
|
skillNeedsInstall = (): Promise<boolean> =>
|
|
Promise.resolve(this.skill !== "unchanged");
|
|
installSkill = (): Promise<"installed" | "unchanged" | "failed"> => {
|
|
this.skillInstalls += 1;
|
|
return Promise.resolve(this.skill);
|
|
};
|
|
doctor = (scope?: DoctorScope): Promise<DoctorReport> => {
|
|
this.doctorCalls += 1;
|
|
this.doctorScopes.push(scope);
|
|
const checks: DoctorCheck[] = [
|
|
...(this.linuxDemoRuntimeMissing
|
|
? ([
|
|
{
|
|
name: "hopper-demo-runtime",
|
|
ok: false,
|
|
classification: "missing_dependency",
|
|
},
|
|
] as const)
|
|
: []),
|
|
...(this.unsupportedHopperVersion
|
|
? ([
|
|
{
|
|
name: "hopper-version",
|
|
ok: false,
|
|
classification: "config_drift",
|
|
detail: this.hopper ?? "",
|
|
},
|
|
] as const)
|
|
: []),
|
|
];
|
|
const environmentHealthy =
|
|
this.doctorHealthy ??
|
|
(this.hopper !== undefined &&
|
|
!this.linuxDemoRuntimeMissing &&
|
|
!this.unsupportedHopperVersion);
|
|
const healthy =
|
|
scope === undefined
|
|
? environmentHealthy
|
|
: (this.scopedDoctorHealthy ?? environmentHealthy);
|
|
return Promise.resolve({
|
|
healthy,
|
|
environment_healthy: environmentHealthy,
|
|
scope: {
|
|
mode: scope === undefined ? "audit-wide" : "explicit",
|
|
clients: scope?.clients ?? [],
|
|
providers: scope?.providers ?? [],
|
|
skill: scope?.skill === true,
|
|
target: null,
|
|
},
|
|
scope_checks: checks,
|
|
informational_checks: [],
|
|
...(this.hopper === undefined ? {} : { hopperPath: this.hopper }),
|
|
checks,
|
|
});
|
|
};
|
|
}
|
|
|
|
const options = (approved: boolean, installHopper = false): SetupOptions => ({
|
|
approved,
|
|
installHopper,
|
|
structured: true,
|
|
});
|
|
|
|
describe("setup workflow", () => {
|
|
it("propagates only the explicit non-secret investigation root policy", () => {
|
|
expect(
|
|
registrationPermissionEnvironment({
|
|
REA_INVESTIGATION_INPUT_ROOTS_JSON: '["/approved/apps"]',
|
|
AUTHORIZATION: "secret",
|
|
}),
|
|
).toEqual({
|
|
REA_INVESTIGATION_INPUT_ROOTS_JSON: '["/approved/apps"]',
|
|
});
|
|
expect(registrationPermissionEnvironment({})).toEqual({});
|
|
});
|
|
|
|
it("returns a complete plan without mutation", async () => {
|
|
const host = new FakeSetupHost();
|
|
host.clients = [{ name: "cursor", configPath: "/cursor.json" }];
|
|
const result = await runSetup(options(false), host);
|
|
expect(result.status).toBe("needs_confirmation");
|
|
expect(result.plannedActions.map(({ kind }) => kind)).toEqual([
|
|
"install_hopper",
|
|
"configure_client",
|
|
"install_skill",
|
|
]);
|
|
expect(host.hopperInstalls).toBe(0);
|
|
expect(host.configurations).toBe(0);
|
|
expect(result.remediation).toBe(
|
|
"Review the setup plan, then rerun interactively or with --yes.",
|
|
);
|
|
});
|
|
|
|
it("reports ready after an accepted Linux plan installs healthy Hopper", async () => {
|
|
const host = new FakeSetupHost("linux");
|
|
host.distribution = {
|
|
id: "ubuntu",
|
|
versionId: "24.04",
|
|
packageFamily: "deb",
|
|
supported: true,
|
|
};
|
|
const result = await runSetup(
|
|
{ ...options(false), structured: false },
|
|
host,
|
|
() => Promise.resolve(true),
|
|
);
|
|
expect(result.status).toBe("ready");
|
|
expect(result.appliedActions).toEqual([
|
|
"installed_hopper",
|
|
"installed_skill",
|
|
]);
|
|
expect(host.hopperInstalls).toBe(1);
|
|
expect(result.remediation).toBeUndefined();
|
|
});
|
|
|
|
it("requires first-run activation after installing Hopper on macOS", async () => {
|
|
const host = new FakeSetupHost();
|
|
const result = await runSetup(
|
|
{ ...options(false), structured: false },
|
|
host,
|
|
() => Promise.resolve(true),
|
|
);
|
|
expect(result.status).toBe("needs_human");
|
|
expect(result.remediation).toContain("choose its demo mode");
|
|
});
|
|
|
|
it("declines an interactive plan without mutation", async () => {
|
|
const host = new FakeSetupHost();
|
|
const result = await runSetup(
|
|
{ ...options(false), structured: false },
|
|
host,
|
|
() => Promise.resolve(false),
|
|
);
|
|
expect(result.status).toBe("planned");
|
|
expect(result.appliedActions).toEqual([]);
|
|
expect(host.hopperInstalls).toBe(0);
|
|
expect(host.doctorCalls).toBe(1);
|
|
});
|
|
|
|
it("treats an empty interactive selection as cancellation", async () => {
|
|
const host = new FakeSetupHost();
|
|
host.clients = [{ name: "codex", configPath: "/codex.toml" }];
|
|
|
|
const result = await runSetup(
|
|
{ ...options(false), structured: false },
|
|
host,
|
|
() => Promise.resolve({ approved: false, selectedActionIds: [] }),
|
|
);
|
|
|
|
expect(result.status).toBe("planned");
|
|
expect(result.plannedActions).toEqual([]);
|
|
expect(result.appliedActions).toEqual([]);
|
|
expect(host.configurations).toBe(0);
|
|
expect(host.skillInstalls).toBe(0);
|
|
expect(host.hopperInstalls).toBe(0);
|
|
expect(host.doctorCalls).toBe(1);
|
|
});
|
|
|
|
it("requires the Hopper flag for unattended setup", async () => {
|
|
const host = new FakeSetupHost();
|
|
const result = await runSetup(options(true), host);
|
|
expect(result.status).toBe("needs_human");
|
|
expect(host.hopperInstalls).toBe(0);
|
|
expect(result.appliedActions).toEqual(["installed_skill"]);
|
|
expect(result.remediation).toContain("--install-hopper");
|
|
expect(result.remediation).toBe(
|
|
"Hopper is optional for non-Hopper providers. Rerun with --yes --install-hopper for deep native analysis.",
|
|
);
|
|
});
|
|
|
|
it("installs Hopper when unattended authorization is explicit", async () => {
|
|
const host = new FakeSetupHost("linux");
|
|
host.distribution = {
|
|
id: "ubuntu",
|
|
versionId: "24.04",
|
|
packageFamily: "deb",
|
|
supported: true,
|
|
};
|
|
const result = await runSetup(options(true, true), host);
|
|
expect(result.appliedActions).toEqual([
|
|
"installed_hopper",
|
|
"installed_skill",
|
|
]);
|
|
expect(host.hopperInstalls).toBe(1);
|
|
expect(result.status).toBe("ready");
|
|
expect(result.remediation).toBeUndefined();
|
|
});
|
|
|
|
it("reuses existing Hopper and configures detected clients", async () => {
|
|
const host = new FakeSetupHost();
|
|
host.hopper = "/Applications/Hopper";
|
|
host.clients = [
|
|
{ name: "claude", configPath: "/claude.json" },
|
|
{ name: "cursor", configPath: "/cursor.json" },
|
|
];
|
|
const result = await runSetup(options(true), host);
|
|
expect(result.status).toBe("ready");
|
|
expect(result.appliedActions).toEqual([
|
|
"configured_claude",
|
|
"configured_cursor",
|
|
"installed_skill",
|
|
]);
|
|
expect(host.hopperInstalls).toBe(0);
|
|
});
|
|
|
|
it("reports append-only lifecycle progress for each selected action", async () => {
|
|
const host = new FakeSetupHost();
|
|
host.hopper = "/Applications/Hopper";
|
|
host.clients = [
|
|
{
|
|
name: "codex",
|
|
displayName: "Codex",
|
|
configPath: "/codex.toml",
|
|
},
|
|
];
|
|
const progress: SetupProgressEvent[] = [];
|
|
await runSetup(
|
|
{ ...options(true), onProgress: (event) => progress.push(event) },
|
|
host,
|
|
);
|
|
expect(progress).toEqual([
|
|
{ actionId: "configure_client:codex", label: "Codex", state: "started" },
|
|
{
|
|
actionId: "configure_client:codex",
|
|
label: "Codex",
|
|
state: "completed",
|
|
},
|
|
{
|
|
actionId: "install_skill",
|
|
label: "REA reverse-engineering skill",
|
|
state: "started",
|
|
},
|
|
{
|
|
actionId: "install_skill",
|
|
label: "REA reverse-engineering skill",
|
|
state: "completed",
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("plans and propagates BYO Ghidra without installing software", async () => {
|
|
const host = new FakeSetupHost("linux");
|
|
host.distribution = {
|
|
id: "ubuntu",
|
|
versionId: "24.04",
|
|
packageFamily: "deb",
|
|
supported: true,
|
|
};
|
|
host.ghidra = "/opt/ghidra_12.1.2_PUBLIC";
|
|
host.javaHome = "/usr/lib/jvm/jdk-21";
|
|
host.doctorHealthy = true;
|
|
host.skill = "unchanged";
|
|
host.clients = [{ name: "cursor", configPath: "/cursor.json" }];
|
|
|
|
const plan = await runSetup(options(false), host);
|
|
expect(plan.plannedActions.map(({ kind }) => kind)).toEqual([
|
|
"configure_client",
|
|
]);
|
|
expect(plan.plannedActions[0]?.detail).toContain(
|
|
"GHIDRA_INSTALL_DIR=/opt/ghidra_12.1.2_PUBLIC",
|
|
);
|
|
expect(plan.plannedActions[0]?.detail).toContain(
|
|
"JAVA_HOME=/usr/lib/jvm/jdk-21",
|
|
);
|
|
expect(host.hopperInstalls).toBe(0);
|
|
expect(host.configurations).toBe(0);
|
|
|
|
const applied = await runSetup(options(true), host);
|
|
expect(applied.status).toBe("ready");
|
|
expect(host.hopperInstalls).toBe(0);
|
|
expect(host.configuredProviderEnvironments).toContainEqual({
|
|
GHIDRA_INSTALL_DIR: "/opt/ghidra_12.1.2_PUBLIC",
|
|
JAVA_HOME: "/usr/lib/jvm/jdk-21",
|
|
});
|
|
});
|
|
|
|
it("reinstalls existing Hopper when explicitly requested", async () => {
|
|
const host = new FakeSetupHost();
|
|
host.hopper = "/Applications/Hopper";
|
|
host.skill = "unchanged";
|
|
host.clients = [{ name: "codex", configPath: "/codex.toml" }];
|
|
host.clientResults.set("codex", { status: "unchanged" });
|
|
|
|
const result = await runSetup(options(true, true), host);
|
|
|
|
expect(result.plannedActions.map(({ kind }) => kind)).toEqual([
|
|
"install_hopper",
|
|
"configure_client",
|
|
]);
|
|
expect(result.appliedActions).toEqual(["installed_hopper"]);
|
|
expect(host.hopperInstalls).toBe(1);
|
|
expect(host.hopperReplaceRequests).toEqual([true]);
|
|
expect(host.configurations).toBe(0);
|
|
expect(host.checkedHopperPaths).toEqual(["/manual/Hopper"]);
|
|
});
|
|
|
|
it("omits an aligned managed skill from an otherwise empty plan", async () => {
|
|
const host = new FakeSetupHost();
|
|
host.hopper = "/Applications/Hopper";
|
|
host.skill = "unchanged";
|
|
const result = await runSetup(options(false), host);
|
|
expect(result.status).toBe("ready");
|
|
expect(result.plannedActions).toEqual([]);
|
|
expect(result.appliedActions).toEqual([]);
|
|
expect(host.skillInstalls).toBe(0);
|
|
});
|
|
|
|
it("installs missing Linux demo dependencies for existing Hopper", async () => {
|
|
const host = new FakeSetupHost("linux");
|
|
host.distribution = {
|
|
id: "ubuntu",
|
|
versionId: "24.04",
|
|
packageFamily: "deb",
|
|
supported: true,
|
|
};
|
|
host.hopper = "/opt/hopper/bin/Hopper";
|
|
host.linuxDemoRuntimeMissing = true;
|
|
const result = await runSetup(
|
|
{ ...options(false), structured: false },
|
|
host,
|
|
() => Promise.resolve(true),
|
|
);
|
|
expect(result.status).toBe("ready");
|
|
expect(host.hopperInstalls).toBe(1);
|
|
expect(result.plannedActions.map(({ kind }) => kind)).toContain(
|
|
"install_hopper",
|
|
);
|
|
});
|
|
|
|
it("does not reinstall Hopper for an unsupported configured path", async () => {
|
|
const host = new FakeSetupHost("linux");
|
|
host.distribution = {
|
|
id: "ubuntu",
|
|
versionId: "24.04",
|
|
packageFamily: "deb",
|
|
supported: true,
|
|
};
|
|
host.hopper = "/custom/Hopper";
|
|
host.unsupportedHopperVersion = true;
|
|
const result = await runSetup(
|
|
{ ...options(false), structured: false },
|
|
host,
|
|
() => Promise.resolve(true),
|
|
);
|
|
expect(result.status).toBe("needs_human");
|
|
expect(host.hopperInstalls).toBe(0);
|
|
expect(result.plannedActions.map(({ kind }) => kind)).not.toContain(
|
|
"install_hopper",
|
|
);
|
|
});
|
|
|
|
it("reports a failed Hopper installation without configuring clients", async () => {
|
|
const host = new FakeSetupHost();
|
|
host.hopperInstallSucceeds = false;
|
|
host.clients = [{ name: "cursor", configPath: "/cursor.json" }];
|
|
const result = await runSetup(options(true, true), host);
|
|
expect(result.status).toBe("needs_human");
|
|
expect(result.code).toBe("download_failed");
|
|
expect(result.remediation).toBe("Download failed.");
|
|
expect(host.configurations).toBe(0);
|
|
});
|
|
|
|
it("blocks malformed client configuration before Hopper or file mutation", async () => {
|
|
const host = new FakeSetupHost();
|
|
host.clients = [
|
|
{ name: "codex", displayName: "Codex", configPath: "/codex.toml" },
|
|
];
|
|
host.clientInspections.set("codex", {
|
|
status: "invalid",
|
|
remediation: "Repair the existing TOML.",
|
|
});
|
|
|
|
const result = await runSetup(options(true, true), host);
|
|
|
|
expect(result.status).toBe("needs_human");
|
|
expect(result.remediation).toBe("Codex: Repair the existing TOML.");
|
|
expect(host.hopperInstalls).toBe(0);
|
|
expect(host.configurations).toBe(0);
|
|
expect(host.skillInstalls).toBe(0);
|
|
});
|
|
|
|
it("blocks malformed configuration before unrelated writes with an existing provider", async () => {
|
|
const host = new FakeSetupHost();
|
|
host.hopper = "/Applications/Hopper";
|
|
host.clients = [
|
|
{
|
|
name: "claude_desktop",
|
|
displayName: "Claude Desktop",
|
|
configPath: "/claude.json",
|
|
},
|
|
{ name: "cursor", displayName: "Cursor", configPath: "/cursor.json" },
|
|
];
|
|
host.clientInspections.set("claude_desktop", {
|
|
status: "invalid",
|
|
remediation: "Repair the existing JSON.",
|
|
});
|
|
|
|
const result = await runSetup(options(true), host);
|
|
|
|
expect(result.status).toBe("needs_human");
|
|
expect(result.remediation).toBe(
|
|
"Claude Desktop: Repair the existing JSON.",
|
|
);
|
|
expect(host.configurations).toBe(0);
|
|
expect(host.skillInstalls).toBe(0);
|
|
});
|
|
|
|
it("plans a client update when Hopper installation changes its desired environment", async () => {
|
|
const host = new FakeSetupHost();
|
|
host.clients = [
|
|
{ name: "codex", displayName: "Codex", configPath: "/codex.toml" },
|
|
];
|
|
host.clientInspections.set("codex", { status: "already_current" });
|
|
|
|
const result = await runSetup(options(false), host);
|
|
|
|
expect(
|
|
result.plannedActions.map(({ id, operation }) => ({
|
|
id,
|
|
operation,
|
|
})),
|
|
).toContainEqual({
|
|
id: "configure_client:codex",
|
|
operation: "update",
|
|
});
|
|
expect(host.configurations).toBe(0);
|
|
});
|
|
|
|
it("executes only actions selected by the interactive adapter", async () => {
|
|
const host = new FakeSetupHost();
|
|
host.hopper = "/Applications/Hopper";
|
|
host.clients = [
|
|
{ name: "codex", displayName: "Codex", configPath: "/codex.toml" },
|
|
{ name: "cursor", displayName: "Cursor", configPath: "/cursor.json" },
|
|
];
|
|
|
|
const result = await runSetup(
|
|
{ ...options(false), structured: false },
|
|
host,
|
|
() =>
|
|
Promise.resolve({
|
|
approved: true,
|
|
selectedActionIds: ["configure_client:codex"],
|
|
}),
|
|
);
|
|
|
|
expect(result.plannedActions.map(({ id }) => id)).toEqual([
|
|
"configure_client:codex",
|
|
]);
|
|
expect(result.clients).toEqual({ codex: { status: "configured" } });
|
|
expect(host.configurations).toBe(1);
|
|
expect(host.skillInstalls).toBe(0);
|
|
});
|
|
|
|
it.each([
|
|
[
|
|
"path",
|
|
"Agent configuration path could not be safely verified. Check its permissions and, if it is a symbolic link, verify that the link resolves to a regular file owned by the current user, then rerun setup.",
|
|
],
|
|
[
|
|
"backup",
|
|
"Agent configuration could not be backed up, so no change was made. Check file permissions, then rerun setup.",
|
|
],
|
|
[
|
|
"write",
|
|
"Agent configuration could not be updated. Check file permissions, then rerun setup.",
|
|
],
|
|
[
|
|
"readback",
|
|
"Agent configuration could not be verified after writing. Repair the configuration file or restore its `.rea.backup`, then rerun setup.",
|
|
],
|
|
] as const)("explains %s configuration recovery", async (reason, message) => {
|
|
const host = new FakeSetupHost();
|
|
host.hopper = "/Applications/Hopper";
|
|
host.clients = [{ name: "internal_client_key", configPath: "/agent.json" }];
|
|
host.clientResults.set("internal_client_key", { status: "failed", reason });
|
|
const result = await runSetup(options(true), host);
|
|
expect(result.status).toBe("needs_human");
|
|
expect(result.remediation).toBe(message);
|
|
expect(result.remediation).not.toContain("internal_client_key");
|
|
});
|
|
|
|
it("records every detected client outcome after an earlier failure", async () => {
|
|
const host = new FakeSetupHost();
|
|
host.hopper = "/Applications/Hopper";
|
|
host.clients = [
|
|
{ name: "first", configPath: "/first.json" },
|
|
{ name: "second", configPath: "/second.json" },
|
|
{ name: "third", configPath: "/third.json" },
|
|
];
|
|
host.clientResults.set("first", { status: "failed", reason: "write" });
|
|
host.clientResults.set("second", { status: "configured" });
|
|
host.clientResults.set("third", { status: "failed", reason: "readback" });
|
|
|
|
const result = await runSetup(options(true), host);
|
|
|
|
expect(host.configurations).toBe(3);
|
|
expect(result.clients).toEqual({
|
|
first: { status: "failed", reason: "write" },
|
|
second: { status: "configured" },
|
|
third: { status: "failed", reason: "readback" },
|
|
});
|
|
expect(result.appliedActions).toEqual(["configured_second"]);
|
|
expect(result.status).toBe("needs_human");
|
|
expect(result.remediation).toContain("could not be updated");
|
|
});
|
|
|
|
it("explains skill installation recovery", async () => {
|
|
const host = new FakeSetupHost();
|
|
host.hopper = "/Applications/Hopper";
|
|
host.skill = "failed";
|
|
const result = await runSetup(options(true), host);
|
|
expect(result.status).toBe("needs_human");
|
|
expect(result.remediation).toBe(
|
|
"REA analysis skill could not be installed or verified. Check permissions for `~/.agents/skills`, then rerun setup.",
|
|
);
|
|
});
|
|
|
|
it("delegates remaining unhealthy checks to doctor remediation", async () => {
|
|
const host = new FakeSetupHost();
|
|
host.hopper = "/Applications/Hopper";
|
|
host.doctorHealthy = false;
|
|
const result = await runSetup(options(true), host);
|
|
expect(result.remediation).toBe(
|
|
"Run rea doctor and apply each reported remediation.",
|
|
);
|
|
});
|
|
|
|
it("uses requested readiness scope without hiding environment drift", async () => {
|
|
const host = new FakeSetupHost();
|
|
host.hopper = "/Applications/Hopper";
|
|
host.doctorHealthy = false;
|
|
host.scopedDoctorHealthy = true;
|
|
const readinessScope = {
|
|
clients: ["codex"],
|
|
providers: [] as string[],
|
|
skill: true,
|
|
};
|
|
|
|
const result = await runSetup(
|
|
{
|
|
...options(true),
|
|
proposeHopper: false,
|
|
installSkill: false,
|
|
readinessScope,
|
|
},
|
|
host,
|
|
);
|
|
|
|
expect(result).toMatchObject({
|
|
status: "ready",
|
|
doctor: { healthy: true, environment_healthy: false },
|
|
});
|
|
expect(host.doctorScopes).toEqual(expect.arrayContaining([readinessScope]));
|
|
});
|
|
|
|
it("explains unsupported Node and macOS recovery", async () => {
|
|
const platformHost = new FakeSetupHost("win32");
|
|
expect((await runSetup(options(true), platformHost)).remediation).toBe(
|
|
"REA supports Hopper on macOS and selected 64-bit Linux distributions.",
|
|
);
|
|
|
|
const nodeHost = new FakeSetupHost();
|
|
nodeHost.nodeVersion = "20.0.0";
|
|
expect((await runSetup(options(true), nodeHost)).remediation).toBe(
|
|
"Install Node.js 22.19+ or 24.11+ and rerun setup.",
|
|
);
|
|
|
|
const macHost = new FakeSetupHost();
|
|
macHost.version = "11.7";
|
|
expect((await runSetup(options(true), macHost)).remediation).toBe(
|
|
"Upgrade to macOS 12 or newer.",
|
|
);
|
|
});
|
|
|
|
it("rejects unsupported hosts before mutation", async () => {
|
|
const host = new FakeSetupHost("linux");
|
|
host.distribution = {
|
|
id: "debian",
|
|
versionId: "13",
|
|
packageFamily: "deb",
|
|
supported: false,
|
|
};
|
|
const result = await runSetup(options(true, true), host);
|
|
expect(result.status).toBe("needs_human");
|
|
expect(host.hopperInstalls).toBe(0);
|
|
expect(result.remediation).toBe(
|
|
"Automated Hopper setup supports Ubuntu 24.04+, Fedora 41+, and 64-bit Arch Linux; configure an existing supported provider instead.",
|
|
);
|
|
});
|
|
|
|
it("configures BYO Ghidra on Linux outside Hopper's installer matrix", async () => {
|
|
const host = new FakeSetupHost("linux");
|
|
host.distribution = {
|
|
id: "debian",
|
|
versionId: "13",
|
|
packageFamily: "deb",
|
|
supported: false,
|
|
};
|
|
host.ghidra = "/opt/ghidra_12.1.2_PUBLIC";
|
|
host.javaHome = "/usr/lib/jvm/jdk-21";
|
|
host.doctorHealthy = true;
|
|
host.skill = "unchanged";
|
|
host.clients = [{ name: "codex", configPath: "/codex.toml" }];
|
|
|
|
const result = await runSetup(options(true), host);
|
|
|
|
expect(result.status).toBe("ready");
|
|
expect(host.hopperInstalls).toBe(0);
|
|
expect(host.configuredProviderEnvironments).toContainEqual({
|
|
GHIDRA_INSTALL_DIR: "/opt/ghidra_12.1.2_PUBLIC",
|
|
JAVA_HOME: "/usr/lib/jvm/jdk-21",
|
|
});
|
|
});
|
|
});
|