220 lines
7.3 KiB
TypeScript
220 lines
7.3 KiB
TypeScript
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;
|
|
},
|
|
};
|
|
}
|