Files
learnharness/tests/dictation-openrouter.test.tsx
T

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");
});