import { join, resolve } from "node:path"; import { describe, expect, it } from "vitest"; import { err, ok, type Result } from "../domain/result.js"; import { runUpdate, type ReleaseLookupFailure, type UpdateHost, type UpdateInstallation, type UpdateOutput, } from "./Update.js"; import { bunGlobalDirectory, detectBunInstallation, detectNpmInstallation, type BunInstallationHost, type NpmInstallationHost, } from "./UpdateRuntime.js"; import type { IntegrationMaintenance } from "./UpdateMaintenance.js"; import { isUpdateFailure } from "./UpdateFailure.js"; class FakeUpdateHost implements UpdateHost { release: Result = ok("3.3.0"); installationResult: UpdateInstallation | undefined = { manager: "npm", prefix: "/fixture", packageRoot: "/fixture/lib/node_modules/rea-agents", }; installResult: Result = ok(undefined); verified: Result = ok("3.3.0"); maintenance: IntegrationMaintenance = { status: "current", plannedActions: [], }; installedVersions: string[] = []; installationInspected = false; verifiedInstallation: UpdateInstallation | undefined; maintenanceInspected = false; output: UpdateOutput | undefined; cleanupFailure: string | undefined; installation = (): Promise => { this.installationInspected = true; return Promise.resolve(this.installationResult); }; latestVersion = (): Promise> => Promise.resolve(this.release); installVersion = ( _installation: UpdateInstallation, version: string, output: UpdateOutput, ): Promise> => { this.installedVersions.push(version); this.output = output; return Promise.resolve(this.installResult); }; installedVersion = ( installation: UpdateInstallation, ): Promise> => { this.verifiedInstallation = installation; return Promise.resolve(this.verified); }; planMaintenance = (): Promise => { this.maintenanceInspected = true; return Promise.resolve(this.maintenance); }; close = (): Promise => Promise.resolve(this.cleanupFailure); } describe("REA update", () => { it("identifies an already-current installation without mutation", async () => { const host = new FakeUpdateHost(); expect(await runUpdate("3.3.0", host)).toEqual({ status: "current", currentVersion: "3.3.0", latestVersion: "3.3.0", installMethod: "npm", }); expect(host.installationInspected).toBe(true); expect(host.installedVersions).toEqual([]); expect(host.maintenanceInspected).toBe(false); }); it("preserves update status while marking incomplete resource cleanup as failure", async () => { const host = new FakeUpdateHost(); host.cleanupFailure = "Regular file remains open: /fixture/skill/SKILL.md"; const result = await runUpdate("3.3.0", host); expect(result).toMatchObject({ status: "current", currentVersion: "3.3.0", cleanupFailure: host.cleanupFailure, }); expect(isUpdateFailure(result)).toBe(true); }); it("reports an unknown current installation honestly", async () => { const host = new FakeUpdateHost(); host.installationResult = undefined; expect(await runUpdate("3.3.0", host)).toMatchObject({ status: "current", installMethod: "unknown", }); expect(host.installedVersions).toEqual([]); }); it("installs the resolved release and verifies the same package root", async () => { const host = new FakeUpdateHost(); expect(await runUpdate("3.2.1", host, "structured")).toMatchObject({ status: "updated", previousVersion: "3.2.1", latestVersion: "3.3.0", installedVersion: "3.3.0", command: [ "npm", "install", "--global", "--ignore-scripts", "--prefix", "/fixture", "rea-agents@3.3.0", ], }); expect(host.installedVersions).toEqual(["3.3.0"]); expect(host.verifiedInstallation).toEqual(host.installationResult); expect(host.output).toBe("structured"); }); it("updates a Bun global installation with Bun and verifies the same package root", async () => { const host = new FakeUpdateHost(); host.installationResult = { manager: "bun", globalDirectory: "/home/user/.bun/install/global", packageRoot: "/home/user/.bun/install/global/node_modules/rea-agents", }; expect(await runUpdate("3.2.1", host, "structured")).toMatchObject({ status: "updated", installMethod: "bun", installedVersion: "3.3.0", command: [ "bun", "add", "--global", "--ignore-scripts", "rea-agents@3.3.0", ], }); expect(host.verifiedInstallation).toEqual(host.installationResult); expect(await runUpdate("3.3.0", host)).toMatchObject({ status: "current", installMethod: "bun", }); }); it("does not report success or plan maintenance when the installed version differs", async () => { const host = new FakeUpdateHost(); host.verified = ok("3.4.0"); expect(await runUpdate("3.2.1", host)).toMatchObject({ status: "failed", reason: "verification", latestVersion: "3.3.0", installedVersion: "3.4.0", }); expect(host.maintenanceInspected).toBe(false); }); it("preserves executable verification diagnostics after npm succeeded", async () => { const host = new FakeUpdateHost(); host.verified = err("ENOENT /fixture/scripts/rea.mjs"); const result = await runUpdate("3.2.1", host); expect(result).toMatchObject({ status: "failed", reason: "verification" }); expect(result).toHaveProperty( "remediation", expect.stringContaining("ENOENT /fixture/scripts/rea.mjs"), ); expect(host.maintenanceInspected).toBe(false); }); it("returns integration maintenance without applying it", async () => { const host = new FakeUpdateHost(); host.maintenance = { status: "planned", scope: { clients: ["codex"], skill: false }, command: ["rea", "setup", "--client", "codex", "--skill=false"], plannedActions: [ { id: "configure_client:codex", kind: "configure_client", label: "Codex", target: "/home/.codex/config.toml", detail: "Refresh REA", external: false, operation: "update", }, ], }; expect(await runUpdate("3.2.1", host)).toMatchObject({ status: "updated", maintenance: host.maintenance, }); }); it("keeps a verified update successful when maintenance planning fails", async () => { const host = new FakeUpdateHost(); host.maintenance = { status: "unavailable", remediation: "EACCES /home/.codex/config.toml", }; expect(await runUpdate("3.2.1", host)).toMatchObject({ status: "updated", installedVersion: "3.3.0", maintenance: host.maintenance, }); }); }); describe("update failures and version boundaries", () => { it("normalizes accepted release metadata before installing and comparing versions", async () => { const host = new FakeUpdateHost(); host.release = ok("v3.3.0"); expect(await runUpdate("3.2.1", host)).toMatchObject({ status: "updated", latestVersion: "3.3.0", installedVersion: "3.3.0", }); expect(host.installedVersions).toEqual(["3.3.0"]); }); it("preserves registry errors without installation", async () => { const host = new FakeUpdateHost(); host.release = err({ kind: "unavailable", detail: "npm registry returned HTTP 503", }); const result = await runUpdate("3.2.1", host); expect(result).toMatchObject({ status: "failed", reason: "version-check", latestVersion: null, }); expect(result).toHaveProperty( "remediation", expect.stringContaining("HTTP 503"), ); expect(result).toHaveProperty( "remediation", expect.stringContaining("registry access"), ); expect(host.installedVersions).toEqual([]); }); it.each(["latest", "", "3.3", "$(echo bad)"])( "rejects invalid release metadata %s before installation", async (version) => { const host = new FakeUpdateHost(); host.release = ok(version); expect(await runUpdate("3.2.1", host)).toMatchObject({ status: "failed", reason: "version-check", }); expect(host.installedVersions).toEqual([]); }, ); it("describes invalid release metadata without blaming registry access", async () => { const host = new FakeUpdateHost(); host.release = err({ kind: "invalid-metadata", detail: "expected one version string", }); const result = await runUpdate("3.2.1", host); expect(result).toMatchObject({ status: "failed", reason: "version-check", latestVersion: null, }); expect(result).toHaveProperty( "remediation", expect.stringContaining("expected one version string"), ); expect(result).toHaveProperty( "remediation", expect.not.stringContaining("registry access"), ); expect(host.installedVersions).toEqual([]); }); it.each([ ["3.4.0", "3.3.0"], ["3.3.0", "3.3.0-rc.1"], ])("does not downgrade %s to %s", async (currentVersion, release) => { const host = new FakeUpdateHost(); host.release = ok(release); expect(await runUpdate(currentVersion, host)).toMatchObject({ status: "current", }); expect(host.installedVersions).toEqual([]); }); it("updates a prerelease to its final release", async () => { const host = new FakeUpdateHost(); expect(await runUpdate("3.3.0-rc.1", host)).toMatchObject({ status: "updated", installedVersion: "3.3.0", }); }); it("refuses to create a global installation for an unrelated invocation", async () => { const host = new FakeUpdateHost(); host.installationResult = undefined; const result = await runUpdate("3.2.1", host); expect(result).toMatchObject({ status: "failed", reason: "unknown-install-method", }); expect(result).toHaveProperty( "remediation", expect.not.stringContaining("npm install --global"), ); expect(host.installedVersions).toEqual([]); }); it("reports actual npm failures and skips verification", async () => { const host = new FakeUpdateHost(); host.installResult = err("EACCES /fixture/lib/node_modules/rea-agents"); const result = await runUpdate("3.2.1", host); expect(result).toMatchObject({ status: "failed", reason: "install" }); expect(result).toHaveProperty( "remediation", expect.stringContaining("EACCES /fixture/lib/node_modules/rea-agents"), ); expect(host.verifiedInstallation).toBeUndefined(); }); }); describe("npm installation ownership", () => { const host = (root: string, prefix = "/usr/local"): NpmInstallationHost => ({ canonicalPath: (path) => Promise.resolve(path), globalRoot: () => Promise.resolve(root), globalPrefix: () => Promise.resolve(prefix), }); it("identifies the global prefix and package root", async () => { const packageRoot = "/usr/local/lib/node_modules/rea-agents"; await expect( detectNpmInstallation(packageRoot, host("/usr/local/lib/node_modules")), ).resolves.toEqual({ manager: "npm", prefix: "/usr/local", packageRoot }); }); it("retains the curl installer's custom Unix prefix", async () => { const packageRoot = "/home/user/.local/lib/node_modules/rea-agents"; await expect( detectNpmInstallation(packageRoot, host("/usr/local/lib/node_modules")), ).resolves.toEqual({ manager: "npm", prefix: "/home/user/.local", packageRoot, }); }); it.each([ "/work/rea", "/work/node_modules/rea-agents", "/cache/_npx/abc/node_modules/rea-agents", ])("rejects unrelated invocation %s", async (packageRoot) => { await expect( detectNpmInstallation(packageRoot, host("/usr/local/lib/node_modules")), ).resolves.toBeUndefined(); }); it("does not trust an unresolvable installation path", async () => { const failing = { ...host("/usr/local/lib/node_modules"), canonicalPath: (): Promise => Promise.reject(new Error("EACCES")), }; await expect( detectNpmInstallation( "/home/.local/lib/node_modules/rea-agents", failing, ), ).resolves.toBeUndefined(); }); }); describe("Bun installation ownership", () => { const globalDirectory = "/home/user/.bun/install/global"; const packageRoot = `${globalDirectory}/node_modules/rea-agents`; // Fixture paths are POSIX; join() may produce Windows separators on Windows hosts. const host = ( manifests: readonly string[] = [`${globalDirectory}/package.json`], ): BunInstallationHost => ({ canonicalPath: (path) => Promise.resolve(path.replaceAll("\\", "/")), isFile: (path) => Promise.resolve(manifests.includes(path.replaceAll("\\", "/"))), }); it("identifies the package inside Bun's global project", async () => { await expect( detectBunInstallation(packageRoot, globalDirectory, host()), ).resolves.toEqual({ manager: "bun", globalDirectory, packageRoot }); }); it("refuses a global directory without its manifest, which Bun would resolve to an ancestor project", async () => { await expect( detectBunInstallation(packageRoot, globalDirectory, host([])), ).resolves.toBeUndefined(); }); it.each([ "/home/user/project/node_modules/rea-agents", "/usr/local/lib/node_modules/rea-agents", "/home/user/.bun/install/cache/rea-agents@6.4.0@@@1", ])("rejects package root %s outside the global project", async (root) => { await expect( detectBunInstallation(root, globalDirectory, host()), ).resolves.toBeUndefined(); }); it("does not trust an unresolvable installation path", async () => { await expect( detectBunInstallation(packageRoot, globalDirectory, { ...host(), canonicalPath: () => Promise.reject(new Error("ENOENT")), }), ).resolves.toBeUndefined(); }); it("follows Bun's global directory precedence", () => { const home = { HOME: "/home/user" }; expect(bunGlobalDirectory(home, "linux")).toBe( resolve("/home/user", ".bun", "install", "global"), ); expect( bunGlobalDirectory({ ...home, XDG_CACHE_HOME: "/cache" }, "linux"), ).toBe(resolve("/cache", ".bun", "install", "global")); expect( bunGlobalDirectory( { ...home, XDG_CACHE_HOME: "/cache", BUN_INSTALL: "/opt/bun" }, "linux", ), ).toBe(resolve("/opt/bun", "install", "global")); expect( bunGlobalDirectory( { ...home, BUN_INSTALL: "/opt/bun", BUN_INSTALL_GLOBAL_DIR: "/srv/bun-global", }, "linux", ), ).toBe(resolve("/srv/bun-global")); const profile = resolve("profile"); expect( bunGlobalDirectory( { USERPROFILE: profile, HOME: resolve("other-home") }, "win32", ), ).toBe(join(profile, ".bun", "install", "global")); }); it("resolves relative installation and cache roots before appending the global layout", () => { expect(bunGlobalDirectory({ BUN_INSTALL: "bun" }, "linux")).toBe( resolve("bun", "install", "global"), ); expect(bunGlobalDirectory({ XDG_CACHE_HOME: "cache" }, "linux")).toBe( resolve("cache", ".bun", "install", "global"), ); }); });