| |
| import fs from "node:fs/promises"; |
| import path from "node:path"; |
| import { beforeEach, describe, expect, it, vi } from "vitest"; |
| import { defaultRuntime } from "../../runtime.js"; |
| import { withTestDir } from "../../test-helpers/temp-dir.js"; |
| import { |
| ensureGitCheckout, |
| parseTimeoutMsOrExit, |
| resolveGlobalManager, |
| resolveUpdateRoot, |
| runUpdateStep, |
| } from "./shared.js"; |
|
|
| const runCommandWithTimeout = vi.hoisted(() => vi.fn()); |
|
|
| vi.mock("../../process/exec.js", () => ({ |
| runCommandWithTimeout, |
| })); |
|
|
| const successfulCommandResult = { |
| stdout: "", |
| stderr: "", |
| code: 0, |
| signal: null, |
| killed: false, |
| termination: "exit" as const, |
| }; |
|
|
| function cloneTarget(argv: string[]): string { |
| const target = argv.at(-1); |
| if (!target) { |
| throw new Error("git clone target missing from command"); |
| } |
| return target; |
| } |
|
|
| describe("update CLI shared helpers", () => { |
| beforeEach(() => { |
| vi.clearAllMocks(); |
| runCommandWithTimeout.mockResolvedValue(successfulCommandResult); |
| }); |
|
|
| it("requires timeout values to be complete positive integer seconds", () => { |
| const error = vi.spyOn(defaultRuntime, "error").mockImplementation(() => undefined); |
| const exit = vi.spyOn(defaultRuntime, "exit").mockImplementation(() => undefined as never); |
|
|
| try { |
| expect(parseTimeoutMsOrExit("")).toBeNull(); |
| expect(parseTimeoutMsOrExit("1.5")).toBeNull(); |
| expect(parseTimeoutMsOrExit("10abc")).toBeNull(); |
| expect(parseTimeoutMsOrExit("0x10")).toBeNull(); |
| expect(parseTimeoutMsOrExit("0")).toBeNull(); |
| expect(parseTimeoutMsOrExit("-1")).toBeNull(); |
| expect(parseTimeoutMsOrExit(" ")).toBeNull(); |
| expect(parseTimeoutMsOrExit(String(Number.MAX_SAFE_INTEGER))).toBeNull(); |
|
|
| expect(error).toHaveBeenCalledTimes(8); |
| expect(error).toHaveBeenCalledWith("--timeout must be a positive integer (seconds)"); |
| expect(exit).toHaveBeenCalledTimes(8); |
| expect(exit).toHaveBeenCalledWith(1); |
| } finally { |
| error.mockRestore(); |
| exit.mockRestore(); |
| } |
| }); |
|
|
| it("keeps failed command diagnostics in both progress and the final result", async () => { |
| runCommandWithTimeout.mockResolvedValueOnce({ |
| ...successfulCommandResult, |
| code: 1, |
| stdout: `${"x".repeat(10_000)}\nBuild type error`, |
| stderr: "Command failed", |
| }); |
| const onStepComplete = vi.fn(); |
| const result = await runUpdateStep({ |
| name: "build", |
| argv: ["pnpm", "build"], |
| timeoutMs: 1200, |
| progress: { onStepComplete }, |
| }); |
|
|
| expect(result.stdoutTail).toContain("Build type error"); |
| expect(result.stdoutTail?.length).toBeLessThanOrEqual(8001); |
| expect(onStepComplete).toHaveBeenCalledWith( |
| expect.objectContaining({ |
| stdoutTail: result.stdoutTail, |
| stderrTail: "Command failed", |
| exitCode: 1, |
| }), |
| ); |
| }); |
|
|
| it("parses complete positive integer timeout values as milliseconds", () => { |
| const error = vi.spyOn(defaultRuntime, "error").mockImplementation(() => undefined); |
| const exit = vi.spyOn(defaultRuntime, "exit").mockImplementation(() => undefined as never); |
|
|
| try { |
| expect(parseTimeoutMsOrExit(" 10 ")).toBe(10_000); |
| expect(parseTimeoutMsOrExit("+10")).toBe(10_000); |
| expect(parseTimeoutMsOrExit("001")).toBe(1_000); |
| expect(parseTimeoutMsOrExit()).toBeUndefined(); |
| expect(error).not.toHaveBeenCalled(); |
| expect(exit).not.toHaveBeenCalled(); |
| } finally { |
| error.mockRestore(); |
| exit.mockRestore(); |
| } |
| }); |
|
|
| it.runIf(process.platform !== "win32")( |
| "resolves update ownership from the lexical invocation path", |
| async () => { |
| await withTestDir({ prefix: "openclaw-update-root-" }, async (base) => { |
| const storeRoot = path.join(base, "store", "openclaw"); |
| const packageRoot = path.join(base, "global", "v11", "install", "node_modules", "openclaw"); |
| await fs.mkdir(path.dirname(packageRoot), { recursive: true }); |
| await fs.mkdir(storeRoot, { recursive: true }); |
| await fs.writeFile( |
| path.join(storeRoot, "package.json"), |
| JSON.stringify({ name: "openclaw", version: "1.0.0" }), |
| "utf8", |
| ); |
| await fs.symlink(storeRoot, packageRoot, "dir"); |
|
|
| const previousArgv = [...process.argv]; |
| process.argv[1] = path.join(packageRoot, "openclaw.mjs"); |
| try { |
| await expect(resolveUpdateRoot()).resolves.toBe(packageRoot); |
| } finally { |
| process.argv.splice(0, process.argv.length, ...previousArgv); |
| } |
| }); |
| }, |
| ); |
|
|
| it("refuses a package root without a proven manager owner", async () => { |
| runCommandWithTimeout.mockResolvedValue({ |
| ...successfulCommandResult, |
| code: 1, |
| stderr: "not owned", |
| }); |
|
|
| await expect( |
| resolveGlobalManager({ |
| root: "/shared/lib/node_modules/openclaw", |
| installKind: "package", |
| timeoutMs: 1_000, |
| }), |
| ).rejects.toMatchObject({ |
| name: "UpdatePreMutationError", |
| message: expect.stringMatching( |
| /No package changes or Gateway restart were attempted\.[\s\S]*Inspected:[\s\S]*\/shared\/lib\/node_modules\/openclaw[\s\S]*npm root -g[\s\S]*pnpm root -g[\s\S]*prefix -g/, |
| ), |
| }); |
| }); |
|
|
| it("publishes a successful fresh clone only after the clone completes", async () => { |
| await withTestDir({ prefix: "openclaw-update-clone-success-" }, async (base) => { |
| const checkoutDir = path.join(base, "nested", "openclaw"); |
| runCommandWithTimeout.mockImplementationOnce(async (argv: string[]) => { |
| const stagingDir = cloneTarget(argv); |
| expect(stagingDir).toMatch(/[/\\]\.openclaw-clone-[^/\\]+$/u); |
| expect(stagingDir).not.toBe(checkoutDir); |
| await expect(fs.stat(checkoutDir)).rejects.toMatchObject({ code: "ENOENT" }); |
| await fs.mkdir(path.join(stagingDir, ".git"), { recursive: true }); |
| await fs.writeFile(path.join(stagingDir, "checkout.marker"), "complete\n"); |
| return successfulCommandResult; |
| }); |
|
|
| await expect( |
| ensureGitCheckout({ dir: checkoutDir, timeoutMs: 1_000, env: process.env }), |
| ).resolves.toMatchObject({ checkoutDir, step: { exitCode: 0 } }); |
|
|
| await expect(fs.readFile(path.join(checkoutDir, "checkout.marker"), "utf8")).resolves.toBe( |
| "complete\n", |
| ); |
| await expect(fs.readdir(path.dirname(checkoutDir))).resolves.toEqual(["openclaw"]); |
| expect(runCommandWithTimeout).toHaveBeenCalledWith( |
| [ |
| "git", |
| "clone", |
| "--filter=blob:none", |
| "https://github.com/openclaw/openclaw.git", |
| expect.stringMatching(/[/\\]\.openclaw-clone-[^/\\]+$/u), |
| ], |
| expect.objectContaining({ env: process.env, timeoutMs: 1_000 }), |
| ); |
| }); |
| }); |
|
|
| it("removes a failed fresh clone without publishing the destination", async () => { |
| await withTestDir({ prefix: "openclaw-update-clone-failure-" }, async (base) => { |
| const checkoutDir = path.join(base, "openclaw"); |
| runCommandWithTimeout.mockImplementationOnce(async (argv: string[]) => { |
| const stagingDir = cloneTarget(argv); |
| await fs.mkdir(path.join(stagingDir, ".git"), { recursive: true }); |
| return { |
| ...successfulCommandResult, |
| stderr: "clone interrupted", |
| code: 42, |
| }; |
| }); |
|
|
| await expect( |
| ensureGitCheckout({ dir: checkoutDir, timeoutMs: 1_000, env: process.env }), |
| ).resolves.toMatchObject({ checkoutDir, step: { exitCode: 42 } }); |
|
|
| await expect(fs.stat(checkoutDir)).rejects.toMatchObject({ code: "ENOENT" }); |
| await expect(fs.readdir(base)).resolves.toEqual([]); |
| }); |
| }); |
|
|
| it("preserves a destination created while a fresh clone is running", async () => { |
| await withTestDir({ prefix: "openclaw-update-clone-race-" }, async (base) => { |
| const checkoutDir = path.join(base, "openclaw"); |
| runCommandWithTimeout.mockImplementationOnce(async (argv: string[]) => { |
| const stagingDir = cloneTarget(argv); |
| await fs.mkdir(path.join(stagingDir, ".git"), { recursive: true }); |
| await fs.mkdir(checkoutDir); |
| await fs.writeFile(path.join(checkoutDir, "user.marker"), "keep\n"); |
| return successfulCommandResult; |
| }); |
|
|
| await expect( |
| ensureGitCheckout({ dir: checkoutDir, timeoutMs: 1_000, env: process.env }), |
| ).rejects.toThrow("appeared while cloning"); |
|
|
| await expect(fs.readFile(path.join(checkoutDir, "user.marker"), "utf8")).resolves.toBe( |
| "keep\n", |
| ); |
| await expect(fs.readdir(base)).resolves.toEqual(["openclaw"]); |
| }); |
| }); |
|
|
| it("keeps an existing empty checkout destination retryable after clone failure", async () => { |
| await withTestDir({ prefix: "openclaw-update-clone-existing-" }, async (base) => { |
| const checkoutDir = path.join(base, "openclaw"); |
| await fs.mkdir(checkoutDir); |
| let attempt = 0; |
| runCommandWithTimeout.mockImplementation(async (argv: string[]) => { |
| attempt += 1; |
| const stagingDir = cloneTarget(argv); |
| expect(stagingDir).not.toBe(checkoutDir); |
| await fs.mkdir(path.join(stagingDir, ".git"), { recursive: true }); |
| if (attempt === 1) { |
| return { ...successfulCommandResult, code: 42, stderr: "clone interrupted" }; |
| } |
| await fs.writeFile(path.join(stagingDir, "checkout.marker"), "complete\n"); |
| return successfulCommandResult; |
| }); |
|
|
| await expect( |
| ensureGitCheckout({ dir: checkoutDir, timeoutMs: 1_000, env: process.env }), |
| ).resolves.toMatchObject({ checkoutDir, step: { exitCode: 42 } }); |
| await expect(fs.readdir(checkoutDir)).resolves.toEqual([]); |
|
|
| await expect( |
| ensureGitCheckout({ dir: checkoutDir, timeoutMs: 1_000, env: process.env }), |
| ).resolves.toMatchObject({ checkoutDir, step: { exitCode: 0 } }); |
| await expect(fs.readFile(path.join(checkoutDir, "checkout.marker"), "utf8")).resolves.toBe( |
| "complete\n", |
| ); |
| expect(runCommandWithTimeout).toHaveBeenCalledTimes(2); |
| }); |
| }); |
|
|
| it.runIf(process.platform !== "win32")( |
| "preserves a stable alias to an existing empty checkout destination", |
| async () => { |
| await withTestDir({ prefix: "openclaw-update-clone-alias-" }, async (base) => { |
| const targetDir = path.join(base, "checkout-target"); |
| const checkoutDir = path.join(base, "openclaw"); |
| await fs.mkdir(targetDir); |
| await fs.symlink(targetDir, checkoutDir, "dir"); |
| runCommandWithTimeout.mockImplementationOnce(async (argv: string[]) => { |
| const stagingDir = cloneTarget(argv); |
| expect(path.dirname(stagingDir)).toBe(targetDir); |
| await fs.mkdir(path.join(stagingDir, ".git"), { recursive: true }); |
| await fs.writeFile(path.join(stagingDir, "checkout.marker"), "complete\n"); |
| return successfulCommandResult; |
| }); |
|
|
| await expect( |
| ensureGitCheckout({ dir: checkoutDir, timeoutMs: 1_000, env: process.env }), |
| ).resolves.toMatchObject({ checkoutDir: targetDir, step: { exitCode: 0 } }); |
|
|
| expect((await fs.lstat(checkoutDir)).isSymbolicLink()).toBe(true); |
| expect((await fs.lstat(targetDir)).isSymbolicLink()).toBe(false); |
| await expect(fs.readFile(path.join(checkoutDir, "checkout.marker"), "utf8")).resolves.toBe( |
| "complete\n", |
| ); |
| }); |
| }, |
| ); |
|
|
| it.runIf(process.platform !== "win32")( |
| "publishes through the original target when an empty-directory alias is retargeted", |
| async () => { |
| await withTestDir({ prefix: "openclaw-update-clone-alias-race-" }, async (base) => { |
| const targetDir = path.join(base, "checkout-target"); |
| const replacementDir = path.join(base, "replacement-target"); |
| const checkoutDir = path.join(base, "openclaw"); |
| await fs.mkdir(targetDir); |
| await fs.mkdir(replacementDir); |
| await fs.symlink(targetDir, checkoutDir, "dir"); |
| runCommandWithTimeout.mockImplementationOnce(async (argv: string[]) => { |
| const stagingDir = cloneTarget(argv); |
| expect(path.dirname(stagingDir)).toBe(targetDir); |
| await fs.mkdir(path.join(stagingDir, ".git"), { recursive: true }); |
| await fs.writeFile(path.join(stagingDir, "checkout.marker"), "complete\n"); |
| await fs.unlink(checkoutDir); |
| await fs.symlink(replacementDir, checkoutDir, "dir"); |
| return successfulCommandResult; |
| }); |
|
|
| await expect( |
| ensureGitCheckout({ dir: checkoutDir, timeoutMs: 1_000, env: process.env }), |
| ).resolves.toMatchObject({ checkoutDir: targetDir, step: { exitCode: 0 } }); |
|
|
| await expect(fs.readFile(path.join(targetDir, "checkout.marker"), "utf8")).resolves.toBe( |
| "complete\n", |
| ); |
| await expect(fs.readdir(replacementDir)).resolves.toEqual([]); |
| }); |
| }, |
| ); |
|
|
| it("retains recovery files when publication and rollback both fail", async () => { |
| await withTestDir({ prefix: "openclaw-update-clone-rollback-" }, async (base) => { |
| const checkoutDir = path.join(base, "openclaw"); |
| await fs.mkdir(checkoutDir); |
| runCommandWithTimeout.mockImplementationOnce(async (argv: string[]) => { |
| const stagingDir = cloneTarget(argv); |
| await fs.mkdir(path.join(stagingDir, ".git"), { recursive: true }); |
| await fs.writeFile(path.join(stagingDir, "checkout.marker"), "complete\n"); |
| return successfulCommandResult; |
| }); |
|
|
| const realRename = fs.rename.bind(fs); |
| const rename = vi.spyOn(fs, "rename").mockImplementation(async (oldPath, newPath) => { |
| const oldName = path.basename(oldPath.toString()); |
| const oldParent = path.dirname(oldPath.toString()); |
| const newParent = path.dirname(newPath.toString()); |
| if (oldName === ".git" && newParent === checkoutDir) { |
| throw new Error("injected publication failure"); |
| } |
| if (oldName === "checkout.marker" && oldParent === checkoutDir) { |
| throw new Error("injected rollback failure"); |
| } |
| await realRename(oldPath, newPath); |
| }); |
|
|
| try { |
| await expect( |
| ensureGitCheckout({ dir: checkoutDir, timeoutMs: 1_000, env: process.env }), |
| ).rejects.toThrow("recovery files remain"); |
| } finally { |
| rename.mockRestore(); |
| } |
|
|
| await expect(fs.readFile(path.join(checkoutDir, "checkout.marker"), "utf8")).resolves.toBe( |
| "complete\n", |
| ); |
| const recoveryDirs = (await fs.readdir(checkoutDir)).filter((entry) => |
| entry.startsWith(".openclaw-clone-"), |
| ); |
| expect(recoveryDirs).toHaveLength(1); |
| await expect( |
| fs.stat(path.join(checkoutDir, recoveryDirs[0]!, ".git")), |
| ).resolves.toBeDefined(); |
| }); |
| }); |
| }); |
|
|