fix(e2e): harden Parallels helper cleanup

This commit is contained in:
Vincent Koc
2026-06-01 16:57:23 +02:00
parent d9f6e03e32
commit dfeb5b81ca
3 changed files with 150 additions and 24 deletions

View File

@@ -81,20 +81,54 @@ export async function startHostServer(input: {
hostIp: input.hostIp,
port: actualPort,
stop: async () => {
child.kill("SIGTERM");
await new Promise<void>((resolve) => {
child.once("exit", () => resolve());
setTimeout(() => {
child.kill("SIGKILL");
resolve();
}, 2_000).unref();
});
await stopHostServerChild(child);
},
urlFor: (filePath) =>
`http://${input.hostIp}:${actualPort}/${encodeURIComponent(path.basename(filePath))}`,
};
}
async function stopHostServerChild(
child: ChildProcessWithoutNullStreams,
terminateTimeoutMs = 2_000,
killTimeoutMs = 1_500,
): Promise<boolean> {
if (child.exitCode != null) {
return true;
}
child.kill("SIGTERM");
if (await waitForChildExit(child, terminateTimeoutMs)) {
return true;
}
child.kill("SIGKILL");
return await waitForChildExit(child, killTimeoutMs);
}
async function waitForChildExit(
child: ChildProcessWithoutNullStreams,
timeoutMs: number,
): Promise<boolean> {
if (child.exitCode != null) {
return true;
}
return await new Promise<boolean>((resolve) => {
let settled = false;
const onExit = () => settle(true);
const timeout = setTimeout(() => settle(child.exitCode != null), timeoutMs);
timeout.unref();
function settle(exited: boolean): void {
if (settled) {
return;
}
settled = true;
clearTimeout(timeout);
child.off("exit", onExit);
resolve(exited);
}
child.once("exit", onExit);
});
}
async function waitForHostServer(
child: ChildProcessWithoutNullStreams,
port: number,
@@ -160,4 +194,5 @@ async function delay(ms: number): Promise<void> {
export const testing = {
appendBoundedOutput,
stopHostServerChild,
};

View File

@@ -1,10 +1,18 @@
import { mkdtempSync } from "node:fs";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { parsePositiveInt, readPositiveIntEnv } from "./env-limits.ts";
import { die, run } from "./host-command.ts";
import type { Mode, Platform, Provider, ProviderAuth } from "./types.ts";
type ResolveLatestVersionDeps = {
createTempDir?: typeof mkdtempSync;
removeDir?: typeof rmSync;
runCommand?: typeof run;
tempDir?: typeof tmpdir;
writeFile?: typeof writeFileSync;
};
export function parseBoolEnv(value: string | undefined): boolean {
return /^(1|true|yes|on)$/i.test(value ?? "");
}
@@ -192,21 +200,26 @@ export function parsePlatformList(value: string): Set<Platform> {
return result;
}
export function resolveLatestVersion(versionOverride = ""): string {
export function resolveLatestVersion(
versionOverride = "",
deps: ResolveLatestVersionDeps = {},
): string {
if (versionOverride) {
return versionOverride;
}
return run(
"npm",
[
"view",
"openclaw",
"version",
"--userconfig",
mkdtempSync(path.join(tmpdir(), "openclaw-npm-")),
],
{
const createTempDir = deps.createTempDir ?? mkdtempSync;
const removeDir = deps.removeDir ?? rmSync;
const runCommand = deps.runCommand ?? run;
const resolveTempDir = deps.tempDir ?? tmpdir;
const writeFile = deps.writeFile ?? writeFileSync;
const userConfigDir = createTempDir(path.join(resolveTempDir(), "openclaw-npm-"));
const userConfigPath = path.join(userConfigDir, "npmrc");
try {
writeFile(userConfigPath, "", "utf8");
return runCommand("npm", ["view", "openclaw", "version", "--userconfig", userConfigPath], {
quiet: true,
},
).stdout.trim();
}).stdout.trim();
} finally {
removeDir(userConfigDir, { force: true, recursive: true });
}
}

View File

@@ -1,13 +1,24 @@
import { chmodSync, copyFileSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { EventEmitter } from "node:events";
import {
chmodSync,
copyFileSync,
existsSync,
mkdtempSync,
readFileSync,
rmSync,
statSync,
writeFileSync,
} from "node:fs";
import { createServer } from "node:net";
import { tmpdir } from "node:os";
import { delimiter, join, win32 } from "node:path";
import { basename, delimiter, join, win32 } from "node:path";
import { setTimeout as delay } from "node:timers/promises";
import { pathToFileURL } from "node:url";
import { beforeAll, describe, expect, it, vi } from "vitest";
import {
modelProviderConfigBatchJson,
readPositiveIntEnv,
resolveLatestVersion,
resolveParallelsModelTimeoutSeconds,
resolveProviderAuth as resolveProviderAuthDirect,
resolveSnapshot,
@@ -84,6 +95,21 @@ function writeFakePrlctl(tempDir: string, posixScript: string, windowsBootstrap:
writeFileSync(join(tempDir, "prlctl-bootstrap.mjs"), windowsBootstrap);
}
class FakeHostServerChild extends EventEmitter {
exitCode: number | null = null;
readonly signals: string[] = [];
kill(signal?: NodeJS.Signals | number): boolean {
this.signals.push(String(signal));
return true;
}
exit(): void {
this.exitCode = 0;
this.emit("exit", 0, null);
}
}
function withEnv<T>(env: Record<string, string>, callback: () => T): T {
const previous = new Map<string, string | undefined>();
for (const [key, _value] of Object.entries(env)) {
@@ -286,6 +312,58 @@ describe("Parallels smoke model selection", () => {
expect(retained).toBe(`${"a".repeat(2)}${"b".repeat(10)}`);
});
it("waits for host artifact server exit after SIGKILL before stop resolves", async () => {
vi.useFakeTimers();
try {
const child = new FakeHostServerChild();
const stop = hostServerTesting.stopHostServerChild(child as never, 100, 100);
expect(child.signals).toEqual(["SIGTERM"]);
await vi.advanceTimersByTimeAsync(100);
expect(child.signals).toEqual(["SIGTERM", "SIGKILL"]);
let resolved = false;
void stop.then(() => {
resolved = true;
});
await Promise.resolve();
expect(resolved).toBe(false);
child.exit();
await expect(stop).resolves.toBe(true);
expect(resolved).toBe(true);
} finally {
vi.useRealTimers();
}
});
it("uses a temporary npmrc file and cleans it after resolving the latest package version", () => {
const tempRoot = mkdtempSync(join(tmpdir(), "openclaw-parallels-version-"));
let userConfigPath = "";
try {
const version = resolveLatestVersion("", {
createTempDir: (prefix) => {
expect(prefix).toBe(join(tmpdir(), "openclaw-npm-"));
return mkdtempSync(join(tempRoot, "npm-"));
},
runCommand: (command, args, options) => {
userConfigPath = args.at(-1) ?? "";
expect(command).toBe("npm");
expect(args).toEqual(["view", "openclaw", "version", "--userconfig", userConfigPath]);
expect(options).toEqual({ quiet: true });
expect(statSync(userConfigPath).isFile()).toBe(true);
return { status: 0, stderr: "", stdout: "2026.6.1\n" };
},
});
expect(version).toBe("2026.6.1");
expect(basename(userConfigPath)).toBe("npmrc");
expect(existsSync(userConfigPath)).toBe(false);
} finally {
rmSync(tempRoot, { force: true, recursive: true });
}
});
it.runIf(process.platform !== "win32")(
"reports only the bounded host artifact server stderr tail",
async () => {