diff --git a/extensions/qa-matrix/src/runners/contract/scenario-runtime-cli.test.ts b/extensions/qa-matrix/src/runners/contract/scenario-runtime-cli.test.ts index 7bc8a27882fd..bfabd142ef0a 100644 --- a/extensions/qa-matrix/src/runners/contract/scenario-runtime-cli.test.ts +++ b/extensions/qa-matrix/src/runners/contract/scenario-runtime-cli.test.ts @@ -3,13 +3,14 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import path from "node:path"; import { setTimeout as sleep } from "node:timers/promises"; import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { formatMatrixQaCliCommand, redactMatrixQaCliOutput, resolveMatrixQaOpenClawCliEntryPath, runMatrixQaOpenClawCli, startMatrixQaOpenClawCli, + testing, } from "./scenario-runtime-cli.js"; function isProcessRunning(pid: number): boolean { @@ -60,6 +61,38 @@ describe("Matrix QA CLI runtime", () => { ).toBe("GET /_matrix/client/v3/sync?access_token=abcdef…ghij"); }); + it("force-kills Windows CLI process trees when graceful taskkill fails", () => { + const platformDescriptor = Object.getOwnPropertyDescriptor(process, "platform"); + Object.defineProperty(process, "platform", { value: "win32", configurable: true }); + try { + const killMock = vi.fn(); + const child = { + pid: 12345, + kill: killMock, + } as unknown as Parameters[0]; + const runTaskkill = vi + .fn() + .mockReturnValueOnce({ status: 1 }) + .mockReturnValueOnce({ status: 0 }); + + testing.killMatrixQaCliChild(child, "SIGTERM", runTaskkill); + + expect(runTaskkill).toHaveBeenNthCalledWith(1, "taskkill", ["/PID", "12345", "/T"], { + stdio: "ignore", + windowsHide: true, + }); + expect(runTaskkill).toHaveBeenNthCalledWith(2, "taskkill", ["/PID", "12345", "/T", "/F"], { + stdio: "ignore", + windowsHide: true, + }); + expect(killMock).not.toHaveBeenCalled(); + } finally { + if (platformDescriptor) { + Object.defineProperty(process, "platform", platformDescriptor); + } + } + }); + it("prefers the ESM OpenClaw CLI entrypoint when present", async () => { const root = await mkdtemp(path.join(resolvePreferredOpenClawTmpDir(), "matrix-qa-cli-entry-")); try { diff --git a/extensions/qa-matrix/src/runners/contract/scenario-runtime-cli.ts b/extensions/qa-matrix/src/runners/contract/scenario-runtime-cli.ts index d6b9e9978d2f..9412d5661bab 100644 --- a/extensions/qa-matrix/src/runners/contract/scenario-runtime-cli.ts +++ b/extensions/qa-matrix/src/runners/contract/scenario-runtime-cli.ts @@ -1,5 +1,5 @@ // Qa Matrix plugin module implements scenario runtime cli behavior. -import { spawn as startOpenClawCliProcess } from "node:child_process"; +import { spawn as startOpenClawCliProcess, spawnSync } from "node:child_process"; import { randomUUID } from "node:crypto"; import { existsSync } from "node:fs"; import { chmod, mkdir, mkdtemp, rm, stat, writeFile } from "node:fs/promises"; @@ -107,8 +107,32 @@ function formatMatrixQaCliTimeoutError(result: MatrixQaCliRunResult, timeoutMs: function killMatrixQaCliChild( child: ReturnType, signal: NodeJS.Signals, + runTaskkill: typeof spawnSync = spawnSync, ): void { - if (process.platform !== "win32" && child.pid) { + if (process.platform === "win32") { + if (child.pid) { + const args = ["/PID", String(child.pid), "/T"]; + if (signal === "SIGKILL") { + args.push("/F"); + } + const result = runTaskkill("taskkill", args, { stdio: "ignore", windowsHide: true }); + if (!result.error && result.status === 0) { + return; + } + if (signal !== "SIGKILL") { + const forceResult = runTaskkill("taskkill", [...args, "/F"], { + stdio: "ignore", + windowsHide: true, + }); + if (!forceResult.error && forceResult.status === 0) { + return; + } + } + } + child.kill(signal); + return; + } + if (child.pid) { try { process.kill(-child.pid, signal); return; @@ -459,3 +483,7 @@ export async function createMatrixQaOpenClawCliRuntime(params: { stateDir, }; } + +export const testing = { + killMatrixQaCliChild, +};