mirror of
https://github.com/morluto/rea.git
synced 2026-11-09 16:26:36 +00:00
* fix(setup): refuse Hopper on macOS below the release's minimum Hopper setup planning accepted any macOS 12+ host as supported, and the installer never compared the host with the selected release. The current publisher-signed Hopper 6.6.1-demo requires macOS 12.4 (feed deployment_target and the app's LSMinimumSystemVersion), so setup on 12.0-12.3 installed an app the host cannot run. The installer now takes the minimum from the release that owns it: the feed's deployment_target before the package download, and the signed app's LSMinimumSystemVersion (decoded in-process after signature verification) before copying. A host below either fails with the new incompatible_host_version code and an installer detail naming both versions; an incomparable app value is a bundle failure. Planning keeps REA's own macOS 12 floor but reports support as unknown, since the release minimum is read only after approval, and the plan discloses the check. Info.plist decoding is shared with app-bundle resolution. Fixes #8502 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(setup): compare the macOS Hopper plan detail with its owning disclosure The planning test froze an explanatory sentence from the setup plan. The macOS install detail now lives in macHopperInstallDisclosure beside the commands and origins the installer owns, the plan uses it, and the test compares the plan with that constant while keeping its independent support-state assertions. An installer test also pins that the signed app's requirement is read only after both signature checks pass. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(setup): compare macOS Hopper refusal details with their owners The installer's refusal details were template literals inside installMacHopper, so tests copied whole sentences. The installer now formats them through exported macHopperHostVersionDetail and macHopperIncomparableMinimumDetail. Tests compare each diagnostic and the setup remediation with those owners and SetupInstallFailure's recovery, and separately assert the URL, versions, setting name, failure code and request order. The Info.plist type refusal is checked by error type, key and path instead of a sentence fragment. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
817 lines
26 KiB
TypeScript
817 lines
26 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
|
|
import { AnalysisError } from "../domain/analysisErrorBase.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";
|
|
|
|
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", 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", 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),
|
|
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", 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");
|
|
});
|
|
|
|
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", 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", 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", 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", 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", configPath: "/codex.toml" }];
|
|
host.productRegistrations = [
|
|
{
|
|
client: "codex",
|
|
config_path: "/codex.toml",
|
|
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", 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", 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",
|
|
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);
|
|
},
|
|
);
|