426e9eeabd
Voice Workbench / headless workbench (mocked backends) (push) Has been cancelled
Voice Workbench / real acoustic lane (nightly, provisioned only) (push) Has been cancelled
ci / test (push) Has been cancelled
ci / lint-and-format (push) Has been cancelled
ci / build (push) Has been cancelled
ci / dev-startup (push) Has been cancelled
gitleaks / gitleaks (push) Has been cancelled
Markdown Links / Relative Markdown Links (push) Has been cancelled
Quality (Extended) / Homepage Build (PR smoke) (push) Has been cancelled
Quality (Extended) / Comment-only diff guard (push) Has been cancelled
Quality (Extended) / Format + Type Safety Ratchet (push) Has been cancelled
Quality (Extended) / Develop Gate (secret scan + UI determinism) (push) Has been cancelled
Quality (Extended) / Develop Gate (lint) (push) Has been cancelled
Chat shell gestures / Chat shell gesture + parity e2e (push) Has been cancelled
Cloud Gateway Discord / Test (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx @biomejs/biome check packages/lifeops-bench/src, benchmark-lint) (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx vitest run --config packages/lifeops-bench/vitest.config.ts --root packages/lifeops-bench --passWithNoTests, benchmark-tests) (push) Has been cancelled
Build Agent Image / build-and-push (push) Has been cancelled
Dev Smoke / bun run dev onboarding chat (push) Has been cancelled
Dev Smoke / Vite HMR dependency-level smoke (push) Has been cancelled
Electrobun Submodule Guard / electrobun gitlink is fetchable (push) Has been cancelled
Publish @elizaos/example-code / check_npm (push) Has been cancelled
Publish @elizaos/example-code / publish_npm (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / verify_version (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / publish_npm (push) Has been cancelled
Sandbox Live Smoke / Sandbox live smoke (push) Has been cancelled
Snap Build & Test / Build Snap (amd64) (push) Has been cancelled
Snap Build & Test / Build Snap (arm64) (push) Has been cancelled
Test Packaging / elizaos CLI global-install smoke (node + bun) (push) Has been cancelled
Cloud Gateway Webhook / Test (push) Has been cancelled
Cloud Tests / lint-and-types (push) Has been cancelled
Cloud Tests / unit-tests (push) Has been cancelled
Cloud Tests / integration-tests (push) Has been cancelled
Cloud Tests / e2e-tests (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Apps Worker (Product 2) / Determine environment (push) Has been cancelled
Deploy Apps Worker (Product 2) / Deploy apps worker to apps-control host (${{ needs.determine-env.outputs.environment }}) (push) Has been cancelled
Deploy Eliza Provisioning Worker / Determine environment (push) Has been cancelled
Deploy Eliza Provisioning Worker / Deploy worker to Hetzner host (${{ needs.determine-env.outputs.environment }} @ ${{ needs.determine-env.outputs.deployment_sha }}) (push) Has been cancelled
Dev Smoke / Classify changed paths (push) Has been cancelled
supply-chain / sbom (push) Has been cancelled
supply-chain / vulnerability-scan (push) Has been cancelled
Build, Push & Deploy to Phala Cloud / build-and-push (push) Has been cancelled
Test Packaging / Validate Packaging Configs (push) Has been cancelled
Test Packaging / Build & Test PyPI Package (push) Has been cancelled
Test Packaging / PyPI on Python ${{ matrix.python }} (push) Has been cancelled
Test Packaging / Pack & Test JS Tarballs (push) Has been cancelled
UI Fixture E2E / ui-fixture-e2e (push) Has been cancelled
UI Fixture E2E / fixture-e2e (push) Has been cancelled
UI Story Gate / story-gate (push) Has been cancelled
vault-ci / test (macos-latest) (push) Has been cancelled
vault-ci / test (ubuntu-latest) (push) Has been cancelled
vault-ci / test (windows-latest) (push) Has been cancelled
vault-ci / app-core wiring tests (push) Has been cancelled
verify-patches / verify patches/CHECKSUMS.sha256 (push) Has been cancelled
Voice Benchmark Smoke / voice-emotion fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voiceagentbench fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench-quality unit smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench TypeScript unit (no audio) (push) Has been cancelled
Voice Benchmark Smoke / voice bench smoke summary (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/app-core test bun run --cwd packages/elizaos test bun run --cwd packages/cloud/shared test], app-and-cli) (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/scenario-runner test bun run --cwd packages/vault test bun run --cwd packages/security test bun run --cwd plugins/plugin-coding-tools test], framework-packages) (push) Has been cancelled
Windows CI / windows ([bun run --cwd plugins/plugin-elizacloud test bun run --cwd plugins/plugin-discord test bun run --cwd plugins/plugin-anthropic test bun run --cwd plugins/plugin-openai test bun run --cwd plugins/plugin-app-control test bun run --cwd plugins/pl… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run build --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/agent --concurrency=4 node packages/scripts/run-bash-linux-only.mjs scripts/verify-riscv64-buildpaths.sh node packages/scripts/run… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run typecheck --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/cloud-shared --concurrency=4 bun run --cwd packages/core test bun run --cwd packages/shared test], core-runtime, 75) (push) Has been cancelled
634 lines
15 KiB
JavaScript
634 lines
15 KiB
JavaScript
/** Supports app-core build, packaging, or development orchestration for managed test command mjs. */
|
|
import { execFileSync, spawn } from "node:child_process";
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
|
|
const SIGNAL_EXIT_CODES = {
|
|
SIGHUP: 129,
|
|
SIGINT: 130,
|
|
SIGTERM: 143,
|
|
};
|
|
const HEARTBEAT_INTERVAL_MS = 5_000;
|
|
|
|
const REPO_TEST_PROCESS_MARKERS = [
|
|
"node_modules/.bin/vitest run --config vitest.config.ts",
|
|
"test/scripts/test-runner.mjs",
|
|
"test/scripts/test-root-unit.mjs",
|
|
"bun run test",
|
|
"bun run test:integration",
|
|
"bun run test:e2e",
|
|
"bun run test:e2e:all",
|
|
"bun run test:orchestrator:integration",
|
|
];
|
|
|
|
let cachedCloudApiKey;
|
|
|
|
function readJson(filePath) {
|
|
try {
|
|
return JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function resolveSavedCloudApiKey() {
|
|
if (cachedCloudApiKey !== undefined) {
|
|
return cachedCloudApiKey;
|
|
}
|
|
|
|
const homeDir =
|
|
process.env.HOME?.trim() ||
|
|
process.env.USERPROFILE?.trim() ||
|
|
process.env.HOMEDRIVE?.trim();
|
|
if (!homeDir) {
|
|
cachedCloudApiKey = null;
|
|
return cachedCloudApiKey;
|
|
}
|
|
|
|
for (const candidate of [
|
|
path.join(homeDir, ".eliza", "eliza.json"),
|
|
path.join(homeDir, ".eliza", "eliza.json"),
|
|
]) {
|
|
const config = readJson(candidate);
|
|
const apiKey = config?.cloud?.apiKey;
|
|
if (typeof apiKey === "string" && apiKey.trim()) {
|
|
cachedCloudApiKey = apiKey.trim();
|
|
return cachedCloudApiKey;
|
|
}
|
|
}
|
|
|
|
cachedCloudApiKey = null;
|
|
return cachedCloudApiKey;
|
|
}
|
|
|
|
function isRealNodeExecutable(candidate) {
|
|
if (!candidate || !fs.existsSync(candidate)) {
|
|
return false;
|
|
}
|
|
const stat = fs.statSync(candidate);
|
|
if (!stat.isFile()) {
|
|
return false;
|
|
}
|
|
const normalized = candidate.replace(/\\/g, "/");
|
|
return !/\/bun-node-[^/]+\/node$/.test(normalized);
|
|
}
|
|
|
|
export function resolveNodeCmd() {
|
|
if (isRealNodeExecutable(process.env.npm_node_execpath)) {
|
|
return process.env.npm_node_execpath;
|
|
}
|
|
for (const candidate of [
|
|
"/opt/homebrew/bin/node",
|
|
"/usr/local/bin/node",
|
|
"/usr/bin/node",
|
|
]) {
|
|
if (isRealNodeExecutable(candidate)) {
|
|
return candidate;
|
|
}
|
|
}
|
|
if (isRealNodeExecutable(process.execPath)) {
|
|
return process.execPath;
|
|
}
|
|
return "node";
|
|
}
|
|
|
|
export function buildTestEnv(cwd) {
|
|
const env = { ...process.env };
|
|
for (const key of Object.keys(env)) {
|
|
if (key.startsWith("npm_") || key === "INIT_CWD") {
|
|
delete env[key];
|
|
}
|
|
}
|
|
env.NODE_NO_WARNINGS = env.NODE_NO_WARNINGS || "1";
|
|
env.ELIZA_LIVE_TEST = env.ELIZA_LIVE_TEST || "0";
|
|
env.ELIZA_LIVE_TEST = "0";
|
|
env.PWD = path.resolve(cwd);
|
|
if (!env.ELIZAOS_CLOUD_API_KEY) {
|
|
const savedCloudApiKey = resolveSavedCloudApiKey();
|
|
if (savedCloudApiKey) {
|
|
env.ELIZAOS_CLOUD_API_KEY = savedCloudApiKey;
|
|
}
|
|
}
|
|
return env;
|
|
}
|
|
|
|
function isPidAlive(pid) {
|
|
if (!Number.isInteger(pid) || pid <= 0) {
|
|
return false;
|
|
}
|
|
try {
|
|
process.kill(pid, 0);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function sleep(ms) {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|
|
|
|
function listSystemProcesses() {
|
|
if (process.platform === "win32") {
|
|
return [];
|
|
}
|
|
|
|
let output = "";
|
|
try {
|
|
output = execFileSync("ps", ["-axo", "pid=,ppid="], {
|
|
encoding: "utf8",
|
|
stdio: ["ignore", "pipe", "ignore"],
|
|
});
|
|
} catch {
|
|
return [];
|
|
}
|
|
|
|
return output
|
|
.split("\n")
|
|
.map((line) => line.trim())
|
|
.filter(Boolean)
|
|
.map((line) => {
|
|
const match = line.match(/^(\d+)\s+(\d+)$/);
|
|
if (!match) {
|
|
return null;
|
|
}
|
|
const pid = Number.parseInt(match[1], 10);
|
|
const ppid = Number.parseInt(match[2], 10);
|
|
if (!pid || !ppid) {
|
|
return null;
|
|
}
|
|
return { pid, ppid };
|
|
})
|
|
.filter((entry) => entry !== null);
|
|
}
|
|
|
|
function collectDescendantPids(rootPid) {
|
|
const descendants = [];
|
|
const queue = [rootPid];
|
|
const processTable = listSystemProcesses();
|
|
|
|
while (queue.length > 0) {
|
|
const currentPid = queue.shift();
|
|
if (!currentPid) {
|
|
continue;
|
|
}
|
|
for (const entry of processTable) {
|
|
if (entry.ppid !== currentPid || descendants.includes(entry.pid)) {
|
|
continue;
|
|
}
|
|
descendants.push(entry.pid);
|
|
queue.push(entry.pid);
|
|
}
|
|
}
|
|
|
|
return descendants;
|
|
}
|
|
|
|
async function waitForProcessesExit(pids, timeoutMs) {
|
|
const uniquePids = [
|
|
...new Set(pids.filter((pid) => Number.isInteger(pid) && pid > 0)),
|
|
];
|
|
const deadline = Date.now() + timeoutMs;
|
|
while (Date.now() < deadline) {
|
|
if (uniquePids.every((pid) => !isPidAlive(pid))) {
|
|
return true;
|
|
}
|
|
await sleep(100);
|
|
}
|
|
return uniquePids.every((pid) => !isPidAlive(pid));
|
|
}
|
|
|
|
async function killProcessTree(pid) {
|
|
if (!isPidAlive(pid)) {
|
|
return;
|
|
}
|
|
|
|
if (process.platform === "win32") {
|
|
try {
|
|
execFileSync("taskkill", ["/PID", String(pid), "/T", "/F"], {
|
|
stdio: "ignore",
|
|
});
|
|
} catch {
|
|
// Best effort.
|
|
}
|
|
return;
|
|
}
|
|
|
|
const descendants = collectDescendantPids(pid);
|
|
for (const childPid of [...descendants].reverse()) {
|
|
try {
|
|
process.kill(childPid, "SIGTERM");
|
|
} catch {
|
|
// Best effort.
|
|
}
|
|
}
|
|
|
|
try {
|
|
process.kill(pid, "SIGTERM");
|
|
} catch {
|
|
return;
|
|
}
|
|
|
|
if (await waitForProcessesExit([pid, ...descendants], 5_000)) {
|
|
return;
|
|
}
|
|
|
|
for (const childPid of [...descendants].reverse()) {
|
|
try {
|
|
process.kill(childPid, "SIGKILL");
|
|
} catch {
|
|
// Best effort.
|
|
}
|
|
}
|
|
|
|
try {
|
|
process.kill(pid, "SIGKILL");
|
|
} catch {
|
|
// Best effort.
|
|
}
|
|
}
|
|
|
|
function ensureDir(dirPath) {
|
|
fs.mkdirSync(dirPath, { recursive: true });
|
|
}
|
|
|
|
function readLock(lockPath) {
|
|
if (!fs.existsSync(lockPath)) {
|
|
return null;
|
|
}
|
|
try {
|
|
return JSON.parse(fs.readFileSync(lockPath, "utf8"));
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function writeLock(lockPath, state) {
|
|
ensureDir(path.dirname(lockPath));
|
|
fs.writeFileSync(lockPath, `${JSON.stringify(state, null, 2)}\n`, "utf8");
|
|
}
|
|
|
|
function removeLock(lockPath) {
|
|
fs.rmSync(lockPath, { force: true });
|
|
}
|
|
|
|
function listLockFiles(lockDir) {
|
|
if (!fs.existsSync(lockDir)) {
|
|
return [];
|
|
}
|
|
return fs
|
|
.readdirSync(lockDir)
|
|
.filter((entry) => entry.endsWith(".json"))
|
|
.map((entry) => path.join(lockDir, entry));
|
|
}
|
|
|
|
function parseLockPid(value) {
|
|
return Number.isInteger(value)
|
|
? value
|
|
: Number.parseInt(String(value ?? ""), 10);
|
|
}
|
|
|
|
async function cleanupStaleLock(lockPath) {
|
|
const existing = readLock(lockPath);
|
|
if (!existing) {
|
|
removeLock(lockPath);
|
|
return;
|
|
}
|
|
|
|
const ownerPid = parseLockPid(existing.ownerPid);
|
|
const childPid = parseLockPid(existing.childPid);
|
|
|
|
if (ownerPid && isPidAlive(ownerPid)) {
|
|
throw new Error(
|
|
`[test-runner] Another "${existing.lockName ?? path.basename(lockPath)}" run is already active (pid ${ownerPid}).`,
|
|
);
|
|
}
|
|
|
|
if (childPid && isPidAlive(childPid)) {
|
|
await killProcessTree(childPid);
|
|
}
|
|
|
|
removeLock(lockPath);
|
|
}
|
|
|
|
function isAncestorPid(candidatePid) {
|
|
let pid = process.ppid;
|
|
const visited = new Set();
|
|
while (Number.isInteger(pid) && pid > 1 && !visited.has(pid)) {
|
|
if (pid === candidatePid) return true;
|
|
visited.add(pid);
|
|
try {
|
|
const stat = execFileSync("ps", ["-o", "ppid=", "-p", String(pid)], {
|
|
encoding: "utf8",
|
|
stdio: ["ignore", "pipe", "ignore"],
|
|
}).trim();
|
|
pid = Number.parseInt(stat, 10);
|
|
} catch {
|
|
break;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
async function cleanupOtherLockFiles(lockDir, currentLockPath) {
|
|
const activeLocks = [];
|
|
|
|
for (const lockPath of listLockFiles(lockDir)) {
|
|
if (lockPath === currentLockPath) {
|
|
continue;
|
|
}
|
|
|
|
const existing = readLock(lockPath);
|
|
if (!existing) {
|
|
removeLock(lockPath);
|
|
continue;
|
|
}
|
|
|
|
const ownerPid = parseLockPid(existing.ownerPid);
|
|
const childPid = parseLockPid(existing.childPid);
|
|
|
|
if (ownerPid && isPidAlive(ownerPid)) {
|
|
// Skip locks held by this process or its ancestors (e.g. parent test-runner)
|
|
if (ownerPid === process.pid || isAncestorPid(ownerPid)) {
|
|
continue;
|
|
}
|
|
activeLocks.push({
|
|
lockName: existing.lockName ?? path.basename(lockPath, ".json"),
|
|
ownerPid,
|
|
});
|
|
continue;
|
|
}
|
|
|
|
if (childPid && isPidAlive(childPid)) {
|
|
await killProcessTree(childPid);
|
|
}
|
|
|
|
removeLock(lockPath);
|
|
}
|
|
|
|
if (activeLocks.length > 0) {
|
|
const summary = activeLocks
|
|
.map((entry) => `"${entry.lockName}" (pid ${entry.ownerPid})`)
|
|
.join(", ");
|
|
throw new Error(
|
|
`[test-runner] Another managed test run is already active: ${summary}.`,
|
|
);
|
|
}
|
|
}
|
|
|
|
function listRepoProcessTable(repoRoot) {
|
|
if (process.platform === "win32") {
|
|
return new Map();
|
|
}
|
|
|
|
let output = "";
|
|
try {
|
|
output = execFileSync("ps", ["-axo", "pid=,ppid=,command="], {
|
|
encoding: "utf8",
|
|
stdio: ["ignore", "pipe", "ignore"],
|
|
});
|
|
} catch {
|
|
return new Map();
|
|
}
|
|
|
|
const processTable = new Map();
|
|
for (const rawLine of output.split("\n")) {
|
|
const line = rawLine.trim();
|
|
if (!line) {
|
|
continue;
|
|
}
|
|
const match = line.match(/^(\d+)\s+(\d+)\s+(.+)$/);
|
|
if (!match) {
|
|
continue;
|
|
}
|
|
const pid = Number.parseInt(match[1], 10);
|
|
const ppid = Number.parseInt(match[2], 10);
|
|
const command = match[3] ?? "";
|
|
if (!pid || !ppid || !command.includes(repoRoot)) {
|
|
continue;
|
|
}
|
|
processTable.set(pid, { pid, ppid, command });
|
|
}
|
|
return processTable;
|
|
}
|
|
|
|
function listRepoTestProcesses(repoRoot) {
|
|
const processTable = listRepoProcessTable(repoRoot);
|
|
return [...processTable.values()].filter((entry) =>
|
|
REPO_TEST_PROCESS_MARKERS.some((marker) => entry.command.includes(marker)),
|
|
);
|
|
}
|
|
|
|
function collectProcessLineage(pid, processTable) {
|
|
const lineage = new Set();
|
|
let currentPid = pid;
|
|
|
|
while (
|
|
Number.isInteger(currentPid) &&
|
|
currentPid > 0 &&
|
|
!lineage.has(currentPid)
|
|
) {
|
|
lineage.add(currentPid);
|
|
const current = processTable.get(currentPid);
|
|
if (!current || current.ppid === currentPid) {
|
|
break;
|
|
}
|
|
currentPid = current.ppid;
|
|
}
|
|
|
|
return lineage;
|
|
}
|
|
|
|
function collectProtectedRepoTestPids(repoRoot) {
|
|
const lockDir = path.join(repoRoot, ".tmp", "test-runner");
|
|
const processTable = listRepoProcessTable(repoRoot);
|
|
const protectedPids = collectProcessLineage(process.pid, processTable);
|
|
|
|
for (const lockPath of listLockFiles(lockDir)) {
|
|
const lockState = readLock(lockPath);
|
|
if (!lockState) {
|
|
continue;
|
|
}
|
|
for (const candidatePid of [lockState.ownerPid, lockState.childPid]) {
|
|
const pid = Number.isInteger(candidatePid)
|
|
? candidatePid
|
|
: Number.parseInt(String(candidatePid ?? ""), 10);
|
|
if (!pid || !isPidAlive(pid)) {
|
|
continue;
|
|
}
|
|
for (const protectedPid of collectProcessLineage(pid, processTable)) {
|
|
protectedPids.add(protectedPid);
|
|
}
|
|
}
|
|
}
|
|
|
|
return protectedPids;
|
|
}
|
|
|
|
function hasAncestorManagedLock(lockDir, currentLockPath) {
|
|
for (const lockPath of listLockFiles(lockDir)) {
|
|
if (lockPath === currentLockPath) {
|
|
continue;
|
|
}
|
|
|
|
const existing = readLock(lockPath);
|
|
if (!existing) {
|
|
continue;
|
|
}
|
|
|
|
for (const candidatePid of [existing.ownerPid, existing.childPid]) {
|
|
const pid = parseLockPid(candidatePid);
|
|
if (pid && isPidAlive(pid) && isAncestorPid(pid)) {
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
async function cleanupOrphanedRepoTestProcesses(repoRoot) {
|
|
const candidates = listRepoTestProcesses(repoRoot);
|
|
const protectedPids = collectProtectedRepoTestPids(repoRoot);
|
|
const killed = [];
|
|
|
|
for (const candidate of candidates) {
|
|
if (protectedPids.has(candidate.pid)) {
|
|
continue;
|
|
}
|
|
await killProcessTree(candidate.pid);
|
|
killed.push(candidate);
|
|
}
|
|
|
|
if (killed.length > 0) {
|
|
console.log(
|
|
`[test-runner] cleaned ${killed.length} orphaned repo test process${killed.length === 1 ? "" : "es"}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
export async function runManagedTestCommand({
|
|
repoRoot,
|
|
lockName,
|
|
label,
|
|
command,
|
|
args,
|
|
cwd = repoRoot,
|
|
env = buildTestEnv(cwd),
|
|
}) {
|
|
const lockPath = path.join(
|
|
repoRoot,
|
|
".tmp",
|
|
"test-runner",
|
|
`${lockName}.json`,
|
|
);
|
|
const lockDir = path.dirname(lockPath);
|
|
await cleanupOtherLockFiles(lockDir, lockPath);
|
|
if (!hasAncestorManagedLock(lockDir, lockPath)) {
|
|
await cleanupOrphanedRepoTestProcesses(repoRoot);
|
|
}
|
|
await cleanupStaleLock(lockPath);
|
|
|
|
const initialState = {
|
|
lockName,
|
|
label,
|
|
ownerPid: process.pid,
|
|
childPid: null,
|
|
startedAt: new Date().toISOString(),
|
|
cwd: path.resolve(cwd),
|
|
command,
|
|
args,
|
|
};
|
|
writeLock(lockPath, initialState);
|
|
|
|
const startedAt = Date.now();
|
|
console.log(`[test-runner] START ${label}: ${[command, ...args].join(" ")}`);
|
|
|
|
let child = null;
|
|
let shuttingDown = false;
|
|
let heartbeatTimer = null;
|
|
|
|
const cleanup = async (reason = "cleanup") => {
|
|
if (shuttingDown) {
|
|
return;
|
|
}
|
|
shuttingDown = true;
|
|
if (child?.pid) {
|
|
await killProcessTree(child.pid);
|
|
}
|
|
removeLock(lockPath);
|
|
if (reason !== "normal") {
|
|
console.error(`[test-runner] STOP ${label}: ${reason}`);
|
|
}
|
|
};
|
|
|
|
const signalHandlers = new Map();
|
|
for (const signal of Object.keys(SIGNAL_EXIT_CODES)) {
|
|
const handler = () => {
|
|
void cleanup(`received ${signal}`).finally(() => {
|
|
process.exit(SIGNAL_EXIT_CODES[signal] ?? 1);
|
|
});
|
|
};
|
|
signalHandlers.set(signal, handler);
|
|
process.on(signal, handler);
|
|
}
|
|
|
|
try {
|
|
await new Promise((resolve, reject) => {
|
|
// shell: false ensures command + args are passed directly to execvp()
|
|
// with no shell expansion — callers supply a fixed command (e.g. "bun")
|
|
// and a static args array, so there is no shell injection risk here.
|
|
// lgtm[js/shell-command-injection-from-environment]
|
|
child = spawn(command, args, {
|
|
cwd,
|
|
env,
|
|
stdio: "inherit",
|
|
shell: false,
|
|
});
|
|
|
|
heartbeatTimer = setInterval(() => {
|
|
console.log(
|
|
`[test-runner] HEARTBEAT ${label} (${Date.now() - startedAt}ms)`,
|
|
);
|
|
}, HEARTBEAT_INTERVAL_MS);
|
|
heartbeatTimer.unref?.();
|
|
|
|
writeLock(lockPath, {
|
|
...initialState,
|
|
childPid: child.pid ?? null,
|
|
});
|
|
|
|
child.on("error", (error) => {
|
|
reject(error);
|
|
});
|
|
|
|
child.on("exit", (code, signal) => {
|
|
if (heartbeatTimer) {
|
|
clearInterval(heartbeatTimer);
|
|
heartbeatTimer = null;
|
|
}
|
|
if (code === 0) {
|
|
resolve();
|
|
return;
|
|
}
|
|
const suffix =
|
|
signal != null
|
|
? `signal ${signal}`
|
|
: `exit code ${code ?? "unknown"}`;
|
|
reject(new Error(`[test-runner] ${label} failed with ${suffix}`));
|
|
});
|
|
});
|
|
|
|
console.log(`[test-runner] PASS ${label} (${Date.now() - startedAt}ms)`);
|
|
} finally {
|
|
if (heartbeatTimer) {
|
|
clearInterval(heartbeatTimer);
|
|
heartbeatTimer = null;
|
|
}
|
|
for (const [signal, handler] of signalHandlers) {
|
|
process.off(signal, handler);
|
|
}
|
|
await cleanup("normal");
|
|
}
|
|
}
|