mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 11:12:54 +00:00
447 lines
17 KiB
TypeScript
447 lines
17 KiB
TypeScript
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import path from "node:path";
|
|
import { afterEach, describe, expect, it } from "vitest";
|
|
import { runWindowsBackgroundPowerShell } from "../../scripts/e2e/parallels/guest-transports.ts";
|
|
import { run as hostCommandRun } from "../../scripts/e2e/parallels/host-command.ts";
|
|
import {
|
|
macosUpdateScript,
|
|
windowsUpdateScript,
|
|
} from "../../scripts/e2e/parallels/npm-update-scripts.ts";
|
|
import {
|
|
freshLaneTimeoutMs,
|
|
NpmUpdateSmoke,
|
|
spawnLoggedCommand,
|
|
} from "../../scripts/e2e/parallels/npm-update-smoke.ts";
|
|
import type { HostServer, Platform } from "../../scripts/e2e/parallels/types.ts";
|
|
|
|
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`);
|
|
}
|
|
|
|
function decodePowerShellFromArgs(args: string[]): string {
|
|
const encoded = args[args.indexOf("-EncodedCommand") + 1];
|
|
return encoded ? Buffer.from(encoded, "base64").toString("utf16le") : "";
|
|
}
|
|
|
|
afterEach(() => {
|
|
for (const dir of tempDirs.splice(0)) {
|
|
rmSync(dir, { force: true, recursive: true });
|
|
}
|
|
});
|
|
|
|
describe("parallels npm update smoke", () => {
|
|
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)}`,
|
|
};
|
|
const previousOpenAiKey = process.env.OPENAI_API_KEY;
|
|
process.env.OPENAI_API_KEY = "test-key";
|
|
|
|
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");
|
|
}
|
|
}
|
|
|
|
try {
|
|
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");
|
|
} finally {
|
|
if (previousOpenAiKey === undefined) {
|
|
delete process.env.OPENAI_API_KEY;
|
|
} else {
|
|
process.env.OPENAI_API_KEY = previousOpenAiKey;
|
|
}
|
|
}
|
|
|
|
expect(stopCalls).toBe(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("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("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", () => {
|
|
const previous = process.env.OPENCLAW_PARALLELS_NPM_UPDATE_FRESH_TIMEOUT_S;
|
|
try {
|
|
delete process.env.OPENCLAW_PARALLELS_NPM_UPDATE_FRESH_TIMEOUT_S;
|
|
expect(freshLaneTimeoutMs("macos")).toBe(75 * 60 * 1000);
|
|
expect(freshLaneTimeoutMs("linux")).toBe(75 * 60 * 1000);
|
|
expect(freshLaneTimeoutMs("windows")).toBe(90 * 60 * 1000);
|
|
|
|
process.env.OPENCLAW_PARALLELS_NPM_UPDATE_FRESH_TIMEOUT_S = "3";
|
|
expect(freshLaneTimeoutMs("macos")).toBe(3000);
|
|
} finally {
|
|
if (previous === undefined) {
|
|
delete process.env.OPENCLAW_PARALLELS_NPM_UPDATE_FRESH_TIMEOUT_S;
|
|
} else {
|
|
process.env.OPENCLAW_PARALLELS_NPM_UPDATE_FRESH_TIMEOUT_S = previous;
|
|
}
|
|
}
|
|
});
|
|
|
|
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("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 fakeRun: typeof hostCommandRun = (_command, args) => {
|
|
const decoded = decodePowerShellFromArgs(args);
|
|
decodedCommands.push(decoded);
|
|
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");
|
|
expect(commands).toContain("$pidPath");
|
|
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).not.toContain("ReadAllBytes");
|
|
});
|
|
|
|
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__")) {
|
|
pollCount += 1;
|
|
return {
|
|
status: 0,
|
|
stderr: "",
|
|
stdout:
|
|
pollCount === 1
|
|
? [
|
|
"__OPENCLAW_LOG_LENGTH__:128",
|
|
"__OPENCLAW_LOG_OFFSET__:64",
|
|
"first chunk",
|
|
"__OPENCLAW_BACKGROUND_EXIT__:0",
|
|
"__OPENCLAW_BACKGROUND_DONE__",
|
|
"",
|
|
].join("\n")
|
|
: [
|
|
"__OPENCLAW_LOG_LENGTH__:128",
|
|
"__OPENCLAW_LOG_OFFSET__:128",
|
|
"second chunk",
|
|
"__OPENCLAW_BACKGROUND_EXIT__:0",
|
|
"__OPENCLAW_BACKGROUND_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("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);
|
|
});
|
|
});
|