From a8b38bb74293bd305cb31e55d35dff24c3f511b8 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 4 May 2026 04:43:18 +0100 Subject: [PATCH] test: improve Parallels beta validation --- .../skills/openclaw-parallels-smoke/SKILL.md | 9 +- scripts/e2e/parallels/filesystem.ts | 31 ++- scripts/e2e/parallels/guest-transports.ts | 6 +- scripts/e2e/parallels/linux-smoke.ts | 25 +++ scripts/e2e/parallels/macos-smoke.ts | 21 +- scripts/e2e/parallels/npm-update-smoke.ts | 208 +++++++++++++++--- scripts/e2e/parallels/package-artifact.ts | 31 +++ scripts/e2e/parallels/windows-smoke.ts | 31 ++- .../parallels-npm-update-smoke.test.ts | 21 ++ test/scripts/parallels-smoke-model.test.ts | 6 + 10 files changed, 351 insertions(+), 38 deletions(-) diff --git a/.agents/skills/openclaw-parallels-smoke/SKILL.md b/.agents/skills/openclaw-parallels-smoke/SKILL.md index ef23697a9a7..472a75fa14b 100644 --- a/.agents/skills/openclaw-parallels-smoke/SKILL.md +++ b/.agents/skills/openclaw-parallels-smoke/SKILL.md @@ -14,7 +14,7 @@ Use this skill for Parallels guest workflows and smoke interpretation. Do not lo - Stable `2026.3.12` pre-upgrade diagnostics may require a plain `gateway status --deep` fallback. - Treat `precheck=latest-ref-fail` on that stable pre-upgrade lane as baseline, not automatically a regression. - Pass `--json` for machine-readable summaries. -- Per-phase logs land under `/tmp/openclaw-parallels-*`. +- Per-phase logs land under `.artifacts/parallels/openclaw-parallels-*` by default. Override with `OPENCLAW_PARALLELS_ARTIFACT_ROOT` when a run needs another artifact volume. - Do not run local and gateway agent turns in parallel on the same fresh workspace or session. - Hard-cap every top-level Parallels lane with host `timeout --foreground` (or `gtimeout --foreground` if that is the available binary) so a stalled install, snapshot switch, or `prlctl exec` transport cannot consume the rest of the testing window. Defaults: - macOS: `75m` @@ -68,8 +68,13 @@ Use this skill for Parallels guest workflows and smoke interpretation. Do not lo - The Windows same-guest update helper should write stage markers to its log before long steps like tgz download and `npm install -g` so the outer progress monitor does not sit on `waiting for first log line` during healthy but quiet installs. - Linux same-guest update verification should also export `HOME=/root`, pass `OPENAI_API_KEY` via `prlctl exec ... /usr/bin/env`, and use `openclaw agent --local`; the fresh Linux baseline does not rely on persisted gateway credentials. - The npm-update wrapper now prints per-lane progress from the nested log files. If a lane still looks stuck, inspect the nested logs in `runDir` first (`macos-fresh.log`, `windows-fresh.log`, `linux-fresh.log`, `macos-update.log`, `windows-update.log`, `linux-update.log`) instead of assuming the outer wrapper hung. -- If the wrapper fails a lane, read the auto-dumped tail first, then the full nested lane log under `/tmp/openclaw-parallels-npm-update.*`. +- Each run writes both `summary.json` and `summary.md`; read the markdown first for quick human triage, then the JSON/timings for automation. +- For full beta validation after a tag is published, prefer one command: + - `timeout --foreground 150m pnpm test:parallels:npm-update -- --beta-validation beta3 --json` + This resolves `beta3` to the latest `*-beta.3` version, runs latest->that-version same-guest update coverage, and then runs fresh install smoke for that exact published target on the same selected OS matrix. Use `--platform macos|windows|linux` to narrow reruns. +- If the wrapper fails a lane, read the auto-dumped tail first, then the full nested lane log under `.artifacts/parallels/openclaw-parallels-npm-update.*`. - Current known macOS update-lane transport signature when the fallback is missing or bypassed: `Unable to authenticate the user. Make sure that the specified credentials are correct and try again.` Treat that as Parallels current-user authentication before blaming npm or OpenClaw. +- A macOS packaged fresh install with global package directories or bundled files mode `0777` usually means the harness used the root `prlctl exec` fallback under a permissive umask. The POSIX guest transports should prepend `umask 022`; verify the phase preflight line before blaming npm. ## CLI invocation footgun diff --git a/scripts/e2e/parallels/filesystem.ts b/scripts/e2e/parallels/filesystem.ts index f795943b1c7..cda9471e7bd 100644 --- a/scripts/e2e/parallels/filesystem.ts +++ b/scripts/e2e/parallels/filesystem.ts @@ -1,8 +1,7 @@ -import { writeFileSync, rmSync } from "node:fs"; -import { mkdtempSync } from "node:fs"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { access, mkdir, readFile, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; import path from "node:path"; +import { repoRoot } from "./host-command.ts"; export async function exists(filePath: string): Promise { try { @@ -23,7 +22,31 @@ export async function writeJson(filePath: string, value: unknown): Promise } export async function makeTempDir(prefix: string): Promise { - return mkdtempSync(path.join(tmpdir(), prefix)); + const root = + process.env.OPENCLAW_PARALLELS_ARTIFACT_ROOT || path.join(repoRoot, ".artifacts", "parallels"); + mkdirSync(root, { recursive: true }); + return mkdtempSync(path.join(root, prefix)); +} + +export async function writeSummaryMarkdown(input: { + summaryPath: string; + title: string; + lines: string[]; +}): Promise { + const markdownPath = path.join(path.dirname(input.summaryPath), "summary.md"); + await writeFile( + markdownPath, + [ + `# ${input.title}`, + "", + ...input.lines, + "", + `JSON: ${path.basename(input.summaryPath)}`, + "", + ].join("\n"), + "utf8", + ); + return markdownPath; } export async function cleanupPath(filePath: string): Promise { diff --git a/scripts/e2e/parallels/guest-transports.ts b/scripts/e2e/parallels/guest-transports.ts index 0018b8720e7..16bd954569b 100644 --- a/scripts/e2e/parallels/guest-transports.ts +++ b/scripts/e2e/parallels/guest-transports.ts @@ -281,7 +281,7 @@ export class LinuxGuest { "prlctl", ["exec", this.vmName, "/usr/bin/env", "HOME=/root", "dd", `of=${scriptPath}`, "bs=1048576"], { - input: script, + input: `umask 022\n${script}`, quiet: true, timeoutMs: this.phases.remainingTimeoutMs(), }, @@ -352,7 +352,9 @@ export class MacosGuest { sh(script: string, env: Record = {}): string { const scriptPath = `/tmp/openclaw-parallels-${process.pid}-${Date.now()}.sh`; - this.exec(["/bin/dd", `of=${scriptPath}`, "bs=1048576"], { input: script }); + this.exec(["/bin/dd", `of=${scriptPath}`, "bs=1048576"], { + input: `umask 022\n${script}`, + }); try { return this.exec(["/bin/bash", scriptPath], { env }); } finally { diff --git a/scripts/e2e/parallels/linux-smoke.ts b/scripts/e2e/parallels/linux-smoke.ts index 8ef8b4225b3..ba81437332b 100755 --- a/scripts/e2e/parallels/linux-smoke.ts +++ b/scripts/e2e/parallels/linux-smoke.ts @@ -26,6 +26,7 @@ import { startHostServer, warn, writeJson, + writeSummaryMarkdown, type HostServer, type Mode, type PackageArtifact, @@ -328,6 +329,7 @@ class LinuxSmoke { private async runFreshLane(): Promise { await this.phase("fresh.restore-snapshot", 180, () => this.restoreSnapshot()); await this.phase("fresh.bootstrap-guest", 600, () => this.bootstrapGuest()); + await this.phase("fresh.preflight", 90, () => this.logGuestPreflight()); await this.phase("fresh.install-latest-bootstrap", 420, () => this.installLatestRelease()); await this.phase("fresh.install-main", 420, () => this.installMainTgz("openclaw-main-fresh.tgz"), @@ -353,6 +355,7 @@ class LinuxSmoke { private async runUpgradeLane(): Promise { await this.phase("upgrade.restore-snapshot", 180, () => this.restoreSnapshot()); await this.phase("upgrade.bootstrap-guest", 600, () => this.bootstrapGuest()); + await this.phase("upgrade.preflight", 90, () => this.logGuestPreflight()); await this.phase("upgrade.install-latest", 420, () => this.installLatestRelease()); this.status.latestInstalledVersion = await this.extractLastVersion("upgrade.install-latest"); await this.phase("upgrade.verify-latest-version", 90, () => @@ -391,6 +394,15 @@ class LinuxSmoke { return this.phases.remainingTimeoutMs(); } + private logGuestPreflight(): void { + this.guestBash(String.raw`set -euo pipefail +printf 'preflight.user=%s\n' "$(whoami)" +printf 'preflight.home=%s\n' "$HOME" +printf 'preflight.path=%s\n' "$PATH" +printf 'preflight.umask=%s\n' "$(umask)" +printf 'preflight.npmRoot=%s\n' "$(npm root -g 2>/dev/null || true)"`); + } + private log(text: string): void { this.phases.append(text); } @@ -784,6 +796,19 @@ fi`, vm: this.options.vmName, }; await writeJson(summaryPath, summary); + await writeSummaryMarkdown({ + lines: [ + `- vm: ${summary.vm}`, + `- target: ${summary.targetPackageSpec || "current main"}`, + `- daemon: ${summary.daemon}`, + `- fresh: ${summary.freshMain.status} ${summary.freshMain.version}`, + `- fresh gateway/agent: ${summary.freshMain.gateway}/${summary.freshMain.agent}`, + `- upgrade: ${summary.upgrade.status} ${summary.upgrade.mainVersion}`, + `- logs: ${summary.runDir}`, + ], + summaryPath, + title: "Linux Parallels Smoke", + }); return summaryPath; } diff --git a/scripts/e2e/parallels/macos-smoke.ts b/scripts/e2e/parallels/macos-smoke.ts index 4463946a2a8..388cf0ebd55 100755 --- a/scripts/e2e/parallels/macos-smoke.ts +++ b/scripts/e2e/parallels/macos-smoke.ts @@ -24,6 +24,7 @@ import { startHostServer, warn, writeJson, + writeSummaryMarkdown, type HostServer, type Mode, type PackageArtifact, @@ -728,6 +729,12 @@ class MacosSmoke { this.guestSh(String.raw`/usr/bin/pkill -f 'openclaw.*gateway run' >/dev/null 2>&1 || true /usr/bin/pkill -f 'openclaw-gateway' >/dev/null 2>&1 || true /usr/bin/pkill -f 'openclaw.mjs gateway' >/dev/null 2>&1 || true +printf 'preflight.user=%s\n' "$(whoami)" +printf 'preflight.home=%s\n' "$HOME" +printf 'preflight.path=%s\n' "$PATH" +printf 'preflight.umask=%s\n' "$(umask)" +printf 'preflight.npmRoot=%s\n' "$(${guestNpm} root -g 2>/dev/null || true)" +${guestNpm} uninstall -g openclaw >/dev/null 2>&1 || true rm -rf "$HOME/.openclaw" rm -f /tmp/openclaw-parallels-macos-gateway.log`); } @@ -1060,7 +1067,7 @@ fi`, private async extractLastVersion(phaseName: string): Promise { const log = await readFile(path.join(this.runDir, `${phaseName}.log`), "utf8").catch(() => ""); - const matches = [...log.matchAll(/openclaw\s+([0-9][^\s]*)/g)]; + const matches = [...log.matchAll(/OpenClaw\s+([0-9][^\s]*)/gi)]; return matches.at(-1)?.[1] ?? ""; } @@ -1104,6 +1111,18 @@ fi`, }; const summaryPath = path.join(this.runDir, "summary.json"); await writeJson(summaryPath, summary); + await writeSummaryMarkdown({ + lines: [ + `- vm: ${summary.vm}`, + `- target: ${summary.targetPackageSpec || "current main"}`, + `- fresh: ${summary.freshMain.status} ${summary.freshMain.version}`, + `- fresh gateway/dashboard/agent: ${summary.freshMain.gateway}/${summary.freshMain.dashboard}/${summary.freshMain.agent}`, + `- upgrade: ${summary.upgrade.status} ${summary.upgrade.mainVersion}`, + `- logs: ${summary.runDir}`, + ], + summaryPath, + title: "macOS Parallels Smoke", + }); return summaryPath; } diff --git a/scripts/e2e/parallels/npm-update-smoke.ts b/scripts/e2e/parallels/npm-update-smoke.ts index 5e832d17f22..cee3397e4b0 100755 --- a/scripts/e2e/parallels/npm-update-smoke.ts +++ b/scripts/e2e/parallels/npm-update-smoke.ts @@ -12,11 +12,14 @@ import { repoRoot, resolveHostIp, resolveLatestVersion, + resolveOpenClawRegistryVersion, resolveProviderAuth, resolveWindowsProviderAuth, run, say, + shellQuote, startHostServer, + writeSummaryMarkdown, writeJson, type HostServer, type PackageArtifact, @@ -29,6 +32,8 @@ import { linuxUpdateScript, macosUpdateScript, windowsUpdateScript } from "./npm import { ensureVmRunning, resolveUbuntuVmName } from "./parallels-vm.ts"; interface NpmUpdateOptions { + betaValidation?: string; + freshTargetSpec?: string; packageSpec: string; updateTarget: string; platforms: Set; @@ -42,8 +47,12 @@ interface Job { done: boolean; durationMs: number; label: string; + lastBytes: number; + lastOutputAt: number; + lastPhase: string; logPath: string; promise: Promise; + rerunCommand: string; startedAt: number; } @@ -61,12 +70,14 @@ interface NpmUpdateSummary { currentHead: string; runDir: string; fresh: Record; + freshTarget: Record; + freshTargetSpec: string; update: Record; timings: Array<{ durationMs: number; label: string; logPath: string; - phase: "fresh" | "update"; + phase: "fresh" | "fresh-target" | "update"; status: string; }>; } @@ -83,6 +94,10 @@ Options: --package-spec Baseline npm package spec. Default: openclaw@latest --update-target Target passed to guest 'openclaw update --tag'. Default: host-served tgz packed from current checkout. + --fresh-target Also run fresh install smoke for this package after update lanes. + --beta-validation [target] Resolve a beta tag/alias/version, then run latest->target update + plus fresh target install. Default target when flag is bare: beta. + Aliases like beta3 resolve to the latest *-beta.3 version. --platform Comma-separated platforms to run: all, macos, windows, linux. Default: all --provider @@ -97,6 +112,8 @@ Options: function parseArgs(argv: string[]): NpmUpdateOptions { const options: NpmUpdateOptions = { apiKeyEnv: undefined, + betaValidation: undefined, + freshTargetSpec: undefined, json: false, modelId: undefined, packageSpec: "", @@ -117,6 +134,20 @@ function parseArgs(argv: string[]): NpmUpdateOptions { options.updateTarget = ensureValue(argv, i, arg); i++; break; + case "--fresh-target": + options.freshTargetSpec = ensureValue(argv, i, arg); + i++; + break; + case "--beta-validation": { + const next = argv[i + 1]; + if (next && !next.startsWith("-")) { + options.betaValidation = next; + i++; + } else { + options.betaValidation = "beta"; + } + break; + } case "--platform": case "--only": options.platforms = parsePlatformList(ensureValue(argv, i, arg)); @@ -165,11 +196,13 @@ class NpmUpdateSmoke { private hostIp = ""; private server: HostServer | null = null; private artifact: PackageArtifact | null = null; + private freshTargetSpec = ""; private updateTargetEffective = ""; private updateExpectedNeedle = ""; private linuxVm = linuxVmDefault; private freshStatus = platformRecord("skip"); + private freshTargetStatus = platformRecord("skip"); private updateStatus = platformRecord("skip"); private updateVersion = platformRecord("skip"); private timings: NpmUpdateSummary["timings"] = []; @@ -198,6 +231,7 @@ class NpmUpdateSmoke { quiet: true, }).stdout.trim(); this.hostIp = resolveHostIp(""); + this.configurePublishedTargets(); if (this.options.platforms.has("linux")) { this.linuxVm = resolveUbuntuVmName(linuxVmDefault); @@ -213,6 +247,11 @@ class NpmUpdateSmoke { say(`Run same-guest openclaw update to ${this.updateTargetEffective}`); await this.runSameGuestUpdates(); + if (this.freshTargetSpec) { + say(`Run fresh target npm install: ${this.freshTargetSpec}`); + await this.runFreshTargetInstalls(); + } + const summaryPath = await this.writeSummary(); if (this.options.json) { process.stdout.write(await readFile(summaryPath, "utf8")); @@ -249,7 +288,44 @@ class NpmUpdateSmoke { this.recordTiming("fresh", job, status); if (status !== "pass") { this.dumpLogTail(job.logPath); - die(`${job.label} fresh baseline failed`); + die(`${job.label} fresh baseline failed; rerun: ${job.rerunCommand}`); + } + } + } + + private async runFreshTargetInstalls(): Promise { + const jobs: Job[] = []; + if (this.options.platforms.has("macos")) { + jobs.push(this.spawnFresh("macOS", "macos", [], {}, this.freshTargetSpec, "fresh-target")); + } + if (this.options.platforms.has("windows")) { + jobs.push( + this.spawnFresh("Windows", "windows", [], {}, this.freshTargetSpec, "fresh-target"), + ); + } + if (this.options.platforms.has("linux")) { + jobs.push( + this.spawnFresh( + "Linux", + "linux", + ["--vm", this.linuxVm], + { + OPENCLAW_PARALLELS_LINUX_DISABLE_BONJOUR: "1", + }, + this.freshTargetSpec, + "fresh-target", + ), + ); + } + await this.monitorJobs("fresh-target", jobs); + for (const job of jobs) { + const status = (await job.promise) === 0 ? "pass" : "fail"; + const platform = this.platformFromLabel(job.label); + this.freshTargetStatus[platform] = status; + this.recordTiming("fresh-target", job, status); + if (status !== "pass") { + this.dumpLogTail(job.logPath); + die(`${job.label} fresh target failed; rerun: ${job.rerunCommand}`); } } } @@ -259,8 +335,10 @@ class NpmUpdateSmoke { platform: Platform, extraArgs: string[], env: NodeJS.ProcessEnv = {}, + packageSpec = this.packageSpec, + phase: "fresh" | "fresh-target" = "fresh", ): Job { - const logPath = path.join(this.runDir, `${platform}-fresh.log`); + const logPath = path.join(this.runDir, `${platform}-${phase}.log`); const auth = this.authForPlatform(platform); const args = [ "exec", @@ -275,19 +353,26 @@ class NpmUpdateSmoke { "--api-key-env", auth.apiKeyEnv, "--target-package-spec", - this.packageSpec, + packageSpec, "--json", ...extraArgs, ]; + const startedAt = Date.now(); const job: Job = { done: false, durationMs: 0, label, + lastBytes: 0, + lastOutputAt: startedAt, + lastPhase: "starting", logPath, promise: Promise.resolve(1), - startedAt: Date.now(), + rerunCommand: this.formatRerun("pnpm", args, env), + startedAt, }; - job.promise = this.spawnLogged("pnpm", args, logPath, env).finally(() => { + job.promise = this.spawnLogged("pnpm", args, logPath, env, (text) => + this.noteJobOutput(job, text), + ).finally(() => { job.durationMs = Date.now() - job.startedAt; job.done = true; }); @@ -314,7 +399,7 @@ class NpmUpdateSmoke { this.updateTargetEffective = this.options.updateTarget; this.updateExpectedNeedle = this.isExplicitPackageTarget(this.updateTargetEffective) ? "" - : this.resolveRegistryTargetVersion(this.updateTargetEffective) || this.updateTargetEffective; + : resolveOpenClawRegistryVersion(this.updateTargetEffective) || this.updateTargetEffective; } private async runSameGuestUpdates(): Promise { @@ -340,7 +425,7 @@ class NpmUpdateSmoke { this.recordTiming("update", job, status); if (status !== "pass") { this.dumpLogTail(job.logPath); - die(`${job.label} update failed`); + die(`${job.label} update failed; rerun: ${job.rerunCommand}`); } } } @@ -351,20 +436,25 @@ class NpmUpdateSmoke { fn: (ctx: UpdateJobContext) => Promise | void, ): Job { const logPath = path.join(this.runDir, `${platform}-update.log`); + const startedAt = Date.now(); const job: Job = { done: false, durationMs: 0, label, + lastBytes: 0, + lastOutputAt: startedAt, + lastPhase: "starting", logPath, promise: Promise.resolve(1), - startedAt: Date.now(), + rerunCommand: `inspect ${logPath}; rerun aggregate phase with --platform ${platform}`, + startedAt, }; job.promise = (async () => { let log = ""; - const append = (chunk: string | Uint8Array): boolean => { + const append = (chunk: string | Uint8Array): void => { const text = typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8"); log += text; - return true; + this.noteJobOutput(job, text); }; const timeout = setTimeout(() => { append(`${label} update timed out after ${updateTimeoutSeconds}s\n`); @@ -425,6 +515,7 @@ class NpmUpdateSmoke { args: string[], logPath: string, env: NodeJS.ProcessEnv = {}, + onOutput: (text: string) => void = () => undefined, ): Promise { return new Promise((resolve, reject) => { const child = spawn(command, args, { @@ -434,10 +525,14 @@ class NpmUpdateSmoke { }); let log = ""; child.stdout.on("data", (chunk: Buffer) => { - log += chunk.toString("utf8"); + const text = chunk.toString("utf8"); + log += text; + onOutput(text); }); child.stderr.on("data", (chunk: Buffer) => { - log += chunk.toString("utf8"); + const text = chunk.toString("utf8"); + log += text; + onOutput(text); }); child.on("error", reject); child.on("close", async (code) => { @@ -460,7 +555,15 @@ class NpmUpdateSmoke { } } if (pending.size > 0) { - say(`${label} still running: ${[...pending].join(", ")}`); + const status = jobs + .filter((job) => pending.has(job.label)) + .map((job) => { + const elapsed = Math.floor((Date.now() - job.startedAt) / 1000); + const stale = Math.floor((Date.now() - job.lastOutputAt) / 1000); + return `${job.label}:${job.lastPhase} ${elapsed}s stale=${stale}s bytes=${job.lastBytes}`; + }) + .join(", "); + say(`${label} still running: ${status}`); } } } @@ -697,16 +800,6 @@ class NpmUpdateSmoke { }); } - 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("://") || @@ -723,8 +816,8 @@ class NpmUpdateSmoke { ) { return; } - const baseline = this.resolveRegistryTargetVersion(this.packageSpec); - const target = this.resolveRegistryTargetVersion(this.options.updateTarget); + const baseline = resolveOpenClawRegistryVersion(this.packageSpec); + const target = resolveOpenClawRegistryVersion(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`, @@ -741,18 +834,19 @@ class NpmUpdateSmoke { private async extractLastVersion(logPath: string): Promise { const log = await readFile(logPath, "utf8").catch(() => ""); - const matches = [...log.matchAll(/openclaw\s+([0-9][^\s]*)/g)]; + const matches = [...log.matchAll(/OpenClaw\s+([0-9][^\s]*)/gi)]; 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(`\n--- tail ${logPath} ---\n`); process.stderr.write(log); } } - private recordTiming(phase: "fresh" | "update", job: Job, status: string): void { + private recordTiming(phase: "fresh" | "fresh-target" | "update", job: Job, status: string): void { this.timings.push({ durationMs: job.durationMs || Date.now() - job.startedAt, label: job.label, @@ -762,10 +856,55 @@ class NpmUpdateSmoke { }); } + private configurePublishedTargets(): void { + if (this.options.betaValidation) { + const version = resolveOpenClawRegistryVersion(this.options.betaValidation); + if (!version) { + die(`could not resolve beta validation target: ${this.options.betaValidation}`); + } + this.options.updateTarget = version; + this.options.freshTargetSpec = `openclaw@${version}`; + say(`Beta validation target: openclaw@${version}`); + } else if ( + this.options.updateTarget && + this.options.updateTarget !== "local-main" && + !this.isExplicitPackageTarget(this.options.updateTarget) + ) { + const version = resolveOpenClawRegistryVersion(this.options.updateTarget); + if (version) { + this.options.updateTarget = version; + } + } + + if (this.options.freshTargetSpec) { + const version = resolveOpenClawRegistryVersion(this.options.freshTargetSpec); + this.freshTargetSpec = version ? `openclaw@${version}` : this.options.freshTargetSpec; + } + } + + private noteJobOutput(job: Job, text: string): void { + job.lastOutputAt = Date.now(); + job.lastBytes += text.length; + const matches = [...text.matchAll(/[=]=>\s*([A-Za-z0-9_.-]+)/g)]; + const phase = matches.at(-1)?.[1]; + if (phase) { + job.lastPhase = phase; + } + } + + private formatRerun(command: string, args: string[], env: NodeJS.ProcessEnv): string { + const envPrefix = Object.entries(env) + .filter(([, value]) => value !== undefined) + .map(([key, value]) => `${key}=${shellQuote(String(value))}`); + return [...envPrefix, command, ...args.map(shellQuote)].join(" "); + } + private async writeSummary(): Promise { const summary: NpmUpdateSummary = { currentHead: this.currentHeadShort, fresh: this.freshStatus, + freshTarget: this.freshTargetStatus, + freshTargetSpec: this.freshTargetSpec, latestVersion: this.latestVersion, packageSpec: this.packageSpec, provider: this.options.provider, @@ -781,6 +920,19 @@ class NpmUpdateSmoke { }; const summaryPath = path.join(this.runDir, "summary.json"); await writeJson(summaryPath, summary); + await writeSummaryMarkdown({ + lines: [ + `- package spec: ${summary.packageSpec}`, + `- update target: ${summary.updateTarget}`, + `- update expected: ${summary.updateExpected}`, + `- fresh: macOS=${summary.fresh.macos}, Windows=${summary.fresh.windows}, Linux=${summary.fresh.linux}`, + `- update: macOS=${summary.update.macos.status} (${summary.update.macos.version}), Windows=${summary.update.windows.status} (${summary.update.windows.version}), Linux=${summary.update.linux.status} (${summary.update.linux.version})`, + `- fresh target: ${summary.freshTargetSpec || "skip"} macOS=${summary.freshTarget.macos}, Windows=${summary.freshTarget.windows}, Linux=${summary.freshTarget.linux}`, + `- logs: ${summary.runDir}`, + ], + summaryPath, + title: "Parallels NPM Update Smoke", + }); return summaryPath; } } diff --git a/scripts/e2e/parallels/package-artifact.ts b/scripts/e2e/parallels/package-artifact.ts index 519fe3dd90e..787c331cdfe 100644 --- a/scripts/e2e/parallels/package-artifact.ts +++ b/scripts/e2e/parallels/package-artifact.ts @@ -24,6 +24,37 @@ export async function packageBuildCommitFromTgz(tgzPath: string): Promise version.endsWith(betaSuffix)) + .toSorted((a, b) => a.localeCompare(b, undefined, { numeric: true })) + .at(-1); + if (!match) { + die(`no openclaw registry version found for alias ${value}`); + } + return match; + } + return ""; +} + +function npmViewVersion(spec: string): string { + return run("npm", ["view", spec, "version"], { quiet: true }).stdout.trim(); +} + export async function ensureCurrentBuild(input: { lockDir: string; requireControlUi?: boolean; diff --git a/scripts/e2e/parallels/windows-smoke.ts b/scripts/e2e/parallels/windows-smoke.ts index ae7c1f2c06a..eef9b46e8c5 100755 --- a/scripts/e2e/parallels/windows-smoke.ts +++ b/scripts/e2e/parallels/windows-smoke.ts @@ -21,6 +21,7 @@ import { say, startHostServer, warn, + writeSummaryMarkdown, writeJson, type HostServer, type Mode, @@ -388,6 +389,7 @@ class WindowsSmoke { await this.phase("fresh.ensure-git", 1200, () => ensureGuestGit({ guest: this.guest, minGitZipPath: this.minGitZipPath, server: this.server }), ); + await this.phase("fresh.preflight", 120, () => this.logGuestPreflight(true)); await this.phase("fresh.install-main", 420, () => this.installMain("openclaw-main-fresh.tgz")); this.status.freshVersion = await this.extractLastVersion("fresh.install-main"); await this.phase("fresh.verify-main-version", 120, () => this.verifyTargetVersion()); @@ -409,6 +411,7 @@ class WindowsSmoke { await this.phase("upgrade.ensure-git", 1200, () => ensureGuestGit({ guest: this.guest, minGitZipPath: this.minGitZipPath, server: this.server }), ); + await this.phase("upgrade.preflight", 120, () => this.logGuestPreflight(false)); if (this.options.targetPackageSpec || this.options.upgradeFromPackedMain) { await this.phase("upgrade.install-baseline-package", 420, () => this.installMain("openclaw-main-upgrade.tgz"), @@ -567,6 +570,21 @@ class WindowsSmoke { throw new Error("Windows guest did not become ready"); } + private logGuestPreflight(cleanOpenClaw: boolean): void { + const cleanScript = cleanOpenClaw + ? "npm.cmd uninstall -g openclaw --no-fund --no-audit --loglevel=error 2>$null; $global:LASTEXITCODE = 0" + : ""; + this.guestPowerShell( + `$ErrorActionPreference = 'Continue' +cmd.exe /d /s /c whoami +Write-Host "USERPROFILE=$env:USERPROFILE" +Write-Host "PATH=$env:PATH" +npm.cmd root -g +${cleanScript}`, + { check: false, timeoutMs: 120_000 }, + ); + } + private installLatestRelease(): void { const versionArg = this.installVersion ? ` -Tag ${psSingleQuote(this.installVersion)}` : ""; this.guestPowerShell( @@ -792,7 +810,7 @@ if (-not $agentOk) { throw 'openclaw agent finished without OK response' }`, private async extractLastVersion(phaseName: string): Promise { const log = await readFile(path.join(this.runDir, `${phaseName}.log`), "utf8").catch(() => ""); - const matches = [...log.matchAll(/openclaw\s+([0-9][^\s]*)/g)]; + const matches = [...log.matchAll(/OpenClaw\s+([0-9][^\s]*)/gi)]; return matches.at(-1)?.[1] ?? ""; } @@ -827,6 +845,17 @@ if (-not $agentOk) { throw 'openclaw agent finished without OK response' }`, }; const summaryPath = path.join(this.runDir, "summary.json"); await writeJson(summaryPath, summary); + await writeSummaryMarkdown({ + lines: [ + `- vm: ${summary.vm}`, + `- target package: ${summary.targetPackageSpec || "local-main"}`, + `- fresh: ${summary.freshMain.status} (${summary.freshMain.version}), gateway=${summary.freshMain.gateway}, agent=${summary.freshMain.agent}`, + `- upgrade: ${summary.upgrade.status} (${summary.upgrade.mainVersion}), precheck=${summary.upgrade.precheck}, gateway=${summary.upgrade.gateway}, agent=${summary.upgrade.agent}`, + `- logs: ${summary.runDir}`, + ], + summaryPath, + title: "Parallels Windows Smoke", + }); return summaryPath; } diff --git a/test/scripts/parallels-npm-update-smoke.test.ts b/test/scripts/parallels-npm-update-smoke.test.ts index 64285d7e9d5..3ae8313d103 100644 --- a/test/scripts/parallels-npm-update-smoke.test.ts +++ b/test/scripts/parallels-npm-update-smoke.test.ts @@ -22,6 +22,27 @@ describe("parallels npm update smoke", () => { expect(script).toContain("await this.server?.stop()"); }); + 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("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("runs Windows updates through a detached done-file runner", () => { const script = readFileSync(SCRIPT_PATH, "utf8"); const transports = readFileSync(GUEST_TRANSPORTS_PATH, "utf8"); diff --git a/test/scripts/parallels-smoke-model.test.ts b/test/scripts/parallels-smoke-model.test.ts index caad8296451..c2cea49a4dc 100644 --- a/test/scripts/parallels-smoke-model.test.ts +++ b/test/scripts/parallels-smoke-model.test.ts @@ -375,6 +375,12 @@ console.log(JSON.stringify(result)); } }); + it("runs POSIX guest shell scripts with a normal install umask", () => { + const guestTransports = readFileSync(TS_PATHS.guestTransports, "utf8"); + + expect(guestTransports.match(/umask 022/g)).toHaveLength(2); + }); + it("provisions portable Git before Windows dev update lanes", () => { const script = readFileSync(TS_PATHS.windows, "utf8"); const windowsGit = readFileSync(TS_PATHS.windowsGit, "utf8");