evOo-local/apps/web/src/lib/tts.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

214 lines
6.5 KiB
TypeScript

import { afterEach, describe, expect, it, vi } from "vitest";
import { MAX_VOICE_AUDIO_BYTES, Speaker, VOICE_RESPONSE_TIMEOUT_MS } from "./tts.js";
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
function stubSelectedSpace(id: string) {
const store = new Map<string, string>([["rakazo:space-id", id]]);
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);
return (next: string) => store.set("rakazo:space-id", next);
}
describe("Speaker", () => {
it("interrupting before audio arrives leaves the speaker idle", async () => {
const speaker = new Speaker();
vi.spyOn(
speaker as unknown as { prepare: () => Promise<string[]> },
"prepare",
).mockImplementation(() => new Promise(() => undefined));
const pending = speaker.speak("Hello there.", { messageId: "m1" });
speaker.stop();
await pending;
expect(speaker.state.status).toBe("idle");
});
it("resolves with an error snapshot instead of rejecting", async () => {
const speaker = new Speaker();
vi.spyOn(
speaker as unknown as { prepare: () => Promise<string[]> },
"prepare",
).mockRejectedValue(new Error("ElevenLabs rejected that key."));
await expect(speaker.speak("Hi there.")).resolves.toBeUndefined();
expect(speaker.state.error).toBe("ElevenLabs rejected that key.");
});
it("keeps speak requests in the space where playback started", async () => {
const changeSelectedSpace = stubSelectedSpace("space-support");
const fetchMock = vi.fn(async (_url: string, _init?: RequestInit) => ({
ok: true,
blob: async () => new Blob(["audio"]),
json: async () => ({}),
}));
vi.stubGlobal("fetch", fetchMock);
vi.stubGlobal(
"Audio",
class {
src = "";
onended: (() => void) | null = null;
onerror: (() => void) | null = null;
play() {
queueMicrotask(() => this.onended?.());
return Promise.resolve();
}
pause() {}
},
);
vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:voice");
vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => undefined);
const speaker = new Speaker();
const prepare = vi
.spyOn(
speaker as unknown as {
prepare: (
text: string,
opts: unknown,
signal: AbortSignal,
spaceId: string | null,
) => Promise<string[]>;
},
"prepare",
)
.mockImplementation(async () => {
changeSelectedSpace("space-other");
return ["Hello."];
});
await speaker.speak("Hello.", { messageId: "m1" });
expect(fetchMock).toHaveBeenCalledWith(
"/api/voice/speak",
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");
expect(prepare.mock.calls[0]?.[3]).toBe("space-support");
});
it("rejects oversized audio without waiting for response cancellation", async () => {
const cancel = vi.fn(() => new Promise<void>(() => undefined));
vi.stubGlobal(
"fetch",
vi.fn(
async () =>
new Response(new ReadableStream({ cancel }), {
headers: { "content-length": String(MAX_VOICE_AUDIO_BYTES + 1) },
}),
),
);
const speaker = new Speaker();
vi.spyOn(
speaker as unknown as { prepare: () => Promise<string[]> },
"prepare",
).mockResolvedValue(["Hello."]);
await expect(speaker.speak("Hello.")).resolves.toBeUndefined();
expect(speaker.state.error).toBe("Voice response is too large.");
expect(cancel).toHaveBeenCalledOnce();
});
it("times out while a voice response body is stalled", async () => {
vi.useFakeTimers();
vi.stubGlobal(
"fetch",
vi.fn(
async () =>
new Response(
new ReadableStream({
pull: () => new Promise<void>(() => undefined),
}),
),
),
);
const speaker = new Speaker();
vi.spyOn(
speaker as unknown as { prepare: () => Promise<string[]> },
"prepare",
).mockResolvedValue(["Hello."]);
const pending = speaker.speak("Hello.");
await vi.advanceTimersByTimeAsync(VOICE_RESPONSE_TIMEOUT_MS);
await pending;
expect(speaker.state.error).toBe(
"Voice server took too long. Check the local voice service on port 8880.",
);
});
it("times out while voice preparation is stalled", async () => {
vi.useFakeTimers();
const speaker = new Speaker();
vi.spyOn(
speaker as unknown as { prepare: () => Promise<string[]> },
"prepare",
).mockImplementation(() => new Promise(() => undefined));
const pending = speaker.speak("Hello.");
await vi.advanceTimersByTimeAsync(VOICE_RESPONSE_TIMEOUT_MS);
await pending;
expect(speaker.state.error).toBe(
"Voice server took too long. Check the local voice service on port 8880.",
);
});
it("falls back to browser speech when the voice server fails", async () => {
stubSelectedSpace("space-voice");
const speak = vi.fn();
const cancel = vi.fn();
vi.stubGlobal(
"SpeechSynthesisUtterance",
class {
onend: (() => void) | null = null;
onerror: (() => void) | null = null;
voice = null;
rate = 1;
pitch = 1;
constructor(public text: string) {}
},
);
vi.stubGlobal("window", {
speechSynthesis: {
cancel,
getVoices: () => [{ lang: "en-US", localService: false }],
speak,
},
localStorage: globalThis.localStorage,
});
vi.stubGlobal(
"fetch",
vi.fn(async () => Response.json({ error: "down" }, { status: 503 })),
);
const speaker = new Speaker();
vi.spyOn(
speaker as unknown as { prepare: () => Promise<string[]> },
"prepare",
).mockResolvedValue(["Hello there."]);
const pending = speaker.speak("Hello there.");
await vi.waitFor(() => expect(speak).toHaveBeenCalledOnce());
const utterance = speak.mock.calls[0]?.[0] as { onend?: () => void };
utterance.onend?.();
await pending;
expect(speaker.state.status).toBe("idle");
expect(speaker.state.error).toBeUndefined();
});
});