import assert from "node:assert/strict"; import { EventEmitter } from "node:events"; import test from "node:test"; import { runOwnedFixtureHook, startOwnedFixtureProcess, type FixtureChildProcess, type FixtureProcessAdapter, type FixtureProcessIdentity, } from "./fixture-process.ts"; class FakeChild extends EventEmitter implements FixtureChildProcess { readonly pid: number; readonly stdout = new EventEmitter(); readonly stderr = new EventEmitter(); constructor(pid: number) { super(); this.pid = pid; } } class FakeAdapter implements FixtureProcessAdapter { readonly platform = "linux" as const; readonly signals: Array<{ group: number; signal: string }> = []; readonly identities = new Map(); readonly groups = new Map(); readonly child: FakeChild; termLeavesDescendant = false; autoExit = false; spawnCount = 0; constructor(pid = 40_001) { this.child = new FakeChild(pid); const root = { pid, pgrp: pid, session: pid, startTime: "root-start", state: "R" }; this.identities.set(pid, root); this.groups.set(pid, [root]); } spawn(): FixtureChildProcess { this.spawnCount += 1; queueMicrotask(() => { this.child.emit("spawn"); if (this.autoExit) this.child.emit("exit", 0, null); }); return this.child; } identity(pid: number): FixtureProcessIdentity | undefined { return this.identities.get(pid); } groupMembers(group: number): readonly FixtureProcessIdentity[] | undefined { return this.groups.get(group); } signalGroup(group: number, signal: "SIGTERM" | "SIGKILL"): void { this.signals.push({ group, signal }); if (signal === "SIGTERM" && this.termLeavesDescendant) { const root = this.groups.get(group)?.find((member) => member.pid === this.child.pid); this.groups.set(group, root ? [{ ...root, state: "Z" }, { pid: group + 1, pgrp: group, session: group, startTime: "descendant", state: "R" }] : []); this.identities.delete(this.child.pid); this.child.emit("exit", 143, "SIGTERM"); } else { this.groups.set(group, []); this.identities.delete(this.child.pid); this.child.emit("exit", 137, "SIGKILL"); } } } const signal = () => new AbortController().signal; function start(adapter: FakeAdapter, overrides: Partial[0]> = {}) { return startOwnedFixtureProcess({ argv: ["fixture", "--foreground"], cwd: "/tmp/fixture", env: { FIXTURE: "1" }, adapter, termTimeoutMs: 20, killTimeoutMs: 20, ...overrides }, signal()); } test("a successful one-shot cleans a descendant after its root exits", async () => { const adapter = new FakeAdapter(); adapter.termLeavesDescendant = true; adapter.autoExit = true; const result = await runOwnedFixtureHook({ argv: ["fixture"], cwd: "/tmp/fixture", env: {}, adapter, timeoutMs: 100, termTimeoutMs: 10, killTimeoutMs: 10 }, signal()); assert.equal(result.exit.code, 0); assert.deepEqual(adapter.signals.map((item) => item.signal), ["SIGTERM", "SIGKILL"]); assert.equal(adapter.groups.get(adapter.child.pid)?.length, 0); }); test("cancellation uses bounded owned cleanup and does not expose the abort reason", async () => { const adapter = new FakeAdapter(); adapter.termLeavesDescendant = true; const controller = new AbortController(); const running = runOwnedFixtureHook({ argv: ["fixture", "--secret-token"], cwd: "/tmp/fixture", env: { TOKEN: "private" }, adapter, timeoutMs: 100, termTimeoutMs: 10, killTimeoutMs: 10 }, controller.signal); controller.abort(new Error("private secret and raw argv must not escape")); await assert.rejects(running, (error: unknown) => { assert.match(String(error), /fixture hook cancelled/); assert.doesNotMatch(String(error), /private|secret-token/); return true; }); assert.deepEqual(adapter.signals.map((item) => item.signal), ["SIGTERM", "SIGKILL"]); }); test("deadline escalates from SIGTERM to SIGKILL", async () => { const adapter = new FakeAdapter(); adapter.termLeavesDescendant = true; await assert.rejects(runOwnedFixtureHook({ argv: ["fixture"], cwd: "/tmp/fixture", env: {}, adapter, timeoutMs: 5, termTimeoutMs: 10, killTimeoutMs: 10 }, signal()), /fixture hook exceeded its deadline/); assert.deepEqual(adapter.signals.map((item) => item.signal), ["SIGTERM", "SIGKILL"]); }); test("invalid, self, and non-integer PIDs are rejected before identity probes or signals", async () => { for (const pid of [0, -1, 1, 1.5, Number.NaN, Number.POSITIVE_INFINITY, process.pid]) { const adapter = new FakeAdapter(pid); let probes = 0; const identity = adapter.identity.bind(adapter); adapter.identity = (candidate) => { probes += 1; return identity(candidate); }; const handle = start(adapter); adapter.child.emit("exit", 0, null); await handle.stop(); assert.equal(probes, 0, `identity was probed for pid ${String(pid)}`); assert.deepEqual(adapter.signals, []); } }); test("PID reuse or an unrelated process-group member fails closed without signaling", async () => { const adapter = new FakeAdapter(); const handle = start(adapter); adapter.groups.set(adapter.child.pid, [{ pid: adapter.child.pid, pgrp: adapter.child.pid, session: adapter.child.pid, startTime: "reused", state: "R" }]); adapter.identities.set(adapter.child.pid, { pid: adapter.child.pid, pgrp: adapter.child.pid, session: adapter.child.pid, startTime: "reused", state: "R" }); adapter.child.emit("exit", 0, null); await assert.rejects(handle.stop(), /ownership could not be confirmed/); assert.deepEqual(adapter.signals, []); }); test("uncertain process-group inspection fails closed without signaling", async () => { const adapter = new FakeAdapter(); const original = adapter.groupMembers.bind(adapter); adapter.groupMembers = () => undefined; const handle = start(adapter); adapter.child.emit("exit", 0, null); await assert.rejects(handle.stop(), /liveness could not be confirmed/); assert.deepEqual(adapter.signals, []); adapter.groupMembers = original; }); test("an unrelated process-group identity fails closed", async () => { const adapter = new FakeAdapter(); const handle = start(adapter); adapter.groups.set(adapter.child.pid, [ { pid: adapter.child.pid, pgrp: adapter.child.pid, session: adapter.child.pid, startTime: "root-start", state: "Z" }, { pid: 40_999, pgrp: adapter.child.pid, session: adapter.child.pid + 1, startTime: "foreign", state: "R" }, ]); adapter.identities.delete(adapter.child.pid); adapter.child.emit("exit", 0, null); await assert.rejects(handle.stop(), /ownership could not be confirmed/); assert.deepEqual(adapter.signals, []); }); test("missing executable is safe and does not leak output to process stdout", async () => { const adapter = new FakeAdapter(); adapter.spawn = function spawn() { this.spawnCount += 1; queueMicrotask(() => { this.groups.set(this.child.pid, []); this.identities.delete(this.child.pid); this.child.emit("error", new Error("ENOENT /secret/private-executable")); }); return this.child; }; const result = await assert.rejects(runOwnedFixtureHook({ argv: ["missing", "raw-secret"], cwd: "/tmp/fixture", env: { PASSWORD: "private" }, adapter, timeoutMs: 100 }, signal()), /fixture hook failed to start/); assert.equal(result, undefined); assert.deepEqual(adapter.signals, []); }); test("stdout and stderr are captured with finite bounds", async () => { const adapter = new FakeAdapter(); const handle = start(adapter, { maxStdoutBytes: 4, maxStderrBytes: 3 }); adapter.child.stdout.emit("data", "abcdef"); adapter.child.stderr.emit("data", "wxyz"); adapter.groups.set(adapter.child.pid, []); adapter.identities.delete(adapter.child.pid); adapter.child.emit("exit", 0, null); await handle.stop(); assert.deepEqual(await handle.stdout, { text: "abcd", truncated: true }); assert.deepEqual(await handle.stderr, { text: "wxy", truncated: true }); }); test("unsupported platforms fail before spawning", () => { const adapter = new FakeAdapter() as FakeAdapter & { platform: NodeJS.Platform }; Object.defineProperty(adapter, "platform", { value: "win32" }); assert.throws(() => startOwnedFixtureProcess({ argv: ["fixture"], cwd: "/tmp", env: {}, adapter }, signal()), /unavailable on this platform/); assert.equal(adapter.spawnCount, 0); });