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
303 lines
9.6 KiB
TypeScript
303 lines
9.6 KiB
TypeScript
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, 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;
|
||
});
|
||
});
|