diff --git a/src/infra/state-migrations.fs.test.ts b/src/infra/state-migrations.fs.test.ts new file mode 100644 index 00000000000..143572ca303 --- /dev/null +++ b/src/infra/state-migrations.fs.test.ts @@ -0,0 +1,71 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { + ensureDir, + existsDir, + fileExists, + isLegacyWhatsAppAuthFile, + readSessionStoreJson5, + safeReadDir, +} from "./state-migrations.fs.js"; + +describe("state migration fs helpers", () => { + it("reads directories safely and creates missing directories", () => { + const base = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-state-migrations-fs-")); + const nested = path.join(base, "nested"); + + expect(safeReadDir(nested)).toEqual([]); + ensureDir(nested); + fs.writeFileSync(path.join(nested, "file.txt"), "ok", "utf8"); + + expect(safeReadDir(nested).map((entry) => entry.name)).toEqual(["file.txt"]); + expect(existsDir(nested)).toBe(true); + expect(existsDir(path.join(nested, "file.txt"))).toBe(false); + }); + + it("distinguishes files from directories", () => { + const base = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-state-migrations-fs-")); + const filePath = path.join(base, "store.json"); + const dirPath = path.join(base, "dir"); + fs.writeFileSync(filePath, "{}", "utf8"); + fs.mkdirSync(dirPath); + + expect(fileExists(filePath)).toBe(true); + expect(fileExists(dirPath)).toBe(false); + expect(fileExists(path.join(base, "missing.json"))).toBe(false); + }); + + it("recognizes legacy whatsapp auth file names", () => { + expect(isLegacyWhatsAppAuthFile("creds.json")).toBe(true); + expect(isLegacyWhatsAppAuthFile("creds.json.bak")).toBe(true); + expect(isLegacyWhatsAppAuthFile("session-123.json")).toBe(true); + expect(isLegacyWhatsAppAuthFile("pre-key-1.json")).toBe(true); + expect(isLegacyWhatsAppAuthFile("sender-key-1.txt")).toBe(false); + expect(isLegacyWhatsAppAuthFile("other.json")).toBe(false); + }); + + it("parses json5 session stores and rejects invalid shapes", () => { + const base = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-state-migrations-fs-")); + const okPath = path.join(base, "store.json"); + const badPath = path.join(base, "bad.json"); + const listPath = path.join(base, "list.json"); + + fs.writeFileSync(okPath, "{session: {sessionId: 'abc', updatedAt: 1}}", "utf8"); + fs.writeFileSync(badPath, "{not valid", "utf8"); + fs.writeFileSync(listPath, "[]", "utf8"); + + expect(readSessionStoreJson5(okPath)).toEqual({ + ok: true, + store: { + session: { + sessionId: "abc", + updatedAt: 1, + }, + }, + }); + expect(readSessionStoreJson5(badPath)).toEqual({ ok: false, store: {} }); + expect(readSessionStoreJson5(listPath)).toEqual({ ok: false, store: {} }); + }); +}); diff --git a/src/infra/update-global.test.ts b/src/infra/update-global.test.ts new file mode 100644 index 00000000000..b95727febbf --- /dev/null +++ b/src/infra/update-global.test.ts @@ -0,0 +1,150 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { captureEnv } from "../test-utils/env.js"; +import { + cleanupGlobalRenameDirs, + detectGlobalInstallManagerByPresence, + detectGlobalInstallManagerForRoot, + globalInstallArgs, + globalInstallFallbackArgs, + resolveGlobalPackageRoot, + resolveGlobalInstallSpec, + resolveGlobalRoot, + type CommandRunner, +} from "./update-global.js"; + +describe("update global helpers", () => { + let envSnapshot: ReturnType | undefined; + + afterEach(() => { + envSnapshot?.restore(); + envSnapshot = undefined; + }); + + it("prefers explicit package spec overrides", () => { + envSnapshot = captureEnv(["OPENCLAW_UPDATE_PACKAGE_SPEC"]); + process.env.OPENCLAW_UPDATE_PACKAGE_SPEC = "file:/tmp/openclaw.tgz"; + + expect(resolveGlobalInstallSpec({ packageName: "openclaw", tag: "latest" })).toBe( + "file:/tmp/openclaw.tgz", + ); + expect( + resolveGlobalInstallSpec({ + packageName: "openclaw", + tag: "beta", + env: { OPENCLAW_UPDATE_PACKAGE_SPEC: "openclaw@next" }, + }), + ).toBe("openclaw@next"); + }); + + it("resolves global roots and package roots from runner output", async () => { + const runCommand: CommandRunner = async (argv) => { + if (argv[0] === "npm") { + return { stdout: "/tmp/npm-root\n", stderr: "", code: 0 }; + } + if (argv[0] === "pnpm") { + return { stdout: "", stderr: "", code: 1 }; + } + throw new Error(`unexpected command: ${argv.join(" ")}`); + }; + + await expect(resolveGlobalRoot("npm", runCommand, 1000)).resolves.toBe("/tmp/npm-root"); + await expect(resolveGlobalRoot("pnpm", runCommand, 1000)).resolves.toBeNull(); + await expect(resolveGlobalRoot("bun", runCommand, 1000)).resolves.toContain( + path.join(".bun", "install", "global", "node_modules"), + ); + await expect(resolveGlobalPackageRoot("npm", runCommand, 1000)).resolves.toBe( + "/tmp/npm-root/openclaw", + ); + }); + + it("detects install managers from resolved roots and on-disk presence", async () => { + const base = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-update-global-")); + const npmRoot = path.join(base, "npm-root"); + const pnpmRoot = path.join(base, "pnpm-root"); + const bunRoot = path.join(base, ".bun", "install", "global", "node_modules"); + const pkgRoot = path.join(pnpmRoot, "openclaw"); + await fs.mkdir(pkgRoot, { recursive: true }); + await fs.mkdir(path.join(npmRoot, "openclaw"), { recursive: true }); + await fs.mkdir(path.join(bunRoot, "openclaw"), { recursive: true }); + + envSnapshot = captureEnv(["BUN_INSTALL"]); + process.env.BUN_INSTALL = path.join(base, ".bun"); + + const runCommand: CommandRunner = async (argv) => { + if (argv[0] === "npm") { + return { stdout: `${npmRoot}\n`, stderr: "", code: 0 }; + } + if (argv[0] === "pnpm") { + return { stdout: `${pnpmRoot}\n`, stderr: "", code: 0 }; + } + throw new Error(`unexpected command: ${argv.join(" ")}`); + }; + + await expect(detectGlobalInstallManagerForRoot(runCommand, pkgRoot, 1000)).resolves.toBe( + "pnpm", + ); + await expect(detectGlobalInstallManagerByPresence(runCommand, 1000)).resolves.toBe("npm"); + + await fs.rm(path.join(npmRoot, "openclaw"), { recursive: true, force: true }); + await fs.rm(path.join(pnpmRoot, "openclaw"), { recursive: true, force: true }); + await expect(detectGlobalInstallManagerByPresence(runCommand, 1000)).resolves.toBe("bun"); + }); + + it("builds install argv and npm fallback argv", () => { + expect(globalInstallArgs("npm", "openclaw@latest")).toEqual([ + "npm", + "i", + "-g", + "openclaw@latest", + "--no-fund", + "--no-audit", + "--loglevel=error", + ]); + expect(globalInstallArgs("pnpm", "openclaw@latest")).toEqual([ + "pnpm", + "add", + "-g", + "openclaw@latest", + ]); + expect(globalInstallArgs("bun", "openclaw@latest")).toEqual([ + "bun", + "add", + "-g", + "openclaw@latest", + ]); + + expect(globalInstallFallbackArgs("npm", "openclaw@latest")).toEqual([ + "npm", + "i", + "-g", + "openclaw@latest", + "--omit=optional", + "--no-fund", + "--no-audit", + "--loglevel=error", + ]); + expect(globalInstallFallbackArgs("pnpm", "openclaw@latest")).toBeNull(); + }); + + it("cleans only renamed package directories", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-update-cleanup-")); + await fs.mkdir(path.join(root, ".openclaw-123"), { recursive: true }); + await fs.mkdir(path.join(root, ".openclaw-456"), { recursive: true }); + await fs.writeFile(path.join(root, ".openclaw-file"), "nope", "utf8"); + await fs.mkdir(path.join(root, "openclaw"), { recursive: true }); + + await expect( + cleanupGlobalRenameDirs({ + globalRoot: root, + packageName: "openclaw", + }), + ).resolves.toEqual({ + removed: [".openclaw-123", ".openclaw-456"], + }); + await expect(fs.stat(path.join(root, "openclaw"))).resolves.toBeDefined(); + await expect(fs.stat(path.join(root, ".openclaw-file"))).resolves.toBeDefined(); + }); +});