Merge upstream v0.4.0 into Gitea fork
This commit is contained in:
@@ -0,0 +1,390 @@
|
||||
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<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((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<string, unknown>;
|
||||
let restore: (() => void) | undefined;
|
||||
let dispose: (() => Promise<void>) | undefined;
|
||||
|
||||
async function fixture(general: GeneralSettings = { sttLanguage: "ru" }) {
|
||||
const dom = parseHTML("<html><body><div id='root'></div></body></html>");
|
||||
const state = {
|
||||
// The contradictory upstream preference must never control recording.
|
||||
record: { version: 1, general, dictation: { language: "pl" } } as UserSettings,
|
||||
nextRead: undefined as Promise<Response> | undefined,
|
||||
nextWrite: undefined as Promise<Response> | undefined,
|
||||
nextMic: undefined as Promise<{ getTracks: () => { stop: () => void }[] }> | undefined,
|
||||
nextToken: undefined as (() => Promise<Response>) | undefined,
|
||||
readFails: false,
|
||||
reads: 0,
|
||||
active: false,
|
||||
captures: 0,
|
||||
stops: 0,
|
||||
appends: [] as string[],
|
||||
};
|
||||
Socket.all = [];
|
||||
Worklet.autoFlush = true;
|
||||
const previous = new Map<string, PropertyDescriptor | undefined>();
|
||||
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<typeof useI18n>;
|
||||
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(<I18nProvider initialGeneral={state.record.general}><Harness /></I18nProvider>); });
|
||||
};
|
||||
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<void>;
|
||||
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<Response>(); f.state.nextWrite = write.promise;
|
||||
let saved!: Promise<void>;
|
||||
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<Response>(); f.state.nextWrite = write.promise;
|
||||
let saved!: Promise<void>;
|
||||
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<Response>(); 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<void>; 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<void>;
|
||||
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<void>;
|
||||
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<void>;
|
||||
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<Response>(); 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<void>;
|
||||
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);
|
||||
});
|
||||
Reference in New Issue
Block a user