import React, { act } from "react"; (globalThis as Record).React = React; import assert from "node:assert/strict"; import { afterEach, it } from "node:test"; import { createRoot, type Root } from "react-dom/client"; import { parseHTML } from "linkedom"; import { I18nProvider, useI18n } from "../components/i18n.tsx"; import { KeysSection } from "../components/settings/keys-section.tsx"; import { DictationBar, finishDictation, isDictationActive, useDictation, useDictationTarget, type Dictation } from "../components/dictation.tsx"; import type { GeneralSettings } from "../src/core/settings.ts"; import { STT_MAX_AUDIO_BYTES } from "../src/core/stt.ts"; const dom = parseHTML(""); Object.assign(globalThis, { window: dom.window, document: dom.document, HTMLElement: dom.window.HTMLElement, Element: dom.window.Element, CustomEvent: dom.window.CustomEvent, IS_REACT_ACT_ENVIRONMENT: true, ResizeObserver: class { observe() {} disconnect() {} }, }); const originalFetch = globalThis.fetch; let root: Root | null = null; let host: HTMLDivElement; let dictation: Dictation; let setGeneral: (patch: Partial) => Promise; let saved: GeneralSettings; let transcripts: string[]; let questionTranscripts: string[]; let uploads: { url: URL; init: RequestInit; audio: Buffer }[]; let heldResponse: ((response: Response) => void) | undefined; let trackStops = 0; let configured = true; let micGate: Promise | undefined; class MockWorklet { static latest: MockWorklet; disconnected = false; port = { onmessage: (_event: { data: unknown }) => {}, postMessage: (_message: unknown) => { queueMicrotask(() => { this.audio([3, 0]); // final partial frame, available only at Stop this.port.onmessage({ data: { type: "flushed" } }); }); }, }; constructor() { MockWorklet.latest = this; } connect() {} disconnect() { this.disconnected = true; } audio(samples: number[]) { this.port.onmessage({ data: { type: "audio", buffer: new Uint8Array(samples).buffer } }); } } class MockSocket { static OPEN = 1; static CONNECTING = 0; static all: MockSocket[] = []; readyState = 0; sent: unknown[] = []; handlers = new Map void)[]>(); constructor(public url: string) { MockSocket.all.push(this); queueMicrotask(() => { this.readyState = 1; this.emit("open", {}); }); } addEventListener(type: string, callback: (event: any) => void) { this.handlers.set(type, [...(this.handlers.get(type) ?? []), callback]); } emit(type: string, event: unknown) { for (const handler of this.handlers.get(type) ?? []) handler(event); } send(value: unknown) { this.sent.push(value); if (value === '{"type":"CloseStream"}') queueMicrotask(() => { this.emit("message", { data: JSON.stringify({ type: "Results", is_final: true, channel: { alternatives: [{ transcript: "Deepgram final" }] } }) }); this.readyState = 3; this.emit("close", { code: 1000 }); }); } close() { this.readyState = 3; this.emit("close", { code: 1000 }); } } Object.assign(globalThis, { AudioWorkletNode: MockWorklet, WebSocket: MockSocket, AudioContext: class { state = "running"; audioWorklet = { addModule: async () => {} }; destination = {}; createMediaStreamSource() { return { connect() {}, disconnect() {} }; } }, }); Object.defineProperty(globalThis, "navigator", { configurable: true, value: { languages: ["ru-RU"], mediaDevices: { getUserMedia: async () => { await micGate; return { getTracks: () => [{ stop: () => { trackStops++; } }] }; } }, } }); async function mount(general: GeneralSettings = { language: "ru", sttModel: "microsoft/mai-transcribe-2", sttLanguage: "multi", sttPreferredLanguages: ["ru", "en"] }, question = false) { saved = general; transcripts = []; questionTranscripts = []; uploads = []; heldResponse = undefined; trackStops = 0; configured = true; micGate = undefined; MockSocket.all = []; globalThis.fetch = async (input, init) => { const url = String(input); if (url === "/api/settings") return Response.json({ settings: { general: saved } }); if (url === "/api/settings/general") { saved = JSON.parse(String(init?.body)); return Response.json({ settings: { general: saved } }); } if (url === "/api/stt/transcribe") return Response.json({ configured }); if (url === "/api/stt/token") return Response.json({ token: "test-deepgram-token" }); assert.equal(init?.method, "POST"); const parsed = new URL(url, "http://localhost"); assert.equal(parsed.pathname, "/api/stt/transcribe"); uploads.push({ url: parsed, init, audio: Buffer.from(init.body as Uint8Array) }); return new Promise((resolve, reject) => { heldResponse = resolve; init.signal?.addEventListener("abort", () => reject(new DOMException("cancelled", "AbortError")), { once: true }); }); }; host = document.createElement("div"); document.body.appendChild(host); root = createRoot(host); function Harness() { setGeneral = useI18n().saveGeneral; dictation = useDictation({ append: (text) => transcripts.push(text) }); useDictationTarget((text) => questionTranscripts.push(text), question); return ; } await act(async () => root!.render()); } async function start() { await act(async () => dictation.toggle()); assert.equal(dictation.phase, "recording"); } async function audio(bytes = [1, 0, 2, 0]) { await act(async () => MockWorklet.latest.audio(bytes)); } async function stop() { await act(async () => dictation.stop()); } async function respond(response = Response.json({ text: "Привет, hello!" })) { await act(async () => heldResponse!(response)); } afterEach(async () => { await act(async () => root?.unmount()); root = null; host?.remove(); globalThis.fetch = originalFetch; }); it("uploads exactly one entire recording after Stop, includes its tail and inserts text only after completion", async () => { await mount(); await start(); await audio(); await audio([4, 0]); assert.equal(uploads.length, 0); assert.deepEqual(transcripts, []); assert.equal(MockSocket.all.length, 0, "OpenRouter never opens a Deepgram socket"); await stop(); assert.equal(dictation.phase, "transcribing"); assert.equal(trackStops, 1, "microphone is off before uploading"); assert.equal(uploads.length, 1); assert.deepEqual([...uploads[0]!.audio.subarray(44)], [1, 0, 2, 0, 4, 0, 3, 0]); assert.equal(uploads[0]!.url.searchParams.get("model"), "microsoft/mai-transcribe-2"); assert.equal(uploads[0]!.url.searchParams.get("language"), "multi"); assert.equal(uploads[0]!.init.headers && new Headers(uploads[0]!.init.headers).get("Authorization"), null); assert(document.querySelector('[data-testid="dictation-transcribing"]')?.textContent?.includes("Распознавание…")); assert.deepEqual(transcripts, []); await respond(); assert.equal(dictation.phase, "idle"); assert.deepEqual(transcripts, ["Привет, hello!"]); assert.equal(isDictationActive(), false); }); it("pins model and language at start and uses changed settings only on the next recording", async () => { await mount(); await start(); await audio(); await act(async () => setGeneral({ sttLanguage: "en", sttModel: "deepgram/nova-3" })); await stop(); assert.equal(uploads[0]!.url.searchParams.get("language"), "multi"); assert.equal(uploads[0]!.url.searchParams.get("model"), "microsoft/mai-transcribe-2"); await respond(); await start(); assert.equal(MockSocket.all.length, 1); assert.equal(new URL(MockSocket.all[0]!.url).searchParams.get("language"), "en"); }); it("waits for the complete transcript before finishing a submit and targets a pending question", async () => { await mount(undefined, true); await start(); await audio(); let finished = false; let completion: Promise; await act(async () => { completion = finishDictation().then(() => { finished = true; }); }); assert.equal(finished, false); assert.deepEqual(questionTranscripts, []); await respond(); await completion!; assert.equal(finished, true); assert.deepEqual(transcripts, []); assert.deepEqual(questionTranscripts, ["Привет, hello!"]); }); it("cancel during recording discards audio without sending it", async () => { await mount(); await start(); await audio(); await act(async () => dictation.cancel()); assert.equal(trackStops, 1); assert.equal(uploads.length, 0); assert.equal(dictation.phase, "idle"); assert.deepEqual(transcripts, []); }); it("cancel during transcription aborts the upload and ignores a late result", async () => { await mount(); await start(); await audio(); await stop(); const signal = uploads[0]!.init.signal!; await act(async () => dictation.cancel()); assert.equal(signal.aborted, true); await respond(); assert.deepEqual(transcripts, []); assert.equal(dictation.phase, "idle"); }); it("navigation during transcription aborts the upload without inserting text", async () => { await mount(); await start(); await audio(); await stop(); await act(async () => root!.unmount()); root = null; assert.equal(uploads[0]!.init.signal!.aborted, true); assert.deepEqual(transcripts, []); assert.equal(isDictationActive(), false); }); it("shows provider failures without inserting a false transcript, then allows another recording", async () => { await mount(); await start(); await audio(); await stop(); await respond(Response.json({ message: "OpenRouter credits are insufficient" }, { status: 502 })); assert.equal(dictation.error, "OpenRouter credits are insufficient"); assert(document.querySelector('[data-testid="dictation-toggle"]')?.textContent?.includes("Недостаточно средств")); assert.deepEqual(transcripts, []); await start(); assert.equal(dictation.error, null); }); it("reports a missing OpenRouter key and shuts the microphone down", async () => { await mount(); configured = false; await act(async () => dictation.toggle()); assert.equal(dictation.phase, "idle"); assert.equal(dictation.error, "Dictation needs an OpenRouter key"); assert.equal(trackStops, 1); assert.equal(uploads.length, 0); }); it("opens the existing Add-provider editor for a missing OpenRouter key and respects Cancel", async () => { await mount(); const appFetch = globalThis.fetch; globalThis.fetch = async (input, init) => String(input) === "/api/keys" ? Response.json({ keys: [], providers: [{ id: "openrouter", envVar: "OPENROUTER_API_KEY", label: "OpenRouter" }] }) : appFetch(input, init); const render = async (initialRow?: string) => act(async () => root!.render()); await render("OPENROUTER_API_KEY"); assert(document.querySelector('[data-testid="key-openrouter-input"]')); await act(async () => document.querySelector('[data-testid="key-openrouter-cancel"]')!.click()); assert.equal(document.querySelector('[data-testid="key-openrouter-input"]'), null); await render("OPENROUTER_API_KEY"); assert.equal(document.querySelector('[data-testid="key-openrouter-input"]'), null, "ordinary renders must not reopen a cancelled editor"); await render(undefined); await render("OPENROUTER_API_KEY"); assert(document.querySelector('[data-testid="key-openrouter-input"]'), "a new deep-link opens the editor again"); }); it("does not duplicate uploads when Stop is pressed twice before rendering", async () => { await mount(); await start(); await audio(); await act(async () => { dictation.stop(); dictation.stop(); }); assert.equal(uploads.length, 1); await respond(); assert.deepEqual(transcripts, ["Привет, hello!"]); }); it("stops oversized capture with an explicit error and never uploads a partial recording", async () => { await mount(); await start(); await act(async () => MockWorklet.latest.port.onmessage({ data: { type: "audio", buffer: new Uint8Array(STT_MAX_AUDIO_BYTES).buffer } })); assert.equal(dictation.phase, "idle"); assert.match(dictation.error!, /25 MB/); assert.equal(trackStops, 1); assert.equal(uploads.length, 0); }); it("cancels safely while permission is pending and stops a late microphone stream", async () => { await mount(); let allowMic: () => void; micGate = new Promise((resolve) => { allowMic = resolve; }); await act(async () => dictation.toggle()); assert.equal(dictation.phase, "connecting"); await stop(); await act(async () => allowMic!()); assert.equal(trackStops, 1); assert.equal(uploads.length, 0); assert.equal(dictation.phase, "idle"); }); it("keeps Deepgram streaming audio during recording and appending its finals after Stop", async () => { await mount({ sttModel: "deepgram/nova-3", sttLanguage: "multi", sttPreferredLanguages: ["ru", "en"] }); await start(); await audio(); const socket = MockSocket.all[0]!; assert.equal(socket.sent.length, 1); assert.deepEqual(new URL(socket.url).searchParams.getAll("language_hint"), ["ru", "en"]); assert.equal(uploads.length, 0); assert.deepEqual(transcripts, []); await stop(); assert.deepEqual(transcripts, ["Deepgram final"]); assert.equal(dictation.phase, "idle"); });