import { describe, expect, it } from "vitest"; import { join, resolve } from "node:path"; import { FakeSetupHost, options } from "./Setup.fixture.js"; import { runSetup } from "./Setup.js"; import { setupRegistrationCommand } from "./SetupHost.js"; import type { MsixPackageView } from "./MsixPackageStorage.js"; import type { SetupClient } from "./SupportedClients.js"; // Host joins keep containment checks meaningful on POSIX test hosts too. const PROFILE = String.raw`C:\Users\a`; const ROAMING = join(PROFILE, "AppData", "Roaming"); const LOCAL = join(PROFILE, "AppData", "Local"); const PRIVATE_CONFIG = join( LOCAL, "Packages", "Claude_pzs8sxrjxfjjc", "LocalCache", "Roaming", "Claude", "claude_desktop_config.json", ); const APPDATA_CONFIG = join(ROAMING, "Claude", "claude_desktop_config.json"); /** Claude Desktop as setup resolves it from its MSIX package storage. */ const msixClaudeDesktop = ( processView: MsixPackageView["processView"], ): SetupClient => ({ name: "claude_desktop", displayName: "Claude Desktop", configPath: PRIVATE_CONFIG, quitBeforeSetup: true, msixPackage: { packageFamilyName: "Claude_pzs8sxrjxfjjc", privateConfigPath: PRIVATE_CONFIG, appDataConfigPath: APPDATA_CONFIG, virtualizedRoots: [ROAMING, LOCAL], processView, }, }); describe("setup workflow", () => { 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", hopperSetupSupported: 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", hopperSetupSupported: true, }; host.hopper = "/custom/Hopper"; host.unsupportedHopperVersion = true; const result = await runSetup( { ...options(true), clientIds: [], installSkill: false, proposeHopper: false, readinessScope: { providers: ["hopper"] }, }, host, ); 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), clientIds: [] }, 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), clientIds: ["codex"] }, 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.skill = "unchanged"; 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), clientIds: ["claude_desktop"] }, 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, true), clientIds: ["codex"] }, host, ); expect( result.plannedActions.map(({ id, operation }) => ({ id, operation, })), ).toContainEqual({ id: "configure_client:codex", operation: "update", }); expect(host.configurations).toBe(0); }); }); describe("setup workflow action selection", () => { it("executes only actions selected by the interactive adapter", async () => { const host = new FakeSetupHost(); host.hopper = "/Applications/Hopper"; host.skill = "unchanged"; 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("preflights only selected clients and offers supported clients without markers", async () => { const host = new FakeSetupHost(); host.hopper = "/Applications/Hopper"; host.skill = "unchanged"; host.clients = []; host.availableClients = [ { name: "codex", displayName: "Codex", configPath: "/codex.toml" }, { name: "cursor", displayName: "Cursor", configPath: "/cursor.json" }, ]; host.clientInspections.set("cursor", { status: "invalid", remediation: "Repair the deselected Cursor configuration.", }); const result = await runSetup( { ...options(true), clientIds: ["codex"] }, host, ); expect(result.status).toBe("ready"); expect(result.plannedActions.map(({ id }) => id)).toEqual([ "configure_client:codex", ]); expect(host.configurations).toBe(1); expect(result.clientStates).toMatchObject([ { client: { name: "codex" }, detected: false, status: "missing" }, { client: { name: "cursor" }, detected: false, status: "missing" }, ]); }); it("does not default to a foreign command registered under the rea key", 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: ["unrelated-agent", "serve"], state: "stale", remediation: "Refresh this registration.", }, ]; const result = await runSetup(options(true), host); expect(result.status).toBe("ready"); expect(result.plannedActions).toEqual([]); expect(host.configurations).toBe(0); expect(result.clientStates).toMatchObject([ { client: { name: "codex" }, configured: false }, ]); }); it("uses a Windows-safe executable command for an explicit client", async () => { const host = new FakeSetupHost("win32"); host.scopedDoctorHealthy = true; host.skill = "unchanged"; host.clients = [{ name: "codex", configPath: "C:\\config\\codex.toml" }]; const result = await runSetup( { ...options(true), clientIds: ["codex"], installSkill: false }, host, ); expect(result.status).toBe("ready"); expect(host.configuredCommands).toEqual([ setupRegistrationCommand("win32", undefined), ]); expect(setupRegistrationCommand("win32", undefined)).toEqual([ process.execPath, resolve(process.argv[1] ?? "rea"), "mcp", ]); }); 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), clientIds: ["internal_client_key"] }, host, ); expect(result.status).toBe("needs_human"); expect(result.remediation).toBe(message); expect(result.remediation).not.toContain("internal_client_key"); }); }); describe("MSIX Claude Desktop setup", () => { it("discloses which file MSIX Claude Desktop reads and that it must be quit", async () => { const host = new FakeSetupHost("win32"); host.hopper = join(PROFILE, "Hopper"); host.skill = "unchanged"; host.clients = [msixClaudeDesktop("direct")]; const result = await runSetup( { ...options(false), clientIds: ["claude_desktop"], dryRun: true }, host, ); expect(result.status).toBe("planned"); const [action] = result.plannedActions; expect(action?.target).toBe(PRIVATE_CONFIG); expect(action?.detail).toContain( `Claude Desktop is installed as MSIX package Claude_pzs8sxrjxfjjc, which reads this package-private file instead of ${APPDATA_CONFIG}.`, ); expect(action?.detail).toContain( "Quit Claude Desktop fully before approving; closing its window is not enough.", ); }); it("refuses AppData writes from a process the MSIX package started", async () => { const host = new FakeSetupHost("win32"); host.hopper = join(PROFILE, "Hopper"); host.skill = "unchanged"; const vscode = join(ROAMING, "Code", "User", "mcp.json"); host.clients = [ msixClaudeDesktop("redirected"), { name: "vscode", displayName: "VS Code", configPath: vscode }, { name: "codex", displayName: "Codex", configPath: join(PROFILE, ".codex", "config.toml"), }, ]; const blocked = await runSetup( { ...options(true), clientIds: ["vscode", "codex"] }, host, ); expect(blocked.status).toBe("needs_human"); expect(blocked.remediation).toContain( `Changes to ${vscode} would not reach the files other apps read.`, ); expect(host.configurations).toBe(0); const homeOnly = await runSetup( { ...options(true), clientIds: ["codex"] }, host, ); expect(homeOnly.status).toBe("ready"); expect(host.configurations).toBe(1); }); it("refuses registrations whose launcher only one MSIX package can see", async () => { const host = new FakeSetupHost("win32"); host.hopper = join(PROFILE, "Hopper"); host.registrationCommandBlocker = "Install REA outside the package."; host.clients = [ { name: "codex", displayName: "Codex", configPath: "/codex.toml" }, ]; const blocked = await runSetup( { ...options(true), clientIds: ["codex"] }, host, ); expect(blocked.status).toBe("needs_human"); expect(blocked.remediation).toBe("Install REA outside the package."); expect(host.configurations).toBe(0); expect(host.skillInstalls).toBe(0); const skillOnly = await runSetup( { ...options(true), clientIds: [], installSkill: true }, host, ); expect(skillOnly.status).toBe("ready"); expect(host.skillInstalls).toBe(1); }); });