import { spawn } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { pathToFileURL } from "node:url"; import { describe, expect, it } from "vitest"; import { buildPackageArtifacts, runCommandForTest, } from "../../scripts/package-openclaw-for-docker.mjs"; function isProcessAlive(pid: number): boolean { try { process.kill(pid, 0); return true; } catch { return false; } } async function sleep(ms: number): Promise { await new Promise((resolve) => setTimeout(resolve, ms)); } async function waitForFile(filePath: string, timeoutMs: number): Promise { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { if (fs.existsSync(filePath)) { return; } await sleep(25); } throw new Error(`timeout waiting for ${filePath}`); } async function waitForDead(pid: number, timeoutMs: number): Promise { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { if (!isProcessAlive(pid)) { return; } await sleep(25); } throw new Error(`process still alive: ${pid}`); } async function waitForExit( child: ReturnType, timeoutMs: number, ): Promise<{ signal: NodeJS.Signals | null; status: number | null }> { return await new Promise((resolve, reject) => { const timeout = setTimeout( () => reject(new Error("timeout waiting for child exit")), timeoutMs, ); child.on("close", (status, signal) => { clearTimeout(timeout); resolve({ signal, status }); }); child.on("error", (error) => { clearTimeout(timeout); reject(error); }); }); } describe("package-openclaw-for-docker", () => { it("uses build-all as the single bounded package artifact build step", async () => { const calls: Array<{ command: string; args: string[]; cwd: string; noPnpm: string | undefined; timeoutMs: number | undefined; }> = []; const previousTimeout = process.env.OPENCLAW_DOCKER_PACKAGE_BUILD_TIMEOUT_MS; process.env.OPENCLAW_DOCKER_PACKAGE_BUILD_TIMEOUT_MS = "1234"; try { await buildPackageArtifacts("/repo", { runImpl: async ( command: string, args: string[], cwd: string, options: { env?: NodeJS.ProcessEnv; timeoutMs?: number }, ) => { calls.push({ command, args, cwd, noPnpm: options.env?.OPENCLAW_BUILD_ALL_NO_PNPM, timeoutMs: options.timeoutMs, }); }, }); } finally { if (previousTimeout === undefined) { delete process.env.OPENCLAW_DOCKER_PACKAGE_BUILD_TIMEOUT_MS; } else { process.env.OPENCLAW_DOCKER_PACKAGE_BUILD_TIMEOUT_MS = previousTimeout; } } expect(calls).toEqual([ { command: "node", args: ["scripts/build-all.mjs"], cwd: "/repo", noPnpm: "1", timeoutMs: 1234, }, ]); }); it("kills timed-out child process groups", async () => { if (process.platform === "win32") { return; } const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-package-timeout-")); const childPidPath = path.join(tempDir, "child.pid"); let childPid = 0; try { const childScript = ["process.on('SIGTERM', () => {});", "setInterval(() => {}, 1000);"].join( "", ); const parentScript = [ "const { spawn } = require('node:child_process');", "const fs = require('node:fs');", `const child = spawn(process.execPath, ['-e', ${JSON.stringify(childScript)}], { stdio: 'ignore' });`, "fs.writeFileSync(process.env.OPENCLAW_TEST_CHILD_PID, String(child.pid));", "process.on('SIGTERM', () => {});", "setInterval(() => {}, 1000);", ].join(""); await expect( runCommandForTest(process.execPath, ["-e", parentScript], process.cwd(), { env: { ...process.env, OPENCLAW_TEST_CHILD_PID: childPidPath }, killAfterMs: 50, timeoutMs: 2000, }), ).rejects.toThrow(/timed out after 2000ms/u); await waitForFile(childPidPath, 2000); childPid = Number(fs.readFileSync(childPidPath, "utf8")); await waitForDead(childPid, 2000); } finally { if (childPid && isProcessAlive(childPid)) { process.kill(childPid, "SIGKILL"); } fs.rmSync(tempDir, { force: true, recursive: true }); } }); it("forwards external termination to active child process groups", async () => { if (process.platform === "win32") { return; } const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-package-signal-")); const childPidPath = path.join(tempDir, "child.pid"); const scriptUrl = pathToFileURL(path.resolve("scripts/package-openclaw-for-docker.mjs")).href; let childPid = 0; let runnerPid = 0; try { const childScript = "setInterval(() => {}, 1000);"; const parentScript = [ "const { spawn } = require('node:child_process');", "const fs = require('node:fs');", `const child = spawn(process.execPath, ['-e', ${JSON.stringify(childScript)}], { stdio: 'ignore' });`, "fs.writeFileSync(process.env.OPENCLAW_TEST_CHILD_PID, String(child.pid));", "setInterval(() => {}, 1000);", ].join(""); const runnerScript = [ `import { runCommandForTest } from ${JSON.stringify(scriptUrl)};`, `await runCommandForTest(process.execPath, ['-e', ${JSON.stringify(parentScript)}], process.cwd(), { timeoutMs: 60000 });`, ].join("\n"); const runner = spawn(process.execPath, ["--input-type=module", "-e", runnerScript], { cwd: process.cwd(), env: { ...process.env, OPENCLAW_TEST_CHILD_PID: childPidPath }, stdio: ["ignore", "ignore", "pipe"], }); runnerPid = runner.pid ?? 0; await waitForFile(childPidPath, 2000); childPid = Number(fs.readFileSync(childPidPath, "utf8")); runner.kill("SIGTERM"); const result = await waitForExit(runner, 5000); expect(result).toEqual({ signal: null, status: 143 }); await waitForDead(childPid, 2000); } finally { if (runnerPid && isProcessAlive(runnerPid)) { process.kill(runnerPid, "SIGKILL"); } if (childPid && isProcessAlive(childPid)) { process.kill(childPid, "SIGKILL"); } fs.rmSync(tempDir, { force: true, recursive: true }); } }); });