mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:50:43 +00:00
refactor: simplify parallels smoke helpers
This commit is contained in:
@@ -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";
|
||||
|
||||
@@ -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, string> = {}): string {
|
||||
|
||||
18
scripts/e2e/parallels/lane-runner.ts
Normal file
18
scripts/e2e/parallels/lane-runner.ts
Normal file
@@ -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<void>,
|
||||
setStatus: (name: SmokeLane, status: SmokeLaneStatus) => void,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await fn();
|
||||
setStatus(name, "pass");
|
||||
} catch (error) {
|
||||
setStatus(name, "fail");
|
||||
warn(`${name} lane failed: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
@@ -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<void>): Promise<void> {
|
||||
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<void> {
|
||||
@@ -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();
|
||||
|
||||
200
scripts/e2e/parallels/macos-discord.ts
Normal file
200
scripts/e2e/parallels/macos-discord.ts
Normal file
@@ -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<void> {
|
||||
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<void> {
|
||||
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<string> {
|
||||
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<void> {
|
||||
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<string> {
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -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<void>): Promise<void> {
|
||||
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<void> {
|
||||
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<string> {
|
||||
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<void> {
|
||||
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<string> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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 {
|
||||
|
||||
150
scripts/e2e/parallels/npm-update-scripts.ts
Normal file
150
scripts/e2e/parallels/npm-update-scripts.ts
Normal file
@@ -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}`)} }`;
|
||||
}
|
||||
@@ -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<void> {
|
||||
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 (
|
||||
|
||||
94
scripts/e2e/parallels/parallels-vm.ts
Normal file
94
scripts/e2e/parallels/parallels-vm.ts
Normal file
@@ -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;
|
||||
}
|
||||
@@ -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<void>): Promise<void> {
|
||||
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) {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
Reference in New Issue
Block a user