Skip to content

Commit 64bd2a2

Browse files
committed
refactor: simplify parallels smoke helpers
1 parent 579334f commit 64bd2a2

12 files changed

Lines changed: 640 additions & 508 deletions

scripts/e2e/parallels/common.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
export * from "./filesystem.ts";
22
export * from "./host-command.ts";
33
export * from "./host-server.ts";
4+
export * from "./lane-runner.ts";
45
export * from "./package-artifact.ts";
6+
export * from "./parallels-vm.ts";
57
export * from "./provider-auth.ts";
68
export * from "./snapshots.ts";
79
export * from "./types.ts";

scripts/e2e/parallels/guest-transports.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { run } from "./host-command.ts";
22
import type { PhaseRunner } from "./phase-runner.ts";
33
import { encodePowerShell } from "./powershell.ts";
4+
import type { CommandResult } from "./types.ts";
45

56
export interface GuestExecOptions {
67
check?: boolean;
@@ -47,6 +48,10 @@ export class MacosGuest {
4748
) {}
4849

4950
exec(args: string[], options: MacosGuestOptions = {}): string {
51+
return this.run(args, options).stdout.trim();
52+
}
53+
54+
run(args: string[], options: MacosGuestOptions = {}): CommandResult {
5055
const envArgs = Object.entries({ PATH: this.input.path, ...options.env }).map(
5156
([key, value]) => `${key}=${value}`,
5257
);
@@ -75,7 +80,7 @@ export class MacosGuest {
7580
});
7681
this.phases.append(result.stdout);
7782
this.phases.append(result.stderr);
78-
return result.stdout.trim();
83+
return result;
7984
}
8085

