openclaw / src /cli /update-cli /shared.command-runner.test.ts
SaylorTwift's picture
SaylorTwift HF Staff
Add files using upload-large-folder tool
eb3f11e verified
Raw
History Blame Contribute Delete
15 kB
// Shared command runner tests cover update helper command execution and error capture.
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); // includes the truncation marker
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();
});
});
});