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.
665 lines
20 KiB
TypeScript
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);
|
|
});
|
|
});
|