import type { ChildProcess } from "node:child_process"; import { EventEmitter } from "node:events"; import { PassThrough, type Readable, type Writable } from "node:stream"; export interface FakeSpawnPlan { /** Bytes/messages to push to stdout in order (each becomes one "data" event). */ readonly stdout?: readonly Uint8Array[]; readonly stderr?: readonly string[]; /** Exit code emitted on "close". */ readonly exitCode?: number | null; /** Signal returned on "close" — distinguishes signal-kill cases. */ readonly closeSignal?: NodeJS.Signals | null; /** Throw via "error" event immediately instead of returning normally. */ readonly spawnError?: Error; /** Delay (ms) before stdout/stderr/close emission. */ readonly delayMs?: number; /** If true, never auto-close — caller drives close via `child.kill`. */ readonly hold?: boolean; /** Echo stdin into stdout (loopback). */ readonly echo?: boolean; /** Ignore `kill()` calls so SIGTERM->SIGKILL escalation can be exercised. */ readonly ignoreKill?: boolean; /** Force stdin to be null so the no-stdin guard branch fires. */ readonly noStdin?: boolean; /** * Cause `stdin.write()` to return false on first call so the caller has to * wait on the `drain` event before continuing. Used to exercise the * backpressure branch in `runEncode`. */ readonly stdinBackpressure?: boolean; /** * Trigger close on stdin end with these values. Used to exercise the * `code ?? -1` and `signal ?? ""` fallback branches by feeding `null`. */ readonly closeOnStdinEnd?: { code: number | null; signal: NodeJS.Signals | null; }; } export interface FakeChild extends ChildProcess { /** Internal hook for tests to manually trigger close. */ triggerClose(code: number, signal?: NodeJS.Signals | null): void; } class StubChild extends EventEmitter implements FakeChild { public stdin: Writable | null; public stdout: Readable; public stderr: Readable; public exitCode: number | null = null; public signalCode: NodeJS.Signals | null = null; public pid = 4242; private closed = false; public constructor(public plan: FakeSpawnPlan) { super(); const stdoutStream = new PassThrough(); this.stdout = stdoutStream; this.stderr = new PassThrough(); if (plan.noStdin === true) { this.stdin = null; return; } const stdin = new PassThrough(); this.stdin = stdin; if (plan.stdinBackpressure === true) { const realWrite = stdin.write.bind(stdin); let triggered = false; stdin.write = ((chunk: Buffer | string): boolean => { realWrite(chunk); if (!triggered) { triggered = true; // Schedule a drain emission so the awaited promise resolves. setImmediate(() => stdin.emit("drain")); return false; } return true; }) as typeof stdin.write; } if (plan.echo === true) { stdin.on("data", (c) => stdoutStream.write(c)); stdin.on("end", () => { stdoutStream.end(); if (plan.hold !== true) { if (plan.closeOnStdinEnd !== undefined) { this.triggerClose( plan.closeOnStdinEnd.code ?? 0, plan.closeOnStdinEnd.signal ); } else { this.triggerClose(plan.exitCode ?? 0); } } }); } if (plan.closeOnStdinEnd !== undefined && plan.echo !== true) { stdin.on("end", () => { this.triggerCloseRaw( plan.closeOnStdinEnd!.code, plan.closeOnStdinEnd!.signal ); }); } } public kill(signal?: NodeJS.Signals | number): boolean { if (this.closed) return false; const s = typeof signal === "string" ? signal : "SIGTERM"; if (this.plan.ignoreKill === true && s !== "SIGKILL") { // Pretend the process is stubbornly alive — escalation needed. return true; } this.signalCode = s; this.triggerClose(0, this.signalCode); return true; } public triggerClose(code: number, signal?: NodeJS.Signals | null): void { if (this.closed) return; this.closed = true; this.exitCode = code; // Settle on next microtask so listeners attach first. setImmediate(() => { this.emit("close", code, signal ?? null); }); } /** * Like `triggerClose` but emits the raw `code` value (including `null`) * so the runner exercises its `code ?? -1` fallback branch. */ public triggerCloseRaw( code: number | null, signal: NodeJS.Signals | null ): void { if (this.closed) return; this.closed = true; this.exitCode = code; setImmediate(() => { this.emit("close", code, signal); }); } public ref(): this { return this; } public unref(): this { return this; } public disconnect(): void { // no-op stub } public send(): boolean { return true; } public get connected(): boolean { return false; } public get killed(): boolean { return this.closed; } public get stdio(): ChildProcess["stdio"] { return [ this.stdin, this.stdout, this.stderr, null, null, ] as unknown as ChildProcess["stdio"]; } public get stdinWritable(): Writable | null { return this.stdin; } public get spawnargs(): string[] { return []; } public get spawnfile(): string { return "fake-ffmpeg"; } } export function makeFakeSpawn(plan: FakeSpawnPlan): { spawn: (cmd: string, args: readonly string[]) => ChildProcess; last: () => FakeChild | undefined; } { let last: FakeChild | undefined; return { spawn(_cmd, _args) { const child = new StubChild(plan) as FakeChild; last = child; // Synchronous error path. if (plan.spawnError !== undefined) { setImmediate(() => child.emit("error", plan.spawnError)); return child; } const delay = plan.delayMs ?? 0; setTimeout(() => { if (plan.echo !== true) { for (const chunk of plan.stdout ?? []) { (child.stdout as PassThrough).write(Buffer.from(chunk)); } } for (const e of plan.stderr ?? []) { (child.stderr as PassThrough).write(e); } if (plan.hold !== true && plan.echo !== true) { child.triggerClose( plan.exitCode ?? 0, plan.closeSignal ?? null ); } }, delay); return child; }, last() { return last; }, }; }