Skip to content
26 changes: 26 additions & 0 deletions packages/cli/src/commands/init.transcodeToMp4.windowsHide.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { EventEmitter } from "node:events";
import { describe, expect, it, vi } from "vitest";

const { spawnMock } = vi.hoisted(() => ({ spawnMock: vi.fn() }));

vi.mock("node:child_process", () => ({ spawn: spawnMock }));
vi.mock("../browser/ffmpeg.js", () => ({
findFFmpeg: () => "/fake/bin/ffmpeg",
findFFprobe: () => "/fake/bin/ffprobe",
getFFmpegInstallHint: () => "install ffmpeg",
}));

import { transcodeToMp4 } from "./init.js";

describe("transcodeToMp4 child-process options", () => {
it("hides the ffmpeg console window on Windows", async () => {
const proc = new EventEmitter();
spawnMock.mockReturnValue(proc);

const result = transcodeToMp4("/fake/in.webm", "/fake/out.mp4");
proc.emit("close", 0);
await result;

expect(spawnMock.mock.calls[0]?.[2]).toEqual(expect.objectContaining({ windowsHide: true }));
});
});
4 changes: 2 additions & 2 deletions packages/cli/src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,7 @@ function isWebCompatible(codec: string): boolean {

// hasFFmpeg is imported from whisper/manager.ts to avoid duplication

function transcodeToMp4(inputPath: string, outputPath: string): Promise<boolean> {
export function transcodeToMp4(inputPath: string, outputPath: string): Promise<boolean> {
return new Promise((resolvePromise) => {
const ffmpegPath = findFFmpeg();
if (!ffmpegPath) {
Expand All @@ -210,7 +210,7 @@ function transcodeToMp4(inputPath: string, outputPath: string): Promise<boolean>
"-y",
outputPath,
],
{ stdio: "pipe" },
{ stdio: "pipe", windowsHide: true },
);

child.on("close", (code) => resolvePromise(code === 0));
Expand Down
84 changes: 80 additions & 4 deletions packages/cli/src/commands/preview.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import * as clack from "@clack/prompts";
import { afterEach, describe, expect, it, vi } from "vitest";
import { runCommand } from "citty";
Expand Down Expand Up @@ -484,8 +485,21 @@ describe("waitForStudioChildClose", () => {

await expect(waitForStudioChildClose(child, signalTarget)).resolves.toBeUndefined();
expect(child.once).not.toHaveBeenCalled();
expect(signalTarget.once).toHaveBeenCalledTimes(2);
expect(signalTarget.off).toHaveBeenCalledTimes(2);
expect(signalTarget.once).toHaveBeenCalledTimes(3);
expect(signalTarget.off).toHaveBeenCalledTimes(3);
});

it("reaps the dev server when the terminal closes (SIGHUP)", async () => {
const signalTarget = { once: vi.fn(), off: vi.fn() };
const child = { exitCode: 0, signalCode: null, once: vi.fn() } as unknown as Parameters<
typeof waitForStudioChildClose
>[0];

await waitForStudioChildClose(child, signalTarget);

const hupListener = signalTarget.once.mock.calls.find(([event]) => event === "SIGHUP")?.[1];
expect(hupListener).toBeTypeOf("function");
expect(signalTarget.off).toHaveBeenCalledWith("SIGHUP", hupListener);
});

it("reaps on process exit even when stdio never emits close", async () => {
Expand All @@ -511,6 +525,68 @@ describe("waitForStudioChildClose", () => {
exit?.();
await waiting;
expect(resolved).toBe(true);
expect(signalTarget.off).toHaveBeenCalledTimes(2);
expect(signalTarget.off).toHaveBeenCalledTimes(3);
});
});

describe("studio dev-server spawns", () => {
function fakeStudioChild() {
return {
pid: 4321,
exitCode: 0,
signalCode: null,
stdout: { on: vi.fn(), removeListener: vi.fn() },
stderr: { on: vi.fn(), removeListener: vi.fn() },
on: vi.fn(),
once: vi.fn(),
};
}

// Returns the spawn spy. mkdirSync/existsSync are stubbed too, so
// linkProjectIntoStudioData never touches the real studio data directory.
function mockStudioSpawn() {
const spawn = vi.fn((_command: string, _args: string[], _options: unknown) =>
fakeStudioChild(),
);
vi.doMock("node:child_process", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:child_process")>();
return { ...actual, spawn };
});
vi.doMock("node:fs", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:fs")>();
return { ...actual, mkdirSync: () => undefined, existsSync: () => true };
});
vi.resetModules();
return spawn;
}

afterEach(() => {
vi.doUnmock("node:child_process");
vi.doUnmock("node:fs");
vi.resetModules();
});

it("runDevMode passes windowsHide to the studio dev-server spawn", async () => {
const spawn = mockStudioSpawn();

const { runDevMode } = await import("./preview.js");
await runDevMode("/tmp/hf-preview-devmode-test", { json: true });

expect(spawn).toHaveBeenCalledOnce();
expect(spawn.mock.calls[0]?.[2]).toMatchObject({ windowsHide: true });
});

it("runLocalStudioMode passes windowsHide to the local Vite spawn", async () => {
const spawn = mockStudioSpawn();

// @hyperframes/studio is resolved for real, so the project dir has to sit
// inside the monorepo's node_modules tree.
const thisFile = fileURLToPath(import.meta.url);
const localStudioProjectDir = resolve(dirname(thisFile), "..", "..");
const { runLocalStudioMode } = await import("./preview.js");
await runLocalStudioMode(localStudioProjectDir, { json: true });

expect(spawn).toHaveBeenCalledOnce();
expect(spawn.mock.calls[0]?.[2]).toMatchObject({ windowsHide: true });
});
});
21 changes: 13 additions & 8 deletions packages/cli/src/commands/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,9 +100,11 @@ interface EmbeddedStudioOptions extends StudioLaunchOptions {
}

type StudioChildProcess = ChildProcessByStdio<null, Readable, Readable>;
const STUDIO_CHILD_SHUTDOWN_SIGNALS = ["SIGINT", "SIGTERM", "SIGHUP"] as const;
type StudioShutdownSignal = (typeof STUDIO_CHILD_SHUTDOWN_SIGNALS)[number];
interface StudioSignalTarget {
once(event: "SIGINT" | "SIGTERM", listener: () => void): unknown;
off(event: "SIGINT" | "SIGTERM", listener: () => void): unknown;
once(event: StudioShutdownSignal, listener: () => void): unknown;
off(event: StudioShutdownSignal, listener: () => void): unknown;
}
type ContextField = "server" | "selection" | "lint" | "capabilities";
type CompactSelectionPayload = Pick<
Expand Down Expand Up @@ -1337,8 +1339,7 @@ export function waitForStudioChildClose(
const shutdown = (): void => {
if (child.pid) killProcessTree(child.pid);
};
signalTarget.once("SIGINT", shutdown);
signalTarget.once("SIGTERM", shutdown);
for (const signal of STUDIO_CHILD_SHUTDOWN_SIGNALS) signalTarget.once(signal, shutdown);

// A short-lived Vite child can exit before launch setup reaches this point.
// ChildProcess does not replay lifecycle events to listeners attached later,
Expand All @@ -1355,8 +1356,7 @@ export function waitForStudioChildClose(
return closed.finally(() => {
// Signal listeners keep Bun's event loop alive even after Vite exits. Leaving
// them registered makes `preview --stop` close the port but leak the wrapper.
signalTarget.off("SIGINT", shutdown);
signalTarget.off("SIGTERM", shutdown);
for (const signal of STUDIO_CHILD_SHUTDOWN_SIGNALS) signalTarget.off(signal, shutdown);
});
}

Expand Down Expand Up @@ -1422,7 +1422,7 @@ export function reportPreviewShutdown(json: boolean): void {
/**
* Dev mode: spawn the studio dev server from the monorepo.
*/
async function runDevMode(dir: string, options?: StudioLaunchOptions): Promise<void> {
export async function runDevMode(dir: string, options?: StudioLaunchOptions): Promise<void> {
// Find monorepo root by navigating from packages/cli/src/commands/
const thisFile = fileURLToPath(import.meta.url);
const repoRoot = resolve(dirname(thisFile), "..", "..", "..", "..");
Expand All @@ -1442,6 +1442,7 @@ async function runDevMode(dir: string, options?: StudioLaunchOptions): Promise<v
const child = spawn("bun", ["run", "dev", "--", ...previewViteArgs(options?.port)], {
cwd: studioPkgDir,
stdio: ["ignore", "pipe", "pipe"],
windowsHide: true,
env: studioProxyEnv(options?.autoProxy ?? true, process.env, {
projectDir: dir,
projectName: pName,
Expand Down Expand Up @@ -1487,7 +1488,10 @@ function hasLocalStudio(dir: string): boolean {
* Local studio mode: spawn Vite using a locally installed @hyperframes/studio.
* Provides full Vite HMR and the complete studio experience.
*/
async function runLocalStudioMode(dir: string, options?: StudioLaunchOptions): Promise<void> {
export async function runLocalStudioMode(
dir: string,
options?: StudioLaunchOptions,
): Promise<void> {
const req = createRequire(join(dir, "package.json"));
const studioPkgPath = dirname(req.resolve("@hyperframes/studio/package.json"));
const pName = options?.projectName ?? basename(dir);
Expand All @@ -1504,6 +1508,7 @@ async function runLocalStudioMode(dir: string, options?: StudioLaunchOptions): P
const child = spawn(viteCommand.command, viteCommand.args, {
cwd: studioPkgPath,
stdio: ["ignore", "pipe", "pipe"],
windowsHide: true,
env: studioProxyEnv(options?.autoProxy ?? true, process.env, {
projectDir: dir,
projectName: pName,
Expand Down
1 change: 1 addition & 0 deletions packages/studio-server/src/helpers/loudness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -468,6 +468,7 @@ export async function measureAudio(
encoding: "utf8",
maxBuffer: 16 * 1024 * 1024,
timeout: FFMPEG_TIMEOUT_MS,
windowsHide: true,
});
return parseEbur128Summary(result.stderr);
}
Expand Down
25 changes: 25 additions & 0 deletions packages/studio-server/src/helpers/loudness.windowsHide.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { describe, expect, it, vi } from "vitest";

const { execFileMock } = vi.hoisted(() => ({
execFileMock: vi.fn(
(_file: string, _args: string[], _options: unknown, callback: (error: Error) => void) =>
callback(new Error("ffmpeg stub")),
),
}));

vi.mock("node:child_process", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:child_process")>();
return { ...actual, default: { ...actual, execFile: execFileMock }, execFile: execFileMock };
});

import { measureAudio } from "./loudness.js";

describe("measureAudio child-process options", () => {
it("hides the ffmpeg console window on Windows", async () => {
await expect(
measureAudio("/fake/ffmpeg", "/fake/a.wav", { mediaStart: 0, duration: 1 }),
).rejects.toThrow();

expect(execFileMock.mock.calls[0]?.[2]).toEqual(expect.objectContaining({ windowsHide: true }));
});
});
Loading