mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 21:40:44 +00:00
refactor: simplify parallels smoke helpers
This commit is contained in:
@@ -1,7 +1,9 @@
|
|||||||
export * from "./filesystem.ts";
|
export * from "./filesystem.ts";
|
||||||
export * from "./host-command.ts";
|
export * from "./host-command.ts";
|
||||||
export * from "./host-server.ts";
|
export * from "./host-server.ts";
|
||||||
|
export * from "./lane-runner.ts";
|
||||||
export * from "./package-artifact.ts";
|
export * from "./package-artifact.ts";
|
||||||
|
export * from "./parallels-vm.ts";
|
||||||
export * from "./provider-auth.ts";
|
export * from "./provider-auth.ts";
|
||||||
export * from "./snapshots.ts";
|
export * from "./snapshots.ts";
|
||||||
export * from "./types.ts";
|
export * from "./types.ts";
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { run } from "./host-command.ts";
|
import { run } from "./host-command.ts";
|
||||||
import type { PhaseRunner } from "./phase-runner.ts";
|
import type { PhaseRunner } from "./phase-runner.ts";
|
||||||
import { encodePowerShell } from "./powershell.ts";
|
import { encodePowerShell } from "./powershell.ts";
|
||||||
|
import type { CommandResult } from "./types.ts";
|
||||||
|
|
||||||
export interface GuestExecOptions {
|
export interface GuestExecOptions {
|
||||||
check?: boolean;
|
check?: boolean;
|
||||||
@@ -47,6 +48,10 @@ export class MacosGuest {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
exec(args: string[], options: MacosGuestOptions = {}): string {
|
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(
|
const envArgs = Object.entries({ PATH: this.input.path, ...options.env }).map(
|
||||||
([key, value]) => `${key}=${value}`,
|
([key, value]) => `${key}=${value}`,
|
||||||
);
|
);
|
||||||
@@ -75,7 +80,7 @@ export class MacosGuest {
|
|||||||
});
|
});
|
||||||
this.phases.append(result.stdout);
|
this.phases.append(result.stdout);
|
||||||
this.phases.append(result.stderr);
|
this.phases.append(result.stderr);
|
||||||
return result.stdout.trim();
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
sh(script: string, env: Record<string, string> = {}): string {
|
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,
|
type SnapshotInfo,
|
||||||
} from "./common.ts";
|
} from "./common.ts";
|
||||||
import { LinuxGuest } from "./guest-transports.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";
|
import { PhaseRunner } from "./phase-runner.ts";
|
||||||
|
|
||||||
interface LinuxOptions {
|
interface LinuxOptions {
|
||||||
@@ -302,20 +304,14 @@ class LinuxSmoke {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async runLane(name: "fresh" | "upgrade", fn: () => Promise<void>): Promise<void> {
|
private async runLane(name: "fresh" | "upgrade", fn: () => Promise<void>): Promise<void> {
|
||||||
try {
|
await runSmokeLane(name, fn, (lane, status) => this.setLaneStatus(lane, status));
|
||||||
await fn();
|
}
|
||||||
if (name === "fresh") {
|
|
||||||
this.status.freshMain = "pass";
|
private setLaneStatus(name: SmokeLane, status: SmokeLaneStatus): void {
|
||||||
} else {
|
if (name === "fresh") {
|
||||||
this.status.upgrade = "pass";
|
this.status.freshMain = status;
|
||||||
}
|
} else {
|
||||||
} catch (error) {
|
this.status.upgrade = status;
|
||||||
if (name === "fresh") {
|
|
||||||
this.status.freshMain = "fail";
|
|
||||||
} else {
|
|
||||||
this.status.upgrade = "fail";
|
|
||||||
}
|
|
||||||
warn(`${name} lane failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -324,34 +320,7 @@ class LinuxSmoke {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private resolveVmName(): string {
|
private resolveVmName(): string {
|
||||||
const payload = JSON.parse(
|
return resolveUbuntuVmName(this.options.vmName, this.options.vmNameExplicit);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async runFreshLane(): Promise<void> {
|
private async runFreshLane(): Promise<void> {
|
||||||
@@ -428,21 +397,6 @@ class LinuxSmoke {
|
|||||||
return this.guest.bash(script);
|
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 {
|
private waitForGuestReady(timeoutSeconds = 180): void {
|
||||||
const deadline = Date.now() + timeoutSeconds * 1000;
|
const deadline = Date.now() + timeoutSeconds * 1000;
|
||||||
while (Date.now() < deadline) {
|
while (Date.now() < deadline) {
|
||||||
@@ -466,7 +420,7 @@ class LinuxSmoke {
|
|||||||
quiet: true,
|
quiet: true,
|
||||||
});
|
});
|
||||||
if (this.snapshot.state === "poweroff") {
|
if (this.snapshot.state === "poweroff") {
|
||||||
this.waitForVmStatus("stopped");
|
waitForVmStatus(this.options.vmName, "stopped", 180);
|
||||||
say(`Start restored poweroff snapshot ${this.snapshot.name}`);
|
say(`Start restored poweroff snapshot ${this.snapshot.name}`);
|
||||||
run("prlctl", ["start", this.options.vmName], { quiet: true });
|
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));
|
const options = parseArgs(process.argv.slice(2));
|
||||||
await mkdir(repoRoot, { recursive: true });
|
await mkdir(repoRoot, { recursive: true });
|
||||||
await new LinuxSmoke(options).run();
|
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
|
#!/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 path from "node:path";
|
||||||
import { posixAgentWorkspaceScript } from "./agent-workspace.ts";
|
import { posixAgentWorkspaceScript } from "./agent-workspace.ts";
|
||||||
import {
|
import {
|
||||||
@@ -30,6 +30,9 @@ import {
|
|||||||
type SnapshotInfo,
|
type SnapshotInfo,
|
||||||
} from "./common.ts";
|
} from "./common.ts";
|
||||||
import { MacosGuest } from "./guest-transports.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";
|
import { PhaseRunner } from "./phase-runner.ts";
|
||||||
|
|
||||||
interface MacosOptions {
|
interface MacosOptions {
|
||||||
@@ -247,6 +250,7 @@ class MacosSmoke {
|
|||||||
private snapshot!: SnapshotInfo;
|
private snapshot!: SnapshotInfo;
|
||||||
private phases!: PhaseRunner;
|
private phases!: PhaseRunner;
|
||||||
private guest!: MacosGuest;
|
private guest!: MacosGuest;
|
||||||
|
private discord: MacosDiscordSmoke | null = null;
|
||||||
private guestUser = "";
|
private guestUser = "";
|
||||||
private guestTransport: "current-user" | "sudo" = "current-user";
|
private guestTransport: "current-user" | "sudo" = "current-user";
|
||||||
|
|
||||||
@@ -289,6 +293,7 @@ class MacosSmoke {
|
|||||||
},
|
},
|
||||||
this.phases,
|
this.phases,
|
||||||
);
|
);
|
||||||
|
this.discord = this.createDiscordSmoke();
|
||||||
this.tgzDir = await makeTempDir("openclaw-parallels-macos-tgz.");
|
this.tgzDir = await makeTempDir("openclaw-parallels-macos-tgz.");
|
||||||
try {
|
try {
|
||||||
this.snapshot = resolveSnapshot(this.options.vmName, this.options.snapshotHint);
|
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 {
|
private targetInstallsDirectly(): boolean {
|
||||||
const spec = this.options.targetPackageSpec;
|
const spec = this.options.targetPackageSpec;
|
||||||
return Boolean(spec && !/^(https?:|file:|\/|\.\/|\.\.\/|.*\.tgz$)/.test(spec));
|
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> {
|
private async runLane(name: "fresh" | "upgrade", fn: () => Promise<void>): Promise<void> {
|
||||||
try {
|
await runSmokeLane(name, fn, (lane, status) => this.setLaneStatus(lane, status));
|
||||||
await fn();
|
}
|
||||||
if (name === "fresh") {
|
|
||||||
this.status.freshMain = "pass";
|
private setLaneStatus(name: SmokeLane, status: SmokeLaneStatus): void {
|
||||||
} else {
|
if (name === "fresh") {
|
||||||
this.status.upgrade = "pass";
|
this.status.freshMain = status;
|
||||||
}
|
} else {
|
||||||
} catch (error) {
|
this.status.upgrade = status;
|
||||||
if (name === "fresh") {
|
|
||||||
this.status.freshMain = "fail";
|
|
||||||
} else {
|
|
||||||
this.status.upgrade = "fail";
|
|
||||||
}
|
|
||||||
warn(`${name} lane failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -559,21 +577,6 @@ class MacosSmoke {
|
|||||||
return this.guest.sh(script, env);
|
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 {
|
private waitForCurrentUser(timeoutSeconds = 360): void {
|
||||||
const prlctlDeadline = Date.now() + 45_000;
|
const prlctlDeadline = Date.now() + 45_000;
|
||||||
const deadline = Date.now() + timeoutSeconds * 1000;
|
const deadline = Date.now() + timeoutSeconds * 1000;
|
||||||
@@ -697,7 +700,7 @@ class MacosSmoke {
|
|||||||
}).stdout;
|
}).stdout;
|
||||||
if (status.includes(" running") || status.includes(" suspended")) {
|
if (status.includes(" running") || status.includes(" suspended")) {
|
||||||
run("prlctl", ["stop", this.options.vmName, "--kill"], { check: false, quiet: true });
|
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 });
|
run("sleep", ["3"], { quiet: true });
|
||||||
}
|
}
|
||||||
@@ -705,7 +708,7 @@ class MacosSmoke {
|
|||||||
throw new Error("snapshot restore failed");
|
throw new Error("snapshot restore failed");
|
||||||
}
|
}
|
||||||
if (this.snapshot.state === "poweroff") {
|
if (this.snapshot.state === "poweroff") {
|
||||||
this.waitForVmStatus("stopped");
|
waitForVmStatus(this.options.vmName, "stopped", 360);
|
||||||
say(`Start restored poweroff snapshot ${this.snapshot.name}`);
|
say(`Start restored poweroff snapshot ${this.snapshot.name}`);
|
||||||
run("prlctl", ["start", this.options.vmName], { quiet: true });
|
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 {
|
private configureDiscord(): void {
|
||||||
const guilds = JSON.stringify({
|
this.discord?.configure();
|
||||||
[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`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async runDiscordRoundtrip(phase: "fresh" | "upgrade"): Promise<void> {
|
private async runDiscordRoundtrip(phase: "fresh" | "upgrade"): Promise<void> {
|
||||||
const nonce = `${Date.now()}-${Math.floor(Math.random() * 100000)}`;
|
if (!this.discord) {
|
||||||
const outboundNonce = `${phase}-out-${nonce}`;
|
throw new Error("Discord smoke is not configured");
|
||||||
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");
|
|
||||||
}
|
}
|
||||||
return id;
|
await this.discord.runRoundtrip(phase);
|
||||||
}
|
|
||||||
|
|
||||||
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");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async cleanupDiscordMessages(): Promise<void> {
|
private async cleanupDiscordMessages(): Promise<void> {
|
||||||
if (!this.discordEnabled() || !this.runDir) {
|
await this.discord?.cleanupMessages();
|
||||||
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(() => "");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async stopVmAfterSuccessfulDiscordSmoke(): Promise<void> {
|
private async stopVmAfterSuccessfulDiscordSmoke(): Promise<void> {
|
||||||
if (!this.discordEnabled()) {
|
this.discord?.stopVmAfterSuccessfulSmoke(this.status.freshDiscord, this.status.upgradeDiscord);
|
||||||
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})`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private guestHome(): string {
|
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 { spawn } from "node:child_process";
|
||||||
import { readFile, rm, writeFile } from "node:fs/promises";
|
import { readFile, rm, writeFile } from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { posixAgentWorkspaceScript, windowsAgentWorkspaceScript } from "./agent-workspace.ts";
|
|
||||||
import {
|
import {
|
||||||
die,
|
die,
|
||||||
ensureValue,
|
ensureValue,
|
||||||
@@ -16,9 +15,7 @@ import {
|
|||||||
resolveProviderAuth,
|
resolveProviderAuth,
|
||||||
run,
|
run,
|
||||||
say,
|
say,
|
||||||
shellQuote,
|
|
||||||
startHostServer,
|
startHostServer,
|
||||||
warn,
|
|
||||||
writeJson,
|
writeJson,
|
||||||
type HostServer,
|
type HostServer,
|
||||||
type PackageArtifact,
|
type PackageArtifact,
|
||||||
@@ -26,7 +23,9 @@ import {
|
|||||||
type Provider,
|
type Provider,
|
||||||
type ProviderAuth,
|
type ProviderAuth,
|
||||||
} from "./common.ts";
|
} 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 {
|
interface NpmUpdateOptions {
|
||||||
packageSpec: string;
|
packageSpec: string;
|
||||||
@@ -179,7 +178,7 @@ class NpmUpdateSmoke {
|
|||||||
this.hostIp = resolveHostIp("");
|
this.hostIp = resolveHostIp("");
|
||||||
|
|
||||||
if (this.options.platforms.has("linux")) {
|
if (this.options.platforms.has("linux")) {
|
||||||
this.linuxVm = this.resolveLinuxVmName();
|
this.linuxVm = resolveUbuntuVmName(linuxVmDefault);
|
||||||
}
|
}
|
||||||
this.preflightRegistryUpdateTarget();
|
this.preflightRegistryUpdateTarget();
|
||||||
|
|
||||||
@@ -295,15 +294,15 @@ class NpmUpdateSmoke {
|
|||||||
private async runSameGuestUpdates(): Promise<void> {
|
private async runSameGuestUpdates(): Promise<void> {
|
||||||
const jobs: Job[] = [];
|
const jobs: Job[] = [];
|
||||||
if (this.options.platforms.has("macos")) {
|
if (this.options.platforms.has("macos")) {
|
||||||
this.ensureVmRunning(macosVm);
|
ensureVmRunning(macosVm);
|
||||||
jobs.push(this.spawnUpdate("macOS", "macos", () => this.runMacosUpdate()));
|
jobs.push(this.spawnUpdate("macOS", "macos", () => this.runMacosUpdate()));
|
||||||
}
|
}
|
||||||
if (this.options.platforms.has("windows")) {
|
if (this.options.platforms.has("windows")) {
|
||||||
this.ensureVmRunning(windowsVm);
|
ensureVmRunning(windowsVm);
|
||||||
jobs.push(this.spawnUpdate("Windows", "windows", () => this.runWindowsUpdate()));
|
jobs.push(this.spawnUpdate("Windows", "windows", () => this.runWindowsUpdate()));
|
||||||
}
|
}
|
||||||
if (this.options.platforms.has("linux")) {
|
if (this.options.platforms.has("linux")) {
|
||||||
this.ensureVmRunning(this.linuxVm);
|
ensureVmRunning(this.linuxVm);
|
||||||
jobs.push(this.spawnUpdate("Linux", "linux", () => this.runLinuxUpdate()));
|
jobs.push(this.spawnUpdate("Linux", "linux", () => this.runLinuxUpdate()));
|
||||||
}
|
}
|
||||||
await this.monitorJobs("update", jobs);
|
await this.monitorJobs("update", jobs);
|
||||||
@@ -363,143 +362,32 @@ class NpmUpdateSmoke {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private runMacosUpdate(): void {
|
private runMacosUpdate(): void {
|
||||||
const expectedCheck = this.updateExpectedNeedle
|
this.guestMacos(this.updateScript("macos"), updateTimeoutSeconds * 1000);
|
||||||
? `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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private runWindowsUpdate(): void {
|
private runWindowsUpdate(): void {
|
||||||
const expected = this.updateExpectedNeedle;
|
this.guestWindows(this.updateScript("windows"), updateTimeoutSeconds * 1000);
|
||||||
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,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private runLinuxUpdate(): void {
|
private runLinuxUpdate(): void {
|
||||||
const expectedCheck = this.updateExpectedNeedle
|
this.guestLinux(this.updateScript("linux"), updateTimeoutSeconds * 1000);
|
||||||
? `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
|
private updateScript(platform: Platform): string {
|
||||||
scrub_future_plugin_entries() {
|
const input = {
|
||||||
node - <<'JS'
|
auth: this.auth,
|
||||||
const fs = require("node:fs");
|
expectedNeedle: this.updateExpectedNeedle,
|
||||||
const path = require("node:path");
|
updateTarget: this.updateTargetEffective,
|
||||||
const configPath = path.join(process.env.HOME || "/root", ".openclaw", "openclaw.json");
|
};
|
||||||
if (!fs.existsSync(configPath)) process.exit(0);
|
switch (platform) {
|
||||||
let config;
|
case "macos":
|
||||||
try { config = JSON.parse(fs.readFileSync(configPath, "utf8")); } catch { process.exit(0); }
|
return macosUpdateScript(input);
|
||||||
const plugins = config.plugins;
|
case "windows":
|
||||||
if (!plugins || typeof plugins !== "object") process.exit(0);
|
return windowsUpdateScript(input);
|
||||||
if (plugins.entries && typeof plugins.entries === "object") {
|
case "linux":
|
||||||
delete plugins.entries.feishu;
|
return linuxUpdateScript(input);
|
||||||
delete plugins.entries.whatsapp;
|
}
|
||||||
}
|
return die("unsupported platform");
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private spawnLogged(
|
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 {
|
private resolveRegistryTargetVersion(target: string): string {
|
||||||
const spec = target.startsWith("openclaw@") ? target : `openclaw@${target}`;
|
const spec = target.startsWith("openclaw@") ? target : `openclaw@${target}`;
|
||||||
return (
|
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,
|
type SnapshotInfo,
|
||||||
} from "./common.ts";
|
} from "./common.ts";
|
||||||
import { WindowsGuest } from "./guest-transports.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 { PhaseRunner } from "./phase-runner.ts";
|
||||||
import { psArray, psSingleQuote } from "./powershell.ts";
|
import { psArray, psSingleQuote } from "./powershell.ts";
|
||||||
import { ensureGuestGit, prepareMinGitZip } from "./windows-git.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> {
|
private async runLane(name: "fresh" | "upgrade", fn: () => Promise<void>): Promise<void> {
|
||||||
try {
|
await runSmokeLane(name, fn, (lane, status) => this.setLaneStatus(lane, status));
|
||||||
await fn();
|
}
|
||||||
if (name === "fresh") {
|
|
||||||
this.status.freshMain = "pass";
|
private setLaneStatus(name: SmokeLane, status: SmokeLaneStatus): void {
|
||||||
} else {
|
if (name === "fresh") {
|
||||||
this.status.upgrade = "pass";
|
this.status.freshMain = status;
|
||||||
}
|
} else {
|
||||||
} catch (error) {
|
this.status.upgrade = status;
|
||||||
if (name === "fresh") {
|
|
||||||
this.status.freshMain = "fail";
|
|
||||||
} else {
|
|
||||||
this.status.upgrade = "fail";
|
|
||||||
}
|
|
||||||
warn(`${name} lane failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -494,27 +490,12 @@ class WindowsSmoke {
|
|||||||
quiet: true,
|
quiet: true,
|
||||||
});
|
});
|
||||||
if (this.snapshot.state === "poweroff") {
|
if (this.snapshot.state === "poweroff") {
|
||||||
this.waitForVmStatus("stopped");
|
waitForVmStatus(this.options.vmName, "stopped", 240);
|
||||||
say(`Start restored poweroff snapshot ${this.snapshot.name}`);
|
say(`Start restored poweroff snapshot ${this.snapshot.name}`);
|
||||||
run("prlctl", ["start", this.options.vmName], { quiet: true });
|
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 {
|
private waitForGuestReady(timeoutSeconds = 240): void {
|
||||||
const deadline = Date.now() + timeoutSeconds * 1000;
|
const deadline = Date.now() + timeoutSeconds * 1000;
|
||||||
while (Date.now() < deadline) {
|
while (Date.now() < deadline) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { readFileSync } from "node:fs";
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
const SCRIPT_PATH = "scripts/e2e/parallels/npm-update-smoke.ts";
|
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", () => {
|
describe("parallels npm update smoke", () => {
|
||||||
it("does not leave guard/server children attached to the wrapper", () => {
|
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", () => {
|
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("Remove-FuturePluginEntries");
|
||||||
expect(script).toContain("scrub_future_plugin_entries");
|
expect(script).toContain("scrub_future_plugin_entries");
|
||||||
|
|||||||
@@ -17,10 +17,14 @@ const TS_PATHS = {
|
|||||||
guestTransports: "scripts/e2e/parallels/guest-transports.ts",
|
guestTransports: "scripts/e2e/parallels/guest-transports.ts",
|
||||||
hostCommand: "scripts/e2e/parallels/host-command.ts",
|
hostCommand: "scripts/e2e/parallels/host-command.ts",
|
||||||
hostServer: "scripts/e2e/parallels/host-server.ts",
|
hostServer: "scripts/e2e/parallels/host-server.ts",
|
||||||
|
laneRunner: "scripts/e2e/parallels/lane-runner.ts",
|
||||||
linux: "scripts/e2e/parallels/linux-smoke.ts",
|
linux: "scripts/e2e/parallels/linux-smoke.ts",
|
||||||
|
macosDiscord: "scripts/e2e/parallels/macos-discord.ts",
|
||||||
macos: "scripts/e2e/parallels/macos-smoke.ts",
|
macos: "scripts/e2e/parallels/macos-smoke.ts",
|
||||||
|
npmUpdateScripts: "scripts/e2e/parallels/npm-update-scripts.ts",
|
||||||
npmUpdate: "scripts/e2e/parallels/npm-update-smoke.ts",
|
npmUpdate: "scripts/e2e/parallels/npm-update-smoke.ts",
|
||||||
packageArtifact: "scripts/e2e/parallels/package-artifact.ts",
|
packageArtifact: "scripts/e2e/parallels/package-artifact.ts",
|
||||||
|
parallelsVm: "scripts/e2e/parallels/parallels-vm.ts",
|
||||||
phaseRunner: "scripts/e2e/parallels/phase-runner.ts",
|
phaseRunner: "scripts/e2e/parallels/phase-runner.ts",
|
||||||
providerAuth: "scripts/e2e/parallels/provider-auth.ts",
|
providerAuth: "scripts/e2e/parallels/provider-auth.ts",
|
||||||
snapshots: "scripts/e2e/parallels/snapshots.ts",
|
snapshots: "scripts/e2e/parallels/snapshots.ts",
|
||||||
@@ -100,15 +104,22 @@ describe("Parallels smoke model selection", () => {
|
|||||||
const common = readFileSync(TS_PATHS.common, "utf8");
|
const common = readFileSync(TS_PATHS.common, "utf8");
|
||||||
const hostCommand = readFileSync(TS_PATHS.hostCommand, "utf8");
|
const hostCommand = readFileSync(TS_PATHS.hostCommand, "utf8");
|
||||||
const hostServer = readFileSync(TS_PATHS.hostServer, "utf8");
|
const hostServer = readFileSync(TS_PATHS.hostServer, "utf8");
|
||||||
|
const laneRunner = readFileSync(TS_PATHS.laneRunner, "utf8");
|
||||||
const packageArtifact = readFileSync(TS_PATHS.packageArtifact, "utf8");
|
const packageArtifact = readFileSync(TS_PATHS.packageArtifact, "utf8");
|
||||||
|
const parallelsVm = readFileSync(TS_PATHS.parallelsVm, "utf8");
|
||||||
const snapshots = readFileSync(TS_PATHS.snapshots, "utf8");
|
const snapshots = readFileSync(TS_PATHS.snapshots, "utf8");
|
||||||
|
|
||||||
expect(common).toContain('export * from "./host-command.ts"');
|
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 "./package-artifact.ts"');
|
||||||
|
expect(common).toContain('export * from "./parallels-vm.ts"');
|
||||||
expect(common).toContain('export * from "./snapshots.ts"');
|
expect(common).toContain('export * from "./snapshots.ts"');
|
||||||
expect(hostCommand).toContain("export function shellQuote");
|
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 packageVersionFromTgz");
|
||||||
expect(packageArtifact).toContain("export async function packOpenClaw");
|
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(hostServer).toContain("export async function startHostServer");
|
||||||
expect(snapshots).toContain("export function resolveSnapshot");
|
expect(snapshots).toContain("export function resolveSnapshot");
|
||||||
|
|
||||||
@@ -116,6 +127,7 @@ describe("Parallels smoke model selection", () => {
|
|||||||
const script = readFileSync(scriptPath, "utf8");
|
const script = readFileSync(scriptPath, "utf8");
|
||||||
|
|
||||||
expect(script, scriptPath).toContain("resolveSnapshot");
|
expect(script, scriptPath).toContain("resolveSnapshot");
|
||||||
|
expect(script, scriptPath).toContain("runSmokeLane");
|
||||||
expect(script, scriptPath).not.toContain("def aliases(name: str)");
|
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", () => {
|
it("resolves provider defaults and explicit model overrides", () => {
|
||||||
expect(resolveProviderAuth("openai", { env: { OPENAI_API_KEY: "sk-openai" } })).toEqual({
|
expect(resolveProviderAuth("openai", { env: { OPENAI_API_KEY: "sk-openai" } })).toEqual({
|
||||||
apiKeyEnv: "OPENAI_API_KEY",
|
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");
|
expect(script, scriptPath).toContain("agents.defaults.skipBootstrap");
|
||||||
}
|
}
|
||||||
|
|
||||||
const npmUpdate = readFileSync(TS_PATHS.npmUpdate, "utf8");
|
const npmUpdateScripts = readFileSync(TS_PATHS.npmUpdateScripts, "utf8");
|
||||||
expect(npmUpdate).toContain("posixAgentWorkspaceScript");
|
expect(npmUpdateScripts).toContain("posixAgentWorkspaceScript");
|
||||||
expect(npmUpdate).toContain("windowsAgentWorkspaceScript");
|
expect(npmUpdateScripts).toContain("windowsAgentWorkspaceScript");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("clears phase timers and applies phase deadlines to guest commands", () => {
|
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");
|
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", () => {
|
it("keeps Windows gateway reachability on a real deadline with start recovery", () => {
|
||||||
const script = readFileSync(TS_PATHS.windows, "utf8");
|
const script = readFileSync(TS_PATHS.windows, "utf8");
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user