import React, { act } from "react"; import assert from "node:assert/strict"; import { afterEach, test } from "node:test"; import { parseHTML } from "linkedom"; import { finishDictation, useDictation, type Dictation } from "../components/dictation.tsx"; import { I18nProvider, useI18n } from "../components/i18n.tsx"; import { notifySettingsSaved } from "../components/settings-signal.ts"; import { isDictationActive, useDictationActive } from "../components/dictation-state.ts"; import type { GeneralSettings, UserSettings } from "../src/core/index.ts"; function deferred() { let resolve!: (value: T) => void; const promise = new Promise((done) => { resolve = done; }); return { promise, resolve }; } class Socket extends EventTarget { static CONNECTING = 0; static OPEN = 1; static all: Socket[] = []; readyState = 0; sent: unknown[] = []; constructor(public url: string) { super(); Socket.all.push(this); } get language() { return new URL(this.url).searchParams.get("language"); } open() { this.readyState = 1; this.dispatchEvent(new Event("open")); } send(value: unknown) { this.sent.push(value); } close() { this.readyState = 3; } end(code = 1000) { this.close(); this.dispatchEvent(Object.assign(new Event("close"), { code })); } final(text: string) { this.dispatchEvent(Object.assign(new Event("message"), { data: JSON.stringify({ type: "Results", is_final: true, channel: { alternatives: [{ transcript: text }] } }), })); } } class AudioGraph { state = "running"; destination = {}; audioWorklet = { addModule: async () => {} }; createMediaStreamSource() { return { connect() {}, disconnect() {} }; } } class Worklet { static latest: Worklet; static autoFlush = true; disconnected = false; port = { onmessage: (_event: { data: unknown }) => {}, postMessage: (_message: unknown) => { if (Worklet.autoFlush) queueMicrotask(() => this.flush()); }, }; constructor() { Worklet.latest = this; } flush() { this.port.onmessage({ data: { type: "audio", buffer: new Int16Array([7]).buffer } }); this.port.onmessage({ data: { type: "flushed" } }); } connect() {} disconnect() { this.disconnected = true; } } const globals = globalThis as Record; let restore: (() => void) | undefined; let dispose: (() => Promise) | undefined; async function fixture(general: GeneralSettings = { sttLanguage: "ru" }) { const dom = parseHTML("
"); const state = { // The contradictory upstream preference must never control recording. record: { version: 1, general, dictation: { language: "pl" } } as UserSettings, nextRead: undefined as Promise | undefined, nextWrite: undefined as Promise | undefined, nextMic: undefined as Promise<{ getTracks: () => { stop: () => void }[] }> | undefined, nextToken: undefined as (() => Promise) | undefined, readFails: false, reads: 0, active: false, captures: 0, stops: 0, appends: [] as string[], }; Socket.all = []; Worklet.autoFlush = true; const previous = new Map(); for (const [key, value] of Object.entries({ React, window: dom.window, document: dom.document, CustomEvent: dom.window.CustomEvent, navigator: { userAgent: "node", languages: ["en-US"], mediaDevices: { getUserMedia: async () => { state.captures++; if (state.nextMic) return state.nextMic; return { getTracks: () => [{ stop() { state.stops++; } }] }; } } }, WebSocket: Socket, AudioContext: AudioGraph, AudioWorkletNode: Worklet, IS_REACT_ACT_ENVIRONMENT: true, fetch: async (url: string, init?: RequestInit) => { if (url === "/api/stt/token") return state.nextToken ? state.nextToken() : Response.json({ token: "test-token" }); if (url === "/api/settings") { state.reads++; if (state.nextRead) { const response = state.nextRead; state.nextRead = undefined; return response; } return state.readFails ? Response.json({}, { status: 500 }) : Response.json({ settings: state.record }); } assert.equal(url, "/api/settings/general"); assert.equal(init?.method, "PUT"); if (state.nextWrite) { const response = state.nextWrite; state.nextWrite = undefined; return response; } state.record = { ...state.record, general: JSON.parse(init!.body as string) }; return Response.json({ settings: state.record }); }, })) { previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key)); Object.defineProperty(globalThis, key, { configurable: true, writable: true, value }); } restore = () => { for (const [key, descriptor] of previous) { if (descriptor) Object.defineProperty(globalThis, key, descriptor); else delete globals[key]; } }; const { createRoot } = await import("react-dom/client"); let latest!: Dictation; let setting!: ReturnType; function Harness() { latest = useDictation({ append: (text) => state.appends.push(text) }); setting = useI18n(); state.active = useDictationActive(); return null; } let root = createRoot(document.getElementById("root")!); const mount = async () => { await act(async () => { root.render(); }); }; dispose = async () => { await act(async () => root.unmount()); }; return { state, get dictation() { return latest; }, get setting() { return setting; }, mount, unmount: () => dispose!(), remount: async () => { await dispose!(); root = createRoot(document.getElementById("root")!); await mount(); }, }; } afterEach(async () => { await dispose?.(); restore?.(); dispose = undefined; restore = undefined; }); test("general language and hints are snapshotted; reconnect and final delivery retain them", async () => { const f = await fixture({ sttLanguage: "multi", sttPreferredLanguages: ["ru", "en"] }); await f.mount(); await act(async () => { f.dictation.toggle(); }); assert.equal(f.state.active, true); const first = Socket.all[0]!; assert.equal(first.language, "multi"); assert.deepEqual(new URL(first.url).searchParams.getAll("language_hint"), ["ru", "en"]); await act(async () => { first.open(); first.final("Первое"); }); await act(async () => { await f.setting.saveGeneral({ sttLanguage: "en", sttPreferredLanguages: [] }); }); await act(async () => { first.end(1006); }); const retry = Socket.all[1]!; assert.equal(retry.url, first.url); await act(async () => { retry.open(); retry.final("second"); }); let finished = false; let done!: Promise; await act(async () => { done = f.dictation.finish().then(() => { finished = true; }); }); assert.equal(finished, false); assert.deepEqual(f.state.appends, []); assert.equal(retry.sent.at(-1), '{"type":"CloseStream"}'); assert(retry.sent.at(-2) instanceof ArrayBuffer, "the final PCM frame precedes CloseStream"); await act(async () => { retry.final("last"); retry.end(); await done; }); assert.deepEqual(f.state.appends, ["Первое second last"]); assert.equal(f.state.active, false); await act(async () => { retry.final("stale"); retry.end(); }); assert.deepEqual(f.state.appends, ["Первое second last"]); await act(async () => { f.dictation.toggle(); }); assert.equal(Socket.all[2]!.language, "en"); }); for (const [selection, expected] of [[undefined, "en"], ["en", "en"], ["ru", "ru"], ["multi", "multi"]] as const) { test(`fork language ${selection ?? "system"} ignores contradictory dictation.language`, async () => { const f = await fixture({ sttLanguage: selection }); await f.mount(); await act(async () => { f.dictation.toggle(); }); assert.equal(Socket.all[0]!.language, expected); assert.equal(f.state.reads, 0, "recording does not load an independent preference"); }); } test("a failed general save retains the confirmed language, including a shortcut during saving", async () => { const f = await fixture(); await f.mount(); const write = deferred(); f.state.nextWrite = write.promise; let saved!: Promise; await act(async () => { saved = f.setting.saveGeneral({ sttLanguage: "multi" }); saved.catch(() => {}); }); await act(async () => { window.dispatchEvent(new window.Event("lh:kbd-toggle-dictation")); }); assert.equal(Socket.all[0]!.language, "ru"); await act(async () => { write.resolve(Response.json({}, { status: 500 })); await assert.rejects(saved, /Could not save/); }); assert.equal(f.setting.sttLanguage, "ru"); }); test("confirmed general choices survive navigation and a failed refresh does not substitute upstream defaults", async () => { const f = await fixture(); await f.mount(); await act(async () => { await f.setting.saveGeneral({ sttLanguage: "multi" }); }); await f.remount(); f.state.readFails = true; await act(async () => { notifySettingsSaved("general"); }); await act(async () => { f.dictation.toggle(); }); assert.equal(Socket.all[0]!.language, "multi"); assert.equal(f.dictation.error, null); }); test("capture uses the confirmed general choice while a save is pending and the new one on the next run", async () => { const f = await fixture(); await f.mount(); const write = deferred(); f.state.nextWrite = write.promise; let saved!: Promise; await act(async () => { saved = f.setting.saveGeneral({ sttLanguage: "multi" }); }); await act(async () => { f.dictation.toggle(); }); assert.equal(Socket.all[0]!.language, "ru"); f.state.record.general = { sttLanguage: "multi" }; await act(async () => { write.resolve(Response.json({ settings: f.state.record })); await saved; }); await act(async () => { f.dictation.cancel(); f.dictation.toggle(); }); assert.equal(Socket.all[1]!.language, "multi"); }); test("a stale general refresh cannot overwrite a newer confirmed choice", async () => { const f = await fixture(); await f.mount(); const read = deferred(); f.state.nextRead = read.promise; await act(async () => { notifySettingsSaved("general"); }); await act(async () => { await f.setting.saveGeneral({ sttLanguage: "multi" }); }); await act(async () => { read.resolve(Response.json({ settings: { general: { sttLanguage: "ru" } } })); }); assert.equal(f.setting.sttLanguage, "multi"); await act(async () => { f.dictation.toggle(); }); assert.equal(Socket.all[0]!.language, "multi"); }); test("cancel while microphone permission is pending prevents orphaned capture", async () => { const f = await fixture(); await f.mount(); const mic = deferred<{ getTracks: () => { stop: () => void }[] }>(); f.state.nextMic = mic.promise; await act(async () => { f.dictation.toggle(); f.dictation.cancel(); }); await act(async () => { mic.resolve({ getTracks: () => [{ stop() { f.state.stops++; } }] }); }); assert.equal(f.state.stops, 1); assert.equal(Socket.all.length, 0); assert.equal(isDictationActive(), false); }); test("finish in the start turn cancels a pending microphone without hanging", async () => { const f = await fixture(); await f.mount(); const mic = deferred<{ getTracks: () => { stop: () => void }[] }>(); f.state.nextMic = mic.promise; await act(async () => { f.dictation.toggle(); await finishDictation(); }); assert.equal(f.dictation.phase, "idle"); await act(async () => { mic.resolve({ getTracks: () => [{ stop() { f.state.stops++; } }] }); }); assert.equal(f.state.stops, 1); assert.equal(Socket.all.length, 0); }); test("Stop while the socket connects drains PCM and waits for the final transcript", async () => { const f = await fixture(); await f.mount(); await act(async () => { f.dictation.toggle(); }); const socket = Socket.all[0]!; let done!: Promise; let finished = false; await act(async () => { done = finishDictation().then(() => { finished = true; }); }); assert.equal(f.dictation.phase, "stopping"); assert.equal(finished, false); await act(async () => { socket.open(); }); assert.equal(f.dictation.phase, "stopping"); assert(socket.sent[0] instanceof ArrayBuffer); assert.equal(socket.sent[1], '{"type":"CloseStream"}'); await act(async () => { socket.final("tail"); socket.end(); await done; }); assert.deepEqual(f.state.appends, ["tail"]); }); test("a socket opening during drain cannot close the stream before the tail PCM", async () => { const f = await fixture(); await f.mount(); Worklet.autoFlush = false; await act(async () => { f.dictation.toggle(); }); const socket = Socket.all[0]!; let done!: Promise; await act(async () => { done = finishDictation(); socket.open(); }); assert.equal(socket.sent.length, 0); assert.equal(f.dictation.phase, "stopping"); await act(async () => { Worklet.latest.flush(); }); assert(socket.sent[0] instanceof ArrayBuffer); assert.equal(socket.sent[1], '{"type":"CloseStream"}'); await act(async () => { socket.final("complete"); socket.end(); await done; }); assert.deepEqual(f.state.appends, ["complete"]); }); test("a reconnect while stopping retains its session and still finishes exactly once", async () => { const f = await fixture(); await f.mount(); await act(async () => { f.dictation.toggle(); }); const socket = Socket.all[0]!; await act(async () => { socket.open(); socket.final("before"); }); let done!: Promise; await act(async () => { done = finishDictation(); }); await act(async () => { socket.end(1006); }); const retry = Socket.all[1]!; assert.equal(retry.url, socket.url); await act(async () => { retry.open(); }); assert.equal(retry.sent.at(-1), '{"type":"CloseStream"}'); assert.equal(f.dictation.phase, "stopping"); await act(async () => { retry.final("after"); retry.end(); await done; }); assert.deepEqual(f.state.appends, ["before after"]); assert.equal(f.state.active, false); }); test("cancel during drain resolves finish and ignores late worklet/socket messages", async () => { const f = await fixture(); await f.mount(); Worklet.autoFlush = false; await act(async () => { f.dictation.toggle(); }); await act(async () => { Socket.all[0]!.open(); Socket.all[0]!.final("discard"); }); let done!: Promise; await act(async () => { done = f.dictation.finish(); f.dictation.cancel(); await done; }); await act(async () => { Worklet.latest.flush(); Socket.all[0]!.final("late"); Socket.all[0]!.end(1006); }); assert.deepEqual(f.state.appends, []); assert.equal(Socket.all.length, 1); assert.equal(f.state.active, false); }); test("unmount during a pending general refresh prevents orphaned capture", async () => { const f = await fixture(); await f.mount(); const read = deferred(); f.state.nextRead = read.promise; await act(async () => { notifySettingsSaved("general"); }); await f.unmount(); await act(async () => { read.resolve(Response.json({ settings: f.state.record })); }); assert.equal(f.state.captures, 0); assert.equal(Socket.all.length, 0); assert.equal(isDictationActive(), false); }); test("unmount releases an active stream/socket and discards deferred finals", async () => { const f = await fixture(); await f.mount(); await act(async () => { f.dictation.toggle(); }); const socket = Socket.all[0]!; await act(async () => { socket.open(); socket.final("do not append into another page"); }); await f.unmount(); assert.equal(f.state.stops, 1); assert.equal(socket.readyState, 3); assert.equal(isDictationActive(), false); assert.deepEqual(f.state.appends, []); await act(async () => { socket.final("late event"); socket.end(1006); }); assert.equal(Socket.all.length, 1); assert.deepEqual(f.state.appends, []); }); test("system STT follows the fork's detected Russian locale, not dictation.language", async () => { const f = await fixture({}); Object.defineProperty(globalThis, "navigator", { configurable: true, value: { userAgent: "node", languages: ["ru-RU"], mediaDevices: { getUserMedia: async () => ({ getTracks: () => [{ stop() { f.state.stops++; } }] }) }, } }); await f.mount(); await act(async () => { f.dictation.toggle(); }); assert.equal(Socket.all[0]!.language, "ru"); }); test("a failed token request releases capture and leaves an honest error", async () => { const f = await fixture(); f.state.nextToken = async () => Response.json({ message: "token denied" }, { status: 500 }); await f.mount(); await act(async () => { f.dictation.toggle(); }); assert.equal(f.dictation.error, "token denied"); assert.equal(f.dictation.phase, "idle"); assert.equal(f.state.stops, 1); assert.equal(Socket.all.length, 0); assert.equal(isDictationActive(), false); }); test("one reconnect is allowed and a second socket failure ends the run", async () => { const f = await fixture(); await f.mount(); await act(async () => { f.dictation.toggle(); }); await act(async () => { Socket.all[0]!.open(); Socket.all[0]!.final("kept"); Socket.all[0]!.end(1006); }); assert.equal(Socket.all.length, 2); await act(async () => { Socket.all[1]!.end(1006); }); assert.equal(f.dictation.error, "Deepgram connection lost (1006)"); assert.equal(f.dictation.phase, "idle"); assert.deepEqual(f.state.appends, ["kept"]); assert.equal(f.state.stops, 1); }); test("a missing drain acknowledgement fails visibly and resolves submit waiters", async () => { const f = await fixture(); await f.mount(); Worklet.autoFlush = false; await act(async () => { f.dictation.toggle(); }); await act(async () => { Socket.all[0]!.open(); }); let done!: Promise; await act(async () => { done = finishDictation(); }); await act(async () => { await done; }); assert.equal(f.dictation.error, "Could not finish microphone capture"); assert.equal(f.dictation.phase, "idle"); assert.equal(Worklet.latest.disconnected, true); assert.equal(isDictationActive(), false); }); test("microphone permission resolving after unmount immediately stops the late stream", async () => { const f = await fixture(); await f.mount(); const mic = deferred<{ getTracks: () => { stop: () => void }[] }>(); f.state.nextMic = mic.promise; await act(async () => { f.dictation.toggle(); }); await f.unmount(); await act(async () => { mic.resolve({ getTracks: () => [{ stop() { f.state.stops++; } }] }); }); assert.equal(f.state.stops, 1); assert.equal(Socket.all.length, 0); assert.equal(isDictationActive(), false); });