257 lines
13 KiB
TypeScript
257 lines
13 KiB
TypeScript
import React, { act } from "react";
|
|
(globalThis as Record<string, unknown>).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("<html><body></body></html>");
|
|
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<GeneralSettings>) => Promise<void>;
|
|
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<void> | 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<string, ((event: any) => 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<Response>((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 <DictationBar dictation={dictation} />;
|
|
}
|
|
await act(async () => root!.render(<I18nProvider initialGeneral={general}><Harness /></I18nProvider>));
|
|
}
|
|
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<void>;
|
|
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(<I18nProvider initialGeneral={saved}><KeysSection initialRow={initialRow} /></I18nProvider>));
|
|
await render("OPENROUTER_API_KEY");
|
|
assert(document.querySelector('[data-testid="key-openrouter-input"]'));
|
|
await act(async () => document.querySelector<HTMLElement>('[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<void>((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");
|
|
});
|