diff --git a/scripts/e2e/parallels/host-server.ts b/scripts/e2e/parallels/host-server.ts index 31ea1946e19a..1c84edb6670c 100644 --- a/scripts/e2e/parallels/host-server.ts +++ b/scripts/e2e/parallels/host-server.ts @@ -81,20 +81,54 @@ export async function startHostServer(input: { hostIp: input.hostIp, port: actualPort, stop: async () => { - child.kill("SIGTERM"); - await new Promise((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 { + 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 { + if (child.exitCode != null) { + return true; + } + return await new Promise((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 { export const testing = { appendBoundedOutput, + stopHostServerChild, }; diff --git a/scripts/e2e/parallels/provider-auth.ts b/scripts/e2e/parallels/provider-auth.ts index 885a683d8736..e9c08f4fd20e 100644 --- a/scripts/e2e/parallels/provider-auth.ts +++ b/scripts/e2e/parallels/provider-auth.ts @@ -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 { 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 }); + } } diff --git a/test/scripts/parallels-smoke-model.test.ts b/test/scripts/parallels-smoke-model.test.ts index 8e8b710492ad..6a6f863ede1f 100644 --- a/test/scripts/parallels-smoke-model.test.ts +++ b/test/scripts/parallels-smoke-model.test.ts @@ -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(env: Record, callback: () => T): T { const previous = new Map(); 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 () => {