diff --git a/scripts/e2e/parallels/common.ts b/scripts/e2e/parallels/common.ts index 932affbbaee..069b1ab2340 100644 --- a/scripts/e2e/parallels/common.ts +++ b/scripts/e2e/parallels/common.ts @@ -1,7 +1,9 @@ export * from "./filesystem.ts"; export * from "./host-command.ts"; export * from "./host-server.ts"; +export * from "./lane-runner.ts"; export * from "./package-artifact.ts"; +export * from "./parallels-vm.ts"; export * from "./provider-auth.ts"; export * from "./snapshots.ts"; export * from "./types.ts"; diff --git a/scripts/e2e/parallels/guest-transports.ts b/scripts/e2e/parallels/guest-transports.ts index 108eb025873..de4482f2950 100644 --- a/scripts/e2e/parallels/guest-transports.ts +++ b/scripts/e2e/parallels/guest-transports.ts @@ -1,6 +1,7 @@ import { run } from "./host-command.ts"; import type { PhaseRunner } from "./phase-runner.ts"; import { encodePowerShell } from "./powershell.ts"; +import type { CommandResult } from "./types.ts"; export interface GuestExecOptions { check?: boolean; @@ -47,6 +48,10 @@ export class MacosGuest { ) {} exec(args: string[], options: MacosGuestOptions = {}): string { + return this.run(args, options).stdout.trim(); + } + + run(args: string[], options: MacosGuestOptions = {}): CommandResult { const envArgs = Object.entries({ PATH: this.input.path, ...options.env }).map( ([key, value]) => `${key}=${value}`, ); @@ -75,7 +80,7 @@ export class MacosGuest { }); this.phases.append(result.stdout); this.phases.append(result.stderr); - return result.stdout.trim(); + return result; } sh(script: string, env: Record = {}): string { diff --git a/scripts/e2e/parallels/lane-runner.ts b/scripts/e2e/parallels/lane-runner.ts new file mode 100644 index 00000000000..d81598b0063 --- /dev/null +++ b/scripts/e2e/parallels/lane-runner.ts @@ -0,0 +1,18 @@ +import { warn } from "./host-command.ts"; + +export type SmokeLane = "fresh" | "upgrade"; +export type SmokeLaneStatus = "pass" | "fail"; + +export async function runSmokeLane( + name: SmokeLane, + fn: () => Promise, + setStatus: (name: SmokeLane, status: SmokeLaneStatus) => void, +): Promise { + try { + await fn(); + setStatus(name, "pass"); + } catch (error) { + setStatus(name, "fail"); + warn(`${name} lane failed: ${error instanceof Error ? error.message : String(error)}`); + } +} diff --git a/scripts/e2e/parallels/linux-smoke.ts b/scripts/e2e/parallels/linux-smoke.ts index 2713808d270..841f29c2f13 100755 --- a/scripts/e2e/parallels/linux-smoke.ts +++ b/scripts/e2e/parallels/linux-smoke.ts @@ -32,6 +32,8 @@ import { type SnapshotInfo, } from "./common.ts"; import { LinuxGuest } from "./guest-transports.ts"; +import { runSmokeLane, type SmokeLane, type SmokeLaneStatus } from "./lane-runner.ts"; +import { resolveUbuntuVmName, waitForVmStatus } from "./parallels-vm.ts"; import { PhaseRunner } from "./phase-runner.ts"; interface LinuxOptions { @@ -302,20 +304,14 @@ class LinuxSmoke { } private async runLane(name: "fresh" | "upgrade", fn: () => Promise): Promise { - try { - await fn(); - if (name === "fresh") { - this.status.freshMain = "pass"; - } else { - this.status.upgrade = "pass"; - } - } catch (error) { - if (name === "fresh") { - this.status.freshMain = "fail"; - } else { - this.status.upgrade = "fail"; - } - warn(`${name} lane failed: ${error instanceof Error ? error.message : String(error)}`); + await runSmokeLane(name, fn, (lane, status) => this.setLaneStatus(lane, status)); + } + + private setLaneStatus(name: SmokeLane, status: SmokeLaneStatus): void { + if (name === "fresh") { + this.status.freshMain = status; + } else { + this.status.upgrade = status; } } @@ -324,34 +320,7 @@ class LinuxSmoke { } private resolveVmName(): string { - const payload = JSON.parse( - run("prlctl", ["list", "--all", "--json"], { quiet: true }).stdout, - ) as Array<{ - name?: string; - }>; - const names = payload.map((item) => (item.name ?? "").trim()).filter(Boolean); - if (names.includes(this.options.vmName)) { - return this.options.vmName; - } - if (this.options.vmNameExplicit) { - die(`VM not found: ${this.options.vmName}`); - } - const ubuntu = names - .map((name) => ({ name, version: /ubuntu\s+(\d+(?:\.\d+)*)/i.exec(name)?.[1] })) - .filter((item): item is { name: string; version: string } => Boolean(item.version)) - .map((item) => ({ - name: item.name, - parts: item.version.split(".").map(Number), - version: item.version, - })) - .filter((item) => item.parts[0] >= 24) - .toSorted((a, b) => compareVersions(a.parts, b.parts)); - const fallback = ubuntu[0]?.name ?? names.find((name) => /ubuntu/i.test(name)); - if (!fallback) { - die(`VM not found: ${this.options.vmName}`); - } - warn(`requested VM ${this.options.vmName} not found; using ${fallback}`); - return fallback; + return resolveUbuntuVmName(this.options.vmName, this.options.vmNameExplicit); } private async runFreshLane(): Promise { @@ -428,21 +397,6 @@ class LinuxSmoke { return this.guest.bash(script); } - private waitForVmStatus(expected: string, timeoutSeconds = 180): void { - const deadline = Date.now() + timeoutSeconds * 1000; - while (Date.now() < deadline) { - const status = run("prlctl", ["status", this.options.vmName], { - check: false, - quiet: true, - }).stdout; - if (status.includes(` ${expected}`)) { - return; - } - run("sleep", ["1"], { quiet: true }); - } - die(`VM ${this.options.vmName} did not reach ${expected}`); - } - private waitForGuestReady(timeoutSeconds = 180): void { const deadline = Date.now() + timeoutSeconds * 1000; while (Date.now() < deadline) { @@ -466,7 +420,7 @@ class LinuxSmoke { quiet: true, }); if (this.snapshot.state === "poweroff") { - this.waitForVmStatus("stopped"); + waitForVmStatus(this.options.vmName, "stopped", 180); say(`Start restored poweroff snapshot ${this.snapshot.name}`); run("prlctl", ["start", this.options.vmName], { quiet: true }); } @@ -774,16 +728,6 @@ setsid sh -lc ` + } } -function compareVersions(a: number[], b: number[]): number { - for (let index = 0; index < Math.max(a.length, b.length); index++) { - const diff = (a[index] ?? 0) - (b[index] ?? 0); - if (diff !== 0) { - return diff; - } - } - return 0; -} - const options = parseArgs(process.argv.slice(2)); await mkdir(repoRoot, { recursive: true }); await new LinuxSmoke(options).run(); diff --git a/scripts/e2e/parallels/macos-discord.ts b/scripts/e2e/parallels/macos-discord.ts new file mode 100644 index 00000000000..379fdb2fc32 --- /dev/null +++ b/scripts/e2e/parallels/macos-discord.ts @@ -0,0 +1,200 @@ +import { readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; +import type { MacosGuest } from "./guest-transports.ts"; +import { run, say, shellQuote, warn } from "./host-command.ts"; + +export type DiscordSmokePhase = "fresh" | "upgrade"; + +export interface MacosDiscordConfig { + channelId: string; + guildId: string; + token: string; +} + +export class MacosDiscordSmoke { + constructor( + private input: { + config: MacosDiscordConfig; + guest: MacosGuest; + guestNode: string; + guestOpenClaw: string; + guestOpenClawEntry: string; + runDir: string; + vmName: string; + }, + ) {} + + configure(): void { + const guilds = JSON.stringify({ + [this.input.config.guildId]: { + channels: { + [this.input.config.channelId]: { + enabled: true, + requireMention: false, + }, + }, + }, + }); + this.input.guest.sh(`set -eu +${this.input.guestNode} ${this.input.guestOpenClawEntry} config set channels.discord.token ${shellQuote(this.input.config.token)} +${this.input.guestNode} ${this.input.guestOpenClawEntry} config set channels.discord.enabled true +${this.input.guestNode} ${this.input.guestOpenClawEntry} config set channels.discord.groupPolicy allowlist +${this.input.guestNode} ${this.input.guestOpenClawEntry} config set channels.discord.guilds ${shellQuote(guilds)} --strict-json +${this.input.guestNode} ${this.input.guestOpenClawEntry} gateway restart +${this.input.guestNode} ${this.input.guestOpenClawEntry} channels status --probe --json`); + } + + async runRoundtrip(phase: DiscordSmokePhase): Promise { + const nonce = `${Date.now()}-${Math.floor(Math.random() * 100000)}`; + const outboundNonce = `${phase}-out-${nonce}`; + const inboundNonce = `${phase}-in-${nonce}`; + const outboundLog = path.join(this.input.runDir, `${phase}.discord-send.json`); + const sentIdFile = path.join(this.input.runDir, `${phase}.discord-sent-message-id`); + const hostIdFile = path.join(this.input.runDir, `${phase}.discord-host-message-id`); + const outbound = this.input.guest.exec([ + this.input.guestOpenClaw, + "message", + "send", + "--channel", + "discord", + "--target", + `channel:${this.input.config.channelId}`, + "--message", + `parallels-macos-smoke-outbound-${outboundNonce}`, + "--silent", + "--json", + ]); + await writeFile(outboundLog, `${outbound}\n`, "utf8"); + const sentId = this.discordMessageId(outbound); + await writeFile(sentIdFile, `${sentId}\n`, "utf8"); + await this.waitForHostVisibility(outboundNonce, sentId); + const hostId = await this.postDiscordMessage(`parallels-macos-smoke-inbound-${inboundNonce}`); + await writeFile(hostIdFile, `${hostId}\n`, "utf8"); + this.waitForGuestReadback(inboundNonce); + } + + async cleanupMessages(): Promise { + for (const name of [ + "fresh.discord-sent-message-id", + "fresh.discord-host-message-id", + "upgrade.discord-sent-message-id", + "upgrade.discord-host-message-id", + ]) { + const filePath = path.join(this.input.runDir, name); + const id = await readFile(filePath, "utf8").catch(() => ""); + if (id.trim()) { + await this.discordApi( + "DELETE", + `/channels/${this.input.config.channelId}/messages/${id.trim()}`, + ).catch(() => ""); + } + } + } + + stopVmAfterSuccessfulSmoke(freshDiscord: string, upgradeDiscord: string): void { + if (freshDiscord !== "pass" && upgradeDiscord !== "pass") { + return; + } + say(`Stop ${this.input.vmName} after successful Discord smoke`); + const result = run("prlctl", ["stop", this.input.vmName], { + check: false, + quiet: true, + timeoutMs: 120_000, + }); + if (result.status !== 0) { + warn( + `failed to stop ${this.input.vmName} after successful Discord smoke (rc=${result.status})`, + ); + } + } + + private discordMessageId(payloadText: string): string { + const payload = JSON.parse(payloadText) as { + payload?: { messageId?: string; result?: { messageId?: string } }; + }; + const id = payload.payload?.messageId || payload.payload?.result?.messageId; + if (!id) { + throw new Error("messageId missing from send output"); + } + return id; + } + + private async discordApi(method: string, apiPath: string, payload?: unknown): Promise { + const args = [ + "-fsS", + "-X", + method, + "-H", + `Authorization: Bot ${this.input.config.token}`, + ...(payload == null + ? [] + : ["-H", "Content-Type: application/json", "--data", JSON.stringify(payload)]), + `https://discord.com/api/v10${apiPath}`, + ]; + return run("curl", args, { quiet: true }).stdout; + } + + private async waitForHostVisibility(nonce: string, messageId: string): Promise { + const deadline = Date.now() + 180_000; + while (Date.now() < deadline) { + const direct = await this.discordApi( + "GET", + `/channels/${this.input.config.channelId}/messages/${messageId}`, + ).catch(() => ""); + if (direct.includes(nonce)) { + return; + } + const recent = await this.discordApi( + "GET", + `/channels/${this.input.config.channelId}/messages?limit=20`, + ).catch(() => ""); + if (recent.includes(nonce)) { + return; + } + run("sleep", ["2"], { quiet: true }); + } + throw new Error("Discord host visibility timed out"); + } + + private async postDiscordMessage(content: string): Promise { + const response = await this.discordApi( + "POST", + `/channels/${this.input.config.channelId}/messages`, + { + content, + flags: 4096, + }, + ); + const id = (JSON.parse(response) as { id?: string }).id; + if (!id) { + throw new Error("host Discord post missing message id"); + } + return id; + } + + private waitForGuestReadback(nonce: string): void { + const deadline = Date.now() + 180_000; + while (Date.now() < deadline) { + const result = this.input.guest.run( + [ + this.input.guestOpenClaw, + "message", + "read", + "--channel", + "discord", + "--target", + `channel:${this.input.config.channelId}`, + "--limit", + "20", + "--json", + ], + { check: false }, + ); + if (result.status === 0 && result.stdout.includes(nonce)) { + return; + } + run("sleep", ["3"], { quiet: true }); + } + throw new Error("Discord guest readback timed out"); + } +} diff --git a/scripts/e2e/parallels/macos-smoke.ts b/scripts/e2e/parallels/macos-smoke.ts index b10282bebf8..d894a5f05a3 100755 --- a/scripts/e2e/parallels/macos-smoke.ts +++ b/scripts/e2e/parallels/macos-smoke.ts @@ -1,5 +1,5 @@ #!/usr/bin/env -S pnpm tsx -import { readFile, rm, writeFile } from "node:fs/promises"; +import { readFile, rm } from "node:fs/promises"; import path from "node:path"; import { posixAgentWorkspaceScript } from "./agent-workspace.ts"; import { @@ -30,6 +30,9 @@ import { type SnapshotInfo, } from "./common.ts"; 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 { PhaseRunner } from "./phase-runner.ts"; interface MacosOptions { @@ -247,6 +250,7 @@ class MacosSmoke { private snapshot!: SnapshotInfo; private phases!: PhaseRunner; private guest!: MacosGuest; + private discord: MacosDiscordSmoke | null = null; private guestUser = ""; private guestTransport: "current-user" | "sudo" = "current-user"; @@ -289,6 +293,7 @@ class MacosSmoke { }, this.phases, ); + this.discord = this.createDiscordSmoke(); this.tgzDir = await makeTempDir("openclaw-parallels-macos-tgz."); try { this.snapshot = resolveSnapshot(this.options.vmName, this.options.snapshotHint); @@ -401,6 +406,25 @@ class MacosSmoke { ); } + private createDiscordSmoke(): MacosDiscordSmoke | null { + if (!this.discordEnabled()) { + return null; + } + return new MacosDiscordSmoke({ + config: { + channelId: this.options.discordChannelId || "", + guildId: this.options.discordGuildId || "", + token: this.discordToken, + }, + guest: this.guest, + guestNode, + guestOpenClaw, + guestOpenClawEntry, + runDir: this.runDir, + vmName: this.options.vmName, + }); + } + private targetInstallsDirectly(): boolean { const spec = this.options.targetPackageSpec; return Boolean(spec && !/^(https?:|file:|\/|\.\/|\.\.\/|.*\.tgz$)/.test(spec)); @@ -421,20 +445,14 @@ class MacosSmoke { } private async runLane(name: "fresh" | "upgrade", fn: () => Promise): Promise { - try { - await fn(); - if (name === "fresh") { - this.status.freshMain = "pass"; - } else { - this.status.upgrade = "pass"; - } - } catch (error) { - if (name === "fresh") { - this.status.freshMain = "fail"; - } else { - this.status.upgrade = "fail"; - } - warn(`${name} lane failed: ${error instanceof Error ? error.message : String(error)}`); + await runSmokeLane(name, fn, (lane, status) => this.setLaneStatus(lane, status)); + } + + private setLaneStatus(name: SmokeLane, status: SmokeLaneStatus): void { + if (name === "fresh") { + this.status.freshMain = status; + } else { + this.status.upgrade = status; } } @@ -559,21 +577,6 @@ class MacosSmoke { return this.guest.sh(script, env); } - private waitForVmStatus(expected: string, timeoutSeconds = 360): void { - const deadline = Date.now() + timeoutSeconds * 1000; - while (Date.now() < deadline) { - const status = run("prlctl", ["status", this.options.vmName], { - check: false, - quiet: true, - }).stdout; - if (status.includes(` ${expected}`)) { - return; - } - run("sleep", ["1"], { quiet: true }); - } - throw new Error(`VM ${this.options.vmName} did not reach ${expected}`); - } - private waitForCurrentUser(timeoutSeconds = 360): void { const prlctlDeadline = Date.now() + 45_000; const deadline = Date.now() + timeoutSeconds * 1000; @@ -697,7 +700,7 @@ class MacosSmoke { }).stdout; if (status.includes(" running") || status.includes(" suspended")) { run("prlctl", ["stop", this.options.vmName, "--kill"], { check: false, quiet: true }); - this.waitForVmStatus("stopped"); + waitForVmStatus(this.options.vmName, "stopped", 360); } run("sleep", ["3"], { quiet: true }); } @@ -705,7 +708,7 @@ class MacosSmoke { throw new Error("snapshot restore failed"); } if (this.snapshot.state === "poweroff") { - this.waitForVmStatus("stopped"); + waitForVmStatus(this.options.vmName, "stopped", 360); say(`Start restored poweroff snapshot ${this.snapshot.name}`); run("prlctl", ["start", this.options.vmName], { quiet: true }); } @@ -970,201 +973,22 @@ exec /usr/bin/env ${shellQuote(`${this.auth.apiKeyEnv}=${this.auth.apiKeyValue}` } private configureDiscord(): void { - const guilds = JSON.stringify({ - [this.options.discordGuildId || ""]: { - channels: { - [this.options.discordChannelId || ""]: { - enabled: true, - requireMention: false, - }, - }, - }, - }); - this.guestSh( - `set -eu -${guestNode} ${guestOpenClawEntry} config set channels.discord.token ${shellQuote(this.discordToken)} -${guestNode} ${guestOpenClawEntry} config set channels.discord.enabled true -${guestNode} ${guestOpenClawEntry} config set channels.discord.groupPolicy allowlist -${guestNode} ${guestOpenClawEntry} config set channels.discord.guilds ${shellQuote(guilds)} --strict-json -${guestNode} ${guestOpenClawEntry} gateway restart -${guestNode} ${guestOpenClawEntry} channels status --probe --json`, - ); + this.discord?.configure(); } private async runDiscordRoundtrip(phase: "fresh" | "upgrade"): Promise { - const nonce = `${Date.now()}-${Math.floor(Math.random() * 100000)}`; - const outboundNonce = `${phase}-out-${nonce}`; - const inboundNonce = `${phase}-in-${nonce}`; - const outboundLog = path.join(this.runDir, `${phase}.discord-send.json`); - const sentIdFile = path.join(this.runDir, `${phase}.discord-sent-message-id`); - const hostIdFile = path.join(this.runDir, `${phase}.discord-host-message-id`); - const outbound = this.guestExec([ - guestOpenClaw, - "message", - "send", - "--channel", - "discord", - "--target", - `channel:${this.options.discordChannelId}`, - "--message", - `parallels-macos-smoke-outbound-${outboundNonce}`, - "--silent", - "--json", - ]); - await writeFile(outboundLog, `${outbound}\n`, "utf8"); - const sentId = this.discordMessageId(outbound); - await writeFile(sentIdFile, `${sentId}\n`, "utf8"); - await this.waitForDiscordHostVisibility(outboundNonce, sentId); - const hostId = await this.postDiscordMessage(`parallels-macos-smoke-inbound-${inboundNonce}`); - await writeFile(hostIdFile, `${hostId}\n`, "utf8"); - this.waitForGuestDiscordReadback(inboundNonce); - } - - private discordMessageId(payloadText: string): string { - const payload = JSON.parse(payloadText) as { - payload?: { messageId?: string; result?: { messageId?: string } }; - }; - const id = payload.payload?.messageId || payload.payload?.result?.messageId; - if (!id) { - throw new Error("messageId missing from send output"); + if (!this.discord) { + throw new Error("Discord smoke is not configured"); } - return id; - } - - private async discordApi(method: string, apiPath: string, payload?: unknown): Promise { - const args = [ - "-fsS", - "-X", - method, - "-H", - `Authorization: Bot ${this.discordToken}`, - ...(payload == null - ? [] - : ["-H", "Content-Type: application/json", "--data", JSON.stringify(payload)]), - `https://discord.com/api/v10${apiPath}`, - ]; - return run("curl", args, { quiet: true }).stdout; - } - - private async waitForDiscordHostVisibility(nonce: string, messageId: string): Promise { - const deadline = Date.now() + 180_000; - while (Date.now() < deadline) { - const direct = await this.discordApi( - "GET", - `/channels/${this.options.discordChannelId}/messages/${messageId}`, - ).catch(() => ""); - if (direct.includes(nonce)) { - return; - } - const recent = await this.discordApi( - "GET", - `/channels/${this.options.discordChannelId}/messages?limit=20`, - ).catch(() => ""); - if (recent.includes(nonce)) { - return; - } - run("sleep", ["2"], { quiet: true }); - } - throw new Error("Discord host visibility timed out"); - } - - private async postDiscordMessage(content: string): Promise { - const response = await this.discordApi( - "POST", - `/channels/${this.options.discordChannelId}/messages`, - { - content, - flags: 4096, - }, - ); - const id = (JSON.parse(response) as { id?: string }).id; - if (!id) { - throw new Error("host Discord post missing message id"); - } - return id; - } - - private waitForGuestDiscordReadback(nonce: string): void { - const deadline = Date.now() + 180_000; - while (Date.now() < deadline) { - const result = run( - "prlctl", - [ - "exec", - this.options.vmName, - ...(this.guestTransport === "sudo" - ? [ - "/usr/bin/sudo", - "-H", - "-u", - this.guestUser, - "/usr/bin/env", - `HOME=${this.guestHome()}`, - `PATH=${guestPath}`, - ] - : ["--current-user", "/usr/bin/env", `PATH=${guestPath}`]), - guestOpenClaw, - "message", - "read", - "--channel", - "discord", - "--target", - `channel:${this.options.discordChannelId}`, - "--limit", - "20", - "--json", - ], - { check: false, quiet: true, timeoutMs: this.remainingPhaseTimeoutMs() }, - ); - this.log(result.stdout); - this.log(result.stderr); - if (result.status === 0 && result.stdout.includes(nonce)) { - return; - } - run("sleep", ["3"], { quiet: true }); - } - throw new Error("Discord guest readback timed out"); + await this.discord.runRoundtrip(phase); } private async cleanupDiscordMessages(): Promise { - if (!this.discordEnabled() || !this.runDir) { - return; - } - for (const name of [ - "fresh.discord-sent-message-id", - "fresh.discord-host-message-id", - "upgrade.discord-sent-message-id", - "upgrade.discord-host-message-id", - ]) { - const filePath = path.join(this.runDir, name); - const id = await readFile(filePath, "utf8").catch(() => ""); - if (id.trim()) { - await this.discordApi( - "DELETE", - `/channels/${this.options.discordChannelId}/messages/${id.trim()}`, - ).catch(() => ""); - } - } + await this.discord?.cleanupMessages(); } private async stopVmAfterSuccessfulDiscordSmoke(): Promise { - if (!this.discordEnabled()) { - return; - } - if (this.status.freshDiscord !== "pass" && this.status.upgradeDiscord !== "pass") { - return; - } - say(`Stop ${this.options.vmName} after successful Discord smoke`); - const result = run("prlctl", ["stop", this.options.vmName], { - check: false, - quiet: true, - timeoutMs: 120_000, - }); - if (result.status !== 0) { - warn( - `failed to stop ${this.options.vmName} after successful Discord smoke (rc=${result.status})`, - ); - } + this.discord?.stopVmAfterSuccessfulSmoke(this.status.freshDiscord, this.status.upgradeDiscord); } private guestHome(): string { diff --git a/scripts/e2e/parallels/npm-update-scripts.ts b/scripts/e2e/parallels/npm-update-scripts.ts new file mode 100644 index 00000000000..cec83ae2284 --- /dev/null +++ b/scripts/e2e/parallels/npm-update-scripts.ts @@ -0,0 +1,150 @@ +import { posixAgentWorkspaceScript, windowsAgentWorkspaceScript } from "./agent-workspace.ts"; +import { shellQuote } from "./host-command.ts"; +import { psSingleQuote } from "./powershell.ts"; +import type { ProviderAuth } from "./types.ts"; + +export interface NpmUpdateScriptInput { + auth: ProviderAuth; + expectedNeedle: string; + updateTarget: string; +} + +export function macosUpdateScript(input: NpmUpdateScriptInput): string { + return String.raw`set -euo pipefail +scrub_future_plugin_entries() { + python3 - <<'PY' +import json +from pathlib import Path +path = Path.home() / ".openclaw" / "openclaw.json" +if not path.exists(): + raise SystemExit(0) +try: + config = json.loads(path.read_text()) +except Exception: + raise SystemExit(0) +plugins = config.get("plugins") +if not isinstance(plugins, dict): + raise SystemExit(0) +entries = plugins.get("entries") +if isinstance(entries, dict): + entries.pop("feishu", None) + entries.pop("whatsapp", None) +allow = plugins.get("allow") +if isinstance(allow, list): + plugins["allow"] = [item for item in allow if item not in {"feishu", "whatsapp"}] +path.write_text(json.dumps(config, indent=2) + "\n") +PY +} +stop_openclaw_gateway_processes() { + OPENCLAW_DISABLE_BUNDLED_PLUGINS=1 /opt/homebrew/bin/openclaw gateway stop || true + pkill -f 'openclaw.*gateway' >/dev/null 2>&1 || true +} +scrub_future_plugin_entries +stop_openclaw_gateway_processes +OPENCLAW_DISABLE_BUNDLED_PLUGINS=1 /opt/homebrew/bin/openclaw update --tag ${shellQuote(input.updateTarget)} --yes --json +${posixVersionCheck("/opt/homebrew/bin/openclaw", input.expectedNeedle)} +/opt/homebrew/bin/openclaw gateway restart +/opt/homebrew/bin/openclaw gateway status --deep --require-rpc +/opt/homebrew/bin/openclaw models set ${shellQuote(input.auth.modelId)} +/opt/homebrew/bin/openclaw config set agents.defaults.skipBootstrap true --strict-json +${posixAgentWorkspaceScript("Parallels npm update smoke test assistant.")} +${input.auth.apiKeyEnv}=${shellQuote(input.auth.apiKeyValue)} /opt/homebrew/bin/openclaw agent --local --agent main --session-id parallels-npm-update-macos --message 'Reply with exact ASCII text OK only.' --json`; +} + +export function windowsUpdateScript(input: NpmUpdateScriptInput): string { + return `$ErrorActionPreference = 'Stop' +$PSNativeCommandUseErrorActionPreference = $false +function Remove-FuturePluginEntries { + $configPath = Join-Path $env:USERPROFILE '.openclaw\\openclaw.json' + if (-not (Test-Path $configPath)) { return } + try { $config = Get-Content $configPath -Raw | ConvertFrom-Json -AsHashtable } catch { return } + $plugins = $config['plugins'] + if (-not ($plugins -is [hashtable])) { return } + $entries = $plugins['entries'] + if ($entries -is [hashtable]) { + foreach ($pluginId in @('feishu', 'whatsapp')) { + if ($entries.ContainsKey($pluginId)) { $entries.Remove($pluginId) } + } + } + $allow = $plugins['allow'] + if ($allow -is [array]) { + $plugins['allow'] = @($allow | Where-Object { $_ -notin @('feishu', 'whatsapp') }) + } + $config | ConvertTo-Json -Depth 100 | Set-Content -Path $configPath -Encoding UTF8 +} +function Stop-OpenClawGatewayProcesses { + $openclaw = Join-Path $env:APPDATA 'npm\\openclaw.cmd' + & $openclaw gateway stop *>&1 | Out-Host + Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | + Where-Object { $_.CommandLine -match 'openclaw.*gateway' } | + ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue } +} +Remove-FuturePluginEntries +Stop-OpenClawGatewayProcesses +$env:OPENCLAW_DISABLE_BUNDLED_PLUGINS = '1' +$openclaw = Join-Path $env:APPDATA 'npm\\openclaw.cmd' +& $openclaw update --tag ${psSingleQuote(input.updateTarget)} --yes --json +if ($LASTEXITCODE -ne 0) { throw "openclaw update failed with exit code $LASTEXITCODE" } +$version = & $openclaw --version +$version +${windowsVersionCheck(input.expectedNeedle)} +& $openclaw gateway restart +& $openclaw gateway status --deep --require-rpc +& $openclaw models set ${psSingleQuote(input.auth.modelId)} +& $openclaw config set agents.defaults.skipBootstrap true --strict-json +${windowsAgentWorkspaceScript("Parallels npm update smoke test assistant.")} +Set-Item -Path ('Env:' + ${psSingleQuote(input.auth.apiKeyEnv)}) -Value ${psSingleQuote(input.auth.apiKeyValue)} +& $openclaw agent --local --agent main --session-id parallels-npm-update-windows --message 'Reply with exact ASCII text OK only.' --json`; +} + +export function linuxUpdateScript(input: NpmUpdateScriptInput): string { + return String.raw`set -euo pipefail +scrub_future_plugin_entries() { + node - <<'JS' +const fs = require("node:fs"); +const path = require("node:path"); +const configPath = path.join(process.env.HOME || "/root", ".openclaw", "openclaw.json"); +if (!fs.existsSync(configPath)) process.exit(0); +let config; +try { config = JSON.parse(fs.readFileSync(configPath, "utf8")); } catch { process.exit(0); } +const plugins = config.plugins; +if (!plugins || typeof plugins !== "object") process.exit(0); +if (plugins.entries && typeof plugins.entries === "object") { + delete plugins.entries.feishu; + delete plugins.entries.whatsapp; +} +if (Array.isArray(plugins.allow)) { + plugins.allow = plugins.allow.filter((id) => id !== "feishu" && id !== "whatsapp"); +} +fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n"); +JS +} +stop_openclaw_gateway_processes() { + OPENCLAW_DISABLE_BUNDLED_PLUGINS=1 openclaw gateway stop || true + pkill -f 'openclaw.*gateway' >/dev/null 2>&1 || true +} +scrub_future_plugin_entries +stop_openclaw_gateway_processes +OPENCLAW_DISABLE_BUNDLED_PLUGINS=1 openclaw update --tag ${shellQuote(input.updateTarget)} --yes --json +${posixVersionCheck("openclaw", input.expectedNeedle)} +openclaw gateway restart +openclaw gateway status --deep --require-rpc +openclaw models set ${shellQuote(input.auth.modelId)} +openclaw config set agents.defaults.skipBootstrap true --strict-json +${posixAgentWorkspaceScript("Parallels npm update smoke test assistant.")} +${input.auth.apiKeyEnv}=${shellQuote(input.auth.apiKeyValue)} openclaw agent --local --agent main --session-id parallels-npm-update-linux --message 'Reply with exact ASCII text OK only.' --json`; +} + +function posixVersionCheck(command: string, expectedNeedle: string): string { + if (!expectedNeedle) { + return `${command} --version`; + } + return `version="$(${command} --version)"; printf '%s\\n' "$version"; case "$version" in *${shellQuote(expectedNeedle)}*) ;; *) echo "version mismatch: expected ${expectedNeedle}" >&2; exit 1 ;; esac`; +} + +function windowsVersionCheck(expectedNeedle: string): string { + if (!expectedNeedle) { + return ""; + } + return `if (($version | Out-String) -notlike ${psSingleQuote(`*${expectedNeedle}*`)}) { throw ${psSingleQuote(`version mismatch: expected ${expectedNeedle}`)} }`; +} diff --git a/scripts/e2e/parallels/npm-update-smoke.ts b/scripts/e2e/parallels/npm-update-smoke.ts index 2d32993e5e8..c71450b88df 100755 --- a/scripts/e2e/parallels/npm-update-smoke.ts +++ b/scripts/e2e/parallels/npm-update-smoke.ts @@ -2,7 +2,6 @@ import { spawn } from "node:child_process"; import { readFile, rm, writeFile } from "node:fs/promises"; import path from "node:path"; -import { posixAgentWorkspaceScript, windowsAgentWorkspaceScript } from "./agent-workspace.ts"; import { die, ensureValue, @@ -16,9 +15,7 @@ import { resolveProviderAuth, run, say, - shellQuote, startHostServer, - warn, writeJson, type HostServer, type PackageArtifact, @@ -26,7 +23,9 @@ import { type Provider, type ProviderAuth, } from "./common.ts"; -import { encodePowerShell, psSingleQuote } from "./powershell.ts"; +import { linuxUpdateScript, macosUpdateScript, windowsUpdateScript } from "./npm-update-scripts.ts"; +import { ensureVmRunning, resolveUbuntuVmName } from "./parallels-vm.ts"; +import { encodePowerShell } from "./powershell.ts"; interface NpmUpdateOptions { packageSpec: string; @@ -179,7 +178,7 @@ class NpmUpdateSmoke { this.hostIp = resolveHostIp(""); if (this.options.platforms.has("linux")) { - this.linuxVm = this.resolveLinuxVmName(); + this.linuxVm = resolveUbuntuVmName(linuxVmDefault); } this.preflightRegistryUpdateTarget(); @@ -295,15 +294,15 @@ class NpmUpdateSmoke { private async runSameGuestUpdates(): Promise { const jobs: Job[] = []; if (this.options.platforms.has("macos")) { - this.ensureVmRunning(macosVm); + ensureVmRunning(macosVm); jobs.push(this.spawnUpdate("macOS", "macos", () => this.runMacosUpdate())); } if (this.options.platforms.has("windows")) { - this.ensureVmRunning(windowsVm); + ensureVmRunning(windowsVm); jobs.push(this.spawnUpdate("Windows", "windows", () => this.runWindowsUpdate())); } if (this.options.platforms.has("linux")) { - this.ensureVmRunning(this.linuxVm); + ensureVmRunning(this.linuxVm); jobs.push(this.spawnUpdate("Linux", "linux", () => this.runLinuxUpdate())); } await this.monitorJobs("update", jobs); @@ -363,143 +362,32 @@ class NpmUpdateSmoke { } private runMacosUpdate(): void { - const expectedCheck = this.updateExpectedNeedle - ? `version="$(/opt/homebrew/bin/openclaw --version)"; printf '%s\\n' "$version"; case "$version" in *${shellQuote( - this.updateExpectedNeedle, - )}*) ;; *) echo "version mismatch: expected ${this.updateExpectedNeedle}" >&2; exit 1 ;; esac` - : "/opt/homebrew/bin/openclaw --version"; - const script = String.raw`set -euo pipefail -scrub_future_plugin_entries() { - python3 - <<'PY' -import json -from pathlib import Path -path = Path.home() / ".openclaw" / "openclaw.json" -if not path.exists(): - raise SystemExit(0) -try: - config = json.loads(path.read_text()) -except Exception: - raise SystemExit(0) -plugins = config.get("plugins") -if not isinstance(plugins, dict): - raise SystemExit(0) -entries = plugins.get("entries") -if isinstance(entries, dict): - entries.pop("feishu", None) - entries.pop("whatsapp", None) -allow = plugins.get("allow") -if isinstance(allow, list): - plugins["allow"] = [item for item in allow if item not in {"feishu", "whatsapp"}] -path.write_text(json.dumps(config, indent=2) + "\n") -PY -} -stop_openclaw_gateway_processes() { - OPENCLAW_DISABLE_BUNDLED_PLUGINS=1 /opt/homebrew/bin/openclaw gateway stop || true - pkill -f 'openclaw.*gateway' >/dev/null 2>&1 || true -} -scrub_future_plugin_entries -stop_openclaw_gateway_processes -OPENCLAW_DISABLE_BUNDLED_PLUGINS=1 /opt/homebrew/bin/openclaw update --tag ${shellQuote(this.updateTargetEffective)} --yes --json -${expectedCheck} -/opt/homebrew/bin/openclaw gateway restart -/opt/homebrew/bin/openclaw gateway status --deep --require-rpc -/opt/homebrew/bin/openclaw models set ${shellQuote(this.auth.modelId)} -/opt/homebrew/bin/openclaw config set agents.defaults.skipBootstrap true --strict-json -${posixAgentWorkspaceScript("Parallels npm update smoke test assistant.")} -${this.auth.apiKeyEnv}=${shellQuote(this.auth.apiKeyValue)} /opt/homebrew/bin/openclaw agent --local --agent main --session-id parallels-npm-update-macos --message 'Reply with exact ASCII text OK only.' --json`; - this.guestMacos(script, updateTimeoutSeconds * 1000); + this.guestMacos(this.updateScript("macos"), updateTimeoutSeconds * 1000); } private runWindowsUpdate(): void { - const expected = this.updateExpectedNeedle; - this.guestWindows( - `$ErrorActionPreference = 'Stop' -$PSNativeCommandUseErrorActionPreference = $false -function Remove-FuturePluginEntries { - $configPath = Join-Path $env:USERPROFILE '.openclaw\\openclaw.json' - if (-not (Test-Path $configPath)) { return } - try { $config = Get-Content $configPath -Raw | ConvertFrom-Json -AsHashtable } catch { return } - $plugins = $config['plugins'] - if (-not ($plugins -is [hashtable])) { return } - $entries = $plugins['entries'] - if ($entries -is [hashtable]) { - foreach ($pluginId in @('feishu', 'whatsapp')) { - if ($entries.ContainsKey($pluginId)) { $entries.Remove($pluginId) } - } - } - $allow = $plugins['allow'] - if ($allow -is [array]) { - $plugins['allow'] = @($allow | Where-Object { $_ -notin @('feishu', 'whatsapp') }) - } - $config | ConvertTo-Json -Depth 100 | Set-Content -Path $configPath -Encoding UTF8 -} -function Stop-OpenClawGatewayProcesses { - $openclaw = Join-Path $env:APPDATA 'npm\\openclaw.cmd' - & $openclaw gateway stop *>&1 | Out-Host - Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | - Where-Object { $_.CommandLine -match 'openclaw.*gateway' } | - ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue } -} -Remove-FuturePluginEntries -Stop-OpenClawGatewayProcesses -$env:OPENCLAW_DISABLE_BUNDLED_PLUGINS = '1' -$openclaw = Join-Path $env:APPDATA 'npm\\openclaw.cmd' -& $openclaw update --tag ${psSingleQuote(this.updateTargetEffective)} --yes --json -if ($LASTEXITCODE -ne 0) { throw "openclaw update failed with exit code $LASTEXITCODE" } -$version = & $openclaw --version -$version -${expected ? `if (($version | Out-String) -notlike ${psSingleQuote(`*${expected}*`)}) { throw ${psSingleQuote(`version mismatch: expected ${expected}`)} }` : ""} -& $openclaw gateway restart -& $openclaw gateway status --deep --require-rpc -& $openclaw models set ${psSingleQuote(this.auth.modelId)} -& $openclaw config set agents.defaults.skipBootstrap true --strict-json -${windowsAgentWorkspaceScript("Parallels npm update smoke test assistant.")} -Set-Item -Path ('Env:' + ${psSingleQuote(this.auth.apiKeyEnv)}) -Value ${psSingleQuote(this.auth.apiKeyValue)} -& $openclaw agent --local --agent main --session-id parallels-npm-update-windows --message 'Reply with exact ASCII text OK only.' --json`, - updateTimeoutSeconds * 1000, - ); + this.guestWindows(this.updateScript("windows"), updateTimeoutSeconds * 1000); } private runLinuxUpdate(): void { - const expectedCheck = this.updateExpectedNeedle - ? `version="$(openclaw --version)"; printf '%s\\n' "$version"; case "$version" in *${shellQuote(this.updateExpectedNeedle)}*) ;; *) echo "version mismatch: expected ${this.updateExpectedNeedle}" >&2; exit 1 ;; esac` - : "openclaw --version"; - const script = String.raw`set -euo pipefail -scrub_future_plugin_entries() { - node - <<'JS' -const fs = require("node:fs"); -const path = require("node:path"); -const configPath = path.join(process.env.HOME || "/root", ".openclaw", "openclaw.json"); -if (!fs.existsSync(configPath)) process.exit(0); -let config; -try { config = JSON.parse(fs.readFileSync(configPath, "utf8")); } catch { process.exit(0); } -const plugins = config.plugins; -if (!plugins || typeof plugins !== "object") process.exit(0); -if (plugins.entries && typeof plugins.entries === "object") { - delete plugins.entries.feishu; - delete plugins.entries.whatsapp; -} -if (Array.isArray(plugins.allow)) { - plugins.allow = plugins.allow.filter((id) => id !== "feishu" && id !== "whatsapp"); -} -fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n"); -JS -} -stop_openclaw_gateway_processes() { - OPENCLAW_DISABLE_BUNDLED_PLUGINS=1 openclaw gateway stop || true - pkill -f 'openclaw.*gateway' >/dev/null 2>&1 || true -} -scrub_future_plugin_entries -stop_openclaw_gateway_processes -OPENCLAW_DISABLE_BUNDLED_PLUGINS=1 openclaw update --tag ${shellQuote(this.updateTargetEffective)} --yes --json -${expectedCheck} -openclaw gateway restart -openclaw gateway status --deep --require-rpc -openclaw models set ${shellQuote(this.auth.modelId)} -openclaw config set agents.defaults.skipBootstrap true --strict-json -${posixAgentWorkspaceScript("Parallels npm update smoke test assistant.")} -${this.auth.apiKeyEnv}=${shellQuote(this.auth.apiKeyValue)} openclaw agent --local --agent main --session-id parallels-npm-update-linux --message 'Reply with exact ASCII text OK only.' --json`; - this.guestLinux(script, updateTimeoutSeconds * 1000); + this.guestLinux(this.updateScript("linux"), updateTimeoutSeconds * 1000); + } + + private updateScript(platform: Platform): string { + const input = { + auth: this.auth, + expectedNeedle: this.updateExpectedNeedle, + updateTarget: this.updateTargetEffective, + }; + switch (platform) { + case "macos": + return macosUpdateScript(input); + case "windows": + return windowsUpdateScript(input); + case "linux": + return linuxUpdateScript(input); + } + return die("unsupported platform"); } private spawnLogged( @@ -588,55 +476,6 @@ ${this.auth.apiKeyEnv}=${shellQuote(this.auth.apiKeyValue)} openclaw agent --loc }); } - private ensureVmRunning(vmName: string): void { - const deadline = Date.now() + 180_000; - while (Date.now() < deadline) { - const status = this.vmStatus(vmName); - if (status === "running") { - return; - } - if (status === "stopped") { - say(`Start ${vmName} before update phase`); - run("prlctl", ["start", vmName], { quiet: true }); - } else if (status === "suspended" || status === "paused") { - say(`Resume ${vmName} before update phase`); - run("prlctl", ["resume", vmName], { quiet: true }); - } else if (status === "missing") { - die(`VM not found before update phase: ${vmName}`); - } - run("sleep", ["5"], { quiet: true }); - } - die(`VM did not become running before update phase: ${vmName}`); - } - - private vmStatus(vmName: string): string { - const payload = JSON.parse( - run("prlctl", ["list", "--all", "--json"], { quiet: true }).stdout, - ) as Array<{ - name?: string; - status?: string; - }>; - return payload.find((vm) => vm.name === vmName)?.status || "missing"; - } - - private resolveLinuxVmName(): string { - const payload = JSON.parse( - run("prlctl", ["list", "--all", "--json"], { quiet: true }).stdout, - ) as Array<{ - name?: string; - }>; - const names = payload.map((item) => (item.name ?? "").trim()).filter(Boolean); - if (names.includes(linuxVmDefault)) { - return linuxVmDefault; - } - const fallback = names.find((name) => /ubuntu/i.test(name)); - if (!fallback) { - die(`VM not found: ${linuxVmDefault}`); - } - warn(`requested VM ${linuxVmDefault} not found; using ${fallback}`); - return fallback; - } - private resolveRegistryTargetVersion(target: string): string { const spec = target.startsWith("openclaw@") ? target : `openclaw@${target}`; return ( diff --git a/scripts/e2e/parallels/parallels-vm.ts b/scripts/e2e/parallels/parallels-vm.ts new file mode 100644 index 00000000000..7925e654d96 --- /dev/null +++ b/scripts/e2e/parallels/parallels-vm.ts @@ -0,0 +1,94 @@ +import { die, run, say, warn } from "./host-command.ts"; + +interface PrlctlVmListItem { + name?: string; + status?: string; +} + +export function listVmNames(): string[] { + return listVms() + .map((item) => (item.name ?? "").trim()) + .filter(Boolean); +} + +export function vmStatus(vmName: string): string { + return listVms().find((vm) => vm.name === vmName)?.status || "missing"; +} + +export function waitForVmStatus(vmName: string, expected: string, timeoutSeconds: number): void { + const deadline = Date.now() + timeoutSeconds * 1000; + while (Date.now() < deadline) { + const status = run("prlctl", ["status", vmName], { + check: false, + quiet: true, + }).stdout; + if (status.includes(` ${expected}`)) { + return; + } + run("sleep", ["1"], { quiet: true }); + } + throw new Error(`VM ${vmName} did not reach ${expected}`); +} + +export function ensureVmRunning(vmName: string, timeoutSeconds = 180): void { + const deadline = Date.now() + timeoutSeconds * 1000; + while (Date.now() < deadline) { + const status = vmStatus(vmName); + if (status === "running") { + return; + } + if (status === "stopped") { + say(`Start ${vmName} before update phase`); + run("prlctl", ["start", vmName], { quiet: true }); + } else if (status === "suspended" || status === "paused") { + say(`Resume ${vmName} before update phase`); + run("prlctl", ["resume", vmName], { quiet: true }); + } else if (status === "missing") { + die(`VM not found before update phase: ${vmName}`); + } + run("sleep", ["5"], { quiet: true }); + } + die(`VM did not become running before update phase: ${vmName}`); +} + +export function resolveUbuntuVmName(requested: string, explicit = false): string { + const names = listVmNames(); + if (names.includes(requested)) { + return requested; + } + if (explicit) { + die(`VM not found: ${requested}`); + } + const fallback = + names + .map((name) => ({ name, version: /ubuntu\s+(\d+(?:\.\d+)*)/i.exec(name)?.[1] })) + .filter((item): item is { name: string; version: string } => Boolean(item.version)) + .map((item) => ({ + name: item.name, + parts: item.version.split(".").map(Number), + })) + .filter((item) => item.parts[0] >= 24) + .toSorted((a, b) => compareVersions(a.parts, b.parts))[0]?.name ?? + names.find((name) => /ubuntu/i.test(name)); + if (!fallback) { + die(`VM not found: ${requested}`); + } + warn(`requested VM ${requested} not found; using ${fallback}`); + return fallback; +} + +function listVms(): PrlctlVmListItem[] { + return JSON.parse( + run("prlctl", ["list", "--all", "--json"], { quiet: true }).stdout, + ) as PrlctlVmListItem[]; +} + +function compareVersions(a: number[], b: number[]): number { + for (let index = 0; index < Math.max(a.length, b.length); index++) { + const diff = (a[index] ?? 0) - (b[index] ?? 0); + if (diff !== 0) { + return diff; + } + } + return 0; +} diff --git a/scripts/e2e/parallels/windows-smoke.ts b/scripts/e2e/parallels/windows-smoke.ts index ad89adeb7e3..9744fe425d5 100755 --- a/scripts/e2e/parallels/windows-smoke.ts +++ b/scripts/e2e/parallels/windows-smoke.ts @@ -29,6 +29,8 @@ import { type SnapshotInfo, } from "./common.ts"; import { WindowsGuest } from "./guest-transports.ts"; +import { runSmokeLane, type SmokeLane, type SmokeLaneStatus } from "./lane-runner.ts"; +import { waitForVmStatus } from "./parallels-vm.ts"; import { PhaseRunner } from "./phase-runner.ts"; import { psArray, psSingleQuote } from "./powershell.ts"; import { ensureGuestGit, prepareMinGitZip } from "./windows-git.ts"; @@ -359,20 +361,14 @@ class WindowsSmoke { } private async runLane(name: "fresh" | "upgrade", fn: () => Promise): Promise { - try { - await fn(); - if (name === "fresh") { - this.status.freshMain = "pass"; - } else { - this.status.upgrade = "pass"; - } - } catch (error) { - if (name === "fresh") { - this.status.freshMain = "fail"; - } else { - this.status.upgrade = "fail"; - } - warn(`${name} lane failed: ${error instanceof Error ? error.message : String(error)}`); + await runSmokeLane(name, fn, (lane, status) => this.setLaneStatus(lane, status)); + } + + private setLaneStatus(name: SmokeLane, status: SmokeLaneStatus): void { + if (name === "fresh") { + this.status.freshMain = status; + } else { + this.status.upgrade = status; } } @@ -494,27 +490,12 @@ class WindowsSmoke { quiet: true, }); if (this.snapshot.state === "poweroff") { - this.waitForVmStatus("stopped"); + waitForVmStatus(this.options.vmName, "stopped", 240); say(`Start restored poweroff snapshot ${this.snapshot.name}`); run("prlctl", ["start", this.options.vmName], { quiet: true }); } } - private waitForVmStatus(expected: string, timeoutSeconds = 240): void { - const deadline = Date.now() + timeoutSeconds * 1000; - while (Date.now() < deadline) { - const status = run("prlctl", ["status", this.options.vmName], { - check: false, - quiet: true, - }).stdout; - if (status.includes(` ${expected}`)) { - return; - } - run("sleep", ["1"], { quiet: true }); - } - throw new Error(`VM ${this.options.vmName} did not reach ${expected}`); - } - private waitForGuestReady(timeoutSeconds = 240): void { const deadline = Date.now() + timeoutSeconds * 1000; while (Date.now() < deadline) { diff --git a/test/scripts/parallels-npm-update-smoke.test.ts b/test/scripts/parallels-npm-update-smoke.test.ts index 7111a9a4d7a..fa35452e520 100644 --- a/test/scripts/parallels-npm-update-smoke.test.ts +++ b/test/scripts/parallels-npm-update-smoke.test.ts @@ -2,6 +2,7 @@ import { readFileSync } from "node:fs"; import { describe, expect, it } from "vitest"; const SCRIPT_PATH = "scripts/e2e/parallels/npm-update-smoke.ts"; +const UPDATE_SCRIPTS_PATH = "scripts/e2e/parallels/npm-update-scripts.ts"; describe("parallels npm update smoke", () => { it("does not leave guard/server children attached to the wrapper", () => { @@ -13,7 +14,7 @@ describe("parallels npm update smoke", () => { }); it("scrubs future plugin entries before invoking old same-guest updaters", () => { - const script = readFileSync(SCRIPT_PATH, "utf8"); + const script = readFileSync(UPDATE_SCRIPTS_PATH, "utf8"); expect(script).toContain("Remove-FuturePluginEntries"); expect(script).toContain("scrub_future_plugin_entries"); diff --git a/test/scripts/parallels-smoke-model.test.ts b/test/scripts/parallels-smoke-model.test.ts index 5e812279bfb..780d40cedf9 100644 --- a/test/scripts/parallels-smoke-model.test.ts +++ b/test/scripts/parallels-smoke-model.test.ts @@ -17,10 +17,14 @@ const TS_PATHS = { guestTransports: "scripts/e2e/parallels/guest-transports.ts", hostCommand: "scripts/e2e/parallels/host-command.ts", hostServer: "scripts/e2e/parallels/host-server.ts", + laneRunner: "scripts/e2e/parallels/lane-runner.ts", linux: "scripts/e2e/parallels/linux-smoke.ts", + macosDiscord: "scripts/e2e/parallels/macos-discord.ts", macos: "scripts/e2e/parallels/macos-smoke.ts", + npmUpdateScripts: "scripts/e2e/parallels/npm-update-scripts.ts", npmUpdate: "scripts/e2e/parallels/npm-update-smoke.ts", packageArtifact: "scripts/e2e/parallels/package-artifact.ts", + parallelsVm: "scripts/e2e/parallels/parallels-vm.ts", phaseRunner: "scripts/e2e/parallels/phase-runner.ts", providerAuth: "scripts/e2e/parallels/provider-auth.ts", snapshots: "scripts/e2e/parallels/snapshots.ts", @@ -100,15 +104,22 @@ describe("Parallels smoke model selection", () => { const common = readFileSync(TS_PATHS.common, "utf8"); const hostCommand = readFileSync(TS_PATHS.hostCommand, "utf8"); const hostServer = readFileSync(TS_PATHS.hostServer, "utf8"); + const laneRunner = readFileSync(TS_PATHS.laneRunner, "utf8"); const packageArtifact = readFileSync(TS_PATHS.packageArtifact, "utf8"); + const parallelsVm = readFileSync(TS_PATHS.parallelsVm, "utf8"); const snapshots = readFileSync(TS_PATHS.snapshots, "utf8"); expect(common).toContain('export * from "./host-command.ts"'); + expect(common).toContain('export * from "./lane-runner.ts"'); expect(common).toContain('export * from "./package-artifact.ts"'); + expect(common).toContain('export * from "./parallels-vm.ts"'); expect(common).toContain('export * from "./snapshots.ts"'); expect(hostCommand).toContain("export function shellQuote"); + expect(laneRunner).toContain("export async function runSmokeLane"); 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 waitForVmStatus"); expect(hostServer).toContain("export async function startHostServer"); expect(snapshots).toContain("export function resolveSnapshot"); @@ -116,6 +127,7 @@ describe("Parallels smoke model selection", () => { const script = readFileSync(scriptPath, "utf8"); expect(script, scriptPath).toContain("resolveSnapshot"); + expect(script, scriptPath).toContain("runSmokeLane"); expect(script, scriptPath).not.toContain("def aliases(name: str)"); } }); @@ -160,6 +172,43 @@ console.log([snapshot.id, snapshot.state, snapshot.name].join("\\t")); } }); + it("uses one Ubuntu VM fallback resolver for Linux lanes", () => { + const tempDir = mkdtempSync(join(tmpdir(), "openclaw-parallels-vm-helper-")); + const prlctlPath = join(tempDir, "prlctl"); + writeFileSync( + prlctlPath, + `#!/usr/bin/env bash +set -euo pipefail +if [[ "$1" == "list" ]]; then + cat <<'JSON' +[ + {"name": "Ubuntu 25.10"}, + {"name": "Ubuntu 23.10"}, + {"name": "Ubuntu 24.04.3 ARM64"} +] +JSON + exit 0 +fi +exit 1 +`, + ); + chmodSync(prlctlPath, 0o755); + + try { + const output = runTsEval( + ` +import { resolveUbuntuVmName } from "./${TS_PATHS.common}"; +console.log(resolveUbuntuVmName("Ubuntu missing")); +`, + { PATH: `${tempDir}:${process.env.PATH ?? ""}` }, + ); + + expect(output.trim()).toBe("Ubuntu 24.04.3 ARM64"); + } finally { + rmSync(tempDir, { force: true, recursive: true }); + } + }); + it("resolves provider defaults and explicit model overrides", () => { expect(resolveProviderAuth("openai", { env: { OPENAI_API_KEY: "sk-openai" } })).toEqual({ apiKeyEnv: "OPENAI_API_KEY", @@ -232,9 +281,9 @@ console.log([snapshot.id, snapshot.state, snapshot.name].join("\\t")); expect(script, scriptPath).toContain("agents.defaults.skipBootstrap"); } - const npmUpdate = readFileSync(TS_PATHS.npmUpdate, "utf8"); - expect(npmUpdate).toContain("posixAgentWorkspaceScript"); - expect(npmUpdate).toContain("windowsAgentWorkspaceScript"); + const npmUpdateScripts = readFileSync(TS_PATHS.npmUpdateScripts, "utf8"); + expect(npmUpdateScripts).toContain("posixAgentWorkspaceScript"); + expect(npmUpdateScripts).toContain("windowsAgentWorkspaceScript"); }); it("clears phase timers and applies phase deadlines to guest commands", () => { @@ -277,6 +326,31 @@ console.log([snapshot.id, snapshot.state, snapshot.name].join("\\t")); expect(script).toContain("OPENCLAW_PARALLELS_LINUX_DISABLE_BONJOUR"); }); + it("keeps aggregate update guest scripts isolated from the npm-update orchestrator", () => { + const orchestrator = readFileSync(TS_PATHS.npmUpdate, "utf8"); + const updateScripts = readFileSync(TS_PATHS.npmUpdateScripts, "utf8"); + + expect(orchestrator).toContain("macosUpdateScript"); + expect(orchestrator).toContain("windowsUpdateScript"); + expect(orchestrator).toContain("linuxUpdateScript"); + expect(orchestrator).not.toContain("Remove-FuturePluginEntries"); + expect(updateScripts).toContain("Remove-FuturePluginEntries"); + expect(updateScripts).toContain("scrub_future_plugin_entries"); + expect(updateScripts).toContain("Parallels npm update smoke test assistant."); + }); + + it("keeps macOS Discord roundtrip isolated from the lane orchestrator", () => { + const macos = readFileSync(TS_PATHS.macos, "utf8"); + const discord = readFileSync(TS_PATHS.macosDiscord, "utf8"); + + expect(macos).toContain("MacosDiscordSmoke"); + expect(macos).not.toContain("Authorization: Bot"); + expect(discord).toContain("Authorization: Bot"); + expect(discord).toContain('"--silent"'); + expect(discord).toContain("channels status --probe --json"); + expect(discord).toContain("Stop ${this.input.vmName} after successful Discord smoke"); + }); + it("keeps Windows gateway reachability on a real deadline with start recovery", () => { const script = readFileSync(TS_PATHS.windows, "utf8");