8186
sh(script: string, env: Record<string, string> = {}): string {
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { warn } from "./host-command.ts";
2+
3+
export type SmokeLane = "fresh" | "upgrade";
4+
export type SmokeLaneStatus = "pass" | "fail";
5+
6+
export async function runSmokeLane(
7+
name: SmokeLane,
8+
fn: () => Promise<void>,
9+
setStatus: (name: SmokeLane, status: SmokeLaneStatus) => void,
10+
): Promise<void> {
11+
try {
12+
await fn();
13+
setStatus(name, "pass");
14+
} catch (error) {
15+
setStatus(name, "fail");
16+
warn(`${name} lane failed: ${error instanceof Error ? error.message : String(error)}`);
17+
}
18+
}

scripts/e2e/parallels/linux-smoke.ts

Lines changed: 12 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ import {
3232
type SnapshotInfo,
3333
} from "./common.ts";
3434
import { LinuxGuest } from "./guest-transports.ts";
35+
import { runSmokeLane, type SmokeLane, type SmokeLaneStatus } from "./lane-runner.ts";
36+
import { resolveUbuntuVmName, waitForVmStatus } from "./parallels-vm.ts";
3537
import { PhaseRunner } from "./phase-runner.ts";
3638

3739
interface LinuxOptions {
@@ -302,20 +304,14 @@ class LinuxSmoke {
302304
}
303305

304306
private async runLane(name: "fresh" | "upgrade", fn: () => Promise<void>): Promise<void> {
305-
try {
306-
await fn();
307-
if (name === "fresh") {
308-
this.status.freshMain = "pass";
309-
} else {
310-
this.status.upgrade = "pass";
311-
}
312-
} catch (error) {
313-
if (name === "fresh") {
314-
this.status.freshMain = "fail";
315-
} else {
316-
this.status.upgrade = "fail";
317-
}
318-
warn(`${name} lane failed: ${error instanceof Error ? error.message : String(error)}`);
307+
await runSmokeLane(name, fn, (lane, status) => this.setLaneStatus(lane, status));
308+
}
309+
310+
private setLaneStatus(name: SmokeLane, status: SmokeLaneStatus): void {
311+
if (name === "fresh") {
312+
this.status.freshMain = status;
313+
} else {
314+
this.status.upgrade = status;
319315
}
320316
}
321317

@@ -324,34 +320,7 @@ class LinuxSmoke {
324320
}
325321

326322
private resolveVmName(): string {
327-
const payload = JSON.parse(
328-
run("prlctl", ["list", "--all", "--json"], { quiet: true }).stdout,
329-
) as Array<{
330-
name?: string;
331-
}>;
332-
const names = payload.map((item) => (item.name ?? "").trim()).filter(Boolean);
333-
if (names.includes(this.options.vmName)) {
334-
return this.options.vmName;
335-
}
336-
if (this.options.vmNameExplicit) {
337-
die(`VM not found: ${this.options.vmName}`);
338-
}
339-
const ubuntu = names
340-
.map((name) => ({ name, version: /ubuntu\s+(\d+(?:\.\d+)*)/i.exec(name)?.[1] }))
341-
.filter((item): item is { name: string; version: string } => Boolean(item.version))
342-
.map((item) => ({
343-
name: item.name,
344-
parts: item.version.split(".").map(Number),
345-
version: item.version,
346-
}))
347-
.filter((item) => item.parts[0] >= 24)
348-
.toSorted((a, b) => compareVersions(a.parts, b.parts));
349-
const fallback = ubuntu[0]?.name ?? names.find((name) => /ubuntu/i.test(name));
350-
if (!fallback) {
351-
die(`VM not found: ${this.options.vmName}`);
352-
}
353-
warn(`requested VM ${this.options.vmName} not found; using ${fallback}`);
354-
return fallback;
323+
return resolveUbuntuVmName(this.options.vmName, this.options.vmNameExplicit);
355324
}
356325

357326
private async runFreshLane(): Promise<void> {
@@ -428,21 +397,6 @@ class LinuxSmoke {
428397
return this.guest.bash(script);
429398
}
430399

431-
private waitForVmStatus(expected: string, timeoutSeconds = 180): void {
432-
const deadline = Date.now() + timeoutSeconds * 1000;
433-
while (Date.now() < deadline) {
434-
const status = run("prlctl", ["status", this.options.vmName], {
435-
check: false,
436-
quiet: true,
437-
}).stdout;
438-
if (status.includes(` ${expected}`)) {
439-
return;
440-
}
441-
run("sleep", ["1"], { quiet: true });
442-
}
443-
die(`VM ${this.options.vmName} did not reach ${expected}`);
444-
}
445-
446400
private waitForGuestReady(timeoutSeconds = 180): void {
447401
const deadline = Date.now() + timeoutSeconds * 1000;
448402
while (Date.now() < deadline) {
@@ -466,7 +420,7 @@ class LinuxSmoke {
466420
quiet: true,
467421
});
468422
if (this.snapshot.state === "poweroff") {
469-
this.waitForVmStatus("stopped");
423+
waitForVmStatus(this.options.vmName, "stopped", 180);
470424
say(`Start restored poweroff snapshot ${this.snapshot.name}`);
471425
run("prlctl", ["start", this.options.vmName], { quiet: true });
472426
}
@@ -774,16 +728,6 @@ setsid sh -lc ` +
774728
}
775729
}
776730

777-
function compareVersions(a: number[], b: number[]): number {
778-
for (let index = 0; index < Math.max(a.length, b.length); index++) {
779-
const diff = (a[index] ?? 0) - (b[index] ?? 0);
780-
if (diff !== 0) {
781-
return diff;
782-
}
783-
}
784-
return 0;
785-
}
786-
787731
const options = parseArgs(process.argv.slice(2));
788732
await mkdir(repoRoot, { recursive: true });
789733
await new LinuxSmoke(options).run();
Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
import { readFile, writeFile } from "node:fs/promises";
2+
import path from "node:path";
3+
import type { MacosGuest } from "./guest-transports.ts";
4+
import { run, say, shellQuote, warn } from "./host-command.ts";
5+
6+
export type DiscordSmokePhase = "fresh" | "upgrade";
7+
8+
export interface MacosDiscordConfig {
9+
channelId: string;
10+
guildId: string;
11+
token: string;
12+
}
13+
14+
export class MacosDiscordSmoke {
15+
constructor(
16+
private input: {
17+
config: MacosDiscordConfig;
18+
guest: MacosGuest;
19+
guestNode: string;
20+
guestOpenClaw: string;
21+
guestOpenClawEntry: string;
22+
runDir: string;
23+
vmName: string;
24+
},
25+
) {}
26+
27+
configure(): void {
28+
const guilds = JSON.stringify({
29+
[this.input.config.guildId]: {
30+
channels: {
31+
[this.input.config.channelId]: {
32+
enabled: true,
33+
requireMention: false,
34+
},
35+
},
36+
},
37+
});
38+
this.input.guest.sh(`set -eu
39+
${this.input.guestNode} ${this.input.guestOpenClawEntry} config set channels.discord.token ${shellQuote(this.input.config.token)}
40+
${this.input.guestNode} ${this.input.guestOpenClawEntry} config set channels.discord.enabled true
41+
${this.input.guestNode} ${this.input.guestOpenClawEntry} config set channels.discord.groupPolicy allowlist
42+
${this.input.guestNode} ${this.input.guestOpenClawEntry} config set channels.discord.guilds ${shellQuote(guilds)} --strict-json
43+
${this.input.guestNode} ${this.input.guestOpenClawEntry} gateway restart
44+
${this.input.guestNode} ${this.input.guestOpenClawEntry} channels status --probe --json`);
45+
}
46+
47+
async runRoundtrip(phase: DiscordSmokePhase): Promise<void> {
48+
const nonce = `${Date.now()}-${Math.floor(Math.random() * 100000)}`;
49+
const outboundNonce = `${phase}-out-${nonce}`;
50+
const inboundNonce = `${phase}-in-${nonce}`;
51+
const outboundLog = path.join(this.input.runDir, `${phase}.discord-send.json`);
52+
const sentIdFile = path.join(this.input.runDir, `${phase}.discord-sent-message-id`);
53+
const hostIdFile = path.join(this.input.runDir, `${phase}.discord-host-message-id`);
54+
const outbound = this.input.guest.exec([
55+
this.input.guestOpenClaw,
56+
"message",
57+
"send",
58+
"--channel",
59+
"discord",
60+
"--target",
61+
`channel:${this.input.config.channelId}`,
62+
"--message",
63+
`parallels-macos-smoke-outbound-${outboundNonce}`,
64+
"--silent",
65+
"--json",
66+
]);
67+
await writeFile(outboundLog, `${outbound}\n`, "utf8");
68+
const sentId = this.discordMessageId(outbound);
69+
await writeFile(sentIdFile, `${sentId}\n`, "utf8");
70+
await this.waitForHostVisibility(outboundNonce, sentId);
71+
const hostId = await this.postDiscordMessage(`parallels-macos-smoke-inbound-${inboundNonce}`);
72+
await writeFile(hostIdFile, `${hostId}\n`, "utf8");
73+
this.waitForGuestReadback(inboundNonce);
74+
}
75+
76+
async cleanupMessages(): Promise<void> {
77+
for (const name of [
78+
"fresh.discord-sent-message-id",
79+
"fresh.discord-host-message-id",
80+
"upgrade.discord-sent-message-id",
81+
"upgrade.discord-host-message-id",
82+
]) {
83+
const filePath = path.join(this.input.runDir, name);
84+
const id = await readFile(filePath, "utf8").catch(() => "");
85+
if (id.trim()) {
86+
await this.discordApi(
87+
"DELETE",
88+
`/channels/${this.input.config.channelId}/messages/${id.trim()}`,
89+
).catch(() => "");
90+
}
91+
}
92+
}
93+
94+
stopVmAfterSuccessfulSmoke(freshDiscord: string, upgradeDiscord: string): void {
95+
if (freshDiscord !== "pass" && upgradeDiscord !== "pass") {
96+
return;
97+
}
98+
say(`Stop ${this.input.vmName} after successful Discord smoke`);
99+
const result = run("prlctl", ["stop", this.input.vmName], {
100+
check: false,
101+
quiet: true,
102+
timeoutMs: 120_000,
103+
});
104+
if (result.status !== 0) {
105+
warn(
106+
`failed to stop ${this.input.vmName} after successful Discord smoke (rc=${result.status})`,
107+
);
108+
}
109+
}
110+
111+
private discordMessageId(payloadText: string): string {
112+
const payload = JSON.parse(payloadText) as {
113+
payload?: { messageId?: string; result?: { messageId?: string } };
114+
};
115+
const id = payload.payload?.messageId || payload.payload?.result?.messageId;
116+
if (!id) {
117+
throw new Error("messageId missing from send output");
118+
}
119+
return id;
120+
}
121+
122+
private async discordApi(method: string, apiPath: string, payload?: unknown): Promise<string> {
123+
const args = [
124+
"-fsS",
125+
"-X",
126+
method,
127+
"-H",
128+
`Authorization: Bot ${this.input.config.token}`,
129+
...(payload == null
130+
? []
131+
: ["-H", "Content-Type: application/json", "--data", JSON.stringify(payload)]),
132+
`https://discord.com/api/v10${apiPath}`,
133+
];
134+
return run("curl", args, { quiet: true }).stdout;
135+
}
136+
137+
private async waitForHostVisibility(nonce: string, messageId: string): Promise<void> {
138+
const deadline = Date.now() + 180_000;
139+
while (Date.now() < deadline) {
140+
const direct = await this.discordApi(
141+
"GET",
142+
`/channels/${this.input.config.channelId}/messages/${messageId}`,
143+
).catch(() => "");
144+
if (direct.includes(nonce)) {
145+
return;
146+
}
147+
const recent = await this.discordApi(
148+
"GET",
149+
`/channels/${this.input.config.channelId}/messages?limit=20`,
150+
).catch(() => "");
151+
if (recent.includes(nonce)) {
152+
return;
153+
}
154+
run("sleep", ["2"], { quiet: true });
155+
}
156+
throw new Error("Discord host visibility timed out");
157+
}
158+
159+
private async postDiscordMessage(content: string): Promise<string> {
160+
const response = await this.discordApi(
161+
"POST",
162+
`/channels/${this.input.config.channelId}/messages`,
163+
{
164+
content,
165+
flags: 4096,
166+
},
167+
);
168+
const id = (JSON.parse(response) as { id?: string }).id;
169+
if (!id) {
170+
throw new Error("host Discord post missing message id");
171+
}
172+
return id;
173+
}
174+
175+
private waitForGuestReadback(nonce: string): void {
176+
const deadline = Date.now() + 180_000;
177+
while (Date.now() < deadline) {
178+
const result = this.input.guest.run(
179+
[
180+
this.input.guestOpenClaw,
181+
"message",
182+
"read",
183+
"--channel",
184+
"discord",
185+
"--target",
186+
`channel:${this.input.config.channelId}`,
187+
"--limit",
188+
"20",
189+
"--json",
190+
],
191+
{ check: false },
192+
);
193+
if (result.status === 0 && result.stdout.includes(nonce)) {
194+
return;
195+
}
196+
run("sleep", ["3"], { quiet: true });
197+
}
198+
throw new Error("Discord guest readback timed out");
199+
}
200+
}

0 commit comments

Comments
 (0)