fix(e2e): bound Parallels host VM commands

This commit is contained in:
Vincent Koc
2026-06-01 14:41:42 +02:00
parent 1f91e97353
commit 421ea1f458
6 changed files with 91 additions and 20 deletions

View File

@@ -363,7 +363,8 @@ class LinuxSmoke extends SmokeRunController<LinuxOptions> {
private phase = async (name: string, timeoutSeconds: number, fn: () => Promise<void> | void) =>
await this.phases.phase(name, timeoutSeconds, fn);
private remainingPhaseTimeoutMs = (): number | undefined => this.phases.remainingTimeoutMs();
private remainingPhaseTimeoutMs = (fallbackMs?: number): number | undefined =>
this.phases.remainingTimeoutMs(fallbackMs);
private logGuestPreflight(): void {
this.guestBash(String.raw`set -euo pipefail
@@ -406,11 +407,17 @@ printf 'preflight.npmRoot=%s\n' "$(npm root -g 2>/dev/null || true)"`);
say(`Restore snapshot ${this.options.snapshotHint} (${this.snapshot.id})`);
run("prlctl", ["snapshot-switch", this.options.vmName, "--id", this.snapshot.id], {
quiet: true,
timeoutMs: this.remainingPhaseTimeoutMs(),
});
if (this.snapshot.state === "poweroff") {
waitForVmStatus(this.options.vmName, "stopped", 180);
waitForVmStatus(this.options.vmName, "stopped", 180, {
probeTimeoutMs: () => this.remainingPhaseTimeoutMs(30_000),
});
say(`Start restored poweroff snapshot ${this.snapshot.name}`);
run("prlctl", ["start", this.options.vmName], { quiet: true });
run("prlctl", ["start", this.options.vmName], {
quiet: true,
timeoutMs: this.remainingPhaseTimeoutMs(120_000),
});
}
this.waitForGuestReady();
}

View File

@@ -565,8 +565,8 @@ class MacosSmoke {
await this.phases.phase(name, timeoutSeconds, fn);
}
private remainingPhaseTimeoutMs(): number | undefined {
return this.phases.remainingTimeoutMs();
private remainingPhaseTimeoutMs(fallbackMs?: number): number | undefined {
return this.phases.remainingTimeoutMs(fallbackMs);
}
private async phaseReturns(
@@ -653,6 +653,7 @@ exec node "$entry" ${argv}`,
run("prlctl", ["exec", this.options.vmName, "/usr/bin/stat", "-f", "%Su", "/dev/console"], {
check: false,
quiet: true,
timeoutMs: this.remainingPhaseTimeoutMs(30_000),
})
.stdout.trim()
.replaceAll("\r", "")
@@ -671,6 +672,7 @@ exec node "$entry" ${argv}`,
{
check: false,
quiet: true,
timeoutMs: this.remainingPhaseTimeoutMs(30_000),
},
).stdout.replaceAll("\r", "");
for (const line of users.split("\n")) {
@@ -700,7 +702,7 @@ exec node "$entry" ${argv}`,
`/Users/${user}`,
"NFSHomeDirectory",
],
{ check: false, quiet: true },
{ check: false, quiet: true, timeoutMs: this.remainingPhaseTimeoutMs(30_000) },
).stdout.replaceAll("\r", "");
const match = /^NFSHomeDirectory:\s+(.+)$/m.exec(output);
return match?.[1]?.trim() || `/Users/${user}`;
@@ -713,7 +715,7 @@ exec node "$entry" ${argv}`,
const result = run(
"prlctl",
["snapshot-switch", this.options.vmName, "--id", this.snapshot.id, "--skip-resume"],
{ check: false, quiet: true, timeoutMs: 360_000 },
{ check: false, quiet: true, timeoutMs: this.remainingPhaseTimeoutMs(360_000) },
);
this.log(result.stdout);
this.log(result.stderr);
@@ -725,10 +727,17 @@ exec node "$entry" ${argv}`,
const status = run("prlctl", ["status", this.options.vmName], {
check: false,
quiet: true,
timeoutMs: this.remainingPhaseTimeoutMs(60_000),
}).stdout;
if (status.includes(" running") || status.includes(" suspended")) {
run("prlctl", ["stop", this.options.vmName, "--kill"], { check: false, quiet: true });
waitForVmStatus(this.options.vmName, "stopped", 360);
run("prlctl", ["stop", this.options.vmName, "--kill"], {
check: false,
quiet: true,
timeoutMs: this.remainingPhaseTimeoutMs(120_000),
});
waitForVmStatus(this.options.vmName, "stopped", 360, {
probeTimeoutMs: () => this.remainingPhaseTimeoutMs(30_000),
});
}
run("sleep", ["3"], { quiet: true });
}
@@ -738,15 +747,23 @@ exec node "$entry" ${argv}`,
const status = run("prlctl", ["status", this.options.vmName], {
check: false,
quiet: true,
timeoutMs: 60_000,
timeoutMs: this.remainingPhaseTimeoutMs(60_000),
}).stdout;
if (this.snapshot.state === "poweroff" || status.includes(" stopped")) {
waitForVmStatus(this.options.vmName, "stopped", 360);
waitForVmStatus(this.options.vmName, "stopped", 360, {
probeTimeoutMs: () => this.remainingPhaseTimeoutMs(30_000),
});
say(`Start restored poweroff snapshot ${this.snapshot.name}`);
run("prlctl", ["start", this.options.vmName], { quiet: true });
run("prlctl", ["start", this.options.vmName], {
quiet: true,
timeoutMs: this.remainingPhaseTimeoutMs(120_000),
});
} else if (status.includes(" suspended")) {
say(`Resume restored snapshot ${this.snapshot.name}`);
run("prlctl", ["start", this.options.vmName], { quiet: true });
run("prlctl", ["start", this.options.vmName], {
quiet: true,
timeoutMs: this.remainingPhaseTimeoutMs(120_000),
});
}
this.waitForCurrentUser();
}

View File

@@ -1,10 +1,17 @@
import { die, run, say, warn } from "./host-command.ts";
const PRLCTL_STATUS_TIMEOUT_MS = 30_000;
const PRLCTL_TRANSITION_TIMEOUT_MS = 120_000;
interface PrlctlVmListItem {
name?: string;
status?: string;
}
export interface WaitForVmStatusOptions {
probeTimeoutMs?: () => number | undefined;
}
export function listVmNames(): string[] {
return listVms()
.map((item) => (item.name ?? "").trim())
@@ -15,12 +22,18 @@ 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 {
export function waitForVmStatus(
vmName: string,
expected: string,
timeoutSeconds: number,
options: WaitForVmStatusOptions = {},
): void {
const deadline = Date.now() + timeoutSeconds * 1000;
while (Date.now() < deadline) {
const status = run("prlctl", ["status", vmName], {
check: false,
quiet: true,
timeoutMs: options.probeTimeoutMs?.() ?? PRLCTL_STATUS_TIMEOUT_MS,
}).stdout;
if (status.includes(` ${expected}`)) {
return;
@@ -39,10 +52,16 @@ export function ensureVmRunning(vmName: string, timeoutSeconds = 180): void {
}
if (status === "stopped") {
say(`Start ${vmName} before update phase`);
run("prlctl", ["start", vmName], { quiet: true });
run("prlctl", ["start", vmName], {
quiet: true,
timeoutMs: PRLCTL_TRANSITION_TIMEOUT_MS,
});
} else if (status === "suspended" || status === "paused") {
say(`Resume ${vmName} before update phase`);
run("prlctl", ["resume", vmName], { quiet: true });
run("prlctl", ["resume", vmName], {
quiet: true,
timeoutMs: PRLCTL_TRANSITION_TIMEOUT_MS,
});
} else if (status === "missing") {
die(`VM not found before update phase: ${vmName}`);
}
@@ -79,7 +98,10 @@ export function resolveUbuntuVmName(requested: string, explicit = false): string
function listVms(): PrlctlVmListItem[] {
return JSON.parse(
run("prlctl", ["list", "--all", "--json"], { quiet: true }).stdout,
run("prlctl", ["list", "--all", "--json"], {
quiet: true,
timeoutMs: PRLCTL_STATUS_TIMEOUT_MS,
}).stdout,
) as PrlctlVmListItem[];
}

View File

@@ -1,8 +1,13 @@
import { die, run } from "./host-command.ts";
import type { SnapshotInfo } from "./types.ts";
const SNAPSHOT_LIST_TIMEOUT_MS = 120_000;
export function resolveSnapshot(vmName: string, hint: string): SnapshotInfo {
const output = run("prlctl", ["snapshot-list", vmName, "--json"], { quiet: true }).stdout;
const output = run("prlctl", ["snapshot-list", vmName, "--json"], {
quiet: true,
timeoutMs: SNAPSHOT_LIST_TIMEOUT_MS,
}).stdout;
const payload = JSON.parse(output) as Record<string, { name?: string; state?: string }>;
let best: SnapshotInfo | null = null;
let bestScore = -1;

View File

@@ -449,6 +449,7 @@ class WindowsSmoke extends SmokeRunController<WindowsOptions> {
{
check: false,
quiet: true,
timeoutMs: this.remainingPhaseTimeoutMs(),
},
);
this.log(result.stdout);
@@ -469,9 +470,14 @@ class WindowsSmoke extends SmokeRunController<WindowsOptions> {
}
this.waitForVmNotRestoring(240);
if (this.snapshot.state === "poweroff") {
waitForVmStatus(this.options.vmName, "stopped", 240);
waitForVmStatus(this.options.vmName, "stopped", 240, {
probeTimeoutMs: () => this.remainingPhaseTimeoutMs(30_000),
});
say(`Start restored poweroff snapshot ${this.snapshot.name}`);
run("prlctl", ["start", this.options.vmName], { quiet: true });
run("prlctl", ["start", this.options.vmName], {
quiet: true,
timeoutMs: this.remainingPhaseTimeoutMs(120_000),
});
}
}
@@ -481,6 +487,7 @@ class WindowsSmoke extends SmokeRunController<WindowsOptions> {
const status = run("prlctl", ["status", this.options.vmName], {
check: false,
quiet: true,
timeoutMs: this.remainingPhaseTimeoutMs(30_000),
}).stdout;
if (!status.includes(" restoring")) {
return;

View File

@@ -602,10 +602,15 @@ if (isPrlctl) {
it("clears phase timers and applies phase deadlines to guest commands", () => {
const phaseRunner = readFileSync(TS_PATHS.phaseRunner, "utf8");
const guestTransports = readFileSync(TS_PATHS.guestTransports, "utf8");
const parallelsVm = readFileSync(TS_PATHS.parallelsVm, "utf8");
const snapshots = readFileSync(TS_PATHS.snapshots, "utf8");
expect(phaseRunner).toContain("clearTimeout(timer)");
expect(phaseRunner).toContain("remainingTimeoutMs");
expect(guestTransports).toContain("this.phases.remainingTimeoutMs");
expect(parallelsVm).toContain("PRLCTL_STATUS_TIMEOUT_MS");
expect(parallelsVm).toContain("probeTimeoutMs");
expect(snapshots).toContain("SNAPSHOT_LIST_TIMEOUT_MS");
for (const scriptPath of OS_TS_PATHS) {
const script = readFileSync(scriptPath, "utf8");
@@ -614,6 +619,14 @@ if (isPrlctl) {
expect(script, scriptPath).toContain("remainingPhaseTimeoutMs");
expect(script, scriptPath).toContain("timeoutMs:");
}
const linux = readFileSync(TS_PATHS.linux, "utf8");
const macos = readFileSync(TS_PATHS.macos, "utf8");
const windows = readFileSync(TS_PATHS.windows, "utf8");
expect(linux).toContain("probeTimeoutMs: () => this.remainingPhaseTimeoutMs(30_000)");
expect(windows).toContain("probeTimeoutMs: () => this.remainingPhaseTimeoutMs(30_000)");
expect(macos).toContain("probeTimeoutMs: () => this.remainingPhaseTimeoutMs(30_000)");
expect(macos).toContain("timeoutMs: this.remainingPhaseTimeoutMs(360_000)");
});
it("streams full phase logs to disk while bounding the failure tail", async () => {