diff --git a/packages/cli/src/commands/init.transcodeToMp4.windowsHide.test.ts b/packages/cli/src/commands/init.transcodeToMp4.windowsHide.test.ts new file mode 100644 index 00000000000..05c11c3229a --- /dev/null +++ b/packages/cli/src/commands/init.transcodeToMp4.windowsHide.test.ts @@ -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 })); + }); +}); diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 22b82baa7df..9fd043e3bd0 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -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 { +export function transcodeToMp4(inputPath: string, outputPath: string): Promise { return new Promise((resolvePromise) => { const ffmpegPath = findFFmpeg(); if (!ffmpegPath) { @@ -210,7 +210,7 @@ function transcodeToMp4(inputPath: string, outputPath: string): Promise "-y", outputPath, ], - { stdio: "pipe" }, + { stdio: "pipe", windowsHide: true }, ); child.on("close", (code) => resolvePromise(code === 0)); diff --git a/packages/cli/src/commands/preview.test.ts b/packages/cli/src/commands/preview.test.ts index 862465622cc..74e08cf6946 100644 --- a/packages/cli/src/commands/preview.test.ts +++ b/packages/cli/src/commands/preview.test.ts @@ -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"; @@ -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 () => { @@ -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(); + return { ...actual, spawn }; + }); + vi.doMock("node:fs", async (importOriginal) => { + const actual = await importOriginal(); + 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 }); }); }); diff --git a/packages/cli/src/commands/preview.ts b/packages/cli/src/commands/preview.ts index c453dec6808..92ce2bbe433 100644 --- a/packages/cli/src/commands/preview.ts +++ b/packages/cli/src/commands/preview.ts @@ -100,9 +100,11 @@ interface EmbeddedStudioOptions extends StudioLaunchOptions { } type StudioChildProcess = ChildProcessByStdio; +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< @@ -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, @@ -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); }); } @@ -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 { +export async function runDevMode(dir: string, options?: StudioLaunchOptions): Promise { // Find monorepo root by navigating from packages/cli/src/commands/ const thisFile = fileURLToPath(import.meta.url); const repoRoot = resolve(dirname(thisFile), "..", "..", "..", ".."); @@ -1442,6 +1442,7 @@ async function runDevMode(dir: string, options?: StudioLaunchOptions): Promise { +export async function runLocalStudioMode( + dir: string, + options?: StudioLaunchOptions, +): Promise { const req = createRequire(join(dir, "package.json")); const studioPkgPath = dirname(req.resolve("@hyperframes/studio/package.json")); const pName = options?.projectName ?? basename(dir); @@ -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, diff --git a/packages/studio-server/src/helpers/loudness.ts b/packages/studio-server/src/helpers/loudness.ts index 75c8790deac..ba1cf1e473c 100644 --- a/packages/studio-server/src/helpers/loudness.ts +++ b/packages/studio-server/src/helpers/loudness.ts @@ -468,6 +468,7 @@ export async function measureAudio( encoding: "utf8", maxBuffer: 16 * 1024 * 1024, timeout: FFMPEG_TIMEOUT_MS, + windowsHide: true, }); return parseEbur128Summary(result.stderr); } diff --git a/packages/studio-server/src/helpers/loudness.windowsHide.test.ts b/packages/studio-server/src/helpers/loudness.windowsHide.test.ts new file mode 100644 index 00000000000..200954d589d --- /dev/null +++ b/packages/studio-server/src/helpers/loudness.windowsHide.test.ts @@ -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(); + 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 })); + }); +});