mirror of
https://github.com/morluto/rea.git
synced 2026-11-13 18:22:42 +00:00
* fix(cli)!: own MCP client registration boundaries * fix(cli): bind registration to native connection facts * test(cli): isolate registration failure and fixture controls * test(cli): distinguish native syntax and launcher alignment * fix(cli): preserve registration selection and cancellation * fix(cli): honor Goose and Zed native settings paths * fix(cli): honor registration output controls * fix(cli): reject disabled Junie global locations
1168 lines
37 KiB
TypeScript
1168 lines
37 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
|
|
import { AnalysisError } from "../domain/analysisErrorBase.js";
|
|
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
|
|
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
|
|
import { ConfigurationError } from "../domain/configurationErrors.js";
|
|
import { SUPPORTED_NODE_VERSION_PROSE } from "../domain/runtimeVersion.js";
|
|
import { FakeSetupHost, options } from "./Setup.fixture.js";
|
|
import { runSetup } from "./Setup.js";
|
|
import { discoverSetupState } from "./SetupPlan.js";
|
|
import { macHopperInstallDisclosure } from "./MacHopper.js";
|
|
import {
|
|
parseLinuxDistribution,
|
|
type LinuxDistribution,
|
|
} from "./LinuxHopper.js";
|
|
import type { SetupProgressEvent } from "./SetupTypes.js";
|
|
import { setupCleanupError, SetupCleanupOwner } from "./SetupCleanupOwner.js";
|
|
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
|
|
import { err, ok } from "../domain/result.js";
|
|
import { SetupSkillInstallError } from "./SetupSkill.js";
|
|
import { supportedClientRegistrationProfile } from "./SupportedClients.js";
|
|
|
|
describe("setup workflow", () => {
|
|
it("rejects Node 25 before applying setup actions", async () => {
|
|
const host = new FakeSetupHost();
|
|
host.nodeVersion = "25.1.0";
|
|
host.clients = [
|
|
{
|
|
name: "codex",
|
|
registrationProfile: supportedClientRegistrationProfile("codex"),
|
|
configPath: "/codex.toml",
|
|
},
|
|
];
|
|
|
|
const result = await runSetup(
|
|
{ ...options(true, true), installSkill: true, clientIds: ["codex"] },
|
|
host,
|
|
);
|
|
|
|
expect(result.status).toBe("needs_human");
|
|
expect(result.appliedActions).toEqual([]);
|
|
expect(host.hopperInstalls).toBe(0);
|
|
expect(host.configurations).toBe(0);
|
|
expect(host.skillInstalls).toBe(0);
|
|
});
|
|
|
|
it("records every detected client outcome after an earlier failure", async () => {
|
|
const host = new FakeSetupHost();
|
|
host.hopper = "/Applications/Hopper";
|
|
host.clients = [
|
|
{
|
|
name: "first",
|
|
// Source-only recovery actors model Cursor JSON registration.
|
|
registrationProfile: supportedClientRegistrationProfile("cursor"),
|
|
configPath: "/first.json",
|
|
},
|
|
{
|
|
name: "second",
|
|
// Source-only recovery actor models Cursor JSON registration.
|
|
registrationProfile: supportedClientRegistrationProfile("cursor"),
|
|
configPath: "/second.json",
|
|
},
|
|
{
|
|
name: "third",
|
|
// Source-only recovery actor models Cursor JSON registration.
|
|
registrationProfile: supportedClientRegistrationProfile("cursor"),
|
|
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),
|
|
clientIds: ["first", "second", "third"],
|
|
installSkill: false,
|
|
},
|
|
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("reports a failed config rollback with its destination and recovery backup", async () => {
|
|
const host = new FakeSetupHost();
|
|
host.hopper = "/Applications/Hopper";
|
|
host.clients = [
|
|
{
|
|
name: "codex",
|
|
registrationProfile: supportedClientRegistrationProfile("codex"),
|
|
configPath: "/fixture/codex.json",
|
|
},
|
|
];
|
|
host.clientResults.set("codex", {
|
|
status: "failed",
|
|
reason: "rollback",
|
|
backupPath: "/fixture/codex.json.rea.backup",
|
|
failure: {
|
|
kind: "restore",
|
|
path: "/fixture/codex.json",
|
|
detail: "permission denied",
|
|
},
|
|
});
|
|
|
|
const result = await runSetup(
|
|
{ ...options(true), clientIds: ["codex"] },
|
|
host,
|
|
);
|
|
|
|
expect(result.clients.codex).toEqual({
|
|
status: "failed",
|
|
reason: "rollback",
|
|
backupPath: "/fixture/codex.json.rea.backup",
|
|
failure: {
|
|
kind: "restore",
|
|
path: "/fixture/codex.json",
|
|
detail: "permission denied",
|
|
},
|
|
});
|
|
expect(result.remediation).toContain("permission denied");
|
|
expect(result.remediation).toContain("/fixture/codex.json.rea.backup");
|
|
});
|
|
});
|
|
|
|
describe("setup recovery and readiness guidance", () => {
|
|
it("explains skill installation recovery", async () => {
|
|
const host = new FakeSetupHost();
|
|
host.hopper = "/Applications/Hopper";
|
|
host.skill = "failed";
|
|
const progress: SetupProgressEvent[] = [];
|
|
const result = await runSetup(
|
|
{
|
|
...options(true),
|
|
installSkill: true,
|
|
clientIds: [],
|
|
onProgress: (event) => {
|
|
if (event.actionId === "install_skill") progress.push(event);
|
|
},
|
|
},
|
|
host,
|
|
);
|
|
expect(result.status).toBe("needs_human");
|
|
expect(result.code).toBe("skill_install_failed");
|
|
expect(result.remediation).toContain("REA skill write failed at");
|
|
expect(result.remediation).toContain("injected skill write failure");
|
|
expect(result.remediation).toContain(
|
|
"Correct the reported skill installation failure and rerun setup.",
|
|
);
|
|
expect(result.remediation).not.toContain("Check permissions");
|
|
expect(progress.at(-1)).toMatchObject({
|
|
state: "failed",
|
|
detail: expect.stringContaining("injected skill write failure"),
|
|
});
|
|
});
|
|
|
|
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), readinessScope: { providers: ["hopper"] } },
|
|
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;
|
|
host.clients = [
|
|
{
|
|
name: "codex",
|
|
registrationProfile: supportedClientRegistrationProfile("codex"),
|
|
configPath: "/codex.toml",
|
|
},
|
|
];
|
|
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]));
|
|
});
|
|
});
|
|
|
|
describe("setup failure cleanup projection", () => {
|
|
it("projects a configuration failure together with failed host cleanup", async () => {
|
|
const configPath = "/fixture/home/vscode/settings.json";
|
|
const primary = new ConfigurationError("Client config could not be read", {
|
|
settings: [
|
|
{
|
|
setting: configPath,
|
|
constraint: "The selected config could not be read.",
|
|
},
|
|
],
|
|
cleanup: {
|
|
reason: "The admitted config handle remains open.",
|
|
resources: [configPath],
|
|
},
|
|
});
|
|
class SetupHostWithCleanupFailure extends FakeSetupHost {
|
|
close = async () =>
|
|
setupCleanupError(undefined, {
|
|
reason: "Setup could not release its client configuration resources.",
|
|
resources: [],
|
|
});
|
|
}
|
|
const host = new SetupHostWithCleanupFailure();
|
|
host.clients = [
|
|
{
|
|
name: "codex",
|
|
registrationProfile: supportedClientRegistrationProfile("codex"),
|
|
configPath,
|
|
},
|
|
];
|
|
host.inspectClientConfiguration = async () => {
|
|
throw primary;
|
|
};
|
|
|
|
const failure = await runSetup(
|
|
{ ...options(true), clientIds: ["codex"] },
|
|
host,
|
|
).then(
|
|
() => undefined,
|
|
(cause: unknown) => cause,
|
|
);
|
|
expect(failure).toBeInstanceOf(AnalysisError);
|
|
if (!(failure instanceof AnalysisError))
|
|
throw new Error("Expected setup to preserve a typed analysis failure");
|
|
|
|
const projected = projectAnalysisError(failure);
|
|
expect(projected).toMatchObject({
|
|
code: "cleanup_incomplete",
|
|
details: {
|
|
cleanup: "incomplete",
|
|
resources: [configPath],
|
|
diagnostics: {
|
|
primary_error: {
|
|
code: "cleanup_incomplete",
|
|
details: { resources: [configPath] },
|
|
},
|
|
cleanup_error: {
|
|
code: "cleanup_incomplete",
|
|
details: { resources: [] },
|
|
},
|
|
},
|
|
},
|
|
});
|
|
expect(projected.details).toMatchObject({
|
|
diagnostics: {
|
|
primary_error: { message: expect.stringContaining("selected config") },
|
|
cleanup_error: {
|
|
details: {
|
|
diagnostics: {
|
|
reason:
|
|
"Setup could not release its client configuration resources.",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
});
|
|
|
|
it.each(["report", "throw"] as const)(
|
|
"reports host-owned cleanup resources alongside the primary's: %s",
|
|
async (outcome) => {
|
|
const configPath = "/fixture/home/vscode/settings.json";
|
|
const retainedPath = "/fixture/home/retained/handle.bin";
|
|
const primary = new ConfigurationError(
|
|
"Client config could not be read",
|
|
{
|
|
settings: [
|
|
{
|
|
setting: configPath,
|
|
constraint: "The selected config could not be read.",
|
|
},
|
|
],
|
|
cleanup: {
|
|
reason: "The admitted config handle remains open.",
|
|
resources: [configPath],
|
|
},
|
|
},
|
|
);
|
|
class HostWithRetainedResource extends FakeSetupHost {
|
|
close = async () => {
|
|
const reason = `Artifact cleanup remains incomplete: ${retainedPath}`;
|
|
if (outcome === "throw")
|
|
throw new ProviderCleanupError("setup", [retainedPath], { reason });
|
|
return setupCleanupError(undefined, {
|
|
reason,
|
|
resources: [retainedPath],
|
|
});
|
|
};
|
|
}
|
|
const host = new HostWithRetainedResource();
|
|
host.clients = [
|
|
{
|
|
name: "codex",
|
|
registrationProfile: supportedClientRegistrationProfile("codex"),
|
|
configPath,
|
|
},
|
|
];
|
|
host.inspectClientConfiguration = async () => {
|
|
throw primary;
|
|
};
|
|
|
|
const failure = await runSetup(
|
|
{ ...options(true), clientIds: ["codex"] },
|
|
host,
|
|
).then(
|
|
() => undefined,
|
|
(cause: unknown) => cause,
|
|
);
|
|
expect(failure).toBeInstanceOf(AnalysisError);
|
|
if (!(failure instanceof AnalysisError))
|
|
throw new Error("Expected setup to preserve a typed analysis failure");
|
|
|
|
const projected = projectAnalysisError(failure);
|
|
// The host still owns a resource the primary failure never saw; the
|
|
// projection must list it, not bury it in a free-text reason.
|
|
expect(projected).toMatchObject({
|
|
code: "cleanup_incomplete",
|
|
details: {
|
|
resources: [configPath, retainedPath],
|
|
diagnostics: {
|
|
primary_error: { details: { resources: [configPath] } },
|
|
cleanup_error: { details: { resources: [retainedPath] } },
|
|
},
|
|
},
|
|
});
|
|
},
|
|
);
|
|
});
|
|
|
|
describe("setup opaque failure cleanup", () => {
|
|
it("retains the typed host cleanup report alongside an opaque primary", async () => {
|
|
const primary = new Error("Controlled setup workflow failure");
|
|
const retainedPath = "/fixture/home/retained/handle.bin";
|
|
class HostWithRetainedResource extends FakeSetupHost {
|
|
close = async () =>
|
|
setupCleanupError(undefined, {
|
|
reason: "Controlled host close failure",
|
|
resources: [retainedPath],
|
|
});
|
|
}
|
|
const host = new HostWithRetainedResource();
|
|
host.initialSetupState = async () => {
|
|
throw primary;
|
|
};
|
|
const failure = await runSetup(options(true), host).then(
|
|
() => undefined,
|
|
(cause: unknown) => cause,
|
|
);
|
|
expect(failure).toBeInstanceOf(AggregateError);
|
|
if (!(failure instanceof AggregateError))
|
|
throw new Error("Expected setup and cleanup failures to remain together");
|
|
expect(failure.cause).toBe(primary);
|
|
expect(failure.errors[0]).toBe(primary);
|
|
const cleanup: unknown = failure.errors[1];
|
|
expect(cleanup).toBeInstanceOf(ProviderCleanupError);
|
|
if (!(cleanup instanceof ProviderCleanupError))
|
|
throw new Error("Expected typed cleanup facts in the aggregate");
|
|
expect(projectAnalysisError(cleanup)).toMatchObject({
|
|
code: "cleanup_incomplete",
|
|
details: { resources: [retainedPath] },
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("setup returned result cleanup", () => {
|
|
it.each([
|
|
{ state: "ready", outcome: "return" },
|
|
{ state: "ready", outcome: "throw" },
|
|
{ state: "needs_human", outcome: "return" },
|
|
{ state: "needs_human", outcome: "throw" },
|
|
] as const)(
|
|
"preserves $state result facts and reports retained resources: $outcome",
|
|
async ({ state, outcome }) => {
|
|
let held = true;
|
|
const cleanup = new SetupCleanupOwner([
|
|
{
|
|
close: async () =>
|
|
held
|
|
? {
|
|
reason: "Controlled host cleanup",
|
|
resources: ["/fixture/retained"],
|
|
}
|
|
: undefined,
|
|
},
|
|
]);
|
|
class HostWithCleanup extends FakeSetupHost {
|
|
close = async () => {
|
|
const failure = await cleanup.close();
|
|
if (outcome === "throw" && failure !== undefined) throw failure;
|
|
return failure;
|
|
};
|
|
}
|
|
const host = new HostWithCleanup();
|
|
const control = new FakeSetupHost();
|
|
for (const selected of [host, control]) {
|
|
selected.hopper = "/Applications/Hopper";
|
|
selected.doctorHealthy = state === "ready";
|
|
selected.scopedDoctorHealthy = state === "ready";
|
|
if (state === "needs_human") selected.nodeVersion = "25.0.0";
|
|
}
|
|
const input = {
|
|
...options(true),
|
|
installSkill: false,
|
|
proposeHopper: false,
|
|
clientIds: [],
|
|
};
|
|
const baseline = await runSetup(input, control);
|
|
expect(baseline.status).toBe(state);
|
|
const result = await runSetup(input, host);
|
|
expect(result).toMatchObject({
|
|
status: "needs_human",
|
|
code: baseline.code ?? "cleanup_failed",
|
|
plannedActions: baseline.plannedActions,
|
|
appliedActions: baseline.appliedActions,
|
|
clients: baseline.clients,
|
|
doctor: baseline.doctor,
|
|
cleanup: {
|
|
reason: "Controlled host cleanup",
|
|
resources: ["/fixture/retained"],
|
|
},
|
|
});
|
|
expect(JSON.parse(JSON.stringify(result))).toMatchObject({
|
|
cleanup: { resources: ["/fixture/retained"] },
|
|
});
|
|
const retainedReport = result.cleanup;
|
|
held = false;
|
|
expect(await host.close()).toBeUndefined();
|
|
expect(result.cleanup).toBeUndefined();
|
|
expect(retainedReport?.resources).toEqual([]);
|
|
expect(JSON.parse(JSON.stringify(result))).not.toHaveProperty("cleanup");
|
|
},
|
|
);
|
|
});
|
|
|
|
describe("setup scoped readiness", () => {
|
|
it("limits Hopper host checks to workflows that request Hopper", async () => {
|
|
const platformHost = new FakeSetupHost("win32");
|
|
platformHost.hopper = "/Applications/Hopper";
|
|
platformHost.skill = "unchanged";
|
|
platformHost.clients = [
|
|
{
|
|
name: "codex",
|
|
registrationProfile: supportedClientRegistrationProfile("codex"),
|
|
configPath: "/codex.toml",
|
|
},
|
|
];
|
|
const windowsAgent = await runSetup(
|
|
{ ...options(true), clientIds: ["codex"] },
|
|
platformHost,
|
|
);
|
|
expect(windowsAgent.status).toBe("ready");
|
|
expect(platformHost.configurations).toBe(1);
|
|
|
|
const nodeHost = new FakeSetupHost();
|
|
nodeHost.nodeVersion = "20.0.0";
|
|
expect((await runSetup(options(true), nodeHost)).remediation).toBe(
|
|
`Install ${SUPPORTED_NODE_VERSION_PROSE} and rerun setup.`,
|
|
);
|
|
|
|
const macHost = new FakeSetupHost();
|
|
macHost.version = "11.7";
|
|
expect((await runSetup(options(true), macHost)).status).toBe("ready");
|
|
expect((await runSetup(options(true, true), macHost)).remediation).toBe(
|
|
"Upgrade to macOS 12 or newer.",
|
|
);
|
|
});
|
|
|
|
it("keeps an unchanged selected client in final readiness checks", async () => {
|
|
const host = new FakeSetupHost();
|
|
host.hopper = "/Applications/Hopper";
|
|
host.skill = "unchanged";
|
|
host.clients = [
|
|
{
|
|
name: "codex",
|
|
registrationProfile: supportedClientRegistrationProfile("codex"),
|
|
configPath: "/codex.toml",
|
|
},
|
|
];
|
|
host.productRegistrations = [
|
|
{
|
|
client: "codex",
|
|
config_path: "/codex.toml",
|
|
command_identity: "owned",
|
|
command: ["rea", "mcp"],
|
|
state: "aligned",
|
|
remediation: null,
|
|
},
|
|
];
|
|
host.clientInspections.set("codex", { status: "already_current" });
|
|
host.scopedDoctorHealthy = true;
|
|
|
|
const result = await runSetup(options(true), host);
|
|
|
|
expect(result.status).toBe("ready");
|
|
expect(result.plannedActions).toEqual([]);
|
|
expect(host.doctorScopes.at(-1)).toEqual({
|
|
clients: ["codex"],
|
|
providers: [],
|
|
skill: true,
|
|
skillClients: ["codex"],
|
|
});
|
|
});
|
|
|
|
it("does not advertise an unhealthy Hopper in the selected readiness summary", async () => {
|
|
const host = new FakeSetupHost("win32");
|
|
host.hopper = "C:\\Hopper\\Hopper.exe";
|
|
host.unsupportedHopperVersion = true;
|
|
host.clients = [
|
|
{
|
|
name: "codex",
|
|
registrationProfile: supportedClientRegistrationProfile("codex"),
|
|
configPath: "C:\\codex.toml",
|
|
},
|
|
];
|
|
host.scopedDoctorHealthy = true;
|
|
|
|
const result = await runSetup(
|
|
{
|
|
...options(true),
|
|
clientIds: ["codex"],
|
|
installSkill: false,
|
|
},
|
|
host,
|
|
);
|
|
|
|
expect(result.status).toBe("ready");
|
|
expect(result.doctor.availableProviders).toEqual([]);
|
|
});
|
|
|
|
it("rejects unsupported hosts before mutation", async () => {
|
|
const host = new FakeSetupHost("linux");
|
|
host.distribution = {
|
|
id: "debian",
|
|
versionId: "13",
|
|
packageFamily: "deb",
|
|
hopperSetupSupported: 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 requires an x86_64 host running Ubuntu 24.04+, Fedora 41+, Nobara 44+, Arch Linux, CachyOS, or Omarchy; 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",
|
|
hopperSetupSupported: false,
|
|
};
|
|
host.ghidra = "/opt/ghidra_12.1.4_PUBLIC";
|
|
host.javaHome = "/usr/lib/jvm/jdk-21";
|
|
host.doctorHealthy = true;
|
|
host.skill = "unchanged";
|
|
host.clients = [
|
|
{
|
|
name: "codex",
|
|
registrationProfile: supportedClientRegistrationProfile("codex"),
|
|
configPath: "/codex.toml",
|
|
},
|
|
];
|
|
|
|
const result = await runSetup(
|
|
{ ...options(true), clientIds: ["codex"] },
|
|
host,
|
|
);
|
|
|
|
expect(result.status).toBe("ready");
|
|
expect(host.hopperInstalls).toBe(0);
|
|
expect(host.configuredProviderEnvironments).toContainEqual({
|
|
GHIDRA_INSTALL_DIR: "/opt/ghidra_12.1.4_PUBLIC",
|
|
JAVA_HOME: "/usr/lib/jvm/jdk-21",
|
|
});
|
|
});
|
|
});
|
|
|
|
const unsupportedDebian = {
|
|
id: "debian",
|
|
versionId: "13",
|
|
packageFamily: "deb",
|
|
hopperSetupSupported: false,
|
|
} as const;
|
|
const supportedUbuntu = {
|
|
id: "ubuntu",
|
|
versionId: "24.04",
|
|
packageFamily: "deb",
|
|
hopperSetupSupported: true,
|
|
} as const;
|
|
const unsupportedLinux =
|
|
"Automated Hopper setup requires an x86_64 host running Ubuntu 24.04+, Fedora 41+, Nobara 44+, Arch Linux, CachyOS, or Omarchy; configure an existing supported provider instead.";
|
|
const unhealthyManualClientHost = (
|
|
platform: NodeJS.Platform,
|
|
facts: {
|
|
readonly version?: string | undefined;
|
|
readonly distribution?: LinuxDistribution;
|
|
},
|
|
) => {
|
|
const host = new FakeSetupHost(platform);
|
|
host.version = facts.version;
|
|
host.distribution = facts.distribution;
|
|
host.doctorHealthy = false;
|
|
host.clients = [
|
|
{
|
|
name: "grok_bot",
|
|
registrationProfile: supportedClientRegistrationProfile("grok_bot"),
|
|
displayName: "Grok Bot",
|
|
configPath: "/fixture/home/.grokbot",
|
|
format: "unsupported",
|
|
},
|
|
];
|
|
return host;
|
|
};
|
|
const manualClientSetup = {
|
|
...options(true),
|
|
clientIds: ["grok_bot"],
|
|
installSkill: false,
|
|
};
|
|
const proposesHopper = async (host: FakeSetupHost) =>
|
|
(
|
|
await runSetup(
|
|
{
|
|
...options(false),
|
|
proposeHopper: true,
|
|
dryRun: true,
|
|
clientIds: [],
|
|
installSkill: false,
|
|
},
|
|
host,
|
|
)
|
|
).plannedActions.some(({ kind }) => kind === "install_hopper");
|
|
|
|
describe("setup Hopper install eligibility", () => {
|
|
it.each([
|
|
{
|
|
label: "Windows",
|
|
platform: "win32",
|
|
refusal:
|
|
"REA supports Hopper on macOS and selected x86_64 Linux distributions.",
|
|
},
|
|
{
|
|
label: "macOS 11",
|
|
platform: "darwin",
|
|
version: "11.7",
|
|
refusal: "Upgrade to macOS 12 or newer.",
|
|
},
|
|
{
|
|
label: "macOS with an unknown version",
|
|
platform: "darwin",
|
|
version: undefined,
|
|
refusal: "Upgrade to macOS 12 or newer.",
|
|
},
|
|
{
|
|
label: "an unsupported Linux distribution",
|
|
platform: "linux",
|
|
distribution: unsupportedDebian,
|
|
refusal: unsupportedLinux,
|
|
},
|
|
{
|
|
label: "Ubuntu on arm64",
|
|
platform: "linux",
|
|
distribution: parseLinuxDistribution(
|
|
'ID=ubuntu\nVERSION_ID="24.04"',
|
|
"arm64",
|
|
),
|
|
refusal: unsupportedLinux,
|
|
},
|
|
{
|
|
label: "an unidentified Linux distribution",
|
|
platform: "linux",
|
|
refusal: unsupportedLinux,
|
|
},
|
|
] as const)(
|
|
"neither offers nor accepts an unsupported Hopper installation on $label",
|
|
async ({ platform, refusal, ...facts }) => {
|
|
const host = unhealthyManualClientHost(platform, facts);
|
|
|
|
const advised = await runSetup(manualClientSetup, host);
|
|
expect(advised.status).toBe("needs_human");
|
|
expect(advised.remediation).not.toContain("--install-hopper");
|
|
expect(advised.remediation).toContain(
|
|
"Run rea doctor and apply each reported remediation.",
|
|
);
|
|
expect(advised.remediation).toContain("hosted computer");
|
|
expect(await proposesHopper(host)).toBe(false);
|
|
|
|
const requested = await runSetup(
|
|
{ ...manualClientSetup, installHopper: true },
|
|
host,
|
|
);
|
|
expect(requested).toMatchObject({
|
|
status: "needs_human",
|
|
remediation: refusal,
|
|
appliedActions: [],
|
|
});
|
|
expect(host.hopperInstalls).toBe(0);
|
|
},
|
|
);
|
|
|
|
it.each([
|
|
{ label: "macOS 14", platform: "darwin", version: "14.5" },
|
|
{
|
|
label: "a supported Linux distribution",
|
|
platform: "linux",
|
|
distribution: supportedUbuntu,
|
|
},
|
|
] as const)(
|
|
"offers a Hopper installation that succeeds on $label",
|
|
async ({ platform, ...facts }) => {
|
|
const host = unhealthyManualClientHost(platform, facts);
|
|
|
|
const advised = await runSetup(manualClientSetup, host);
|
|
expect(advised.status).toBe("needs_human");
|
|
expect(advised.remediation).toContain(
|
|
"Rerun with --yes --install-hopper",
|
|
);
|
|
expect(advised.remediation).toContain("hosted computer");
|
|
expect(await proposesHopper(host)).toBe(true);
|
|
|
|
const followed = await runSetup(
|
|
{ ...manualClientSetup, installHopper: true },
|
|
host,
|
|
);
|
|
expect(followed.appliedActions).toContain("installed_hopper");
|
|
expect(host.hopperInstalls).toBe(1);
|
|
},
|
|
);
|
|
|
|
it.each([
|
|
{ version: "", installs: false },
|
|
{ version: "14beta", installs: false },
|
|
{ version: "14.garbage", installs: false },
|
|
{ version: "14.", installs: false },
|
|
{ version: "14.0\n", installs: false },
|
|
{ version: "99999999999999999", installs: false },
|
|
// REA's own floor admits these; the selected release's minimum is
|
|
// compared by the installer, which this recording host replaces.
|
|
{ version: "12", installs: true },
|
|
{ version: "12.0", installs: true },
|
|
])(
|
|
"decides an explicit Hopper installation for macOS %j",
|
|
async ({ version, installs }) => {
|
|
const host = new FakeSetupHost("darwin");
|
|
host.version = version;
|
|
|
|
const result = await runSetup(options(true, true), host);
|
|
|
|
expect(result.appliedActions.includes("installed_hopper")).toBe(installs);
|
|
expect(host.hopperInstalls).toBe(installs ? 1 : 0);
|
|
if (!installs)
|
|
expect(result).toMatchObject({
|
|
status: "needs_human",
|
|
remediation: "Upgrade to macOS 12 or newer.",
|
|
});
|
|
},
|
|
);
|
|
|
|
it.each(["12.0", "12.3", "12.4", "14.5"])(
|
|
"leaves Hopper support on macOS %s unknown until the release minimum is read",
|
|
async (version) => {
|
|
const host = new FakeSetupHost("darwin");
|
|
host.version = version;
|
|
|
|
const discovery = await discoverSetupState({
|
|
host,
|
|
providerConfigured: false,
|
|
forceHopperInstall: false,
|
|
proposeHopper: true,
|
|
initialDoctor: await host.doctor(),
|
|
});
|
|
expect(discovery).toMatchObject({
|
|
hopperInstall: { supported: "unknown" },
|
|
installHopper: true,
|
|
});
|
|
const plan = await runSetup(
|
|
{ ...options(false, true), dryRun: true },
|
|
host,
|
|
);
|
|
// The installer owns the disclosed check; its refusal is tested there.
|
|
expect(
|
|
plan.plannedActions.find(({ kind }) => kind === "install_hopper")
|
|
?.detail,
|
|
).toBe(macHopperInstallDisclosure.detail);
|
|
},
|
|
);
|
|
});
|
|
|
|
it("leaves ownerless caller-host failures unconfirmed after an unobserved direct retry", async () => {
|
|
const failure = setupCleanupError(undefined, {
|
|
reason: "Unknown cleanup authority",
|
|
resources: ["/fixture/unconfirmed"],
|
|
});
|
|
let held = true;
|
|
class Host extends FakeSetupHost {
|
|
close = async () => {
|
|
if (held) throw failure;
|
|
return undefined;
|
|
};
|
|
}
|
|
const host = new Host();
|
|
const result = await runSetup(options(true), host);
|
|
const report = result.cleanup;
|
|
held = false;
|
|
expect(await host.close()).toBeUndefined();
|
|
expect(failure.hasCleanupOwner).toBe(false);
|
|
expect(failure.cleanupIncomplete).toBe(true);
|
|
expect(report?.resources).toEqual(["/fixture/unconfirmed"]);
|
|
expect(result.cleanup).toBeDefined();
|
|
});
|
|
|
|
it.each(["return", "throw"] as const)(
|
|
"does not report already confirmed host cleanup: %s",
|
|
async (outcome) => {
|
|
let held = true;
|
|
const cleanup = new SetupCleanupOwner([
|
|
{ close: async () => (held ? "held" : undefined) },
|
|
]);
|
|
const resolved = await cleanup.close();
|
|
held = false;
|
|
await cleanup.close();
|
|
class Host extends FakeSetupHost {
|
|
close = async () => {
|
|
if (outcome === "throw") throw resolved;
|
|
return resolved;
|
|
};
|
|
}
|
|
const input = options(true);
|
|
const control = await runSetup(input, new FakeSetupHost());
|
|
expect(await runSetup(input, new Host())).toEqual(control);
|
|
},
|
|
);
|
|
|
|
it.each(["return", "throw"] as const)(
|
|
"reports ordinary typed host close failures consistently: %s",
|
|
async (outcome) => {
|
|
const failure = new ConfigurationError(
|
|
"Controlled close configuration failure",
|
|
);
|
|
class Host extends FakeSetupHost {
|
|
close = async () => {
|
|
if (outcome === "throw") throw failure;
|
|
return failure;
|
|
};
|
|
}
|
|
const input = options(true);
|
|
const control = await runSetup(input, new FakeSetupHost());
|
|
const result = await runSetup(input, new Host());
|
|
expect(result).toMatchObject({
|
|
status: "needs_human",
|
|
code: control.code ?? "cleanup_failed",
|
|
cleanup: { resources: [] },
|
|
});
|
|
expect(result.cleanup?.reason).toContain(failure.message);
|
|
expect(failure.cleanupIncomplete).toBe(false);
|
|
expect(failure.hasCleanupOwner).toBe(false);
|
|
},
|
|
);
|
|
|
|
describe("setup cancellation", () => {
|
|
it.each([
|
|
{ status: "configured", backupPath: "/fixture/codex.bak" },
|
|
{
|
|
status: "failed",
|
|
reason: "rollback",
|
|
backupPath: "/fixture/codex.bak",
|
|
failure: {
|
|
kind: "restore",
|
|
path: "/fixture/codex.toml",
|
|
detail: "denied",
|
|
},
|
|
},
|
|
] as const)(
|
|
"preserves completed client results after cancellation: $status",
|
|
async (completed) => {
|
|
const host = new FakeSetupHost();
|
|
host.hopper = "/Applications/Hopper";
|
|
host.clients = [
|
|
{
|
|
name: "codex",
|
|
registrationProfile: supportedClientRegistrationProfile("codex"),
|
|
configPath: "/fixture/codex.toml",
|
|
},
|
|
];
|
|
const controller = new AbortController();
|
|
const reason = new Error("cancel after client write");
|
|
host.configureClient = async () => {
|
|
controller.abort(reason);
|
|
return completed;
|
|
};
|
|
const failure = await runSetup(
|
|
{
|
|
...options(true),
|
|
installSkill: false,
|
|
clientIds: ["codex"],
|
|
signal: controller.signal,
|
|
},
|
|
host,
|
|
).catch((cause: unknown) => cause);
|
|
expect(failure).toBeInstanceOf(AnalysisCancelledError);
|
|
expect(
|
|
projectAnalysisError(failure as AnalysisCancelledError),
|
|
).toMatchObject({
|
|
code: "cancelled",
|
|
details: {
|
|
diagnostics: { setup_progress: { clients: { codex: completed } } },
|
|
},
|
|
});
|
|
if (completed.status === "configured")
|
|
expect((failure as AnalysisCancelledError).diagnostics).toMatchObject({
|
|
setup_progress: { appliedActions: ["configured_codex"] },
|
|
});
|
|
},
|
|
);
|
|
|
|
it.each([true, false])(
|
|
"preserves a completed skill result when cancellation arrives: success %s",
|
|
async (success) => {
|
|
const host = new FakeSetupHost();
|
|
host.hopper = "/Applications/Hopper";
|
|
const controller = new AbortController();
|
|
const reason = new Error("cancel after skill write");
|
|
const skillFailure = new SetupSkillInstallError({
|
|
path: "/fixture/skill",
|
|
stage: "write",
|
|
reason: "denied",
|
|
rollbackFailures: [
|
|
{ path: "/fixture/skill", reason: "restore failed" },
|
|
],
|
|
recoveryBackupPaths: ["/fixture/skill.backup"],
|
|
cause: new Error("denied"),
|
|
});
|
|
host.installSkill = async () => {
|
|
controller.abort(reason);
|
|
return success ? ok("installed") : err(skillFailure);
|
|
};
|
|
const failure = await runSetup(
|
|
{
|
|
...options(true),
|
|
installSkill: true,
|
|
clientIds: [],
|
|
signal: controller.signal,
|
|
},
|
|
host,
|
|
).catch((cause: unknown) => cause);
|
|
expect(failure).toBeInstanceOf(AnalysisCancelledError);
|
|
expect((failure as AnalysisCancelledError).diagnostics).toMatchObject({
|
|
setup_progress: success
|
|
? {
|
|
appliedActions: ["installed_skill"],
|
|
skill: { status: "installed" },
|
|
}
|
|
: {
|
|
skill: {
|
|
status: "failed",
|
|
path: "/fixture/skill",
|
|
reason: "denied",
|
|
rollbackFailures: [{ reason: "restore failed" }],
|
|
recoveryBackupPaths: ["/fixture/skill.backup"],
|
|
},
|
|
},
|
|
});
|
|
},
|
|
);
|
|
});
|
|
|
|
describe("setup cancellation during installation and cleanup", () => {
|
|
it("carries the caller's cancellation into the Hopper install", async () => {
|
|
const host = new FakeSetupHost("darwin");
|
|
host.clients = [
|
|
{
|
|
name: "codex",
|
|
registrationProfile: supportedClientRegistrationProfile("codex"),
|
|
configPath: "/codex.toml",
|
|
},
|
|
];
|
|
const controller = new AbortController();
|
|
const reason = new Error("stop setup");
|
|
let received: AbortSignal | undefined;
|
|
host.installHopper = (_replaceExisting, signal?: AbortSignal) => {
|
|
received = signal;
|
|
controller.abort(reason);
|
|
return Promise.resolve({
|
|
status: "failed",
|
|
code: "setup_cancelled",
|
|
remediation: "Setup was cancelled.",
|
|
});
|
|
};
|
|
|
|
const failure: unknown = await runSetup(
|
|
{
|
|
...options(true, true),
|
|
clientIds: ["codex"],
|
|
signal: controller.signal,
|
|
},
|
|
host,
|
|
).catch((cause: unknown) => cause);
|
|
|
|
expect(received).toBe(controller.signal);
|
|
expect(failure).toBeInstanceOf(AnalysisCancelledError);
|
|
expect(failure).toMatchObject({ cause: reason });
|
|
expect((failure as AnalysisCancelledError).diagnostics).toMatchObject({
|
|
setup_progress: {
|
|
hopperOutcome: { status: "failed", code: "setup_cancelled" },
|
|
},
|
|
});
|
|
expect(host.configurations).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe("setup cancellation during final cleanup", () => {
|
|
it("reports cancellation during final cleanup with the completed setup result", async () => {
|
|
const clients = {
|
|
codex: { status: "configured", backupPath: "/fixture/codex.toml.bak" },
|
|
cursor: { status: "configured", backupPath: "/fixture/cursor.json.bak" },
|
|
} as const;
|
|
const input = {
|
|
...options(true),
|
|
installSkill: false,
|
|
proposeHopper: false,
|
|
clientIds: Object.keys(clients),
|
|
};
|
|
const prepared = () => {
|
|
const host = new FakeSetupHost();
|
|
host.hopper = "/Applications/Hopper";
|
|
host.clients = [
|
|
{
|
|
name: "codex",
|
|
registrationProfile: supportedClientRegistrationProfile("codex"),
|
|
configPath: "/fixture/codex.toml",
|
|
},
|
|
{
|
|
name: "cursor",
|
|
registrationProfile: supportedClientRegistrationProfile("cursor"),
|
|
configPath: "/fixture/cursor.json",
|
|
},
|
|
];
|
|
for (const [name, result] of Object.entries(clients))
|
|
host.clientResults.set(name, result);
|
|
return host;
|
|
};
|
|
const control = await runSetup(input, prepared());
|
|
expect(control).toMatchObject({
|
|
status: "ready",
|
|
appliedActions: ["configured_codex", "configured_cursor"],
|
|
clients,
|
|
doctor: { healthy: true, scope: { clients: ["codex", "cursor"] } },
|
|
});
|
|
const controller = new AbortController();
|
|
const reason = new Error("stop setup");
|
|
const host = Object.assign(prepared(), {
|
|
close: () => {
|
|
controller.abort(reason);
|
|
return Promise.resolve(undefined);
|
|
},
|
|
});
|
|
|
|
const failure: unknown = await runSetup(
|
|
{ ...input, signal: controller.signal },
|
|
host,
|
|
).catch((cause: unknown) => cause);
|
|
|
|
expect(failure).toBeInstanceOf(AnalysisCancelledError);
|
|
if (!(failure instanceof AnalysisCancelledError))
|
|
throw new Error("Expected setup to report its cancellation");
|
|
expect(failure).toMatchObject({
|
|
cause: reason,
|
|
diagnostics: { setup_result: control },
|
|
});
|
|
expect(projectAnalysisError(failure)).toMatchObject({
|
|
code: "cancelled",
|
|
details: {
|
|
diagnostics: { setup_result: { clients, doctor: control.doctor } },
|
|
},
|
|
});
|
|
});
|
|
|
|
it("retains the completed result and cancellation when final cleanup also fails", async () => {
|
|
const controller = new AbortController();
|
|
const reason = new Error("cancel during final cleanup");
|
|
const host = new FakeSetupHost();
|
|
host.hopper = "/Applications/Hopper";
|
|
host.clients = [
|
|
{
|
|
name: "codex",
|
|
registrationProfile: supportedClientRegistrationProfile("codex"),
|
|
configPath: "/fixture/codex.toml",
|
|
},
|
|
];
|
|
host.clientResults.set("codex", {
|
|
status: "configured",
|
|
backupPath: "/fixture/codex.bak",
|
|
});
|
|
Object.assign(host, {
|
|
close: async () => {
|
|
controller.abort(reason);
|
|
return setupCleanupError(undefined, {
|
|
reason: "close failed",
|
|
resources: ["/fixture/handle"],
|
|
});
|
|
},
|
|
});
|
|
const failure = await runSetup(
|
|
{
|
|
...options(true),
|
|
installSkill: false,
|
|
clientIds: ["codex"],
|
|
signal: controller.signal,
|
|
},
|
|
host,
|
|
).catch((cause: unknown) => cause);
|
|
expect(failure).toBeInstanceOf(AnalysisError);
|
|
expect(projectAnalysisError(failure as AnalysisError)).toMatchObject({
|
|
code: "cleanup_incomplete",
|
|
details: {
|
|
resources: ["/fixture/handle"],
|
|
diagnostics: {
|
|
primary_error: {
|
|
code: "cancelled",
|
|
details: {
|
|
diagnostics: {
|
|
setup_result: {
|
|
clients: { codex: { backupPath: "/fixture/codex.bak" } },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
});
|
|
});
|