mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 13:10:43 +00:00
559 lines
17 KiB
TypeScript
Executable File
559 lines
17 KiB
TypeScript
Executable File
#!/usr/bin/env -S pnpm tsx
|
|
import { spawn } from "node:child_process";
|
|
import { readFile, rm, writeFile } from "node:fs/promises";
|
|
import path from "node:path";
|
|
import {
|
|
die,
|
|
ensureValue,
|
|
makeTempDir,
|
|
packOpenClaw,
|
|
parsePlatformList,
|
|
parseProvider,
|
|
repoRoot,
|
|
resolveHostIp,
|
|
resolveLatestVersion,
|
|
resolveProviderAuth,
|
|
run,
|
|
say,
|
|
startHostServer,
|
|
writeJson,
|
|
type HostServer,
|
|
type PackageArtifact,
|
|
type Platform,
|
|
type Provider,
|
|
type ProviderAuth,
|
|
} from "./common.ts";
|
|
import { linuxUpdateScript, macosUpdateScript, windowsUpdateScript } from "./npm-update-scripts.ts";
|
|
import { ensureVmRunning, resolveUbuntuVmName } from "./parallels-vm.ts";
|
|
import { encodePowerShell } from "./powershell.ts";
|
|
|
|
interface NpmUpdateOptions {
|
|
packageSpec: string;
|
|
updateTarget: string;
|
|
platforms: Set<Platform>;
|
|
provider: Provider;
|
|
apiKeyEnv?: string;
|
|
modelId?: string;
|
|
json: boolean;
|
|
}
|
|
|
|
interface Job {
|
|
done: boolean;
|
|
label: string;
|
|
logPath: string;
|
|
promise: Promise<number>;
|
|
}
|
|
|
|
interface NpmUpdateSummary {
|
|
packageSpec: string;
|
|
updateTarget: string;
|
|
updateExpected: string;
|
|
provider: Provider;
|
|
latestVersion: string;
|
|
currentHead: string;
|
|
runDir: string;
|
|
fresh: Record<Platform, string>;
|
|
update: Record<Platform, { status: string; version: string }>;
|
|
}
|
|
|
|
const macosVm = "macOS Tahoe";
|
|
const windowsVm = "Windows 11";
|
|
const linuxVmDefault = "Ubuntu 24.04.3 ARM64";
|
|
const updateTimeoutSeconds = Number(process.env.OPENCLAW_PARALLELS_NPM_UPDATE_TIMEOUT_S || 1200);
|
|
|
|
function usage(): string {
|
|
return `Usage: bash scripts/e2e/parallels-npm-update-smoke.sh [options]
|
|
|
|
Options:
|
|
--package-spec <npm-spec> Baseline npm package spec. Default: openclaw@latest
|
|
--update-target <target> Target passed to guest 'openclaw update --tag'.
|
|
Default: host-served tgz packed from current checkout.
|
|
--platform <list> Comma-separated platforms to run: all, macos, windows, linux.
|
|
Default: all
|
|
--provider <openai|anthropic|minimax>
|
|
--model <provider/model> Override the model used for agent-turn smoke checks.
|
|
--api-key-env <var> Host env var name for provider API key.
|
|
--openai-api-key-env <var> Alias for --api-key-env (backward compatible)
|
|
--json Print machine-readable JSON summary.
|
|
-h, --help Show help.
|
|
`;
|
|
}
|
|
|
|
function parseArgs(argv: string[]): NpmUpdateOptions {
|
|
const options: NpmUpdateOptions = {
|
|
apiKeyEnv: undefined,
|
|
json: false,
|
|
modelId: undefined,
|
|
packageSpec: "",
|
|
platforms: parsePlatformList("all"),
|
|
provider: "openai",
|
|
updateTarget: "",
|
|
};
|
|
for (let i = 0; i < argv.length; i++) {
|
|
const arg = argv[i];
|
|
switch (arg) {
|
|
case "--":
|
|
break;
|
|
case "--package-spec":
|
|
options.packageSpec = ensureValue(argv, i, arg);
|
|
i++;
|
|
break;
|
|
case "--update-target":
|
|
options.updateTarget = ensureValue(argv, i, arg);
|
|
i++;
|
|
break;
|
|
case "--platform":
|
|
case "--only":
|
|
options.platforms = parsePlatformList(ensureValue(argv, i, arg));
|
|
i++;
|
|
break;
|
|
case "--provider":
|
|
options.provider = parseProvider(ensureValue(argv, i, arg));
|
|
i++;
|
|
break;
|
|
case "--model":
|
|
options.modelId = ensureValue(argv, i, arg);
|
|
i++;
|
|
break;
|
|
case "--api-key-env":
|
|
case "--openai-api-key-env":
|
|
options.apiKeyEnv = ensureValue(argv, i, arg);
|
|
i++;
|
|
break;
|
|
case "--json":
|
|
options.json = true;
|
|
break;
|
|
case "-h":
|
|
case "--help":
|
|
process.stdout.write(usage());
|
|
process.exit(0);
|
|
default:
|
|
die(`unknown arg: ${arg}`);
|
|
}
|
|
}
|
|
return options;
|
|
}
|
|
|
|
function platformRecord<T>(value: T): Record<Platform, T> {
|
|
return { linux: value, macos: value, windows: value };
|
|
}
|
|
|
|
class NpmUpdateSmoke {
|
|
private auth: ProviderAuth;
|
|
private runDir = "";
|
|
private tgzDir = "";
|
|
private latestVersion = "";
|
|
private packageSpec = "";
|
|
private currentHead = "";
|
|
private currentHeadShort = "";
|
|
private hostIp = "";
|
|
private server: HostServer | null = null;
|
|
private artifact: PackageArtifact | null = null;
|
|
private updateTargetEffective = "";
|
|
private updateExpectedNeedle = "";
|
|
private linuxVm = linuxVmDefault;
|
|
|
|
private freshStatus = platformRecord("skip");
|
|
private updateStatus = platformRecord("skip");
|
|
private updateVersion = platformRecord("skip");
|
|
|
|
constructor(private options: NpmUpdateOptions) {
|
|
this.auth = resolveProviderAuth({
|
|
apiKeyEnv: options.apiKeyEnv,
|
|
modelId: options.modelId,
|
|
provider: options.provider,
|
|
});
|
|
}
|
|
|
|
async run(): Promise<void> {
|
|
this.runDir = await makeTempDir("openclaw-parallels-npm-update.");
|
|
this.tgzDir = await makeTempDir("openclaw-parallels-npm-update-tgz.");
|
|
try {
|
|
this.latestVersion = resolveLatestVersion();
|
|
this.packageSpec = this.options.packageSpec || `openclaw@${this.latestVersion}`;
|
|
this.currentHead = run("git", ["rev-parse", "HEAD"], { quiet: true }).stdout.trim();
|
|
this.currentHeadShort = run("git", ["rev-parse", "--short=7", "HEAD"], {
|
|
quiet: true,
|
|
}).stdout.trim();
|
|
this.hostIp = resolveHostIp("");
|
|
|
|
if (this.options.platforms.has("linux")) {
|
|
this.linuxVm = resolveUbuntuVmName(linuxVmDefault);
|
|
}
|
|
this.preflightRegistryUpdateTarget();
|
|
|
|
say(`Run fresh npm baseline: ${this.packageSpec}`);
|
|
say(`Platforms: ${[...this.options.platforms].join(",")}`);
|
|
say(`Run dir: ${this.runDir}`);
|
|
await this.runFreshBaselines();
|
|
|
|
await this.prepareUpdateTarget();
|
|
say(`Run same-guest openclaw update to ${this.updateTargetEffective}`);
|
|
await this.runSameGuestUpdates();
|
|
|
|
const summaryPath = await this.writeSummary();
|
|
if (this.options.json) {
|
|
process.stdout.write(await readFile(summaryPath, "utf8"));
|
|
} else {
|
|
say(`Run dir: ${this.runDir}`);
|
|
process.stdout.write(await readFile(summaryPath, "utf8"));
|
|
}
|
|
} finally {
|
|
await this.server?.stop().catch(() => undefined);
|
|
await rm(this.tgzDir, { force: true, recursive: true }).catch(() => undefined);
|
|
}
|
|
}
|
|
|
|
private async runFreshBaselines(): Promise<void> {
|
|
const jobs: Job[] = [];
|
|
if (this.options.platforms.has("macos")) {
|
|
jobs.push(this.spawnFresh("macOS", "macos", []));
|
|
}
|
|
if (this.options.platforms.has("windows")) {
|
|
jobs.push(this.spawnFresh("Windows", "windows", []));
|
|
}
|
|
if (this.options.platforms.has("linux")) {
|
|
jobs.push(
|
|
this.spawnFresh("Linux", "linux", ["--vm", this.linuxVm], {
|
|
OPENCLAW_PARALLELS_LINUX_DISABLE_BONJOUR: "1",
|
|
}),
|
|
);
|
|
}
|
|
await this.monitorJobs("fresh", jobs);
|
|
for (const job of jobs) {
|
|
const status = (await job.promise) === 0 ? "pass" : "fail";
|
|
const platform = this.platformFromLabel(job.label);
|
|
this.freshStatus[platform] = status;
|
|
if (status !== "pass") {
|
|
this.dumpLogTail(job.logPath);
|
|
die(`${job.label} fresh baseline failed`);
|
|
}
|
|
}
|
|
}
|
|
|
|
private spawnFresh(
|
|
label: string,
|
|
platform: Platform,
|
|
extraArgs: string[],
|
|
env: NodeJS.ProcessEnv = {},
|
|
): Job {
|
|
const logPath = path.join(this.runDir, `${platform}-fresh.log`);
|
|
const args = [
|
|
"exec",
|
|
"tsx",
|
|
`scripts/e2e/parallels/${platform}-smoke.ts`,
|
|
"--mode",
|
|
"fresh",
|
|
"--provider",
|
|
this.options.provider,
|
|
"--model",
|
|
this.auth.modelId,
|
|
"--api-key-env",
|
|
this.auth.apiKeyEnv,
|
|
"--target-package-spec",
|
|
this.packageSpec,
|
|
"--json",
|
|
...extraArgs,
|
|
];
|
|
const job: Job = {
|
|
done: false,
|
|
label,
|
|
logPath,
|
|
promise: Promise.resolve(1),
|
|
};
|
|
job.promise = this.spawnLogged("pnpm", args, logPath, env).finally(() => {
|
|
job.done = true;
|
|
});
|
|
return job;
|
|
}
|
|
|
|
private async prepareUpdateTarget(): Promise<void> {
|
|
if (!this.options.updateTarget || this.options.updateTarget === "local-main") {
|
|
this.artifact = await packOpenClaw({
|
|
destination: this.tgzDir,
|
|
requireControlUi: true,
|
|
stageRuntimeDeps: true,
|
|
});
|
|
this.server = await startHostServer({
|
|
artifactPath: this.artifact.path,
|
|
dir: this.tgzDir,
|
|
hostIp: this.hostIp,
|
|
label: "current main tgz",
|
|
port: 0,
|
|
});
|
|
this.updateTargetEffective = this.server.urlFor(this.artifact.path);
|
|
this.updateExpectedNeedle = this.currentHeadShort;
|
|
return;
|
|
}
|
|
this.updateTargetEffective = this.options.updateTarget;
|
|
this.updateExpectedNeedle = this.isExplicitPackageTarget(this.updateTargetEffective)
|
|
? ""
|
|
: this.resolveRegistryTargetVersion(this.updateTargetEffective) || this.updateTargetEffective;
|
|
}
|
|
|
|
private async runSameGuestUpdates(): Promise<void> {
|
|
const jobs: Job[] = [];
|
|
if (this.options.platforms.has("macos")) {
|
|
ensureVmRunning(macosVm);
|
|
jobs.push(this.spawnUpdate("macOS", "macos", () => this.runMacosUpdate()));
|
|
}
|
|
if (this.options.platforms.has("windows")) {
|
|
ensureVmRunning(windowsVm);
|
|
jobs.push(this.spawnUpdate("Windows", "windows", () => this.runWindowsUpdate()));
|
|
}
|
|
if (this.options.platforms.has("linux")) {
|
|
ensureVmRunning(this.linuxVm);
|
|
jobs.push(this.spawnUpdate("Linux", "linux", () => this.runLinuxUpdate()));
|
|
}
|
|
await this.monitorJobs("update", jobs);
|
|
for (const job of jobs) {
|
|
const platform = this.platformFromLabel(job.label);
|
|
const status = (await job.promise) === 0 ? "pass" : "fail";
|
|
this.updateStatus[platform] = status;
|
|
this.updateVersion[platform] = await this.extractLastVersion(job.logPath);
|
|
if (status !== "pass") {
|
|
this.dumpLogTail(job.logPath);
|
|
die(`${job.label} update failed`);
|
|
}
|
|
}
|
|
}
|
|
|
|
private spawnUpdate(label: string, platform: Platform, fn: () => void): Job {
|
|
const logPath = path.join(this.runDir, `${platform}-update.log`);
|
|
const job: Job = {
|
|
done: false,
|
|
label,
|
|
logPath,
|
|
promise: Promise.resolve(1),
|
|
};
|
|
job.promise = (async () => {
|
|
const originalStdout = process.stdout.write.bind(process.stdout);
|
|
const originalStderr = process.stderr.write.bind(process.stderr);
|
|
let log = "";
|
|
const append = (chunk: string | Uint8Array): boolean => {
|
|
const text = typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8");
|
|
log += text;
|
|
return true;
|
|
};
|
|
const timeout = setTimeout(() => {
|
|
append(`${label} update timed out after ${updateTimeoutSeconds}s\n`);
|
|
}, updateTimeoutSeconds * 1000);
|
|
try {
|
|
process.stdout.write = ((chunk: string | Uint8Array) =>
|
|
append(chunk)) as typeof process.stdout.write;
|
|
process.stderr.write = ((chunk: string | Uint8Array) =>
|
|
append(chunk)) as typeof process.stderr.write;
|
|
fn();
|
|
await writeFile(logPath, log, "utf8");
|
|
return 0;
|
|
} catch (error) {
|
|
append(`${error instanceof Error ? error.message : String(error)}\n`);
|
|
await writeFile(logPath, log, "utf8");
|
|
return 1;
|
|
} finally {
|
|
clearTimeout(timeout);
|
|
process.stdout.write = originalStdout;
|
|
process.stderr.write = originalStderr;
|
|
}
|
|
})().finally(() => {
|
|
job.done = true;
|
|
});
|
|
return job;
|
|
}
|
|
|
|
private runMacosUpdate(): void {
|
|
this.guestMacos(this.updateScript("macos"), updateTimeoutSeconds * 1000);
|
|
}
|
|
|
|
private runWindowsUpdate(): void {
|
|
this.guestWindows(this.updateScript("windows"), updateTimeoutSeconds * 1000);
|
|
}
|
|
|
|
private runLinuxUpdate(): void {
|
|
this.guestLinux(this.updateScript("linux"), updateTimeoutSeconds * 1000);
|
|
}
|
|
|
|
private updateScript(platform: Platform): string {
|
|
const input = {
|
|
auth: this.auth,
|
|
expectedNeedle: this.updateExpectedNeedle,
|
|
updateTarget: this.updateTargetEffective,
|
|
};
|
|
switch (platform) {
|
|
case "macos":
|
|
return macosUpdateScript(input);
|
|
case "windows":
|
|
return windowsUpdateScript(input);
|
|
case "linux":
|
|
return linuxUpdateScript(input);
|
|
}
|
|
return die("unsupported platform");
|
|
}
|
|
|
|
private spawnLogged(
|
|
command: string,
|
|
args: string[],
|
|
logPath: string,
|
|
env: NodeJS.ProcessEnv = {},
|
|
): Promise<number> {
|
|
return new Promise((resolve, reject) => {
|
|
const child = spawn(command, args, {
|
|
cwd: repoRoot,
|
|
env: { ...process.env, ...env },
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
});
|
|
let log = "";
|
|
child.stdout.on("data", (chunk: Buffer) => {
|
|
log += chunk.toString("utf8");
|
|
});
|
|
child.stderr.on("data", (chunk: Buffer) => {
|
|
log += chunk.toString("utf8");
|
|
});
|
|
child.on("error", reject);
|
|
child.on("close", async (code) => {
|
|
await writeFile(logPath, log, "utf8");
|
|
resolve(code ?? 1);
|
|
});
|
|
});
|
|
}
|
|
|
|
private async monitorJobs(label: string, jobs: Job[]): Promise<void> {
|
|
const pending = new Set(jobs.map((job) => job.label));
|
|
while (pending.size > 0) {
|
|
await new Promise((resolve) => setTimeout(resolve, 15_000));
|
|
for (const job of jobs) {
|
|
if (!pending.has(job.label)) {
|
|
continue;
|
|
}
|
|
if (job.done) {
|
|
pending.delete(job.label);
|
|
}
|
|
}
|
|
if (pending.size > 0) {
|
|
say(`${label} still running: ${[...pending].join(", ")}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
private guestMacos(script: string, timeoutMs: number): void {
|
|
run(
|
|
"prlctl",
|
|
[
|
|
"exec",
|
|
macosVm,
|
|
"--current-user",
|
|
"/usr/bin/env",
|
|
"PATH=/opt/homebrew/bin:/opt/homebrew/opt/node/bin:/opt/homebrew/sbin:/usr/bin:/bin:/usr/sbin:/sbin",
|
|
"/bin/bash",
|
|
"-lc",
|
|
script,
|
|
],
|
|
{ timeoutMs },
|
|
);
|
|
}
|
|
|
|
private guestWindows(script: string, timeoutMs: number): void {
|
|
run(
|
|
"prlctl",
|
|
[
|
|
"exec",
|
|
windowsVm,
|
|
"--current-user",
|
|
"powershell.exe",
|
|
"-NoProfile",
|
|
"-ExecutionPolicy",
|
|
"Bypass",
|
|
"-EncodedCommand",
|
|
encodePowerShell(script),
|
|
],
|
|
{ timeoutMs },
|
|
);
|
|
}
|
|
|
|
private guestLinux(script: string, timeoutMs: number): void {
|
|
run("prlctl", ["exec", this.linuxVm, "/usr/bin/env", "HOME=/root", "bash", "-lc", script], {
|
|
timeoutMs,
|
|
});
|
|
}
|
|
|
|
private resolveRegistryTargetVersion(target: string): string {
|
|
const spec = target.startsWith("openclaw@") ? target : `openclaw@${target}`;
|
|
return (
|
|
run("npm", ["view", spec, "version"], { check: false, quiet: true })
|
|
.stdout.trim()
|
|
.split("\n")
|
|
.at(-1) ?? ""
|
|
);
|
|
}
|
|
|
|
private isExplicitPackageTarget(target: string): boolean {
|
|
return (
|
|
target.includes("://") ||
|
|
target.includes("#") ||
|
|
/^(file|github|git\+ssh|git\+https|git\+http|git\+file|npm):/.test(target)
|
|
);
|
|
}
|
|
|
|
private preflightRegistryUpdateTarget(): void {
|
|
if (
|
|
!this.options.updateTarget ||
|
|
this.options.updateTarget === "local-main" ||
|
|
this.isExplicitPackageTarget(this.options.updateTarget)
|
|
) {
|
|
return;
|
|
}
|
|
const baseline = this.resolveRegistryTargetVersion(this.packageSpec);
|
|
const target = this.resolveRegistryTargetVersion(this.options.updateTarget);
|
|
if (baseline && target && baseline === target) {
|
|
die(
|
|
`--update-target ${this.options.updateTarget} resolves to openclaw@${target}, same as baseline ${this.packageSpec}; publish or choose a newer --update-target before running VM update coverage`,
|
|
);
|
|
}
|
|
}
|
|
|
|
private platformFromLabel(label: string): Platform {
|
|
if (label === "macOS") {
|
|
return "macos";
|
|
}
|
|
return label.toLowerCase() as Platform;
|
|
}
|
|
|
|
private async extractLastVersion(logPath: string): Promise<string> {
|
|
const log = await readFile(logPath, "utf8").catch(() => "");
|
|
const matches = [...log.matchAll(/openclaw\s+([0-9][^\s]*)/g)];
|
|
return matches.at(-1)?.[1] ?? "";
|
|
}
|
|
|
|
private dumpLogTail(logPath: string): void {
|
|
const log = run("tail", ["-n", "80", logPath], { check: false, quiet: true }).stdout;
|
|
if (log) {
|
|
process.stderr.write(log);
|
|
}
|
|
}
|
|
|
|
private async writeSummary(): Promise<string> {
|
|
const summary: NpmUpdateSummary = {
|
|
currentHead: this.currentHeadShort,
|
|
fresh: this.freshStatus,
|
|
latestVersion: this.latestVersion,
|
|
packageSpec: this.packageSpec,
|
|
provider: this.options.provider,
|
|
runDir: this.runDir,
|
|
update: {
|
|
linux: { status: this.updateStatus.linux, version: this.updateVersion.linux },
|
|
macos: { status: this.updateStatus.macos, version: this.updateVersion.macos },
|
|
windows: { status: this.updateStatus.windows, version: this.updateVersion.windows },
|
|
},
|
|
updateExpected: this.updateExpectedNeedle,
|
|
updateTarget: this.updateTargetEffective,
|
|
};
|
|
const summaryPath = path.join(this.runDir, "summary.json");
|
|
await writeJson(summaryPath, summary);
|
|
return summaryPath;
|
|
}
|
|
}
|
|
|
|
await new NpmUpdateSmoke(parseArgs(process.argv.slice(2))).run().catch((error: unknown) => {
|
|
die(error instanceof Error ? error.message : String(error));
|
|
});
|