Initial Commit
This commit is contained in:
@@ -0,0 +1,219 @@
|
||||
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;
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user