evOo-local/apps/web/src/lib/dictation.test.ts
cynarAI 6c7eed7580 Initial clean evOo-local from f00577b
Fresh history cut from cynarAI/evoo antigravity-handoff/project-timeline
(Voice First + project timeline). Brand docs/titles as evOo; keep @rakazo/*
packages as tech debt. Excludes node_modules, caches, secrets, voice blobs.
2026-09-20 17:15:16 +02:00

665 lines
20 KiB
TypeScript

import { afterEach, describe, expect, it, vi } from "vitest";
import {
Dictation,
MAX_TRANSCRIPTION_RESPONSE_BYTES,
TRANSCRIPTION_RESPONSE_TIMEOUT_MS,
} from "./dictation.js";
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
vi.useRealTimers();
});
function stubRecorderFallback(fetchMock: ReturnType<typeof vi.fn>) {
const track = { stop: vi.fn() };
vi.stubGlobal("navigator", {
mediaDevices: {
getUserMedia: vi.fn(async () => ({ getTracks: () => [track] })),
},
language: "en-US",
});
vi.stubGlobal("fetch", fetchMock);
vi.stubGlobal(
"MediaRecorder",
class {
state = "inactive";
ondataavailable: ((event: { data: Blob }) => void) | null = null;
onstop: (() => void) | null = null;
start() {
this.state = "recording";
}
stop() {
this.state = "inactive";
this.ondataavailable?.({ data: new Blob(["audio"], { type: "audio/webm" }) });
this.onstop?.();
}
},
);
}
describe("Dictation recorder fallback", () => {
it("stops tracks if hold-to-talk is cancelled while the mic prompt is open", async () => {
const track = { stop: vi.fn() };
let grant!: (stream: { getTracks: () => Array<{ stop: () => void }> }) => void;
const pending = new Promise<{ getTracks: () => Array<{ stop: () => void }> }>((resolve) => {
grant = resolve;
});
vi.stubGlobal("navigator", {
mediaDevices: { getUserMedia: vi.fn(() => pending) },
language: "en-US",
});
const started = vi.fn();
vi.stubGlobal(
"MediaRecorder",
class {
state = "inactive";
start() {
started();
this.state = "recording";
}
stop() {
this.state = "inactive";
}
},
);
const dictation = new Dictation();
const listening = dictation.listen({
mode: "hold",
transcribe: true,
onFinal: () => undefined,
});
dictation.stop("cancel");
grant({ getTracks: () => [track] });
await listening;
expect(track.stop).toHaveBeenCalledOnce();
expect(started).not.toHaveBeenCalled();
expect(dictation.state.status).toBe("idle");
});
it("does not deliver a stale transcript to a newer session", async () => {
const track = { stop: vi.fn() };
vi.stubGlobal("navigator", {
mediaDevices: {
getUserMedia: vi.fn(async () => ({ getTracks: () => [track] })),
},
language: "en-US",
});
let transcribe!: (body: { text: string }) => void;
const fetchMock = vi.fn(
(_url: string, init?: { signal?: AbortSignal }) =>
new Promise<Response>((resolve, reject) => {
init?.signal?.addEventListener("abort", () =>
reject(Object.assign(new Error("Aborted"), { name: "AbortError" })),
);
transcribe = (body) => resolve(Response.json(body));
}),
);
vi.stubGlobal("fetch", fetchMock);
class FakeRecorder {
state = "inactive";
ondataavailable: ((event: { data: Blob }) => void) | null = null;
onstop: (() => void) | null = null;
start() {
this.state = "recording";
}
stop() {
this.state = "inactive";
this.ondataavailable?.({ data: new Blob(["audio"], { type: "audio/webm" }) });
this.onstop?.();
}
}
vi.stubGlobal("MediaRecorder", FakeRecorder);
const first = vi.fn();
const second = vi.fn();
const dictation = new Dictation();
await dictation.listen({ mode: "hold", transcribe: true, onFinal: first });
dictation.submitHold();
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledOnce());
await dictation.listen({ mode: "hold", transcribe: true, onFinal: second });
transcribe({ text: "stale take" });
await Promise.resolve();
await Promise.resolve();
expect(first).not.toHaveBeenCalled();
expect(second).not.toHaveBeenCalled();
expect(dictation.state.status).toBe("listening");
});
it("keeps transcription in the space where recording started", async () => {
const store = new Map<string, string>([["rakazo:space-id", "space-support"]]);
const localStorage = {
getItem: (key: string) => store.get(key) ?? null,
setItem: (key: string, value: string) => {
store.set(key, value);
},
removeItem: (key: string) => {
store.delete(key);
},
};
vi.stubGlobal("window", { localStorage });
vi.stubGlobal("localStorage", localStorage);
const track = { stop: vi.fn() };
vi.stubGlobal("navigator", {
mediaDevices: {
getUserMedia: vi.fn(async () => ({ getTracks: () => [track] })),
},
language: "en-US",
});
const fetchMock = vi.fn(async (_url: string, _init?: RequestInit) =>
Response.json({ text: "hello" }),
);
vi.stubGlobal("fetch", fetchMock);
class FakeRecorder {
state = "inactive";
ondataavailable: ((event: { data: Blob }) => void) | null = null;
onstop: (() => void) | null = null;
start() {
this.state = "recording";
}
stop() {
this.state = "inactive";
this.ondataavailable?.({ data: new Blob(["audio"], { type: "audio/webm" }) });
this.onstop?.();
}
}
vi.stubGlobal("MediaRecorder", FakeRecorder);
const onFinal = vi.fn();
const dictation = new Dictation();
await dictation.listen({ mode: "hold", transcribe: true, onFinal });
store.set("rakazo:space-id", "space-other");
dictation.submitHold();
await vi.waitFor(() => expect(onFinal).toHaveBeenCalledWith("hello"));
expect(fetchMock).toHaveBeenCalledWith(
"/api/voice/transcribe",
expect.objectContaining({ credentials: "include" }),
);
const headers = new Headers(fetchMock.mock.calls[0]?.[1]?.headers);
expect(headers.get("x-rakazo-space-id")).toBe("space-support");
expect(headers.get("content-type")).toBe("application/json");
});
it("rejects oversized transcription responses without waiting for cancellation", async () => {
const cancel = vi.fn(() => new Promise<void>(() => undefined));
const fetchMock = vi.fn(
async () =>
new Response(new ReadableStream({ cancel }), {
headers: { "content-length": String(MAX_TRANSCRIPTION_RESPONSE_BYTES + 1) },
}),
);
stubRecorderFallback(fetchMock);
const dictation = new Dictation();
await dictation.listen({ mode: "hold", transcribe: true, onFinal: () => undefined });
dictation.submitHold();
await vi.waitFor(() =>
expect(dictation.state.error).toBe("Transcription response is too large."),
);
expect(cancel).toHaveBeenCalledOnce();
});
it("times out while a transcription response body is stalled", async () => {
vi.useFakeTimers();
const fetchMock = vi.fn(
async () =>
new Response(
new ReadableStream({
pull: () => new Promise<void>(() => undefined),
}),
),
);
stubRecorderFallback(fetchMock);
const dictation = new Dictation();
await dictation.listen({ mode: "hold", transcribe: true, onFinal: () => undefined });
dictation.submitHold();
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledOnce());
await vi.advanceTimersByTimeAsync(TRANSCRIPTION_RESPONSE_TIMEOUT_MS);
expect(dictation.state.error).toBe("Transcription request timed out.");
});
it("surfaces timeout when fetch rejects with AbortError", async () => {
// Browsers reject aborted fetch with DOMException AbortError. Matching token
// means the deadline fired (stop() bumps token first), so this must not stay
// stuck on "transcribing".
const fetchMock = vi.fn(async () => {
throw new DOMException("The operation was aborted.", "AbortError");
});
stubRecorderFallback(fetchMock);
const dictation = new Dictation();
await dictation.listen({ mode: "hold", transcribe: true, onFinal: () => undefined });
dictation.submitHold();
await vi.waitFor(() => expect(dictation.state.error).toBe("Transcription request timed out."));
expect(dictation.state.status).toBe("idle");
});
it("stays silent when stop cancels an in-flight transcription", async () => {
const fetchMock = vi.fn(
(_url: string, init?: { signal?: AbortSignal }) =>
new Promise<Response>((_resolve, reject) => {
init?.signal?.addEventListener(
"abort",
() => {
reject(new DOMException("The operation was aborted.", "AbortError"));
},
{ once: true },
);
}),
);
stubRecorderFallback(fetchMock);
const dictation = new Dictation();
await dictation.listen({ mode: "hold", transcribe: true, onFinal: () => undefined });
dictation.submitHold();
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledOnce());
expect(dictation.state.status).toBe("transcribing");
dictation.stop("cancel");
await Promise.resolve();
await Promise.resolve();
expect(dictation.state.status).toBe("idle");
expect(dictation.state.error).toBeUndefined();
});
it("ignores leftover audio from a replaced recorder", async () => {
const track = { stop: vi.fn() };
vi.stubGlobal("navigator", {
mediaDevices: {
getUserMedia: vi.fn(async () => ({ getTracks: () => [track] })),
},
language: "en-US",
});
const fetchMock = vi.fn(async (_url: string, _init?: { body?: string }) =>
Response.json({ text: "ok" }),
);
vi.stubGlobal("fetch", fetchMock);
const recorders: Array<{
ondataavailable: ((event: { data: Blob }) => void) | null;
onstop: (() => void) | null;
stop: () => void;
}> = [];
class FakeRecorder {
state = "inactive";
ondataavailable: ((event: { data: Blob }) => void) | null = null;
onstop: (() => void) | null = null;
constructor() {
recorders.push(this);
}
start() {
this.state = "recording";
}
stop() {
this.state = "inactive";
this.ondataavailable?.({ data: new Blob(["new"], { type: "audio/webm" }) });
this.onstop?.();
}
}
vi.stubGlobal("MediaRecorder", FakeRecorder);
const dictation = new Dictation();
await dictation.listen({ mode: "hold", transcribe: true, onFinal: () => undefined });
await dictation.listen({ mode: "hold", transcribe: true, onFinal: () => undefined });
recorders[0]?.ondataavailable?.({
data: new Blob(["STALE-AUDIO-DATA"], { type: "audio/webm" }),
});
dictation.submitHold();
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledOnce());
const body = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body ?? "")) as {
audioBase64: string;
};
const decoded = atob(body.audioBase64);
expect(decoded).toContain("new");
expect(decoded).not.toContain("STALE");
});
it("fails visibly when endpoint dictation has no silence detector", async () => {
const track = { stop: vi.fn() };
vi.stubGlobal("navigator", {
mediaDevices: {
getUserMedia: vi.fn(async () => ({ getTracks: () => [track] })),
},
language: "en-US",
});
const started = vi.fn();
vi.stubGlobal(
"MediaRecorder",
class {
start() {
started();
}
},
);
const dictation = new Dictation();
await dictation.listen({
mode: "endpoint",
transcribe: true,
onFinal: () => undefined,
});
expect(started).not.toHaveBeenCalled();
expect(track.stop).toHaveBeenCalledOnce();
expect(dictation.state.error).toMatch(/stop talking/i);
});
it("stops an endpoint recording after silence", async () => {
vi.useFakeTimers({ toFake: ["setInterval", "clearInterval"] });
const level = { current: 0.6 };
class FakeAnalyser {
fftSize = 2048;
getByteTimeDomainData(data: Uint8Array) {
data.fill(Math.round(128 + level.current * 127));
}
}
class FakeContext {
state = "running";
createMediaStreamSource() {
return { connect() {} };
}
createAnalyser() {
return new FakeAnalyser();
}
close() {
return Promise.resolve();
}
resume() {
return Promise.resolve();
}
}
vi.stubGlobal("AudioContext", FakeContext);
const track = { stop: vi.fn() };
vi.stubGlobal("navigator", {
mediaDevices: {
getUserMedia: vi.fn(async () => ({ getTracks: () => [track] })),
},
language: "en-US",
});
const fetchMock = vi.fn(async () => Response.json({ text: "hello" }));
vi.stubGlobal("fetch", fetchMock);
const onFinal = vi.fn();
class FakeRecorder {
state = "inactive";
ondataavailable: ((event: { data: Blob }) => void) | null = null;
onstop: (() => void) | null = null;
start() {
this.state = "recording";
}
stop() {
this.state = "inactive";
this.ondataavailable?.({ data: new Blob(["audio"], { type: "audio/webm" }) });
this.onstop?.();
}
}
vi.stubGlobal("MediaRecorder", FakeRecorder);
const dictation = new Dictation();
await dictation.listen({
mode: "endpoint",
transcribe: true,
endpointMs: 240,
onFinal,
});
expect(dictation.state.status).toBe("listening");
await vi.advanceTimersByTimeAsync(80);
level.current = 0;
await vi.advanceTimersByTimeAsync(240);
await vi.waitFor(() => expect(onFinal).toHaveBeenCalledWith("hello"));
vi.useRealTimers();
});
it("does not let a replaced recorder tear down the new silence detector", async () => {
vi.useFakeTimers({ toFake: ["setInterval", "clearInterval"] });
const level = { current: 0.6 };
class FakeAnalyser {
fftSize = 2048;
getByteTimeDomainData(data: Uint8Array) {
data.fill(Math.round(128 + level.current * 127));
}
}
class FakeContext {
state = "running";
createMediaStreamSource() {
return { connect() {} };
}
createAnalyser() {
return new FakeAnalyser();
}
close() {
return Promise.resolve();
}
resume() {
return Promise.resolve();
}
}
vi.stubGlobal("AudioContext", FakeContext);
const track = { stop: vi.fn() };
vi.stubGlobal("navigator", {
mediaDevices: {
getUserMedia: vi.fn(async () => ({ getTracks: () => [track] })),
},
language: "en-US",
});
const fetchMock = vi.fn(async () => Response.json({ text: "later" }));
vi.stubGlobal("fetch", fetchMock);
const recorders: Array<{
onstop: (() => void) | null;
stop: () => void;
}> = [];
class FakeRecorder {
state = "inactive";
ondataavailable: ((event: { data: Blob }) => void) | null = null;
onstop: (() => void) | null = null;
constructor() {
recorders.push(this);
}
start() {
this.state = "recording";
}
stop() {
this.state = "inactive";
this.ondataavailable?.({ data: new Blob(["audio"], { type: "audio/webm" }) });
this.onstop?.();
}
}
vi.stubGlobal("MediaRecorder", FakeRecorder);
const onFinal = vi.fn();
const dictation = new Dictation();
await dictation.listen({
mode: "endpoint",
transcribe: true,
endpointMs: 240,
onFinal,
});
const staleStop = recorders[0]?.onstop;
await dictation.listen({
mode: "endpoint",
transcribe: true,
endpointMs: 240,
onFinal,
});
staleStop?.();
await vi.advanceTimersByTimeAsync(80);
level.current = 0;
await vi.advanceTimersByTimeAsync(240);
await vi.waitFor(() => expect(onFinal).toHaveBeenCalledWith("later"));
vi.useRealTimers();
});
});
function stubWebSpeechMic(level = { current: 0.6 }) {
const track = { stop: vi.fn() };
vi.stubGlobal("navigator", {
mediaDevices: {
getUserMedia: vi.fn(async () => ({ getTracks: () => [track] })),
},
language: "en-US",
});
class FakeAnalyser {
fftSize = 2048;
getByteTimeDomainData(data: Uint8Array) {
data.fill(Math.round(128 + level.current * 127));
}
}
class FakeContext {
state = "running";
createMediaStreamSource() {
return { connect() {} };
}
createAnalyser() {
return new FakeAnalyser();
}
close() {
return Promise.resolve();
}
resume() {
return Promise.resolve();
}
}
vi.stubGlobal("AudioContext", FakeContext);
return { track };
}
describe("Dictation web speech", () => {
it("restarts endpoint recognition after a quiet end", async () => {
const instances: FakeRecognition[] = [];
class FakeRecognition {
continuous = false;
interimResults = false;
lang = "";
onresult: ((event: unknown) => void) | null = null;
onerror: ((event: { error?: string }) => void) | null = null;
onend: (() => void) | null = null;
start = vi.fn();
stop = vi.fn();
abort = vi.fn();
constructor() {
instances.push(this);
}
}
vi.stubGlobal("window", { SpeechRecognition: FakeRecognition });
stubWebSpeechMic();
const dictation = new Dictation();
await dictation.listen({ mode: "endpoint", onFinal: () => undefined });
const rec = instances[0];
expect(rec?.start).toHaveBeenCalledOnce();
rec?.onend?.();
expect(rec?.start).toHaveBeenCalledTimes(2);
expect(dictation.state.status).toBe("listening");
});
it("requests microphone access before starting web speech", async () => {
class FakeRecognition {
start = vi.fn();
stop = vi.fn();
abort = vi.fn();
}
vi.stubGlobal("window", { SpeechRecognition: FakeRecognition });
const getUserMedia = vi.fn(async () => ({ getTracks: () => [{ stop: vi.fn() }] }));
vi.stubGlobal("navigator", {
mediaDevices: { getUserMedia },
language: "en-US",
});
vi.stubGlobal(
"AudioContext",
class {
state = "running";
createMediaStreamSource() {
return { connect() {} };
}
createAnalyser() {
return { fftSize: 2048, getByteTimeDomainData: (data: Uint8Array) => data.fill(128) };
}
close() {
return Promise.resolve();
}
resume() {
return Promise.resolve();
}
},
);
const dictation = new Dictation();
await dictation.listen({ mode: "endpoint", onFinal: () => undefined });
expect(getUserMedia).toHaveBeenCalledWith({ audio: true });
expect(dictation.state.status).toBe("listening");
});
it("drives waveform energy from the mic analyser, not transcript length", async () => {
vi.useFakeTimers({ toFake: ["setInterval", "clearInterval"] });
const level = { current: 0.75 };
const instances: FakeRecognition[] = [];
class FakeRecognition {
continuous = false;
interimResults = false;
lang = "";
onresult: ((event: unknown) => void) | null = null;
onerror: ((event: { error?: string }) => void) | null = null;
onend: (() => void) | null = null;
start = vi.fn();
stop = vi.fn();
abort = vi.fn();
constructor() {
instances.push(this);
}
}
vi.stubGlobal("window", { SpeechRecognition: FakeRecognition });
stubWebSpeechMic(level);
const dictation = new Dictation();
await dictation.listen({ mode: "endpoint", onFinal: () => undefined });
await vi.advanceTimersByTimeAsync(80);
expect(dictation.state.energy).toBeGreaterThan(0.4);
instances[0]?.onresult?.({
resultIndex: 0,
results: [{ 0: { transcript: "hi" }, isFinal: false }],
});
expect(dictation.state.transcript).toBe("hi");
expect(dictation.state.energy).toBeGreaterThan(0.4);
vi.useRealTimers();
});
it("surfaces a clear error when microphone access is denied", async () => {
class FakeRecognition {
start = vi.fn();
}
vi.stubGlobal("window", { SpeechRecognition: FakeRecognition });
vi.stubGlobal("navigator", {
mediaDevices: {
getUserMedia: vi.fn(async () => {
throw new DOMException("Permission denied", "NotAllowedError");
}),
},
language: "en-US",
});
const dictation = new Dictation();
await dictation.listen({ mode: "endpoint", onFinal: () => undefined });
expect(dictation.state.status).toBe("idle");
expect(dictation.state.error).toMatch(/Microphone access denied/i);
});
});