Skip to content

Commit af44fb9

Browse files
committed
fix(test): preserve vitest batch wrapper signals
1 parent 45e0545 commit af44fb9

2 files changed

Lines changed: 122 additions & 2 deletions

File tree

scripts/lib/vitest-batch-runner.mjs

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,20 @@ const repoRoot = path.resolve(scriptDir, "../..");
1212

1313
export async function runVitestBatch(params) {
1414
return await new Promise((resolve, reject) => {
15+
let forwardedSignal;
1516
const child = spawnPnpmRunner({
1617
cwd: repoRoot,
1718
detached: shouldUseDetachedVitestProcessGroup(),
1819
env: params.env,
1920
pnpmArgs: buildVitestBatchPnpmArgs(params),
2021
stdio: "inherit",
2122
});
22-
const teardownChildCleanup = installVitestProcessGroupCleanup({ child });
23+
const teardownChildCleanup = installVitestProcessGroupCleanup({
24+
child,
25+
onSignal(signal) {
26+
forwardedSignal = signal;
27+
},
28+
});
2329

2430
child.on("error", (error) => {
2531
teardownChildCleanup();
@@ -31,6 +37,10 @@ export async function runVitestBatch(params) {
3137
process.kill(process.pid, signal);
3238
return;
3339
}
40+
if (forwardedSignal) {
41+
process.kill(process.pid, forwardedSignal);
42+
return;
43+
}
3444
resolve(code ?? 1);
3545
});
3646
});

test/scripts/test-extension.test.ts

Lines changed: 111 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
1-
import { spawnSync } from "node:child_process";
1+
import { spawn, spawnSync } from "node:child_process";
2+
import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3+
import { tmpdir } from "node:os";
24
import path from "node:path";
5+
import { setTimeout as delay } from "node:timers/promises";
36
import { bundledPluginFile, bundledPluginRoot } from "openclaw/plugin-sdk/test-fixtures";
47
import { beforeAll, describe, expect, it, vi } from "vitest";
58
import {
@@ -23,6 +26,7 @@ import {
2326
import { expectNoNodeFsScans } from "../../src/test-utils/fs-scan-assertions.js";
2427

2528
const scriptPath = path.join(process.cwd(), "scripts", "test-extension.mjs");
29+
const posixIt = process.platform === "win32" ? it.skip : it;
2630

2731
type RunGroupParams = {
2832
args: string[];
@@ -645,6 +649,52 @@ describe("scripts/test-extension.mjs", () => {
645649
]);
646650
});
647651

652+
posixIt(
653+
"preserves wrapper termination when the pnpm child exits cleanly after SIGTERM",
654+
async () => {
655+
const root = mkdtempSync(path.join(tmpdir(), "openclaw-test-extension-signal-"));
656+
const fakePnpmPath = path.join(root, "pnpm");
657+
const childPidPath = path.join(root, "child.pid");
658+
const signaledPath = path.join(root, "signaled");
659+
660+
writeFakePnpm(fakePnpmPath);
661+
const runner = spawn(process.execPath, [scriptPath, "firecrawl"], {
662+
cwd: process.cwd(),
663+
env: {
664+
...process.env,
665+
OPENCLAW_FAKE_PNPM_PID_PATH: childPidPath,
666+
OPENCLAW_FAKE_PNPM_SIGNALED_PATH: signaledPath,
667+
npm_execpath: fakePnpmPath,
668+
},
669+
stdio: "ignore",
670+
});
671+
let childPid = 0;
672+
673+
try {
674+
await waitFor(() => fileExists(childPidPath), 5_000);
675+
childPid = Number(readFileSync(childPidPath, "utf8"));
676+
expect(Number.isInteger(childPid)).toBe(true);
677+
678+
expect(runner.pid).toBeGreaterThan(0);
679+
process.kill(runner.pid!, "SIGTERM");
680+
const result = await waitForClose(runner);
681+
682+
expect(result).toEqual({ code: null, signal: "SIGTERM" });
683+
await waitFor(() => fileExists(signaledPath), 5_000);
684+
expect(readFileSync(signaledPath, "utf8")).toBe("SIGTERM");
685+
await waitFor(() => !isProcessAlive(childPid), 5_000);
686+
} finally {
687+
if (runner.pid && isProcessAlive(runner.pid)) {
688+
process.kill(runner.pid, "SIGKILL");
689+
}
690+
if (childPid && isProcessAlive(childPid)) {
691+
process.kill(childPid, "SIGKILL");
692+
}
693+
rmSync(root, { force: true, recursive: true });
694+
}
695+
},
696+
);
697+
648698
it("expands extension batch roots before applying exact Vitest excludes", async () => {
649699
const runGroup = vi.fn<() => Promise<number>>().mockResolvedValue(0);
650700
await runExtensionBatchPlan(
@@ -737,3 +787,63 @@ describe("scripts/test-extension.mjs", () => {
737787
expect(result.stderr).toContain(`No tests found for ${bundledPluginRoot(extensionId)}.`);
738788
});
739789
});
790+
791+
function writeFakePnpm(filePath: string): void {
792+
writeFileSync(
793+
filePath,
794+
[
795+
"#!/usr/bin/env node",
796+
'const fs = require("node:fs");',
797+
"fs.writeFileSync(process.env.OPENCLAW_FAKE_PNPM_PID_PATH, String(process.pid));",
798+
'process.on("SIGTERM", () => {',
799+
' fs.writeFileSync(process.env.OPENCLAW_FAKE_PNPM_SIGNALED_PATH, "SIGTERM");',
800+
" process.exit(0);",
801+
"});",
802+
"setInterval(() => {}, 1000);",
803+
"",
804+
].join("\n"),
805+
);
806+
chmodSync(filePath, 0o755);
807+
}
808+
809+
async function waitFor(condition: () => boolean, timeoutMs = 3_000): Promise<void> {
810+
const startedAt = Date.now();
811+
while (!condition()) {
812+
if (Date.now() - startedAt > timeoutMs) {
813+
throw new Error("timed out waiting for condition");
814+
}
815+
await delay(25);
816+
}
817+
}
818+
819+
async function waitForClose(
820+
child: ReturnType<typeof spawn>,
821+
timeoutMs = 5_000,
822+
): Promise<{ code: number | null; signal: NodeJS.Signals | null }> {
823+
return await Promise.race([
824+
new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve) => {
825+
child.once("close", (code, signal) => resolve({ code, signal }));
826+
}),
827+
delay(timeoutMs).then(() => {
828+
throw new Error("timed out waiting for child close");
829+
}),
830+
]);
831+
}
832+
833+
function fileExists(filePath: string): boolean {
834+
try {
835+
readFileSync(filePath);
836+
return true;
837+
} catch {
838+
return false;
839+
}
840+
}
841+
842+
function isProcessAlive(pid: number): boolean {
843+
try {
844+
process.kill(pid, 0);
845+
return true;
846+
} catch {
847+
return false;
848+
}
849+
}

0 commit comments

Comments
 (0)