test: improve Parallels beta validation

This commit is contained in:
Peter Steinberger
2026-05-04 04:43:18 +01:00
parent 616a4e9782
commit a8b38bb742
10 changed files with 351 additions and 38 deletions

View File

@@ -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

View File

@@ -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<boolean> {
try {
@@ -23,7 +22,31 @@ export async function writeJson(filePath: string, value: unknown): Promise<void>
}
export async function makeTempDir(prefix: string): Promise<string> {
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<string> {
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<void> {

View File

@@ -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, string> = {}): 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 {

View File

@@ -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<void> {
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<void> {
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;
}

View File

@@ -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<string> {
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;
}

View File

@@ -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<Platform>;
@@ -42,8 +47,12 @@ interface Job {
done: boolean;
durationMs: number;
label: string;
lastBytes: number;
lastOutputAt: number;
lastPhase: string;
logPath: string;
promise: Promise<number>;
rerunCommand: string;
startedAt: number;
}
@@ -61,12 +70,14 @@ interface NpmUpdateSummary {
currentHead: string;
runDir: string;
fresh: Record<Platform, string>;
freshTarget: Record<Platform, string>;
freshTargetSpec: string;
update: Record<Platform, { status: string; version: string }>;
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 <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.
--fresh-target <npm-spec> 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 <list> Comma-separated platforms to run: all, macos, windows, linux.
Default: all
--provider <openai|anthropic|minimax>
@@ -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<void> {
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<void> {
@@ -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> | 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<number> {
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<string> {
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<string> {
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;
}
}

View File

@@ -24,6 +24,37 @@ export async function packageBuildCommitFromTgz(tgzPath: string): Promise<string
return info.commit ?? "";
}
export function resolveOpenClawRegistryVersion(specOrAlias: string): string {
const rawValue = specOrAlias.trim();
const value = rawValue.startsWith("openclaw@") ? rawValue.slice("openclaw@".length) : rawValue;
if (!value) {
return "";
}
if (value === "latest" || value === "beta" || /^\d/.test(value)) {
return npmViewVersion(`openclaw@${value}`);
}
const betaMatch = /^beta(\d+)$/u.exec(value);
if (betaMatch) {
const betaSuffix = `-beta.${betaMatch[1]}`;
const versions = JSON.parse(
run("npm", ["view", "openclaw", "versions", "--json"], { quiet: true }).stdout,
) as string[];
const match = versions
.filter((version) => 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;

View File

@@ -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<string> {
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;
}

View File

@@ -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");

View File

@@ -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");