mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:50:43 +00:00
test: improve Parallels beta validation
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user