expria-backend/src/lib/__tests__/geminiLive.test.ts
Hermann_Kitio 0662e766d4 Sprint 6d — Migrate Gemini Live to @google/genai SDK
feat(geminiLive): rewrite with GoogleGenAI SDK (vertexai: true, apiKey)
  replaces raw WebSocket to generativelanguage.googleapis.com
feat(geminiLive): restore full setup config (systemInstruction,
  inputAudioTranscription, outputAudioTranscription, VAD)
fix(geminiLive): buildSetupFrame → SDK config object (no manual JSON)
fix(useT2LiveSession): cancelTokenRef for idempotent startDialogue,
  closeAllRef for stable unmount cleanup
chore: add @google/genai@^1.50.1 dependency
test: 11 geminiLive tests rewritten with SDK mock
  292/292 backend tests green
2026-04-27 02:25:58 +03:00

303 lines
9.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<string, unknown>;
callbacks: {
onopen?: () => void;
onmessage?: (msg: unknown) => void;
onerror?: (err: unknown) => void;
onclose?: (evt: unknown) => void;
};
session: {
sendRealtimeInput: ReturnType<typeof vi.fn>;
close: ReturnType<typeof vi.fn>;
};
}
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<void>;
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, cest 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, cest 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;
});
});