Files
Peter Steinberger 35f17c7f5d test(core,plugins,scripts): remove low-value tests (batch d012) (#158802)
* test(agents): deslop s047 tests

* test(codex): deslop s045 tests

* test(gateway): deslop s049 tests

* test(commands): deslop s048 tests

* test(scripts): deslop s012 tests

* test(scripts): deslop s046 tests

* test(workboard): deslop s053 tests

* test(plugins): deslop s051 tests

* test(slack): deslop s054 tests

* test: preserve current fixture contracts after replay

* test: retain distinct cleanup contracts and fixture scopes

* test(gateway): retain idle suspension coverage for local claims
2026-09-26 03:46:53 -07:00

721 lines
26 KiB
TypeScript

// Ui tests cover ui script behavior.
import { spawn, spawnSync, type ChildProcess } from "node:child_process";
import fs from "node:fs";
import { createRequire } from "node:module";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import {
isDirectScriptExecution,
resolveUiBuildEnvironment,
resolvePnpmSpawnCall,
} from "../../scripts/ui.mts";
import { mergeProcessEnv } from "../../src/infra/process-env.js";
import { resolveTestNodeExecPath } from "../../src/test-utils/node-process.js";
import { normalizeControlUiBuildInfo } from "../../ui/src/build-info-normalizers.ts";
import { runQaGatewayFixture } from "../helpers/qa-gateway-cleanup.js";
const testNodeExecPath = resolveTestNodeExecPath();
// writeFileSync creates the file before its content lands, so an existence
// poll can observe an empty file on loaded runners; wait for bytes instead.
function readNonEmpty(file: string): string | null {
try {
const content = fs.readFileSync(file, "utf8");
return content.length > 0 ? content : null;
} catch {
return null;
}
}
async function waitFor(predicate: () => boolean, label: string, timeoutMs = 3_000): Promise<void> {
const startedAt = Date.now();
while (!predicate()) {
if (Date.now() - startedAt > timeoutMs) {
throw new Error(`timed out waiting for ${label}`);
}
await new Promise<void>((resolve) => {
setTimeout(resolve, 5);
});
}
}
async function waitForExit(
child: ChildProcess,
timeoutMs = 3_000,
): Promise<{ code: number | null; signal: NodeJS.Signals | null }> {
return await new Promise((resolve, reject) => {
const timer = setTimeout(() => {
child.kill("SIGKILL");
reject(new Error("timed out waiting for child exit"));
}, timeoutMs);
child.once("exit", (code, signal) => {
clearTimeout(timer);
resolve({ code, signal });
});
child.once("error", (error) => {
clearTimeout(timer);
reject(error);
});
});
}
async function withUiProcessCleanup(
wrapper: ChildProcess,
root: string,
pidFiles: string[],
run: () => Promise<void>,
): Promise<void> {
const readPid = (file: string): number | null => {
const value = readNonEmpty(file);
if (value === null) {
return null;
}
const pid = Number(value);
if (!Number.isInteger(pid) || pid <= 0) {
throw new Error(`Invalid fixture PID in ${file}`);
}
return pid;
};
await runQaGatewayFixture(
run,
() => fs.writeFileSync(path.join(root, "release"), "release"),
async () => {
if (wrapper.exitCode === null && wrapper.signalCode === null) {
await waitForExit(wrapper);
}
},
...pidFiles.map((file) => async () => {
const pid = readPid(file);
if (pid !== null) {
await waitFor(() => !pidAlive(pid), "UI fixture process exit", 5_000);
}
}),
() => {
if (
(wrapper.exitCode === null && wrapper.signalCode === null) ||
pidFiles.some((file) => {
const pid = readPid(file);
return pid !== null && pidAlive(pid);
})
) {
throw new Error(`UI fixture cleanup is unverified; retained ${root}`);
}
fs.rmSync(root, { force: true, recursive: true });
},
);
}
describe("scripts/ui", () => {
it("reuses the runtime identity for the documented standalone UI rebuild", () => {
const commit = "0123456789abcdef0123456789abcdef01234567";
const firstBuild = normalizeControlUiBuildInfo({
version: "2026.8.1",
commit,
builtAt: "2026-08-14T23:00:00.000Z",
});
const env = resolveUiBuildEnvironment({
env: {},
now: () => new Date("2026-08-14T23:05:00.000Z"),
readBuildInfo: () => firstBuild,
readGitCommit: () => commit,
readPackageVersion: () => "2026.8.1",
});
const rebuiltUi = normalizeControlUiBuildInfo({
version: "2026.8.1",
commit: env.GIT_COMMIT,
builtAt: env.OPENCLAW_BUILD_TIMESTAMP,
buildId: env.OPENCLAW_CONTROL_UI_BUILD_ID,
});
expect(rebuiltUi).toMatchObject({
builtAt: firstBuild.builtAt,
buildId: firstBuild.buildId,
commit: firstBuild.commit,
version: firstBuild.version,
});
});
it("does not reuse build info from a different source revision", () => {
const env = resolveUiBuildEnvironment({
env: {},
now: () => new Date("2026-08-14T23:05:00.000Z"),
readBuildInfo: () => ({
version: "2026.8.1",
commit: "a".repeat(40),
builtAt: "2026-08-14T23:00:00.000Z",
}),
readGitCommit: () => "b".repeat(40),
readPackageVersion: () => "2026.8.1",
});
expect(env).toMatchObject({
GIT_COMMIT: "b".repeat(40),
OPENCLAW_BUILD_TIMESTAMP: "2026-08-14T23:05:00.000Z",
});
expect(env.OPENCLAW_CONTROL_UI_BUILD_ID).toBeUndefined();
});
it("does not reuse non-release build info for a release UI build", () => {
const commit = "a".repeat(40);
const env = resolveUiBuildEnvironment({
env: { OPENCLAW_CONTROL_UI_RELEASE_BUILD: "1" },
now: () => new Date("2026-08-14T23:05:00.000Z"),
readBuildInfo: () => ({
version: "2026.8.1",
commit,
builtAt: "2026-08-14T23:00:00.000Z",
release: false,
}),
readGitCommit: () => commit,
readPackageVersion: () => "2026.8.1",
});
expect(env).toMatchObject({
GIT_COMMIT: commit,
OPENCLAW_BUILD_TIMESTAMP: "2026-08-14T23:05:00.000Z",
});
expect(env.OPENCLAW_CONTROL_UI_BUILD_ID).toBeUndefined();
});
it("rejects unsafe Windows pnpm shim arguments before launch", () => {
for (const argument of ["evil&calc", "%PATH%"]) {
expect(() =>
resolvePnpmSpawnCall(
["install", argument],
{ PATH: "" },
{
npmExecPath: "",
platform: "win32",
},
),
).toThrow(/unsafe windows cmd\.exe argument/i);
}
});
it("routes Windows Corepack pnpm entrypoints through node", () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-pnpm-runner-"));
const npmExecPath = path.join(tempDir, "pnpm.mjs");
fs.writeFileSync(npmExecPath, "console.log('pnpm');\n");
try {
expect(
resolvePnpmSpawnCall(
["run", "build"],
{
npm_execpath: npmExecPath,
ComSpec: "C:\\Windows\\System32\\cmd.exe",
},
{
cwd: "C:\\repo\\ui",
nodeExecPath: "C:\\Program Files\\nodejs\\node.exe",
platform: "win32",
},
),
).toEqual({
command: "C:\\Program Files\\nodejs\\node.exe",
args: [npmExecPath, "run", "build"],
options: {
cwd: "C:\\repo\\ui",
stdio: "inherit",
env: {
npm_execpath: npmExecPath,
ComSpec: "C:\\Windows\\System32\\cmd.exe",
},
shell: false,
windowsVerbatimArguments: undefined,
},
});
} finally {
fs.rmSync(tempDir, { force: true, recursive: true });
}
});
it("detects direct execution through a junctioned script path", () => {
const realScriptPath = path.resolve("repo/openclaw/scripts/ui.js");
const junctionScriptPath = path.resolve("linked/openclaw/scripts/ui.js");
const realpath = (entry: string) => (entry === junctionScriptPath ? realScriptPath : entry);
expect(isDirectScriptExecution(junctionScriptPath, realScriptPath, realpath)).toBe(true);
});
it.each(["--help", "-h"])("keeps no-pnpm build %s informational", (helpFlag) => {
const result = spawnSync(testNodeExecPath, ["scripts/ui.js", "build", helpFlag], {
cwd: path.resolve("."),
encoding: "utf8",
env: {
...process.env,
OPENCLAW_BUILD_ALL_NO_PNPM: "1",
PATH: "",
},
});
const output = `${result.stdout}${result.stderr}`;
expect(result.status).toBe(0);
expect(output).not.toContain("Missing UI runner");
expect(output).toContain("vite");
expect(output).not.toContain("Control UI performance");
});
it.each([
{ layout: "hoisted", action: "build", args: ["build"], noPnpm: false },
{ layout: "isolated", action: "build", args: ["build"], noPnpm: true },
{ layout: "hoisted", action: "dev", args: [], noPnpm: false },
{
layout: "isolated",
action: "test",
args: ["run", "--config", "vitest.config.ts"],
noPnpm: false,
},
])(
"runs $action from $layout dependencies without package shims (noPnpm=$noPnpm)",
({ action, args, layout, noPnpm }) => {
const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-ui-layout-")));
const ui = path.join(root, "ui");
const modules = path.join(layout === "isolated" ? ui : root, "node_modules");
const expectedExit = action === "test" ? 17 : 0;
const forwarded = ["--help", "--mode", "fixture with spaces & symbols"];
try {
for (const file of [
"scripts/ui.js",
"scripts/ui.mts",
"scripts/pnpm-runner.mts",
"scripts/run-node-package-bin.mts",
"scripts/windows-cmd-helpers.mjs",
"scripts/lib/build-identity.mts",
"scripts/lib/output-root-guard.mjs",
"scripts/lib/record-shared.mjs",
"src/infra/process-env.ts",
"src/infra/windows-process-start.ts",
"src/shared/freebsd-process-identity.ts",
"src/shared/freebsd-process-identity-native.ts",
"src/shared/pid-alive.ts",
"ui/package.json",
"ui/src/build-info-normalizers.ts",
"packages/normalization-core/src/record-coerce.ts",
"packages/normalization-core/src/string-coerce.ts",
"packages/normalization-core/src/utf16-slice.ts",
]) {
const destination = path.join(root, file);
fs.mkdirSync(path.dirname(destination), { recursive: true });
fs.copyFileSync(file, destination);
}
fs.writeFileSync(path.join(root, "package.json"), '{"type":"module"}\n');
for (const name of [
"vite",
"vitest",
"dompurify",
"@vitest/browser-playwright",
"playwright",
]) {
const directory = path.join(modules, name);
fs.mkdirSync(directory, { recursive: true });
fs.writeFileSync(
path.join(directory, "package.json"),
JSON.stringify({
name,
type: "module",
exports: { ".": "./entry.mjs", "./package.json": "./package.json" },
bin: { [name]: "./entry.mjs" },
}),
);
fs.writeFileSync(
path.join(directory, "entry.mjs"),
`console.log(JSON.stringify({
args: process.argv.slice(2), cwd: process.cwd(),
commit: process.env.GIT_COMMIT, timestamp: process.env.OPENCLAW_BUILD_TIMESTAMP
}));
process.exitCode = ${expectedExit};\n`,
);
}
const pnpm = path.join(root, "pnpm.cjs");
fs.writeFileSync(
pnpm,
'throw new Error("Installed UI tools must not need package shims");\n',
);
const result = spawnSync(testNodeExecPath, ["scripts/ui.js", action, ...forwarded], {
cwd: root,
encoding: "utf8",
env: mergeProcessEnv([
process.env,
{
PATH: "",
npm_execpath: pnpm,
OPENCLAW_BUILD_ALL_NO_PNPM: noPnpm ? "1" : "0",
OPENCLAW_BUILD_TIMESTAMP: "2026-08-27T00:00:00.000Z",
GIT_COMMIT: "a".repeat(40),
},
]),
timeout: 10_000,
});
expect(result.error).toBeUndefined();
expect(result.status, result.stderr).toBe(expectedExit);
expect(JSON.parse(result.stdout)).toEqual({
args: [...args, ...forwarded],
cwd: ui,
commit: "a".repeat(40),
timestamp: "2026-08-27T00:00:00.000Z",
});
} finally {
fs.rmSync(root, { force: true, recursive: true });
}
},
);
it.each([
{ noPnpm: false, failValidator: null },
{ noPnpm: false, failValidator: "check-control-ui-precompressed-assets.mts" },
{ noPnpm: true, failValidator: "check-control-ui-performance.mts" },
])(
"reports budgets and enforces asset validity without compiler children or disk caches (noPnpm=$noPnpm, failure=$failValidator)",
({ noPnpm, failValidator }) => {
const tempDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-ui-cache-")));
const tempRoot = path.join(tempDir, "temp");
const cacheRoots = ["tsx", `tsx-${process.geteuid?.() ?? os.userInfo().username}`].map(
(name) => path.join(tempRoot, name),
);
const accessLog = path.join(tempDir, "cache-access.log");
const guard = path.join(tempDir, "cache-guard.cjs");
const capture = path.join(tempDir, "capture-ui-children.cjs");
const fixture = path.join(tempDir, "validator.mts");
const pnpm = path.join(tempDir, "pnpm.cjs");
const validators = [
"check-control-ui-precompressed-assets.mts",
"check-control-ui-performance.mts",
];
try {
for (const cacheRoot of cacheRoots) {
fs.mkdirSync(cacheRoot, { recursive: true });
fs.writeFileSync(path.join(cacheRoot, "0-sentinel"), "keep");
}
// Record before throwing: tsx catches some cache errors, so exit status alone
// cannot prove that the loader left the cache untouched.
fs.writeFileSync(
guard,
`
const fs = require("node:fs");
const path = require("node:path");
const roots = ${JSON.stringify(cacheRoots)};
if (${JSON.stringify(validators)}.includes(process.argv[2])) {
function rejectRuntimeActivity(operation) {
fs.appendFileSync(${JSON.stringify(accessLog)}, operation + "\\n");
throw new Error("Unexpected validator runtime activity: " + operation);
}
const childProcess = require("node:child_process");
for (const operation of ["spawn", "spawnSync", "exec", "execSync", "execFile", "execFileSync", "fork"]) {
childProcess[operation] = function() { rejectRuntimeActivity(operation); };
}
require("node:worker_threads").Worker = function() { rejectRuntimeActivity("Worker"); };
}
function guardAccess(target, operation) {
const resolved = path.resolve(String(target));
if (roots.some(root => resolved === root || resolved.startsWith(root + path.sep))) {
fs.appendFileSync(${JSON.stringify(accessLog)}, operation + "\\n");
throw new Error("Unexpected tsx disk cache access: " + operation);
}
}
for (const operation of ["readdirSync", "readFileSync", "writeFileSync", "openSync"]) {
const original = fs[operation];
fs[operation] = function(target, ...args) {
guardAccess(target, operation);
return original.call(this, target, ...args);
};
}
for (const operation of ["readdir", "readFile", "writeFile", "open", "unlink", "rm", "rmdir", "access"]) {
const original = fs.promises[operation];
fs.promises[operation] = async function(target, ...args) {
guardAccess(target, operation);
return original.call(this, target, ...args);
};
}
require("node:module").syncBuiltinESMExports();
`,
);
fs.writeFileSync(pnpm, 'throw new Error("build must be intercepted");\n');
fs.writeFileSync(
fixture,
`
const validator: string = process.argv[2];
const reportOnly = process.argv.includes("--report-only");
console.log(JSON.stringify({ validator, reportOnly }));
process.exitCode = validator === ${JSON.stringify(failValidator)} ? 17
: validator === "check-control-ui-performance.mts" && !reportOnly ? 1 : 0;
`,
);
// Run the native launcher, intercept only the build, then replay each real
// validator command/environment with erasable TypeScript. The preload rejects
// compiler workers and subprocesses before they can escape validator completion.
fs.writeFileSync(
capture,
`
const assert = require("node:assert/strict");
const childProcess = require("node:child_process");
const path = require("node:path");
const spawnSync = childProcess.spawnSync;
const validators = ${JSON.stringify(validators)};
assert.equal(process.env.TSX_DISABLE_CACHE, undefined);
assert.equal(process.env.npm_execpath, ${JSON.stringify(pnpm)});
childProcess.spawnSync = function(command, args, options) {
if (args[0] === ${JSON.stringify(path.join(path.dirname(createRequire(path.resolve("ui/package.json")).resolve("vite/package.json")), "bin/vite.js"))}) {
assert.deepEqual(args.slice(1), ["build"]);
return { status: 0 };
}
const validatorIndex = args.findIndex(arg => validators.includes(path.basename(arg)));
if (validatorIndex === -1) throw new Error("Unexpected UI subprocess");
const validator = path.basename(args[validatorIndex]);
const validatorArgs = args.slice(validatorIndex + 1);
assert.deepEqual(validatorArgs, validator === "check-control-ui-performance.mts" ? ["--report-only"] : []);
assert.equal(options.env.TSX_DISABLE_CACHE, undefined);
return spawnSync(command, [...args.slice(0, validatorIndex), ${JSON.stringify(fixture)}, validator, ...validatorArgs], options);
};
require("node:module").syncBuiltinESMExports();
`,
);
// A spread can retain NPM_EXECPATH, which wins over npm_execpath on Windows.
const env = mergeProcessEnv([
process.env,
{
TMPDIR: tempRoot,
TMP: tempRoot,
TEMP: tempRoot,
XDG_CACHE_HOME: path.join(tempDir, "xdg-cache"),
NODE_COMPILE_CACHE: path.join(tempDir, "node-cache"),
NODE_OPTIONS: `--require ${JSON.stringify(guard)}`,
OPENCLAW_BUILD_ALL_NO_PNPM: noPnpm ? "1" : "0",
OPENCLAW_BUILD_TIMESTAMP: "2026-08-27T00:00:00.000Z",
GIT_COMMIT: "a".repeat(40),
npm_execpath: pnpm,
TSX_DISABLE_CACHE: undefined,
TSX_TSCONFIG_PATH: undefined,
PNPM_CONFIG_MODULES_DIR: undefined,
npm_config_modules_dir: undefined,
},
]);
if (!noPnpm && failValidator === null) {
const control = spawnSync(
testNodeExecPath,
["--eval", `require("node:fs").readdirSync(${JSON.stringify(cacheRoots[0])})`],
{ cwd: path.resolve("."), encoding: "utf8", env, timeout: 10_000 },
);
expect(control.error).toBeUndefined();
expect(control.status).toBe(1);
// Check the cache guard without starting the compiler service it protects against.
expect(fs.readFileSync(accessLog, "utf8").trim()).toBe("readdirSync");
fs.unlinkSync(accessLog);
}
const result = spawnSync(
testNodeExecPath,
["--require", capture, "scripts/ui.js", "build"],
{
cwd: path.resolve("."),
encoding: "utf8",
env,
timeout: 10_000,
},
);
expect(result.error).toBeUndefined();
expect(fs.existsSync(accessLog), result.stderr).toBe(false);
expect(result.status, result.stderr).toBe(failValidator ? 17 : 0);
const expectedValidators = failValidator
? validators.slice(0, validators.indexOf(failValidator) + 1)
: validators;
expect(
result.stdout
.trim()
.split("\n")
.map((line) => JSON.parse(line)),
).toEqual(
expectedValidators.map((validator) => ({
validator,
reportOnly: validator === "check-control-ui-performance.mts",
})),
);
for (const cacheRoot of cacheRoots) {
expect(fs.readdirSync(cacheRoot)).toEqual(["0-sentinel"]);
expect(fs.readFileSync(path.join(cacheRoot, "0-sentinel"), "utf8")).toBe("keep");
}
} finally {
fs.rmSync(tempDir, { force: true, recursive: true });
}
},
);
it("keeps the package script on the canonical UI build wrapper", () => {
const packageJson = JSON.parse(fs.readFileSync("package.json", "utf8")) as {
scripts: Record<string, string>;
};
expect(packageJson.scripts["ui:build"]).toBe("node scripts/ui.js build");
});
it.runIf(process.platform !== "win32").each([
{
label: "acknowledged SIGTERM",
requested: "SIGTERM",
childSignal: null,
code: 143,
signal: null,
},
{
label: "acknowledged SIGHUP",
requested: "SIGHUP",
childSignal: null,
code: 129,
signal: null,
},
{
label: "raw SIGTERM",
requested: "SIGTERM",
childSignal: "SIGTERM",
code: null,
signal: "SIGTERM",
},
{
label: "raw SIGKILL",
requested: "SIGTERM",
childSignal: "SIGKILL",
code: null,
signal: "SIGKILL",
},
] as const)(
"preserves $label after UI wrapper shutdown",
async ({ requested, childSignal, code, signal }) => {
// Keep the release outside the disposable Vitest namespace until every
// fixture process is confirmed stopped, including on an assertion failure.
const tempDir = fs.mkdtempSync(
path.join(path.dirname(os.tmpdir()), "openclaw-ui-wrapper-signals-"),
);
const runnerPath = path.join(tempDir, "pnpm.mjs");
const readyFile = path.join(tempDir, "ready");
const runnerPidFile = path.join(tempDir, "runner.pid");
const signaledFile = path.join(tempDir, "signaled");
const releaseFile = path.join(tempDir, "release");
const childOutcome = childSignal
? `process.kill(process.pid, '${childSignal}');`
: "setTimeout(() => process.exit(0), 25);";
const handlerLines = ["SIGTERM", "SIGHUP"].flatMap((handledSignal) => [
`process.once('${handledSignal}', () => {`,
` fs.writeFileSync(process.env.SIGNALED_FILE, '${handledSignal}');`,
childOutcome,
"});",
]);
fs.writeFileSync(
runnerPath,
[
"import fs from 'node:fs';",
...handlerLines,
"fs.writeFileSync(process.env.RUNNER_PID_FILE, String(process.pid));",
"fs.writeFileSync(process.env.READY_FILE, process.argv.slice(2).join(' '));",
"setInterval(() => { if (fs.existsSync(process.env.RELEASE_FILE)) process.exit(0); }, 20);",
].join("\n"),
);
const wrapper = spawn(testNodeExecPath, ["scripts/ui.js", "install"], {
cwd: path.resolve("."),
env: {
...process.env,
npm_execpath: runnerPath,
READY_FILE: readyFile,
RUNNER_PID_FILE: runnerPidFile,
RELEASE_FILE: releaseFile,
SIGNALED_FILE: signaledFile,
},
stdio: "ignore",
});
await withUiProcessCleanup(wrapper, tempDir, [runnerPidFile], async () => {
await waitFor(() => readNonEmpty(readyFile) !== null, "UI runner readiness");
expect(fs.readFileSync(readyFile, "utf8")).toBe("install");
const runnerPid = Number(fs.readFileSync(runnerPidFile, "utf8"));
wrapper.kill(requested);
const exit = await waitForExit(wrapper);
expect(exit).toEqual({ code, signal });
expect(fs.readFileSync(signaledFile, "utf8")).toBe(requested);
expect(pidAlive(runnerPid), "UI wrapper returned before its child stopped").toBe(false);
});
},
);
it.runIf(process.platform !== "win32").each([false, true])(
"keeps resistant-descendant cleanup raw with failed capture=%s",
async (failedCapture) => {
const tempDir = fs.mkdtempSync(
path.join(path.dirname(os.tmpdir()), "openclaw-ui-wrapper-tree-"),
);
const runnerPath = path.join(tempDir, "pnpm.mjs");
const readyFile = path.join(tempDir, "ready");
const runnerPidFile = path.join(tempDir, "runner.pid");
const descendantPidFile = path.join(tempDir, "descendant.pid");
const releaseFile = path.join(tempDir, "release");
const failedCaptureFile = path.join(tempDir, "capture-failed");
const descendantSource = [
"import fs from 'node:fs';",
"process.on('SIGTERM', () => {});",
"fs.writeFileSync(process.env.DESCENDANT_PID_FILE, String(process.pid));",
"setInterval(() => { if (fs.existsSync(process.env.RELEASE_FILE)) process.exit(0); }, 20);",
].join("\n");
fs.writeFileSync(
runnerPath,
[
"import { spawn } from 'node:child_process';",
"import fs from 'node:fs';",
"process.on('SIGTERM', () => process.exit(0));",
"fs.writeFileSync(process.env.RUNNER_PID_FILE, String(process.pid));",
"fs.writeFileSync(process.env.READY_FILE, 'ready');",
`const child = spawn(process.execPath, ['--input-type=module', '--eval', ${JSON.stringify(descendantSource)}], { stdio: 'ignore' });`,
"child.unref();",
"setInterval(() => { if (fs.existsSync(process.env.RELEASE_FILE)) process.exit(0); }, 20);",
].join("\n"),
);
const env: NodeJS.ProcessEnv = {
...process.env,
DESCENDANT_PID_FILE: descendantPidFile,
RUNNER_PID_FILE: runnerPidFile,
RELEASE_FILE: releaseFile,
npm_execpath: runnerPath,
READY_FILE: readyFile,
};
if (failedCapture) {
const bin = path.join(tempDir, "bin");
fs.mkdirSync(bin);
fs.writeFileSync(
path.join(bin, "ps"),
'#!/bin/sh\nprintf failed > "$PS_FAILURE_FILE"\nexit 1\n',
{ mode: 0o755 },
);
env.PATH = `${bin}${path.delimiter}${env.PATH ?? ""}`;
env.PS_FAILURE_FILE = failedCaptureFile;
}
const wrapper = spawn(testNodeExecPath, ["scripts/ui.js", "install"], {
cwd: path.resolve("."),
env,
stdio: "ignore",
});
await withUiProcessCleanup(wrapper, tempDir, [runnerPidFile, descendantPidFile], async () => {
// The descendant publishes only after its resistant handler is installed.
await waitFor(
() => readNonEmpty(descendantPidFile) !== null,
"UI runner descendant readiness",
);
const descendantPid = Number(fs.readFileSync(descendantPidFile, "utf8"));
wrapper.kill("SIGTERM");
const exit = await waitForExit(wrapper, 8_000);
expect(exit).toEqual({ code: null, signal: failedCapture ? "SIGTERM" : "SIGKILL" });
expect(pidAlive(descendantPid)).toBe(failedCapture);
if (failedCapture) {
expect(fs.readFileSync(failedCaptureFile, "utf8")).toBe("failed");
}
});
},
);
});
function pidAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}