diff --git a/scripts/e2e/parallels/macos-smoke.ts b/scripts/e2e/parallels/macos-smoke.ts index c6cde351b30..98f072e08be 100755 --- a/scripts/e2e/parallels/macos-smoke.ts +++ b/scripts/e2e/parallels/macos-smoke.ts @@ -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 { + 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( diff --git a/scripts/e2e/parallels/npm-update-smoke.ts b/scripts/e2e/parallels/npm-update-smoke.ts index baa97afb058..8af7a5038c5 100755 --- a/scripts/e2e/parallels/npm-update-smoke.ts +++ b/scripts/e2e/parallels/npm-update-smoke.ts @@ -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 Comma-separated platforms to run: all, macos, windows, linux. Default: all + --macos-vm Explicit Parallels macOS VM name. --provider --model Override the model used for agent-turn smoke checks. --host-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 { 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 { 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 { 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 { 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); diff --git a/scripts/e2e/parallels/parallels-vm.ts b/scripts/e2e/parallels/parallels-vm.ts index 1f0867dc71c..a662d096f16 100644 --- a/scripts/e2e/parallels/parallels-vm.ts +++ b/scripts/e2e/parallels/parallels-vm.ts @@ -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"], { diff --git a/test/scripts/parallels-smoke-model.test.ts b/test/scripts/parallels-smoke-model.test.ts index d6cba4efa60..162cac1c517 100644 --- a/test/scripts/parallels-smoke-model.test.ts +++ b/test/scripts/parallels-smoke-model.test.ts @@ -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");