fix(e2e): resolve macOS Parallels VM

This commit is contained in:
Vincent Koc
2026-06-15 08:27:48 +08:00
parent a7e0822a1a
commit a231ab8acf
4 changed files with 136 additions and 14 deletions

View File

@@ -45,11 +45,12 @@ import {
import { MacosGuest } from "./guest-transports.ts";
import { runSmokeLane, type SmokeLane, type SmokeLaneStatus } from "./lane-runner.ts";
import { MacosDiscordSmoke } from "./macos-discord.ts";
import { waitForVmStatus } from "./parallels-vm.ts";
import { resolveMacosVmName, waitForVmStatus } from "./parallels-vm.ts";
import { PhaseRunner } from "./phase-runner.ts";
interface MacosOptions {
vmName: string;
vmNameExplicit: boolean;
snapshotHint: string;
mode: Mode;
provider: Provider;
@@ -129,6 +130,7 @@ const defaultOptions = (): MacosOptions => ({
snapshotHint: "macOS 26.5 latest",
targetPackageSpec: "",
vmName: "macOS Tahoe",
vmNameExplicit: false,
});
function usage(): string {
@@ -170,6 +172,7 @@ export function parseArgs(argv: string[]): MacosOptions {
break parseArgv;
case "--vm":
options.vmName = ensureValue(args, i, arg);
options.vmNameExplicit = true;
i++;
break;
case "--snapshot-hint":
@@ -309,6 +312,7 @@ class MacosSmoke {
}
async run(): Promise<void> {
this.options.vmName = resolveMacosVmName(this.options.vmName, this.options.vmNameExplicit);
this.runDir = await makeTempDir("openclaw-parallels-macos.");
this.phases = new PhaseRunner(this.runDir);
this.guest = new MacosGuest(

View File

@@ -37,13 +37,14 @@ import {
} from "./common.ts";
import { runWindowsBackgroundPowerShell } from "./guest-transports.ts";
import { linuxUpdateScript, macosUpdateScript, windowsUpdateScript } from "./npm-update-scripts.ts";
import { ensureVmRunning, resolveUbuntuVmName } from "./parallels-vm.ts";
import { ensureVmRunning, resolveMacosVmName, resolveUbuntuVmName } from "./parallels-vm.ts";
import { runTimedUpdateJob } from "./update-job-timeout.ts";
interface NpmUpdateOptions {
betaValidation?: string;
freshTargetSpec?: string;
hostIp?: string;
macosVm?: string;
packageSpec: string;
targetTarball?: string;
updateTarget: string;
@@ -111,7 +112,7 @@ interface NpmUpdateSummary {
}>;
}
const macosVm = "macOS Tahoe";
const macosVmDefault = "macOS Tahoe";
const windowsVm = "Windows 11";
const linuxVmDefault = "Ubuntu 26.04";
const updateTimeoutSeconds = readPositiveIntEnv("OPENCLAW_PARALLELS_NPM_UPDATE_TIMEOUT_S", 1200);
@@ -298,6 +299,7 @@ Options:
Aliases like beta3 resolve to the latest *-beta.3 version.
--platform <list> Comma-separated platforms to run: all, macos, windows, linux.
Default: all
--macos-vm <name> Explicit Parallels macOS VM name.
--provider <openai|anthropic|minimax>
--model <provider/model> Override the model used for agent-turn smoke checks.
--host-ip <ip> Override Parallels host IP.
@@ -315,6 +317,7 @@ export function parseArgs(argv: string[]): NpmUpdateOptions {
betaValidation: undefined,
freshTargetSpec: undefined,
json: false,
macosVm: undefined,
modelId: undefined,
packageSpec: "",
targetTarball: undefined,
@@ -358,6 +361,10 @@ export function parseArgs(argv: string[]): NpmUpdateOptions {
options.platforms = parsePlatformList(ensureValue(args, i, arg));
i++;
break;
case "--macos-vm":
options.macosVm = ensureValue(args, i, arg);
i++;
break;
case "--provider":
options.provider = parseProvider(ensureValue(args, i, arg));
i++;
@@ -455,6 +462,7 @@ export class NpmUpdateSmoke {
private targetTarballPath = "";
private targetTarballBuildCommit = "";
private targetTarballVersion = "";
private macosVm = macosVmDefault;
private linuxVm = linuxVmDefault;
private freshStatus = platformRecord("skip");
@@ -507,6 +515,12 @@ export class NpmUpdateSmoke {
if (this.options.platforms.has("linux")) {
this.linuxVm = resolveUbuntuVmName(linuxVmDefault);
}
if (this.options.platforms.has("macos")) {
this.macosVm = resolveMacosVmName(
this.options.macosVm ?? macosVmDefault,
Boolean(this.options.macosVm),
);
}
this.preflightRegistryUpdateTarget();
say(`Run fresh npm baseline: ${this.packageSpec}`);
@@ -535,7 +549,7 @@ export class NpmUpdateSmoke {
private async runFreshBaselines(): Promise<void> {
const jobs: Job[] = [];
if (this.options.platforms.has("macos")) {
jobs.push(this.spawnFresh("macOS", "macos", []));
jobs.push(this.spawnFresh("macOS", "macos", ["--vm", this.macosVm]));
}
if (this.options.platforms.has("windows")) {
jobs.push(this.spawnFresh("Windows", "windows", []));
@@ -563,7 +577,16 @@ export class NpmUpdateSmoke {
private async runFreshTargetInstalls(): Promise<void> {
const jobs: Job[] = [];
if (this.options.platforms.has("macos")) {
jobs.push(this.spawnFresh("macOS", "macos", [], {}, this.freshTargetSpec, "fresh-target"));
jobs.push(
this.spawnFresh(
"macOS",
"macos",
["--vm", this.macosVm],
{},
this.freshTargetSpec,
"fresh-target",
),
);
}
if (this.options.platforms.has("windows")) {
jobs.push(
@@ -768,7 +791,7 @@ export class NpmUpdateSmoke {
private async runSameGuestUpdates(): Promise<void> {
const jobs: Job[] = [];
if (this.options.platforms.has("macos")) {
ensureVmRunning(macosVm);
ensureVmRunning(this.macosVm);
jobs.push(this.spawnUpdate("macOS", "macos", (ctx) => this.runMacosUpdate(ctx)));
}
if (this.options.platforms.has("windows")) {
@@ -912,7 +935,7 @@ export class NpmUpdateSmoke {
ctx: UpdateJobContext,
): Promise<void> {
const scriptPath = this.writeGuestScript(
macosVm,
this.macosVm,
script,
"openclaw-parallels-npm-update-macos",
);
@@ -923,14 +946,14 @@ export class NpmUpdateSmoke {
? macosExecArgs[sudoUserArgIndex + 1]
: "";
if (sudoUser) {
run("prlctl", ["exec", macosVm, "/usr/sbin/chown", sudoUser, scriptPath], {
run("prlctl", ["exec", this.macosVm, "/usr/sbin/chown", sudoUser, scriptPath], {
timeoutMs: 30_000,
});
}
try {
const status = await this.runStreamingToJobLog(
"prlctl",
["exec", macosVm, ...macosExecArgs, "/bin/bash", scriptPath],
["exec", this.macosVm, ...macosExecArgs, "/bin/bash", scriptPath],
timeoutMs,
ctx,
);
@@ -938,14 +961,14 @@ export class NpmUpdateSmoke {
throw new Error(`macOS update command failed with exit code ${status}`);
}
} finally {
this.removeGuestScript(macosVm, scriptPath);
this.removeGuestScript(this.macosVm, scriptPath);
}
}
private resolveMacosUpdateExecArgs(ctx: UpdateJobContext): string[] {
const guestPath =
"/opt/homebrew/bin:/opt/homebrew/opt/node/bin:/usr/local/bin:/usr/local/sbin:/opt/homebrew/sbin:/usr/bin:/bin:/usr/sbin:/sbin";
const currentUser = run("prlctl", ["exec", macosVm, "--current-user", "whoami"], {
const currentUser = run("prlctl", ["exec", this.macosVm, "--current-user", "whoami"], {
check: false,
quiet: true,
timeoutMs: 45_000,
@@ -980,7 +1003,7 @@ export class NpmUpdateSmoke {
private resolveMacosDesktopUser(): string {
const consoleUser =
run("prlctl", ["exec", macosVm, "/usr/bin/stat", "-f", "%Su", "/dev/console"], {
run("prlctl", ["exec", this.macosVm, "/usr/bin/stat", "-f", "%Su", "/dev/console"], {
check: false,
quiet: true,
timeoutMs: 30_000,
@@ -998,7 +1021,7 @@ export class NpmUpdateSmoke {
}
const users = run(
"prlctl",
["exec", macosVm, "/usr/bin/dscl", ".", "-list", "/Users", "NFSHomeDirectory"],
["exec", this.macosVm, "/usr/bin/dscl", ".", "-list", "/Users", "NFSHomeDirectory"],
{ check: false, quiet: true, timeoutMs: 30_000 },
).stdout.replaceAll("\r", "");
for (const line of users.split("\n")) {
@@ -1019,7 +1042,7 @@ export class NpmUpdateSmoke {
private resolveMacosDesktopHome(user: string): string {
const output = run(
"prlctl",
["exec", macosVm, "/usr/bin/dscl", ".", "-read", `/Users/${user}`, "NFSHomeDirectory"],
["exec", this.macosVm, "/usr/bin/dscl", ".", "-read", `/Users/${user}`, "NFSHomeDirectory"],
{ check: false, quiet: true, timeoutMs: 30_000 },
).stdout.replaceAll("\r", "");
const match = /NFSHomeDirectory:\s*(\S+)/.exec(output);

View File

@@ -105,6 +105,22 @@ export function resolveUbuntuVmName(requested: string, explicit = false): string
return fallback;
}
export function resolveMacosVmName(requested: string, explicit = false): string {
const names = listVmNames();
if (names.includes(requested)) {
return requested;
}
if (explicit) {
die(`VM not found: ${requested}`);
}
const fallback = names.find((name) => name === "macOS");
if (!fallback) {
die(`VM not found: ${requested}; select a macOS VM explicitly`);
}
warn(`requested VM ${requested} not found; using ${fallback}`);
return fallback;
}
function listVms(timeoutMs = PRLCTL_STATUS_TIMEOUT_MS): PrlctlVmListItem[] {
return JSON.parse(
run("prlctl", ["list", "--all", "--json"], {

View File

@@ -23,6 +23,7 @@ import {
resolveLatestVersion,
resolveParallelsModelTimeoutSeconds,
resolveProviderAuth as resolveProviderAuthDirect,
resolveMacosVmName,
resolveSnapshot,
ensureVmRunning,
shouldSkipSnapshotRestore,
@@ -231,6 +232,8 @@ describe("Parallels smoke model selection", () => {
expect(parseLinuxSmokeArgs(["--mode", "fresh", "--", "--mode", "upgrade"]).mode).toBe("fresh");
expect(parseMacosSmokeArgs(["--", "--mode", "upgrade"]).mode).toBe("upgrade");
expect(parseMacosSmokeArgs(["--mode", "fresh", "--", "--mode", "upgrade"]).mode).toBe("fresh");
expect(parseMacosSmokeArgs([]).vmNameExplicit).toBe(false);
expect(parseMacosSmokeArgs(["--vm", "macOS"]).vmNameExplicit).toBe(true);
expect(parseNpmUpdateSmokeArgs(["--", "--package-spec", "openclaw@2026.5.1"]).packageSpec).toBe(
"openclaw@2026.5.1",
);
@@ -243,6 +246,7 @@ describe("Parallels smoke model selection", () => {
"openclaw@latest",
]).packageSpec,
).toBe("openclaw@2026.5.1");
expect(parseNpmUpdateSmokeArgs(["--macos-vm", "macOS"]).macosVm).toBe("macOS");
expect(parseWindowsSmokeArgs(["--", "--upgrade-from-packed-main"]).upgradeFromPackedMain).toBe(
true,
);
@@ -303,6 +307,7 @@ describe("Parallels smoke model selection", () => {
expect(packageArtifact).toContain("export async function packageVersionFromTgz");
expect(packageArtifact).toContain("export async function packOpenClaw");
expect(parallelsVm).toContain("export function resolveUbuntuVmName");
expect(parallelsVm).toContain("export function resolveMacosVmName");
expect(parallelsVm).toContain("export function waitForVmStatus");
expect(hostServer).toContain("export async function startHostServer");
expect(hostServer).toContain("http.server");
@@ -607,6 +612,80 @@ if (isPrlctl) {
}
});
it("uses the only macOS VM when the default name is unavailable", () => {
const tempDir = mkdtempSync(join(tmpdir(), "openclaw-parallels-macos-vm-helper-"));
writeFakePrlctl(
tempDir,
`#!/usr/bin/env bash
set -euo pipefail
if [[ "$1" == "list" ]]; then
printf '[{"name":"Windows 11"},{"name":"macOS"}]\n'
exit 0
fi
exit 1
`,
`import { basename } from "node:path";
const isPrlctl = [process.argv0, process.execPath].some((value) =>
basename(value).toLowerCase() === "prlctl.exe",
);
if (isPrlctl) {
if (process.argv.some((arg) => arg.includes("list"))) {
console.log(JSON.stringify([{ name: "Windows 11" }, { name: "macOS" }]));
process.exit(0);
}
process.exit(1);
}
`,
);
try {
const output = withEnv(fakePrlctlEnv(tempDir), () => resolveMacosVmName("macOS Tahoe"));
expect(output).toBe("macOS");
} finally {
rmSync(tempDir, { force: true, recursive: true });
}
});
it("does not infer destructive macOS smoke targets from arbitrary names", () => {
const tempDir = mkdtempSync(join(tmpdir(), "openclaw-parallels-macos-vm-guard-"));
writeFakePrlctl(
tempDir,
`#!/usr/bin/env bash
set -euo pipefail
if [[ "$1" == "list" ]]; then
printf '[{"name":"macOS Work"}]\n'
exit 0
fi
exit 1
`,
`import { basename } from "node:path";
const isPrlctl = [process.argv0, process.execPath].some((value) =>
basename(value).toLowerCase() === "prlctl.exe",
);
if (isPrlctl) {
if (process.argv.some((arg) => arg.includes("list"))) {
console.log(JSON.stringify([{ name: "macOS Work" }]));
process.exit(0);
}
process.exit(1);
}
`,
);
try {
const result = spawnNodeEvalSync(
`const { resolveMacosVmName } = await import("./${TS_PATHS.parallelsVm}"); resolveMacosVmName("macOS Tahoe");`,
{ env: { ...process.env, ...fakePrlctlEnv(tempDir) }, imports: ["tsx"] },
);
expect(result.status).toBe(1);
expect(result.stderr).toContain("select a macOS VM explicitly");
} finally {
rmSync(tempDir, { force: true, recursive: true });
}
});
it("resumes suspended Parallels VMs", () => {
const tempDir = mkdtempSync(join(tmpdir(), "openclaw-parallels-vm-resume-"));
const statePath = join(tempDir, "state");