fix(cli): hide Windows child process consoles (#3529)

Rebase #3529 onto current main. Preserve all 16 issue-scoped Studio server, lint, and CLI child-process windowsHide options, including main's PowerShell null guards and stderr suppression in orphanCleanup.

Regression tests continue to assert windowsHide at each scoped spawn site. #3476 and #3430 remain out of scope.

Co-authored-by: heygengenesis[bot] <262951085+heygengenesis[bot]@users.noreply.github.com>
Co-authored-by: miguel.sierra <229591595+miguel-heygen@users.noreply.github.com>
This commit is contained in:
heygengenesis[bot]
2026-08-31 18:11:20 -04:00
committed by GitHub
co-authored by miguel.sierra
parent 73aa71c9ec
commit 9097d539b1
23 changed files with 322 additions and 37 deletions
@@ -0,0 +1,54 @@
import { EventEmitter } from "node:events";
import { describe, expect, it, vi } from "vitest";
const { execFileMock, spawnMock } = vi.hoisted(() => ({
execFileMock: vi.fn(),
spawnMock: vi.fn(),
}));
vi.mock("node:child_process", () => {
const mocked = { execFile: execFileMock, spawn: spawnMock };
return { ...mocked, default: mocked };
});
vi.mock("@hyperframes/parsers/ff-binaries", () => ({
findFfBinary: (name: string) => `/fake/bin/${name}`,
}));
import { probeMediaMetadata } from "./mediaMetadata.js";
import { decodeAudioPeaks } from "./waveform.js";
describe("Studio child-process options", () => {
it("hides the ffprobe console window", async () => {
execFileMock.mockImplementation(
(
_command: string,
_args: string[],
_options: unknown,
callback: (...args: unknown[]) => void,
) => {
callback(
null,
JSON.stringify({ streams: [{ codec_type: "video", codec_name: "h264" }] }),
"",
);
},
);
await probeMediaMetadata("/tmp/clip.mp4");
expect(execFileMock.mock.calls[0]?.[2]).toEqual(expect.objectContaining({ windowsHide: true }));
});
it("hides the waveform FFmpeg console window", async () => {
const proc = new EventEmitter() as EventEmitter & { stdout: EventEmitter };
proc.stdout = new EventEmitter();
spawnMock.mockReturnValue(proc);
const peaks = decodeAudioPeaks("/tmp/audio.wav");
proc.stdout.emit("data", Buffer.from(new Float32Array([0.5]).buffer));
proc.emit("close", 0);
await peaks;
expect(spawnMock.mock.calls[0]?.[2]).toEqual(expect.objectContaining({ windowsHide: true }));
});
});
@@ -28,7 +28,7 @@ const execFileRunner: FfprobeRunner = (command, args, options) =>
execFile(
command,
args,
{ timeout: options?.timeout, maxBuffer: options?.maxBuffer },
{ timeout: options?.timeout, maxBuffer: options?.maxBuffer, windowsHide: true },
(error, stdout, stderr) => {
if (error && error.code === "ENOENT") {
resolvePromise({ status: null, stdout: "", stderr: "", error });
@@ -1,4 +1,4 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { validateUploadedMedia, validateUploadedMediaBuffer } from "./mediaValidation.js";
describe("validateUploadedMedia", () => {
@@ -11,13 +11,18 @@ describe("validateUploadedMedia", () => {
});
it("accepts video files with a video stream", () => {
expect(
validateUploadedMedia("/tmp/test.mp4", () => ({
status: 0,
stdout: JSON.stringify({ streams: [{ codec_type: "video" }] }),
stderr: "",
})),
).toEqual({ ok: true });
const runner = vi.fn(() => ({
status: 0,
stdout: JSON.stringify({ streams: [{ codec_type: "video" }] }),
stderr: "",
}));
expect(validateUploadedMedia("/tmp/test.mp4", runner)).toEqual({ ok: true });
expect(runner).toHaveBeenCalledWith(
"ffprobe",
expect.any(Array),
expect.objectContaining({ windowsHide: true }),
);
});
it("rejects video files with no supported video stream", () => {
@@ -9,6 +9,7 @@ const AUDIO_EXT = /\.(mp3|wav|ogg|m4a|aac)$/i;
type FfprobeRunner = (
command: string,
args: string[],
options: { windowsHide: boolean },
) => {
status: number | null;
stdout: string | Buffer;
@@ -26,16 +27,11 @@ export function validateUploadedMedia(
return { ok: true };
}
const result = runner("ffprobe", [
"-v",
"error",
"-show_entries",
"stream=codec_type",
"-of",
"json",
"--",
filePath,
]);
const result = runner(
"ffprobe",
["-v", "error", "-show_entries", "stream=codec_type", "-of", "json", "--", filePath],
{ windowsHide: true },
);
if (result.error?.code === "ENOENT") {
return { ok: true };
@@ -28,14 +28,15 @@ function createFakeProc(): FakeProc {
return proc;
}
type SpawnCall = { command: string; args: string[]; proc: FakeProc };
type SpawnImpl = (command: string, args: string[]) => FakeProc;
type SpawnOptions = { windowsHide?: boolean };
type SpawnCall = { command: string; args: string[]; options?: SpawnOptions; proc: FakeProc };
type SpawnImpl = (command: string, args: string[], options?: SpawnOptions) => FakeProc;
function createSpawnSpy(): { spawn: SpawnImpl; calls: SpawnCall[] } {
const calls: SpawnCall[] = [];
const spawn: SpawnImpl = (command, args) => {
const spawn: SpawnImpl = (command, args, options) => {
const proc = createFakeProc();
calls.push({ command, args, proc });
calls.push({ command, args, options, proc });
return proc;
};
return { spawn, calls };
@@ -222,6 +223,7 @@ describe("resolveProxy", () => {
await flush();
expect(calls[0]!.args).toEqual(["-hide_banner", "-filters"]);
expect(calls[0]!.options?.windowsHide).toBe(true);
calls[0]!.proc.stdout.emit(
"data",
Buffer.from(" ..C zscale V->V zimg scale\n T.C tonemap V->V tone map\n"),
@@ -459,6 +461,7 @@ describe("resolveProxy", () => {
const retry = resolveProxy(projectDir, sourcePath);
await flush();
expect(calls).toHaveLength(2);
expect(calls[1]!.options?.windowsHide).toBe(true);
succeed(calls[1]!);
await expect(retry).resolves.toBeTruthy();
});
@@ -289,6 +289,7 @@ function ensureHdrFilters(ffmpegPath: string): Promise<void> {
const promise = new Promise<void>((resolveCheck, rejectCheck) => {
const proc = spawn(ffmpegPath, ["-hide_banner", "-filters"], {
stdio: ["ignore", "pipe", "pipe"],
windowsHide: true,
});
let stdout = "";
proc.stdout?.on("data", (chunk: Buffer) => {
@@ -411,6 +412,7 @@ async function runFfmpeg(
stdio: ["ignore", "ignore", "pipe"],
timeout: TRANSCODE_TIMEOUT_MS,
killSignal: "SIGKILL",
windowsHide: true,
});
let stderrTail = "";
proc.stderr?.on("data", (chunk: Buffer) => {
@@ -67,7 +67,7 @@ export function decodeAudioPeaks(audioPath: string): Promise<number[]> {
"-vn",
"pipe:1",
],
{ stdio: ["ignore", "pipe", "ignore"] },
{ stdio: ["ignore", "pipe", "ignore"], windowsHide: true },
);
const chunks: Buffer[] = [];