fast-browser-with-jev
This commit is contained in:
@@ -0,0 +1,181 @@
|
||||
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<number, FixtureProcessIdentity>();
|
||||
readonly groups = new Map<number, FixtureProcessIdentity[]>();
|
||||
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<Parameters<typeof startOwnedFixtureProcess>[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);
|
||||
});
|
||||
Reference in New Issue
Block a user