diff --git a/test/helpers/openclaw-test-instance.test.ts b/test/helpers/openclaw-test-instance.test.ts index 6dded68a2696..97c6374e8b75 100644 --- a/test/helpers/openclaw-test-instance.test.ts +++ b/test/helpers/openclaw-test-instance.test.ts @@ -1,7 +1,7 @@ // OpenClaw test instance tests cover spawned test instance lifecycle. import fs from "node:fs/promises"; import path from "node:path"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { createOpenClawTestInstance, testing } from "./openclaw-test-instance.js"; async function expectPathMissing(targetPath: string): Promise { @@ -44,6 +44,24 @@ describe("openclaw test instance", () => { ).rejects.toThrow("gateway exited before listening"); }); + it("signals test instance process groups on POSIX", () => { + const child = { + pid: 1234, + kill: vi.fn(() => true), + }; + const killProcess = vi.fn(() => true); + + testing.signalOpenClawTestProcess(child, "SIGKILL", killProcess); + + if (process.platform === "win32") { + expect(killProcess).not.toHaveBeenCalled(); + expect(child.kill).toHaveBeenCalledWith("SIGKILL"); + } else { + expect(killProcess).toHaveBeenCalledWith(-1234, "SIGKILL"); + expect(child.kill).not.toHaveBeenCalled(); + } + }); + it("creates isolated config and spawn env without mutating process env", async () => { const previousHome = process.env.HOME; const inst = await createOpenClawTestInstance({ diff --git a/test/helpers/openclaw-test-instance.ts b/test/helpers/openclaw-test-instance.ts index e4b8d4556dcb..48c070f718fa 100644 --- a/test/helpers/openclaw-test-instance.ts +++ b/test/helpers/openclaw-test-instance.ts @@ -73,6 +73,8 @@ type BoundedStringLog = string[] & { truncated?: boolean; }; +type OpenClawTestChildProcess = Pick; + function createBoundedStringLog(): string[] { const log = [] as BoundedStringLog; log.byteLength = 0; @@ -152,6 +154,7 @@ async function prepareGatewayEntrypoint(cwd: string): Promise { cwd, env: { ...process.env, VITEST: "1" }, stdio: ["ignore", "pipe", "pipe"], + detached: shouldUseOpenClawTestProcessGroup(), }); child.stdout?.setEncoding("utf8"); child.stderr?.setEncoding("utf8"); @@ -167,7 +170,7 @@ async function prepareGatewayEntrypoint(cwd: string): Promise { ]); if (completed === null) { - child.kill("SIGKILL"); + signalOpenClawTestProcess(child, "SIGKILL"); throw new Error(`timeout preparing gateway entrypoint\n${formatLogs(stdout, stderr)}`); } if (completed.code !== 0) { @@ -401,6 +404,7 @@ export async function createOpenClawTestInstance( cwd, env, stdio: ["ignore", "pipe", "pipe"], + detached: shouldUseOpenClawTestProcessGroup(), }, ); @@ -428,7 +432,7 @@ export async function createOpenClawTestInstance( } if (!hasChildExited(child) && !child.killed) { try { - child.kill("SIGTERM"); + signalOpenClawTestProcess(child, "SIGTERM"); } catch { // ignore } @@ -439,7 +443,7 @@ export async function createOpenClawTestInstance( ); if (!exited && !hasChildExited(child) && !child.killed) { try { - child.kill("SIGKILL"); + signalOpenClawTestProcess(child, "SIGKILL"); } catch { // ignore } @@ -479,6 +483,7 @@ async function runCommand(params: { cwd: params.cwd, env: params.env, stdio: ["ignore", "pipe", "pipe"], + detached: shouldUseOpenClawTestProcessGroup(), }); child.stdout?.setEncoding("utf8"); child.stderr?.setEncoding("utf8"); @@ -493,7 +498,7 @@ async function runCommand(params: { sleep(params.timeoutMs).then(() => null), ]); if (completed === null) { - child.kill("SIGKILL"); + signalOpenClawTestProcess(child, "SIGKILL"); await waitForGatewayExit(child, GATEWAY_STOP_TIMEOUT_MS); throw new Error( `command timed out after ${params.timeoutMs}ms: ${params.args.join(" ")}\n${formatLogs(stdout, stderr)}`, @@ -506,10 +511,31 @@ async function runCommand(params: { }; } +function shouldUseOpenClawTestProcessGroup(): boolean { + return process.platform !== "win32"; +} + +function signalOpenClawTestProcess( + child: OpenClawTestChildProcess, + signal: NodeJS.Signals, + killProcess: (pid: number, signal: NodeJS.Signals) => boolean = process.kill, +): void { + if (shouldUseOpenClawTestProcessGroup() && typeof child.pid === "number") { + try { + killProcess(-child.pid, signal); + return; + } catch { + // Fall back to the direct child if the process group already exited. + } + } + child.kill(signal); +} + export const testing = { appendLogChunk, createBoundedStringLog, formatLogs, hasChildExited, + signalOpenClawTestProcess, waitForPortOpen, };