mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-23 01:18:11 +00:00
fix(e2e): resolve macOS Parallels VM
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"], {
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user