// @vitest-environment happy-dom import { act } from "react"; import { createRoot } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { useVoiceDictation, type VoiceDictationApi, } from "./useVoiceDictation.js"; class FakeSpeechRecognition { static last: FakeSpeechRecognition | undefined; continuous = false; interimResults = false; lang = ""; onaudiostart: (() => void) | undefined; onresult: ((event: unknown) => void) | undefined; onerror: ((event: { error?: string }) => void) | undefined; onend: (() => void) | undefined; started = false; constructor() { FakeSpeechRecognition.last = this; } start() { this.started = true; } stop() {} abort() {} } class FakeMediaRecorder { static last: FakeMediaRecorder | undefined; static isTypeSupported = () => true; ondataavailable: ((event: unknown) => void) | undefined; onstop: (() => void) | undefined; mimeType = "audio/webm"; started = false; constructor() { FakeMediaRecorder.last = this; } start() { this.started = true; } stop() {} } function stubSpeechEnvironment(getUserMedia = vi.fn()) { FakeSpeechRecognition.last = undefined; FakeMediaRecorder.last = undefined; vi.stubGlobal("SpeechRecognition", FakeSpeechRecognition); vi.stubGlobal("webkitSpeechRecognition", FakeSpeechRecognition); vi.stubGlobal("MediaRecorder", undefined); Object.defineProperty(navigator, "mediaDevices", { configurable: true, value: { getUserMedia }, }); vi.stubGlobal( "fetch", vi.fn().mockResolvedValue(new Response("{}", { status: 404 })), ); return getUserMedia; } async function renderVoiceDictation() { const seen: VoiceDictationApi[] = []; function Probe() { seen.push(useVoiceDictation({ onTranscript: vi.fn() })); return null; } const container = document.createElement("div"); document.body.append(container); const root = createRoot(container); await act(async () => { root.render(); }); return { latest: () => seen[seen.length - 1]!, async cleanup() { await act(async () => { root.unmount(); }); container.remove(); }, }; } beforeEach(() => { (globalThis as Record).IS_REACT_ACT_ENVIRONMENT = true; }); afterEach(() => { vi.unstubAllGlobals(); }); describe("useVoiceDictation — browser speech path", () => { it("reports a session that ends before the microphone ever opened", async () => { stubSpeechEnvironment(); const probe = await renderVoiceDictation(); await act(async () => { await probe.latest().start(); }); const recognition = FakeSpeechRecognition.last!; expect(recognition.started).toBe(true); await act(async () => { recognition.onerror?.({ error: "aborted" }); recognition.onend?.(); }); expect(probe.latest().state).toBe("error"); expect(probe.latest().errorMessage).toContain( "stopped before it captured any audio", ); await probe.cleanup(); }); it("treats a silent but live session as an ordinary empty result", async () => { stubSpeechEnvironment(vi.fn().mockRejectedValue(new Error("no device"))); const probe = await renderVoiceDictation(); await act(async () => { await probe.latest().start(); }); const recognition = FakeSpeechRecognition.last!; await act(async () => { recognition.onaudiostart?.(); recognition.onerror?.({ error: "no-speech" }); recognition.onend?.(); }); expect(probe.latest().state).toBe("idle"); expect(probe.latest().errorMessage).toBeNull(); await probe.cleanup(); }); it("keeps a reported speech error visible when `end` follows `error`", async () => { stubSpeechEnvironment(vi.fn().mockResolvedValue({ getTracks: () => [] })); const probe = await renderVoiceDictation(); await act(async () => { await probe.latest().start(); }); const recognition = FakeSpeechRecognition.last!; await act(async () => { recognition.onaudiostart?.(); recognition.onerror?.({ error: "network" }); recognition.onend?.(); }); expect(probe.latest().state).toBe("error"); expect(probe.latest().errorMessage).toContain("couldn't reach its service"); await probe.cleanup(); }); it("falls back to the upload path when the speech service is unreachable", async () => { stubSpeechEnvironment(vi.fn().mockResolvedValue({ getTracks: () => [] })); vi.stubGlobal("MediaRecorder", FakeMediaRecorder); const probe = await renderVoiceDictation(); await act(async () => { await probe.latest().start(); }); await act(async () => { FakeSpeechRecognition.last!.onaudiostart?.(); FakeSpeechRecognition.last!.onerror?.({ error: "network" }); FakeSpeechRecognition.last!.onend?.(); }); await act(async () => {}); expect(FakeMediaRecorder.last?.started).toBe(true); expect(probe.latest().state).toBe("recording"); expect(probe.latest().errorMessage).toBeNull(); await probe.cleanup(); }); it("delivers partial speech instead of failing over after a mid-session drop", async () => { stubSpeechEnvironment(vi.fn().mockResolvedValue({ getTracks: () => [] })); vi.stubGlobal("MediaRecorder", FakeMediaRecorder); const probe = await renderVoiceDictation(); await act(async () => { await probe.latest().start(); }); await act(async () => { const recognition = FakeSpeechRecognition.last!; recognition.onaudiostart?.(); recognition.onresult?.({ resultIndex: 0, results: [{ 0: { transcript: "make it blue" }, isFinal: true }], }); recognition.onerror?.({ error: "network" }); recognition.onend?.(); }); await act(async () => {}); expect(FakeMediaRecorder.last).toBeUndefined(); expect(probe.latest().state).toBe("idle"); await probe.cleanup(); }); it("falls back to the upload path when the recognizer has no speech backend", async () => { const getUserMedia = stubSpeechEnvironment( vi.fn().mockResolvedValue({ getTracks: () => [] }), ); vi.stubGlobal("MediaRecorder", FakeMediaRecorder); const probe = await renderVoiceDictation(); await act(async () => { await probe.latest().start(); }); await act(async () => { FakeSpeechRecognition.last!.onerror?.({ error: "aborted" }); FakeSpeechRecognition.last!.onend?.(); }); await act(async () => {}); expect(getUserMedia).toHaveBeenCalled(); expect(FakeMediaRecorder.last?.started).toBe(true); expect(probe.latest().state).toBe("recording"); expect(probe.latest().errorMessage).toBeNull(); await probe.cleanup(); }); it("does not retry through the upload path after a denied microphone", async () => { stubSpeechEnvironment(vi.fn().mockResolvedValue({ getTracks: () => [] })); vi.stubGlobal("MediaRecorder", FakeMediaRecorder); const probe = await renderVoiceDictation(); await act(async () => { await probe.latest().start(); }); await act(async () => { FakeSpeechRecognition.last!.onerror?.({ error: "not-allowed" }); FakeSpeechRecognition.last!.onend?.(); }); await act(async () => {}); expect(FakeMediaRecorder.last).toBeUndefined(); expect(probe.latest().state).toBe("error"); expect(probe.latest().errorMessage).toContain("site controls icon"); await probe.cleanup(); }); it("opens the meter capture only after recognition owns the microphone", async () => { const getUserMedia = stubSpeechEnvironment( vi.fn().mockResolvedValue({ getTracks: () => [] }), ); const probe = await renderVoiceDictation(); await act(async () => { await probe.latest().start(); }); expect(getUserMedia).not.toHaveBeenCalled(); await act(async () => { FakeSpeechRecognition.last!.onaudiostart?.(); }); expect(getUserMedia).toHaveBeenCalledTimes(1); await probe.cleanup(); }); });