import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { writeFileSync, readFileSync, mkdtempSync, rmSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { WhisperUnavailableError } from "../whisper/manager.js"; import { CliRuntimeError, consumeCommandResult } from "../utils/commandResult.js"; // Make the whisper core report "unavailable" so we exercise the soft-skip path. const transcribeMock = vi.fn(); vi.mock("../whisper/transcribe.js", () => ({ transcribe: transcribeMock })); const trackTranscribeUnavailable = vi.fn(); const trackCommandFailure = vi.fn(); vi.mock("../telemetry/events.js", () => ({ trackTranscribeUnavailable: (...a: unknown[]) => trackTranscribeUnavailable(...a), trackCommandFailure: (...a: unknown[]) => trackCommandFailure(...a), })); import transcribeCmd from "./transcribe.js"; function dummyAudio(): { dir: string; input: string } { const dir = mkdtempSync(join(tmpdir(), "hf-transcribe-test-")); const input = join(dir, "narration.wav"); writeFileSync(input, "not-real-audio"); return { dir, input }; } describe("transcribe command", () => { let dirs: string[] = []; beforeEach(() => { dirs = []; consumeCommandResult(); transcribeMock.mockReset(); trackTranscribeUnavailable.mockReset(); trackCommandFailure.mockReset(); transcribeMock.mockRejectedValue( new WhisperUnavailableError("whisper-cpp not found. Install: brew install whisper-cpp"), ); vi.spyOn(console, "log").mockImplementation(() => {}); }); afterEach(() => { consumeCommandResult(); for (const d of dirs) rmSync(d, { recursive: true, force: true }); vi.restoreAllMocks(); }); it("explicit run exits non-zero and is NOT reported as a command failure", async () => { const { dir, input } = dummyAudio(); dirs.push(dir); await transcribeCmd.run!({ args: { input, json: true, optional: false } } as never); expect(consumeCommandResult().exitCode).toBe(1); expect(trackTranscribeUnavailable).toHaveBeenCalledWith({ optional: false }); expect(trackCommandFailure).not.toHaveBeenCalled(); }); it("--optional skips cleanly with exit 0", async () => { const { dir, input } = dummyAudio(); dirs.push(dir); await transcribeCmd.run!({ args: { input, json: true, optional: true } } as never); expect(consumeCommandResult().exitCode).toBe(0); expect(trackTranscribeUnavailable).toHaveBeenCalledWith({ optional: true }); expect(trackCommandFailure).not.toHaveBeenCalled(); }); it("imports an SRT and exports an SRT sidecar from transcript.json", async () => { const dir = mkdtempSync(join(tmpdir(), "hf-transcribe-test-")); dirs.push(dir); const input = join(dir, "sample.srt"); const sample = `1 00:00:01,000 --> 00:00:03,500 Write HTML. 2 00:00:03,500 --> 00:00:06,000 Render video. Built for agents. `; writeFileSync(input, sample); await transcribeCmd.run!({ args: { input, dir, json: true } } as never); const transcriptPath = join(dir, "transcript.json"); await transcribeCmd.run!({ args: { input: transcriptPath, to: "srt", json: true } } as never); const outputPath = join(dir, "transcript.srt"); expect(readFileSync(outputPath, "utf-8")).toBe(sample); const log = vi.mocked(console.log).mock.calls.at(-1)?.[0]; expect(typeof log).toBe("string"); if (typeof log !== "string") throw new Error("Expected JSON log output"); expect(JSON.parse(log)).toEqual({ ok: true, format: "srt", wordCount: 2, outputPath, }); }); it("rejects a below-minimum --timeout with a discoverable error", async () => { const { dir, input } = dummyAudio(); dirs.push(dir); const consoleLog = vi.mocked(console.log); // 100 is well below the 5000ms minimum — must fail loud instead of silently // reverting to the auto-scaled default (the whole point of the flag is // that the user explicitly asked for a specific value). await expect( transcribeCmd.run!({ args: { input, json: true, timeout: "100" } } as never), ).rejects.toThrow(CliRuntimeError); const log = consoleLog.mock.calls.at(-1)?.[0]; expect(typeof log).toBe("string"); if (typeof log !== "string") throw new Error("Expected JSON log output"); const parsed = JSON.parse(log); expect(parsed.ok).toBe(false); expect(parsed.error).toContain("--timeout"); expect(parsed.error).toContain("5000"); }); it("--preserve-cues keeps single-word cues separate when exporting from JSON", async () => { const dir = mkdtempSync(join(tmpdir(), "hf-transcribe-test-")); dirs.push(dir); // Single-word cues have no internal whitespace, so the whitespace heuristic // can't tell them from word-level whisper output. --preserve-cues forces 1:1. const transcriptPath = join(dir, "transcript.json"); writeFileSync( transcriptPath, JSON.stringify([ { text: "Yes", start: 0, end: 1 }, { text: "No", start: 1, end: 2 }, ]), ); await transcribeCmd.run!({ args: { input: transcriptPath, to: "srt", "preserve-cues": true, json: true }, } as never); const output = readFileSync(join(dir, "transcript.srt"), "utf-8"); expect(output).toBe( "1\n00:00:00,000 --> 00:00:01,000\nYes\n\n2\n00:00:01,000 --> 00:00:02,000\nNo\n", ); }); });