





















11// Control Ui I18N tests cover control ui i18n script behavior.
2-import { mkdtempSync, readFileSync, rmSync } from "node:fs";
3-import { tmpdir } from "node:os";
2+import { spawn } from "node:child_process";
3+import { readFileSync, writeFileSync } from "node:fs";
44import path from "node:path";
55import process from "node:process";
6+import { pathToFileURL } from "node:url";
67import { describe, expect, it } from "vitest";
78import { appendBoundedProcessOutput, runProcess } from "../../scripts/control-ui-i18n.ts";
9+import { createTempDirTracker } from "../helpers/temp-dir.js";
810911function processIsAlive(pid: number): boolean {
1012try {
@@ -28,6 +30,21 @@ async function waitForProcessExit(pid: number, timeoutMs = 1_000): Promise<void>
2830throw new Error(`process ${pid} was still alive after ${timeoutMs}ms`);
2931}
303233+async function waitForChildClose(
34+child: ReturnType<typeof spawn>,
35+timeoutMs = 2_000,
36+): Promise<{ code: number | null; signal: NodeJS.Signals | null }> {
37+return await new Promise((resolve, reject) => {
38+const timeout = setTimeout(() => {
39+reject(new Error("child did not close before timeout"));
40+}, timeoutMs);
41+child.once("close", (code, signal) => {
42+clearTimeout(timeout);
43+resolve({ code, signal });
44+});
45+});
46+}
47+3148describe("control-ui-i18n process runner", () => {
3249it("keeps a bounded process output tail", () => {
3350const first = appendBoundedProcessOutput({ text: "", truncatedChars: 0 }, "abcdef", 5);
@@ -67,7 +84,8 @@ describe("control-ui-i18n process runner", () => {
6784it.runIf(process.platform !== "win32")(
6885"kills descendant processes after the process timeout",
6986async () => {
70-const tempDir = mkdtempSync(path.join(tmpdir(), "openclaw-control-ui-i18n-timeout-"));
87+const tempDirs = createTempDirTracker();
88+const tempDir = tempDirs.make("openclaw-control-ui-i18n-timeout-");
7189try {
7290const markerPath = path.join(tempDir, "grandchild.pid");
7391const grandchildScript = [
@@ -94,7 +112,113 @@ describe("control-ui-i18n process runner", () => {
94112const grandchildPid = Number(readFileSync(markerPath, "utf8"));
95113await waitForProcessExit(grandchildPid);
96114} finally {
97-rmSync(tempDir, { force: true, recursive: true });
115+tempDirs.cleanup();
116+}
117+},
118+);
119+120+it.runIf(process.platform !== "win32")(
121+"waits for all process groups before re-raising parent signals",
122+async () => {
123+const tempDirs = createTempDirTracker();
124+const tempDir = tempDirs.make("openclaw-control-ui-i18n-signal-");
125+const fastReadyPath = path.join(tempDir, "fast-ready");
126+const fastCommandPath = path.join(tempDir, "fast-command.mjs");
127+const commandPath = path.join(tempDir, "command.mjs");
128+const runnerPath = path.join(tempDir, "runner.mjs");
129+const grandchildPidPath = path.join(tempDir, "grandchild.pid");
130+let grandchildPid = 0;
131+132+try {
133+const grandchildScript = [
134+"process.on('SIGTERM', () => {});",
135+"setInterval(() => {}, 1000);",
136+].join("\n");
137+writeFileSync(
138+fastCommandPath,
139+[
140+"import { writeFileSync } from 'node:fs';",
141+`writeFileSync(${JSON.stringify(fastReadyPath)}, "ready");`,
142+"process.on('SIGTERM', () => process.exit(0));",
143+"setInterval(() => {}, 1000);",
144+].join("\n"),
145+"utf8",
146+);
147+writeFileSync(
148+commandPath,
149+[
150+"import { spawn } from 'node:child_process';",
151+"import { writeFileSync } from 'node:fs';",
152+`const grandchild = spawn(process.execPath, ["--eval", ${JSON.stringify(
153+ grandchildScript,
154+ )}], { stdio: "ignore" });`,
155+`writeFileSync(${JSON.stringify(grandchildPidPath)}, String(grandchild.pid));`,
156+"process.on('SIGTERM', () => process.exit(0));",
157+"setInterval(() => {}, 1000);",
158+].join("\n"),
159+"utf8",
160+);
161+writeFileSync(
162+runnerPath,
163+[
164+`const { runProcess } = await import(${JSON.stringify(
165+ pathToFileURL(path.resolve("scripts/control-ui-i18n.ts")).href,
166+ )});`,
167+"void runProcess(process.execPath,",
168+` [${JSON.stringify(fastCommandPath)}],`,
169+" { killGraceMs: 100, timeoutMs: 30_000 },",
170+").catch(() => undefined);",
171+"void runProcess(process.execPath,",
172+` [${JSON.stringify(commandPath)}],`,
173+" { killGraceMs: 100, timeoutMs: 30_000 },",
174+").catch(() => undefined);",
175+].join("\n"),
176+"utf8",
177+);
178+179+const runner = spawn(process.execPath, ["--import", "tsx", runnerPath], {
180+cwd: process.cwd(),
181+stdio: "ignore",
182+});
183+184+try {
185+const deadline = Date.now() + 1_000;
186+while (Date.now() < deadline) {
187+let fastReady = false;
188+try {
189+fastReady = readFileSync(fastReadyPath, "utf8") === "ready";
190+} catch {}
191+try {
192+grandchildPid = Number(readFileSync(grandchildPidPath, "utf8"));
193+} catch {}
194+if (fastReady && grandchildPid > 0 && processIsAlive(grandchildPid)) {
195+break;
196+}
197+await new Promise((resolve) => {
198+setTimeout(resolve, 10);
199+});
200+}
201+expect(readFileSync(fastReadyPath, "utf8")).toBe("ready");
202+expect(grandchildPid).toBeGreaterThan(0);
203+expect(processIsAlive(grandchildPid)).toBe(true);
204+205+runner.kill("SIGTERM");
206+207+await expect(waitForChildClose(runner)).resolves.toEqual({
208+code: null,
209+signal: "SIGTERM",
210+});
211+await waitForProcessExit(grandchildPid, 2_000);
212+} finally {
213+if (runner.pid && processIsAlive(runner.pid)) {
214+runner.kill("SIGKILL");
215+}
216+if (grandchildPid > 0 && processIsAlive(grandchildPid)) {
217+process.kill(grandchildPid, "SIGKILL");
218+}
219+}
220+} finally {
221+tempDirs.cleanup();
98222}
99223},
100224);
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。