Files
openclaw/test/scripts/parallels-npm-update-smoke.test.ts
2026-06-22 05:11:25 +02:00

955 lines
34 KiB
TypeScript

// Parallels Npm Update Smoke tests cover parallels npm update smoke script behavior.
import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { MAX_TIMER_TIMEOUT_MS } from "@openclaw/normalization-core/number-coercion";
import { afterEach, describe, expect, it, vi } from "vitest";
import { runWindowsBackgroundPowerShell } from "../../scripts/e2e/parallels/guest-transports.ts";
import { run as hostCommandRun } from "../../scripts/e2e/parallels/host-command.ts";
import {
linuxUpdateScript,
macosUpdateScript,
windowsUpdateScript,
} from "../../scripts/e2e/parallels/npm-update-scripts.ts";
import {
freshLaneTimeoutMs,
NpmUpdateSmoke,
parseRegistryPackageMetadata,
parseArgs,
spawnLoggedCommand,
} from "../../scripts/e2e/parallels/npm-update-smoke.ts";
import type { HostServer, Platform } from "../../scripts/e2e/parallels/types.ts";
import { withEnv, withEnvAsync } from "../../src/test-utils/env.js";
const SCRIPT_PATH = "scripts/e2e/parallels/npm-update-smoke.ts";
const GUEST_TRANSPORTS_PATH = "scripts/e2e/parallels/guest-transports.ts";
const UPDATE_SCRIPTS_PATH = "scripts/e2e/parallels/npm-update-scripts.ts";
const TEST_AUTH = {
authChoice: "openai",
authKeyFlag: "--openai-api-key",
apiKeyEnv: "OPENAI_API_KEY",
apiKeyValue: "test-key",
modelId: "gpt-5.4",
};
const tempDirs: string[] = [];
function makeTempDir(): string {
const root = mkdtempSync(path.join(tmpdir(), "openclaw-parallels-npm-update-"));
tempDirs.push(root);
return root;
}
function pidIsAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
async function waitForDead(pid: number, timeoutMs: number): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (!pidIsAlive(pid)) {
return;
}
await new Promise((resolve) => {
setTimeout(resolve, 20);
});
}
throw new Error(`timeout waiting for pid ${pid} to exit`);
}
async function waitFor(predicate: () => boolean, label: string, timeoutMs = 2_000): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (predicate()) {
return;
}
await new Promise((resolve) => {
setTimeout(resolve, 20);
});
}
throw new Error(`timeout waiting for ${label}`);
}
function decodePowerShellFromArgs(args: string[]): string {
const encoded = args[args.indexOf("-EncodedCommand") + 1];
return encoded ? Buffer.from(encoded, "base64").toString("utf16le") : "";
}
function extractWindowsBackgroundControlMarkers(decoded: string): {
done: string;
exitPrefix: string;
lengthPrefix: string;
offsetPrefix: string;
} {
const marker = (name: string, trailingColon: boolean): string => {
const suffix = trailingColon ? ":" : "";
const match = decoded.match(new RegExp(`${name}:[A-Za-z0-9_-]+${suffix}`));
if (!match) {
throw new Error(`missing ${name} control marker`);
}
return match[0];
};
return {
done: marker("__OPENCLAW_BACKGROUND_DONE__", false),
exitPrefix: marker("__OPENCLAW_BACKGROUND_EXIT__", true),
lengthPrefix: marker("__OPENCLAW_LOG_LENGTH__", true),
offsetPrefix: marker("__OPENCLAW_LOG_OFFSET__", true),
};
}
afterEach(() => {
vi.useRealTimers();
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { force: true, recursive: true });
}
});
describe("parallels npm update smoke", () => {
it("accepts one prepared tarball target for update and fresh install", () => {
expect(parseArgs(["--target-tarball", "/tmp/openclaw-candidate.tgz"])).toMatchObject({
targetTarball: "/tmp/openclaw-candidate.tgz",
updateTarget: "",
freshTargetSpec: undefined,
});
expect(() =>
parseArgs(["--target-tarball", "/tmp/openclaw-candidate.tgz", "--update-target", "beta"]),
).toThrow("--target-tarball cannot be combined");
});
it("stops the host artifact server when the wrapper fails mid-run", async () => {
let stopCalls = 0;
const server: HostServer = {
hostIp: "127.0.0.1",
port: 48123,
stop: async () => {
stopCalls += 1;
},
urlFor: (filePath) => `http://127.0.0.1:48123/${path.basename(filePath)}`,
};
class FailingNpmUpdateSmoke extends NpmUpdateSmoke {
protected override async makeRunTempDir(prefix: string): Promise<string> {
void prefix;
return makeTempDir();
}
protected override async runSteps(): Promise<void> {
this.server = server;
throw new Error("forced wrapper failure");
}
}
await withEnvAsync({ OPENAI_API_KEY: "test-key" }, async () => {
const smoke = new FailingNpmUpdateSmoke({
...TEST_AUTH,
json: false,
packageSpec: "openclaw@latest",
platforms: new Set<Platform>(["linux"]),
provider: "openai",
updateTarget: "local-main",
});
await expect(smoke.run()).rejects.toThrow("forced wrapper failure");
});
expect(stopCalls).toBe(1);
});
it("removes uploaded guest update scripts when chmod fails", () => {
const root = makeTempDir();
const logPath = path.join(root, "prlctl.log");
const prlctlPath = path.join(root, "prlctl");
writeFileSync(
prlctlPath,
`#!/usr/bin/env bash
set -euo pipefail
log_path=${JSON.stringify(logPath)}
printf '%s\\n' "$*" >>"$log_path"
args=" $* "
if [[ "$args" == *" /usr/bin/tee /tmp/openclaw-parallels-npm-update-linux-"* ]]; then
cat >/dev/null
exit 0
fi
if [[ "$args" == *" /bin/chmod 755 /tmp/openclaw-parallels-npm-update-linux-"* ]]; then
echo "chmod denied" >&2
exit 7
fi
if [[ "$args" == *" /bin/rm -f /tmp/openclaw-parallels-npm-update-linux-"* ]]; then
printf 'cleanup\\n' >>"$log_path"
exit 0
fi
exit 1
`,
);
chmodSync(prlctlPath, 0o755);
withEnv(
{
OPENAI_API_KEY: "test-key",
PATH: `${root}${path.delimiter}${process.env.PATH ?? ""}`,
},
() => {
const smoke = new NpmUpdateSmoke({
...TEST_AUTH,
json: false,
packageSpec: "openclaw@latest",
platforms: new Set<Platform>(["linux"]),
provider: "openai",
updateTarget: "local-main",
});
const writeGuestScript = Reflect.get(smoke, "writeGuestScript") as (
vm: string,
script: string,
prefix: string,
) => string;
expect(() =>
writeGuestScript.call(
smoke,
"Linux VM",
"echo update",
"openclaw-parallels-npm-update-linux",
),
).toThrow("failed to chmod guest script");
},
);
const log = readFileSync(logPath, "utf8");
expect(log).toContain("/bin/chmod 755 /tmp/openclaw-parallels-npm-update-linux-");
expect(log).toContain("/bin/rm -f /tmp/openclaw-parallels-npm-update-linux-");
expect(log.match(/^cleanup$/gm)).toHaveLength(1);
});
it("has a one-command beta validation mode with fresh target coverage", () => {
const script = readFileSync(SCRIPT_PATH, "utf8");
expect(script).toContain("--beta-validation [target]");
expect(script).toContain("resolveOpenClawRegistryVersion");
expect(script).toContain("this.options.updateTarget = version");
expect(script).toContain("this.options.freshTargetSpec = `openclaw@${version}`");
expect(script).toContain("runFreshTargetInstalls");
expect(script).toContain("freshTargetStatus");
});
it("host-serves a prepared candidate tarball for both proof phases", () => {
const script = readFileSync(SCRIPT_PATH, "utf8");
expect(script).toContain("--target-tarball <path>");
expect(script).toContain('label: "prepared candidate tgz"');
expect(script).toContain("await copyFile(this.targetTarballPath, hostedTarballPath)");
expect(script).toContain("dir: this.tgzDir");
expect(script).toContain("this.updateTargetEffective = targetUrl");
expect(script).toContain("this.freshTargetSpec = targetUrl");
expect(script).toContain("this.updateExpectedNeedle = this.targetTarballVersion");
});
it("accepts keyed and nested npm metadata for published update targets", () => {
const script = readFileSync(SCRIPT_PATH, "utf8");
expect(script).toContain("curl -fsSL --connect-timeout 10 --max-time 120 --retry 2");
expect(script).toContain("timeoutMs: 150_000");
expect(
parseRegistryPackageMetadata(
JSON.stringify({
version: "2026.5.20-beta.1",
"dist.tarball": "https://registry.example/openclaw-keyed.tgz",
gitHead: "abcdef0123456789",
}),
),
).toEqual({
version: "2026.5.20-beta.1",
tarball: "https://registry.example/openclaw-keyed.tgz",
gitHead: "abcdef0123456789",
});
expect(
parseRegistryPackageMetadata(
JSON.stringify({
version: "2026.5.20-beta.1",
dist: { tarball: "https://registry.example/openclaw-nested.tgz" },
}),
),
).toEqual({
version: "2026.5.20-beta.1",
tarball: "https://registry.example/openclaw-nested.tgz",
gitHead: "",
});
});
it("guards beta validation against cross-version harness checkouts", () => {
const script = readFileSync(SCRIPT_PATH, "utf8");
expect(script).toContain("assertPublishedTargetMatchesHarnessCheckout");
expect(script).toContain("readHarnessCheckoutVersion");
expect(script).toContain("openClawVersionFamily");
expect(script).toContain("OPENCLAW_PARALLELS_ALLOW_HARNESS_TARGET_MISMATCH");
expect(script).toContain("checkout the matching release branch");
});
it("lets callers override the Parallels host IP", () => {
const script = readFileSync(SCRIPT_PATH, "utf8");
expect(script).toContain("--host-ip <ip>");
expect(script).toContain("hostIp?: string");
expect(script).toContain("options.hostIp = ensureValue");
expect(script).toContain('resolveHostIp(this.options.hostIp ?? "")');
});
it("prints actionable progress, rerun hints, and markdown summaries", () => {
const script = readFileSync(SCRIPT_PATH, "utf8");
expect(script).toContain("stale=");
expect(script).toContain("bytes=");
expect(script).toContain("rerunCommand");
expect(script).toContain("writeSummaryMarkdown");
expect(script).toContain("Parallels NPM Update Smoke");
});
it("streams aggregate update logs instead of retaining them in memory", () => {
const script = readFileSync(SCRIPT_PATH, "utf8");
const updateBlock = script.slice(
script.indexOf(" private spawnUpdate"),
script.indexOf(" private async runMacosUpdate"),
);
expect(updateBlock).toContain("appendFileSync(logPath, text");
expect(updateBlock).toContain("run: ({ signal }) => fn({ append, logPath, signal })");
expect(updateBlock).not.toContain("log += text");
});
it("bounds POSIX guest failure logs", () => {
const scripts = [
macosUpdateScript({
auth: TEST_AUTH,
expectedNeedle: "2026.5.3-beta.2",
updateTarget: "2026.5.3-beta.2",
}),
linuxUpdateScript({
auth: TEST_AUTH,
expectedNeedle: "2026.5.3-beta.2",
updateTarget: "2026.5.3-beta.2",
}),
].join("\n");
expect(scripts).toContain("print_log_tail()");
expect(scripts).toContain("OPENCLAW_PARALLELS_NPM_UPDATE_LOG_TAIL_BYTES");
expect(scripts).toContain('print_log_tail "$output_file"');
expect(scripts).toContain("print_log_tail /tmp/openclaw-parallels-macos-gateway.log >&2");
expect(scripts).toContain("print_log_tail /tmp/openclaw-parallels-linux-gateway.log >&2");
expect(scripts).not.toContain('cat "$output_file"');
expect(scripts).not.toContain("cat /tmp/openclaw-parallels-");
});
it("passes platform model timeouts to POSIX update agent turns", () => {
const input = {
auth: TEST_AUTH,
expectedNeedle: "2026.5.3-beta.2",
updateTarget: "2026.5.3-beta.2",
};
withEnv(
{
OPENCLAW_PARALLELS_LINUX_MODEL_TIMEOUT_S: undefined,
OPENCLAW_PARALLELS_MACOS_MODEL_TIMEOUT_S: undefined,
OPENCLAW_PARALLELS_MODEL_TIMEOUT_S: undefined,
},
() => {
expect(macosUpdateScript(input)).toContain("--timeout 1800 --json");
expect(linuxUpdateScript(input)).toContain("--timeout 900 --json");
},
);
withEnv(
{
OPENCLAW_PARALLELS_LINUX_MODEL_TIMEOUT_S: "321",
OPENCLAW_PARALLELS_MACOS_MODEL_TIMEOUT_S: "654",
},
() => {
expect(macosUpdateScript(input)).toContain("--timeout 654 --json");
expect(linuxUpdateScript(input)).toContain("--timeout 321 --json");
},
);
});
it("streams fresh lane logs instead of retaining them in memory", async () => {
const root = makeTempDir();
const logPath = path.join(root, "fresh.log");
const output: string[] = [];
const code = await spawnLoggedCommand(
process.execPath,
["-e", "process.stdout.write('fresh-out'); process.stderr.write('fresh-err');"],
logPath,
{},
(text) => output.push(text),
{ timeoutMs: 1000 },
);
expect(code).toBe(0);
const log = readFileSync(logPath, "utf8");
expect(log).toContain("fresh-out");
expect(log).toContain("fresh-err");
expect(output.join("")).toContain("fresh-out");
expect(output.join("")).toContain("fresh-err");
});
it("sets platform-aware fresh lane timeouts", () => {
withEnv({ OPENCLAW_PARALLELS_NPM_UPDATE_FRESH_TIMEOUT_S: undefined }, () => {
expect(freshLaneTimeoutMs("macos")).toBe(75 * 60 * 1000);
expect(freshLaneTimeoutMs("linux")).toBe(75 * 60 * 1000);
expect(freshLaneTimeoutMs("windows")).toBe(90 * 60 * 1000);
});
withEnv({ OPENCLAW_PARALLELS_NPM_UPDATE_FRESH_TIMEOUT_S: "3" }, () => {
expect(freshLaneTimeoutMs("macos")).toBe(3000);
});
withEnv(
{ OPENCLAW_PARALLELS_NPM_UPDATE_FRESH_TIMEOUT_S: String(Number.MAX_SAFE_INTEGER) },
() => {
expect(freshLaneTimeoutMs("linux")).toBe(MAX_TIMER_TIMEOUT_MS);
},
);
});
it("clamps oversized fresh lane command timeouts before scheduling", async () => {
const root = makeTempDir();
const logPath = path.join(root, "fresh.log");
const code = await spawnLoggedCommand(
process.execPath,
["-e", "setTimeout(() => process.exit(0), 25);"],
logPath,
{},
undefined,
{ timeoutMs: Number.MAX_SAFE_INTEGER },
);
expect(code).toBe(0);
});
it.runIf(process.platform !== "win32")("times out fresh lane process groups", async () => {
const root = makeTempDir();
const logPath = path.join(root, "fresh.log");
const scriptPath = path.join(root, "hung-fresh-lane.mjs");
const descendantPidPath = path.join(root, "descendant.pid");
const descendantScript = [
"import { writeFileSync } from 'node:fs';",
`writeFileSync(${JSON.stringify(descendantPidPath)}, String(process.pid));`,
"process.on('SIGTERM', () => {});",
"setInterval(() => {}, 1000);",
].join("\n");
writeFileSync(
scriptPath,
[
"import { spawn } from 'node:child_process';",
`spawn(process.execPath, ["--input-type=module", "--eval", ${JSON.stringify(
descendantScript,
)}], { stdio: "ignore" });`,
"process.on('SIGTERM', () => process.exit(0));",
"setInterval(() => {}, 1000);",
"",
].join("\n"),
"utf8",
);
const code = await spawnLoggedCommand(process.execPath, [scriptPath], logPath, {}, undefined, {
timeoutKillGraceMs: 25,
timeoutLabel: "fresh lane test",
timeoutMs: 250,
});
expect(code).toBe(124);
expect(readFileSync(logPath, "utf8")).toContain("fresh lane test timed out after 250ms");
expect(existsSync(descendantPidPath)).toBe(true);
const descendantPid = Number(readFileSync(descendantPidPath, "utf8"));
await waitForDead(descendantPid, 2000);
});
it.runIf(process.platform !== "win32")(
"lets fresh lane descendants exit during timeout kill grace",
async () => {
const root = makeTempDir();
const logPath = path.join(root, "fresh.log");
const scriptPath = path.join(root, "graceful-fresh-lane.mjs");
const readyPath = path.join(root, "ready");
const donePath = path.join(root, "done");
const descendantScript = [
"import { writeFileSync } from 'node:fs';",
`writeFileSync(${JSON.stringify(readyPath)}, 'ready');`,
"process.on('SIGTERM', () => {",
` setTimeout(() => { writeFileSync(${JSON.stringify(donePath)}, 'done'); process.exit(0); }, 75);`,
"});",
"setInterval(() => {}, 1000);",
].join("\n");
writeFileSync(
scriptPath,
[
"import { spawn } from 'node:child_process';",
`spawn(process.execPath, ["--input-type=module", "--eval", ${JSON.stringify(
descendantScript,
)}], { stdio: "ignore" });`,
"process.on('SIGTERM', () => process.exit(0));",
"setInterval(() => {}, 1000);",
"",
].join("\n"),
"utf8",
);
const command = spawnLoggedCommand(process.execPath, [scriptPath], logPath, {}, undefined, {
timeoutKillGraceMs: 500,
timeoutLabel: "fresh lane grace test",
timeoutMs: 500,
});
await waitFor(() => existsSync(readyPath), "fresh lane descendant readiness");
await expect(command).resolves.toBe(124);
expect(readFileSync(donePath, "utf8")).toBe("done");
},
);
it("clears update stream timers when spawning the guest command fails", async () => {
vi.useFakeTimers();
const smoke = withEnv(
{ OPENAI_API_KEY: "test-key" },
() =>
new NpmUpdateSmoke({
...TEST_AUTH,
json: false,
packageSpec: "openclaw@latest",
platforms: new Set<Platform>(["linux"]),
provider: "openai",
updateTarget: "local-main",
}),
);
const runStreamingToJobLog = Reflect.get(smoke, "runStreamingToJobLog") as (
command: string,
args: string[],
timeoutMs: number,
ctx: {
append(chunk: string | Uint8Array): void;
logPath: string;
signal: AbortSignal;
},
) => Promise<number>;
await expect(
runStreamingToJobLog.call(smoke, "openclaw-definitely-missing-command", [], 60 * 60 * 1000, {
append: () => undefined,
logPath: "",
signal: new AbortController().signal,
}),
).rejects.toMatchObject({ code: "ENOENT" });
expect(vi.getTimerCount()).toBe(0);
});
it.runIf(process.platform !== "win32")(
"lets update stream descendants exit during timeout kill grace",
async () => {
const root = makeTempDir();
const scriptPath = path.join(root, "stream-update-grace.mjs");
const readyPath = path.join(root, "stream-ready");
const donePath = path.join(root, "stream-done");
const smoke = withEnv(
{ OPENAI_API_KEY: "test-key" },
() =>
new NpmUpdateSmoke({
...TEST_AUTH,
json: false,
packageSpec: "openclaw@latest",
platforms: new Set<Platform>(["linux"]),
provider: "openai",
updateTarget: "local-main",
}),
);
const runStreamingToJobLog = Reflect.get(smoke, "runStreamingToJobLog") as (
command: string,
args: string[],
timeoutMs: number,
ctx: {
append(chunk: string | Uint8Array): void;
logPath: string;
signal: AbortSignal;
},
) => Promise<number>;
const descendantScript = [
"import { writeFileSync } from 'node:fs';",
`writeFileSync(${JSON.stringify(readyPath)}, 'ready');`,
"process.on('SIGTERM', () => {",
` setTimeout(() => { writeFileSync(${JSON.stringify(donePath)}, 'done'); process.exit(0); }, 75);`,
"});",
"setInterval(() => {}, 1000);",
].join("\n");
writeFileSync(
scriptPath,
[
"import { spawn } from 'node:child_process';",
`spawn(process.execPath, ["--input-type=module", "--eval", ${JSON.stringify(
descendantScript,
)}], { stdio: "ignore" });`,
"process.on('SIGTERM', () => process.exit(0));",
"setInterval(() => {}, 1000);",
"",
].join("\n"),
"utf8",
);
const command = runStreamingToJobLog.call(smoke, process.execPath, [scriptPath], 500, {
append: () => undefined,
logPath: path.join(root, "update.log"),
signal: new AbortController().signal,
});
await waitFor(() => existsSync(readyPath), "update stream descendant readiness");
await expect(command).resolves.toBe(124);
expect(readFileSync(donePath, "utf8")).toBe("done");
},
);
it("runs Windows updates through a detached done-file runner", () => {
const script = readFileSync(SCRIPT_PATH, "utf8");
const transports = readFileSync(GUEST_TRANSPORTS_PATH, "utf8");
expect(script).toContain("runWindowsBackgroundPowerShell");
expect(transports).toContain("runWindowsBackgroundPowerShell");
expect(transports).toContain("__OPENCLAW_BACKGROUND_EXIT__");
expect(transports).toContain("__OPENCLAW_BACKGROUND_DONE__");
expect(transports).toContain("${options.label} timed out");
});
it("cleans timed-out Windows background work and reads bounded log chunks", async () => {
const decodedCommands: string[] = [];
const inputs: string[] = [];
const fakeRun: typeof hostCommandRun = (_command, args, options) => {
const decoded = decodePowerShellFromArgs(args);
decodedCommands.push(decoded);
if (options?.input) {
inputs.push(String(options.input));
}
if (decoded.includes("Start-Process")) {
return { status: 0, stderr: "", stdout: "started\n" };
}
return { status: 0, stderr: "", stdout: "" };
};
await expect(
runWindowsBackgroundPowerShell({
label: "windows background timeout",
logChunkBytes: 64,
pollIntervalMs: 1,
runCommand: fakeRun,
script: "Start-Sleep -Seconds 60",
timeoutMs: 5,
vmName: "Windows Test",
}),
).rejects.toThrow("windows background timeout timed out");
const commands = decodedCommands.join("\n---\n");
const payloads = inputs.join("\n---\n");
expect(commands).toContain("$pidPath");
expect(commands).toContain("function Write-OpenClawUtf8File");
expect(commands).toContain("[System.Text.UTF8Encoding]::new($false)");
expect(payloads).toContain("Write-OpenClawUtf8File $exitPath '0'");
expect(payloads).toContain("Write-OpenClawUtf8File $donePath 'done'");
expect(commands).toContain("Write-OpenClawUtf8File $pidPath ([string]$process.Id)");
expect(commands).toContain("Start-Process -FilePath powershell.exe");
expect(commands).toContain("-PassThru");
expect(commands).toContain("[System.IO.File]::Open($logPath");
expect(commands).toContain("[Math]::Min($length - $offset, 64)");
expect(commands).toContain("Stop-OpenClawBackgroundProcessTree ([int]$backgroundPid)");
expect(commands).toContain(
'Get-CimInstance Win32_Process -Filter "ParentProcessId=$ProcessId"',
);
expect(commands).toContain(
"Remove-Item -Path $scriptPath, $logPath, $donePath, $exitPath, $pidPath",
);
expect(`${commands}\n${payloads}`).not.toContain("Set-Content -Path $exitPath");
expect(`${commands}\n${payloads}`).not.toContain("Set-Content -Path $donePath");
expect(commands).not.toContain("Set-Content -Path $pidPath");
expect(commands).not.toContain("ReadAllBytes");
});
it("does not treat Windows background log text as completion control", async () => {
const decodedCommands: string[] = [];
const fakeRun: typeof hostCommandRun = (_command, args) => {
const decoded = decodePowerShellFromArgs(args);
decodedCommands.push(decoded);
if (decoded.includes("Start-Process")) {
return { status: 0, stderr: "", stdout: "started\n" };
}
if (decoded.includes("__OPENCLAW_LOG_LENGTH__")) {
const markers = extractWindowsBackgroundControlMarkers(decoded);
return {
status: 0,
stderr: "",
stdout: [
`${markers.lengthPrefix}128`,
`${markers.offsetPrefix}128`,
"__OPENCLAW_BACKGROUND_EXIT__:0",
"__OPENCLAW_BACKGROUND_DONE__",
"",
].join("\n"),
};
}
return { status: 0, stderr: "", stdout: "" };
};
await expect(
runWindowsBackgroundPowerShell({
label: "windows background marker smuggle",
logChunkBytes: 128,
pollIntervalMs: 1,
runCommand: fakeRun,
script: "Write-Output done",
timeoutMs: 5,
vmName: "Windows Test",
}),
).rejects.toThrow("windows background marker smuggle timed out");
expect(decodedCommands.join("\n")).toContain(
"Stop-OpenClawBackgroundProcessTree ([int]$backgroundPid)",
);
});
it("drains completed Windows background logs before cleanup", async () => {
const decodedCommands: string[] = [];
const output: string[] = [];
let pollCount = 0;
const fakeRun: typeof hostCommandRun = (_command, args) => {
const decoded = decodePowerShellFromArgs(args);
decodedCommands.push(decoded);
if (decoded.includes("Start-Process")) {
return { status: 0, stderr: "", stdout: "started\n" };
}
if (decoded.includes("__OPENCLAW_LOG_LENGTH__")) {
const markers = extractWindowsBackgroundControlMarkers(decoded);
pollCount += 1;
return {
status: 0,
stderr: "",
stdout:
pollCount === 1
? [
`${markers.lengthPrefix}128`,
`${markers.offsetPrefix}64`,
"first chunk",
`${markers.exitPrefix}0`,
markers.done,
"",
].join("\n")
: [
`${markers.lengthPrefix}128`,
`${markers.offsetPrefix}128`,
"second chunk",
`${markers.exitPrefix}0`,
markers.done,
"",
].join("\n"),
};
}
return { status: 0, stderr: "", stdout: "" };
};
await expect(
runWindowsBackgroundPowerShell({
append: (chunk) => output.push(String(chunk)),
completedLogDrainGraceMs: 1000,
label: "windows background drain",
logChunkBytes: 64,
pollIntervalMs: 5000,
runCommand: fakeRun,
script: "Write-Output done",
timeoutMs: 20,
vmName: "Windows Test",
}),
).resolves.toBeUndefined();
expect(pollCount).toBe(2);
expect(output.join("")).toContain("first chunk");
expect(output.join("")).toContain("second chunk");
expect(decodedCommands.join("\n")).not.toContain("Stop-OpenClawBackgroundProcessTree");
expect(decodedCommands.join("\n")).toContain(
"Remove-Item -Path $scriptPath, $logPath, $donePath, $exitPath, $pidPath",
);
});
it("keeps macOS sudo fallback update scripts readable by the desktop user", () => {
const script = readFileSync(SCRIPT_PATH, "utf8");
expect(script).toContain('macosExecArgs.indexOf("-u")');
expect(script).toContain('"/usr/sbin/chown", sudoUser, scriptPath');
});
it("selects macOS desktop users with homes on spaced mounted volumes", () => {
const root = makeTempDir();
const prlctlPath = path.join(root, "prlctl");
writeFileSync(
prlctlPath,
`#!/usr/bin/env bash
set -euo pipefail
args=" $* "
if [[ "$args" == *" /usr/bin/stat -f %Su /dev/console"* ]]; then
printf '%s\\n' 'loginwindow'
exit 0
fi
if [[ "$args" == *" /usr/bin/dscl . -list /Users NFSHomeDirectory"* ]]; then
printf '%s\\n' '_daemon /var/root'
printf '%s\\n' 'clawuser /Volumes/Macintosh HD/Users/clawuser'
exit 0
fi
exit 7
`,
);
chmodSync(prlctlPath, 0o755);
withEnv(
{
OPENAI_API_KEY: "test-key",
PATH: `${root}${path.delimiter}${process.env.PATH ?? ""}`,
},
() => {
const smoke = new NpmUpdateSmoke({
...TEST_AUTH,
json: false,
packageSpec: "openclaw@latest",
platforms: new Set<Platform>(["macos"]),
provider: "openai",
updateTarget: "local-main",
});
const resolveMacosDesktopUser = Reflect.get(
smoke,
"resolveMacosDesktopUser",
) as () => string;
expect(resolveMacosDesktopUser.call(smoke)).toBe("clawuser");
},
);
});
it("keeps spaces in macOS sudo fallback desktop homes", () => {
const root = makeTempDir();
const prlctlPath = path.join(root, "prlctl");
writeFileSync(
prlctlPath,
`#!/usr/bin/env bash
set -euo pipefail
args=" $* "
if [[ "$args" == *" /usr/bin/dscl . -read /Users/clawuser NFSHomeDirectory"* ]]; then
printf '%s\\n' 'NFSHomeDirectory: /Volumes/Macintosh HD/Users/clawuser'
exit 0
fi
exit 7
`,
);
chmodSync(prlctlPath, 0o755);
withEnv(
{
OPENAI_API_KEY: "test-key",
PATH: `${root}${path.delimiter}${process.env.PATH ?? ""}`,
},
() => {
const smoke = new NpmUpdateSmoke({
...TEST_AUTH,
json: false,
packageSpec: "openclaw@latest",
platforms: new Set<Platform>(["macos"]),
provider: "openai",
updateTarget: "local-main",
});
const resolveMacosDesktopHome = Reflect.get(smoke, "resolveMacosDesktopHome") as (
user: string,
) => string;
expect(resolveMacosDesktopHome.call(smoke, "clawuser")).toBe(
"/Volumes/Macintosh HD/Users/clawuser",
);
},
);
});
it("scrubs future plugin entries before invoking old same-guest updaters", () => {
const script = readFileSync(UPDATE_SCRIPTS_PATH, "utf8");
const macosScript = macosUpdateScript({
auth: TEST_AUTH,
expectedNeedle: "2026.5.3-beta.2",
updateTarget: "2026.5.3-beta.2",
});
expect(script).toContain("Remove-FuturePluginEntries");
expect(script).toContain("scrub_future_plugin_entries");
expect(script).toContain("delete plugins.entries.feishu");
expect(script).toContain("delete plugins.entries.whatsapp");
expect(script).toContain("Remove-FuturePluginEntries\nStop-OpenClawGatewayProcesses");
expect(script).toContain("scrub_future_plugin_entries\nstop_openclaw_gateway_processes");
expect(script).toContain("Invoke-WithScopedEnv @{ OPENCLAW_DISABLE_BUNDLED_PLUGINS = '1'");
expect(macosScript).toContain('OPENCLAW_BIN="$(resolve_required_command openclaw)"');
expect(macosScript).toContain("/usr/local/bin:/usr/local/sbin");
expect(macosScript).toContain(
'OPENCLAW_DISABLE_BUNDLED_PLUGINS=1 "$OPENCLAW_BIN" update --tag',
);
expect(macosScript).not.toContain("/opt/homebrew/bin/openclaw");
expect(script).toContain("OPENCLAW_DISABLE_BUNDLED_PLUGINS=1 openclaw update --tag");
expect(macosScript).toContain(
'OPENCLAW_DISABLE_BUNDLED_PLUGINS=1 "$OPENCLAW_BIN" gateway stop',
);
expect(script).toContain(
"OPENCLAW_DISABLE_BUNDLED_PLUGINS=1 OPENCLAW_ALLOW_ROOT=1 openclaw gateway stop",
);
});
it("reenables bundled plugins before Windows post-update verification", () => {
const script = windowsUpdateScript({
auth: TEST_AUTH,
expectedNeedle: "2026.5.3-beta.2",
updateTarget: "2026.5.3-beta.2",
});
const updateIndex = script.indexOf("Invoke-OpenClaw update --tag");
const scopedIndex = script.indexOf("Invoke-WithScopedEnv @{ OPENCLAW_DISABLE_BUNDLED_PLUGINS");
const versionIndex = script.indexOf("Invoke-OpenClaw --version", scopedIndex);
const restartIndex = script.indexOf("Invoke-OpenClaw gateway restart");
const agentIndex = script.indexOf("Invoke-OpenClaw agent --local");
expect(updateIndex).toBeGreaterThanOrEqual(0);
expect(scopedIndex).toBeGreaterThanOrEqual(0);
expect(updateIndex).toBeGreaterThan(scopedIndex);
expect(versionIndex).toBeGreaterThan(updateIndex);
expect(restartIndex).toBeGreaterThan(updateIndex);
expect(agentIndex).toBeGreaterThan(updateIndex);
expect(script).not.toContain("$env:OPENCLAW_DISABLE_BUNDLED_PLUGINS = '1'");
});
it("generates a .NET-safe Windows stale import regex in the update-failure guard", () => {
const script = windowsUpdateScript({
auth: TEST_AUTH,
expectedNeedle: "2026.4.30",
updateTarget: "latest",
});
const staleImportLine = script.match(/\$stalePostSwapImport = [^\n]+/)?.[0];
const staleImportMatch = script.match(/\$updateText -match '(node_modules[^']+)'/);
const staleImportPattern = staleImportMatch?.[1];
if (!staleImportLine) {
throw new Error("missing generated Windows stale import guard");
}
if (!staleImportPattern) {
throw new Error("missing generated Windows stale import regex");
}
expect(staleImportLine).toContain("$updateText -match 'ERR_MODULE_NOT_FOUND'");
expect(staleImportLine).toContain(`$updateText -match '${staleImportPattern}'`);
expect(staleImportPattern).toBe(
String.raw`node_modules\\openclaw\\dist\\[^\\]+-[A-Za-z0-9_-]+\.js`,
);
expect(staleImportPattern).not.toContain("node_modules\\openclaw\\dist\\");
expect(staleImportPattern.match(/\\\\/g)).toHaveLength(4);
const representativeUpdateFailure = String.raw`Error [ERR_MODULE_NOT_FOUND]: Cannot find module 'C:\Users\runner\AppData\Roaming\npm\node_modules\openclaw\dist\main-a1_B2.js' imported from C:\Users\runner\AppData\Roaming\npm\node_modules\openclaw\dist\cli.js`;
const generatedRegex = new RegExp(staleImportPattern);
expect(generatedRegex.test(representativeUpdateFailure)).toBe(true);
expect(generatedRegex.test(String.raw`node_modules\openclaw\dist\main.js`)).toBe(false);
});
});