import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { EventEmitter } from "node:events"; // ─── Mock du SDK @google/genai ─────────────────────────────────────────────── // // On capture les callbacks passés à `ai.live.connect` pour pouvoir simuler les // événements (onopen, onmessage, onerror, onclose) depuis les tests. La // fabrique `clientFactory` injectée dans openGeminiLiveSession permet de // remplacer `new GoogleGenAI(...)` par un stub. interface CapturedConnect { model: string; config: Record; callbacks: { onopen?: () => void; onmessage?: (msg: unknown) => void; onerror?: (err: unknown) => void; onclose?: (evt: unknown) => void; }; session: { sendRealtimeInput: ReturnType; close: ReturnType; }; } let capturedConnect: CapturedConnect | null = null; function makeFakeClient() { return { live: { connect: vi.fn(async (params: CapturedConnect) => { const session = { sendRealtimeInput: vi.fn(), close: vi.fn(), }; capturedConnect = { model: params.model, config: params.config, callbacks: params.callbacks, session, }; return session; }), }, }; } import { openGeminiLiveSession, buildT2SystemPrompt, type WebSocketLike, } from "../geminiLive"; class FakeWs extends EventEmitter implements WebSocketLike { public sent: unknown[] = []; public closed = false; public closeCode?: number; public closeReason?: string; send(data: unknown): void { this.sent.push(data); } close(code?: number, reason?: string): void { if (this.closed) return; this.closed = true; this.closeCode = code; this.closeReason = reason; } } const SUJET_OPTS = { role: "un bailleur qui propose un appartement à louer", contexte: "Vous cherchez un appartement de 2 pièces dans le centre-ville, votre budget est limité et vous souhaitez emménager le mois prochain.", }; /** Helper : ouvre une session avec un client mocké et retourne la capture. */ async function openWithMock( client: FakeWs, extra: Partial<{ onSessionEnd: (transcript: string) => void | Promise; timeoutMs: number; warningMs: number; }> = {}, ) { capturedConnect = null; // eslint-disable-next-line @typescript-eslint/no-explicit-any openGeminiLiveSession(client, { ...SUJET_OPTS, apiKey: "test-key", clientFactory: () => makeFakeClient() as any, ...extra, }); // Le `await live.connect()` est dans un `.then()` du code prod ; on laisse // les microtasks se vider avant de retourner la capture. await Promise.resolve(); await Promise.resolve(); if (!capturedConnect) { throw new Error("Le mock du SDK n'a pas capturé de connect()"); } return capturedConnect; } describe("buildT2SystemPrompt", () => { it("substitue role et contexte dans le template", () => { const prompt = buildT2SystemPrompt(SUJET_OPTS); expect(prompt).toContain( "Tu joues le rôle de un bailleur qui propose un appartement à louer", ); expect(prompt).toContain("Vous cherchez un appartement"); expect(prompt).toContain("uniquement en français"); expect(prompt).toContain("Tu ne prends PAS la parole en premier"); }); }); describe("openGeminiLiveSession (SDK)", () => { beforeEach(() => { vi.useFakeTimers(); }); afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); capturedConnect = null; }); it("appelle live.connect avec le modèle + config Live (audio + system + transcripts + VAD)", async () => { const client = new FakeWs(); const capture = await openWithMock(client); expect(capture.model).toMatch(/gemini/); const config = capture.config; expect(config.responseModalities).toContain("AUDIO"); expect(config.systemInstruction).toContain( "un bailleur qui propose un appartement", ); expect(config.inputAudioTranscription).toEqual({}); expect(config.outputAudioTranscription).toEqual({}); // eslint-disable-next-line @typescript-eslint/no-explicit-any const vad: any = (config.realtimeInputConfig as any) ?.automaticActivityDetection; expect(vad?.disabled).toBe(false); expect(vad?.silenceDurationMs).toBe(2000); }); it("forwarde un chunk audio client {type:'audio'} via session.sendRealtimeInput (PCM 16k base64)", async () => { const client = new FakeWs(); const capture = await openWithMock(client); capture.callbacks.onopen?.(); const base64 = "AQIDBA=="; // base64 de [1,2,3,4] client.emit("message", JSON.stringify({ type: "audio", data: base64 })); expect(capture.session.sendRealtimeInput).toHaveBeenCalledTimes(1); expect(capture.session.sendRealtimeInput).toHaveBeenCalledWith({ audio: { data: base64, mimeType: "audio/pcm;rate=16000" }, }); }); it("forwarde un message Gemini (audio inlineData) au client en JSON", async () => { const client = new FakeWs(); const capture = await openWithMock(client); capture.callbacks.onopen?.(); const geminiMsg = { serverContent: { modelTurn: { parts: [ { inlineData: { data: "EAYE", mimeType: "audio/pcm;rate=24000" }, }, ], }, }, }; capture.callbacks.onmessage?.(geminiMsg); expect(client.sent).toHaveLength(1); expect(JSON.parse(client.sent[0] as string)).toEqual(geminiMsg); }); it("accumule input/outputTranscription et reconstruit le transcript chronologique", async () => { const client = new FakeWs(); const onSessionEnd = vi.fn(); const capture = await openWithMock(client, { onSessionEnd }); capture.callbacks.onopen?.(); capture.callbacks.onmessage?.({ serverContent: { inputTranscription: { text: "Bonjour, je voudrais louer." }, }, }); capture.callbacks.onmessage?.({ serverContent: { outputTranscription: { text: "Bonjour, c’est pour quel quartier ?" }, }, }); capture.callbacks.onmessage?.({ serverContent: { inputTranscription: { text: "Le centre-ville." } }, }); client.emit("message", JSON.stringify({ type: "end" })); await vi.runAllTimersAsync(); expect(onSessionEnd).toHaveBeenCalledTimes(1); expect(onSessionEnd.mock.calls[0][0]).toBe( "Candidat : Bonjour, je voudrais louer.\nExaminateur : Bonjour, c’est pour quel quartier ?\nCandidat : Le centre-ville.", ); }); it("ferme la session SDK après onSessionEnd, sans fermer le client", async () => { const client = new FakeWs(); const onSessionEnd = vi.fn(); const capture = await openWithMock(client, { onSessionEnd }); capture.callbacks.onopen?.(); client.emit("message", JSON.stringify({ type: "end" })); await vi.runAllTimersAsync(); expect(capture.session.close).toHaveBeenCalledTimes(1); expect(client.closed).toBe(false); }); it("warning à 180 s puis timeout à 210 s déclenche endSession", async () => { const client = new FakeWs(); const onSessionEnd = vi.fn(); const capture = await openWithMock(client, { onSessionEnd }); capture.callbacks.onopen?.(); await vi.advanceTimersByTimeAsync(180_000); const warningFrame = client.sent.find( (f) => typeof f === "string" && f.includes('"warning"'), ); expect(warningFrame).toBeDefined(); expect(JSON.parse(warningFrame as string)).toEqual({ type: "warning", message: "30 secondes restantes", }); expect(onSessionEnd).not.toHaveBeenCalled(); await vi.advanceTimersByTimeAsync(30_000); expect(onSessionEnd).toHaveBeenCalledTimes(1); expect(capture.session.close).toHaveBeenCalled(); }); it("signal end client est idempotent (un seul onSessionEnd)", async () => { const client = new FakeWs(); const onSessionEnd = vi.fn(); const capture = await openWithMock(client, { onSessionEnd }); capture.callbacks.onopen?.(); client.emit("message", JSON.stringify({ type: "end" })); client.emit("message", JSON.stringify({ type: "end" })); await vi.runAllTimersAsync(); expect(onSessionEnd).toHaveBeenCalledTimes(1); }); it("onclose SDK avant fin → close client 4006 GEMINI_DISCONNECTED", async () => { const client = new FakeWs(); const capture = await openWithMock(client); capture.callbacks.onopen?.(); capture.callbacks.onclose?.({ code: 1000 }); expect(client.closed).toBe(true); expect(client.closeCode).toBe(4006); expect(client.closeReason).toBe("GEMINI_DISCONNECTED"); }); it("onerror SDK → close client 4006", async () => { const client = new FakeWs(); const capture = await openWithMock(client); capture.callbacks.onopen?.(); capture.callbacks.onerror?.(new Error("boom")); expect(client.closed).toBe(true); expect(client.closeCode).toBe(4006); }); it("absence de GEMINI_API_KEY → close client 4005 GEMINI_CONFIG sans appel à live.connect", () => { const originalKey = process.env.GEMINI_API_KEY; delete process.env.GEMINI_API_KEY; capturedConnect = null; const client = new FakeWs(); const factory = vi.fn(() => makeFakeClient()); openGeminiLiveSession(client, { ...SUJET_OPTS, // eslint-disable-next-line @typescript-eslint/no-explicit-any clientFactory: factory as any, }); expect(factory).not.toHaveBeenCalled(); expect(client.closed).toBe(true); expect(client.closeCode).toBe(4005); expect(client.closeReason).toBe("GEMINI_CONFIG"); if (originalKey !== undefined) process.env.GEMINI_API_KEY = originalKey; }); });