/** * Tests for useVoiceAgent React hook. * Mocks PartySocket to isolate from real WebSocket connections. * VoiceClient's real protocol/state logic runs — only the network is mocked. */ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { render, cleanup } from "vitest-browser-react"; import { useEffect, act } from "react"; function sleep(ms: number) { return new Promise((resolve) => setTimeout(resolve, ms)); } // --- Mock plumbing --- // The mock PartySocket instance (set synchronously during construction) let socketInstance: { readyState: number; send: ReturnType; close: ReturnType; onopen: (() => void) | null; onclose: (() => void) | null; onerror: (() => void) | null; onmessage: ((event: MessageEvent) => void) | null; } | null = null; let socketSend: ReturnType; let socketReadyState: number; let socketClose: ReturnType; vi.mock("partysocket", () => ({ PartySocket: vi.fn(function () { const instance = { get readyState() { return socketReadyState; }, send: socketSend, close: socketClose, onopen: null as (() => void) | null, onclose: null as (() => void) | null, onerror: null as (() => void) | null, onmessage: null as ((event: MessageEvent) => void) | null }; socketInstance = instance; queueMicrotask(() => { instance.onopen?.(); }); return instance; }) })); // Import after mock is set up (vitest hoists vi.mock) import { useVoiceAgent, type UseVoiceAgentReturn, type UseVoiceAgentOptions } from "../voice-react"; // --- Audio API mocks --- let workletPortOnMessage: ((event: MessageEvent) => void) | null = null; function createMockAudioContext() { const mockSource = { connect: vi.fn(), buffer: null as AudioBuffer | null, onended: null as (() => void) | null, start: vi.fn(function (this: { onended: (() => void) | null }) { queueMicrotask(() => this.onended?.()); }), stop: vi.fn() }; const mockWorkletNode = { port: { set onmessage(handler: ((event: MessageEvent) => void) | null) { workletPortOnMessage = handler; }, get onmessage() { return workletPortOnMessage; } }, connect: vi.fn(), disconnect: vi.fn() }; return { state: "running" as string, resume: vi.fn(async () => {}), close: vi.fn(async () => {}), destination: {}, audioWorklet: { addModule: vi.fn(async () => {}) }, createMediaStreamSource: vi.fn(() => mockSource), createBufferSource: vi.fn(() => mockSource), decodeAudioData: vi.fn(async () => ({ duration: 0.5, length: 24000, sampleRate: 48000, numberOfChannels: 1, getChannelData: vi.fn(() => new Float32Array(24000)) })), _mockSource: mockSource, _mockWorkletNode: mockWorkletNode }; } let mockAudioCtx: ReturnType; const mockTrackStop = vi.fn(); function setupAudioMocks() { mockAudioCtx = createMockAudioContext(); workletPortOnMessage = null; vi.stubGlobal( "AudioContext", vi.fn(function () { return mockAudioCtx; }) ); vi.stubGlobal( "AudioWorkletNode", vi.fn(function () { return mockAudioCtx._mockWorkletNode; }) ); const mockStream = { getTracks: () => [{ stop: mockTrackStop }] }; if (!navigator.mediaDevices) { Object.defineProperty(navigator, "mediaDevices", { value: { getUserMedia: vi.fn(async () => mockStream) }, configurable: true }); } else { vi.spyOn(navigator.mediaDevices, "getUserMedia").mockResolvedValue( mockStream as unknown as MediaStream ); } vi.stubGlobal( "URL", Object.assign({}, URL, { createObjectURL: vi.fn(() => "blob:mock"), revokeObjectURL: vi.fn() }) ); } // --- Test component --- function TestVoiceComponent({ options, onResult }: { options: UseVoiceAgentOptions; onResult: (result: UseVoiceAgentReturn) => void; }) { const result = useVoiceAgent(options); useEffect(() => { onResult(result); }, [ result.status, result.connected, result.error, result.isMuted, result.transcript, result.metrics, result.audioLevel, onResult, result ]); return (
{result.status} {String(result.connected)} {result.error ?? ""} {String(result.isMuted)} {result.transcript.length}
); } // --- Helpers --- function fireMessage(data: string | ArrayBuffer | Blob) { socketInstance?.onmessage?.(new MessageEvent("message", { data })); } function fireJSON(msg: Record) { fireMessage(JSON.stringify(msg)); } async function renderHook( overrides: Partial = {} ): Promise<{ container: HTMLElement; getResult: () => UseVoiceAgentReturn }> { let latestResult: UseVoiceAgentReturn | null = null; const onResult = vi.fn((r: UseVoiceAgentReturn) => { latestResult = r; }); const { container } = await render( ); await sleep(10); return { container, getResult: () => { if (!latestResult) throw new Error("Hook has not rendered yet"); return latestResult; } }; } // --- Test suites --- beforeEach(() => { socketSend = vi.fn(); socketClose = vi.fn(); socketReadyState = WebSocket.OPEN; socketInstance = null; setupAudioMocks(); }); afterEach(() => { cleanup(); vi.restoreAllMocks(); vi.unstubAllGlobals(); }); describe("useVoiceAgent", () => { describe("initial state", () => { it("should start with idle status and empty transcript", async () => { const { container } = await renderHook(); await vi.waitFor(() => { expect( container.querySelector('[data-testid="status"]')?.textContent ).toBe("idle"); expect( container.querySelector('[data-testid="transcript-count"]') ?.textContent ).toBe("0"); }); }); }); describe("connection lifecycle", () => { it("should set connected=true on open", async () => { const { container } = await renderHook(); await vi.waitFor(() => { expect( container.querySelector('[data-testid="connected"]')?.textContent ).toBe("true"); }); }); it("should set connected=false on close", async () => { const { container } = await renderHook(); await vi.waitFor(() => { expect( container.querySelector('[data-testid="connected"]')?.textContent ).toBe("true"); }); act(() => { socketInstance?.onclose?.(); }); await vi.waitFor(() => { expect( container.querySelector('[data-testid="connected"]')?.textContent ).toBe("false"); }); }); it("should set error on connection error", async () => { const { container } = await renderHook(); act(() => { socketInstance?.onerror?.(); }); await vi.waitFor(() => { expect( container.querySelector('[data-testid="error"]')?.textContent ).toBe("Connection lost. Reconnecting..."); }); }); }); describe("voice protocol — status messages", () => { it("should update status from server message", async () => { const { container } = await renderHook(); act(() => { fireJSON({ type: "status", status: "listening" }); }); await vi.waitFor(() => { expect( container.querySelector('[data-testid="status"]')?.textContent ).toBe("listening"); }); }); it("should cycle through all statuses", async () => { const { container } = await renderHook(); for (const s of ["listening", "thinking", "speaking", "idle"] as const) { act(() => { fireJSON({ type: "status", status: s }); }); await vi.waitFor(() => { expect( container.querySelector('[data-testid="status"]')?.textContent ).toBe(s); }); } }); it("should clear error when status becomes listening", async () => { const { container } = await renderHook(); act(() => { fireJSON({ type: "error", message: "something broke" }); }); await vi.waitFor(() => { expect( container.querySelector('[data-testid="error"]')?.textContent ).toBe("something broke"); }); act(() => { fireJSON({ type: "status", status: "listening" }); }); await vi.waitFor(() => { expect( container.querySelector('[data-testid="error"]')?.textContent ).toBe(""); }); }); }); describe("voice protocol — transcript", () => { it("should add a complete transcript message", async () => { const { getResult } = await renderHook(); await vi.waitFor(() => { expect(getResult().connected).toBe(true); }); act(() => { fireJSON({ type: "transcript", role: "user", text: "Hello agent" }); }); await vi.waitFor(() => { const t = getResult().transcript; expect(t).toHaveLength(1); expect(t[0].role).toBe("user"); expect(t[0].text).toBe("Hello agent"); expect(t[0].timestamp).toBeTypeOf("number"); }); }); it("should handle streaming transcript (start -> delta -> end)", async () => { const { getResult } = await renderHook(); await vi.waitFor(() => { expect(getResult().connected).toBe(true); }); act(() => { fireJSON({ type: "transcript_start" }); }); await vi.waitFor(() => { const t = getResult().transcript; expect(t).toHaveLength(1); expect(t[0].role).toBe("assistant"); expect(t[0].text).toBe(""); }); act(() => { fireJSON({ type: "transcript_delta", text: "Hello" }); }); await vi.waitFor(() => { expect(getResult().transcript[0].text).toBe("Hello"); }); act(() => { fireJSON({ type: "transcript_delta", text: " world" }); }); await vi.waitFor(() => { expect(getResult().transcript[0].text).toBe("Hello world"); }); act(() => { fireJSON({ type: "transcript_end", text: "Hello world, how are you?" }); }); await vi.waitFor(() => { expect(getResult().transcript[0].text).toBe( "Hello world, how are you?" ); }); }); it("should handle interleaved user and assistant messages", async () => { const { getResult } = await renderHook(); await vi.waitFor(() => { expect(getResult().connected).toBe(true); }); act(() => { fireJSON({ type: "transcript", role: "user", text: "What time?" }); }); act(() => { fireJSON({ type: "transcript_start" }); }); act(() => { fireJSON({ type: "transcript_delta", text: "It is 3pm" }); }); act(() => { fireJSON({ type: "transcript_end", text: "It is 3pm." }); }); act(() => { fireJSON({ type: "transcript", role: "user", text: "Thanks!" }); }); await vi.waitFor(() => { const t = getResult().transcript; expect(t).toHaveLength(3); expect(t[0]).toMatchObject({ role: "user", text: "What time?" }); expect(t[1]).toMatchObject({ role: "assistant", text: "It is 3pm." }); expect(t[2]).toMatchObject({ role: "user", text: "Thanks!" }); }); }); it("should ignore transcript_delta when transcript is empty", async () => { const { getResult } = await renderHook(); await vi.waitFor(() => { expect(getResult().connected).toBe(true); }); act(() => { fireJSON({ type: "transcript_delta", text: "orphan delta" }); }); await vi.waitFor(() => { expect(getResult().transcript).toHaveLength(0); }); }); }); describe("voice protocol — metrics", () => { it("should store pipeline metrics from server", async () => { const { getResult } = await renderHook(); await vi.waitFor(() => { expect(getResult().connected).toBe(true); }); act(() => { fireJSON({ type: "metrics", vad_ms: 120, stt_ms: 350, llm_ms: 800, tts_ms: 200, first_audio_ms: 1470, total_ms: 1600 }); }); await vi.waitFor(() => { const m = getResult().metrics; expect(m).not.toBeNull(); expect(m!.vad_ms).toBe(120); expect(m!.stt_ms).toBe(350); expect(m!.llm_ms).toBe(800); expect(m!.tts_ms).toBe(200); expect(m!.first_audio_ms).toBe(1470); expect(m!.total_ms).toBe(1600); }); }); }); describe("voice protocol — error messages", () => { it("should set error from server error message", async () => { const { container } = await renderHook(); act(() => { fireJSON({ type: "error", message: "Pipeline failed" }); }); await vi.waitFor(() => { expect( container.querySelector('[data-testid="error"]')?.textContent ).toBe("Pipeline failed"); }); }); }); describe("voice protocol — non-JSON messages", () => { it("should not crash on non-JSON string messages", async () => { const { container } = await renderHook(); act(() => { fireMessage("this is not json {{{"); }); await vi.waitFor(() => { expect( container.querySelector('[data-testid="status"]')?.textContent ).toBe("idle"); }); }); }); describe("actions — toggleMute", () => { it("should toggle isMuted on and off", async () => { const { container, getResult } = await renderHook(); await vi.waitFor(() => { expect( container.querySelector('[data-testid="muted"]')?.textContent ).toBe("false"); }); act(() => { getResult().toggleMute(); }); await vi.waitFor(() => { expect( container.querySelector('[data-testid="muted"]')?.textContent ).toBe("true"); }); act(() => { getResult().toggleMute(); }); await vi.waitFor(() => { expect( container.querySelector('[data-testid="muted"]')?.textContent ).toBe("false"); }); }); }); describe("actions — startCall", () => { it("should send start_call message to agent", async () => { const { getResult } = await renderHook(); await vi.waitFor(() => { expect(getResult().connected).toBe(true); }); await act(async () => { await getResult().startCall(); }); expect(socketSend).toHaveBeenCalledWith( JSON.stringify({ type: "start_call" }) ); }); it("should request microphone access", async () => { const { getResult } = await renderHook(); await vi.waitFor(() => { expect(getResult().connected).toBe(true); }); await act(async () => { await getResult().startCall(); }); expect(navigator.mediaDevices.getUserMedia).toHaveBeenCalledWith({ audio: expect.objectContaining({ channelCount: 1, echoCancellation: true, noiseSuppression: true, autoGainControl: true }) }); }); it("should clear previous error and metrics", async () => { const { getResult } = await renderHook(); await vi.waitFor(() => { expect(getResult().connected).toBe(true); }); act(() => { fireJSON({ type: "error", message: "old error" }); fireJSON({ type: "metrics", vad_ms: 1, stt_ms: 1, llm_ms: 1, tts_ms: 1, first_audio_ms: 1, total_ms: 1 }); }); await vi.waitFor(() => { expect(getResult().error).toBe("old error"); expect(getResult().metrics).not.toBeNull(); }); await act(async () => { await getResult().startCall(); }); await vi.waitFor(() => { expect(getResult().error).toBeNull(); expect(getResult().metrics).toBeNull(); }); }); it("should not send if WebSocket is not open", async () => { socketReadyState = WebSocket.CLOSED; const { getResult } = await renderHook(); await vi.waitFor(() => { expect(getResult).not.toThrow(); }); await act(async () => { await getResult().startCall(); }); const startCallSent = socketSend.mock.calls.some( (args: unknown[]) => typeof args[0] === "string" && (args[0] as string).includes("start_call") ); expect(startCallSent).toBe(false); }); }); describe("actions — endCall", () => { it("should send end_call message and reset status to idle", async () => { const { container, getResult } = await renderHook(); await vi.waitFor(() => { expect(getResult().connected).toBe(true); }); act(() => { fireJSON({ type: "status", status: "listening" }); }); await vi.waitFor(() => { expect( container.querySelector('[data-testid="status"]')?.textContent ).toBe("listening"); }); act(() => { getResult().endCall(); }); expect(socketSend).toHaveBeenCalledWith( JSON.stringify({ type: "end_call" }) ); await vi.waitFor(() => { expect( container.querySelector('[data-testid="status"]')?.textContent ).toBe("idle"); }); }); it("should stop microphone tracks on endCall", async () => { const { getResult } = await renderHook(); await vi.waitFor(() => { expect(getResult().connected).toBe(true); }); await act(async () => { await getResult().startCall(); }); act(() => { getResult().endCall(); }); expect(mockTrackStop).toHaveBeenCalled(); }); }); describe("binary audio messages", () => { it("should handle ArrayBuffer messages without crashing", async () => { const { getResult } = await renderHook(); await vi.waitFor(() => { expect(getResult().connected).toBe(true); }); const fakeAudio = new ArrayBuffer(100); act(() => { fireMessage(fakeAudio); }); await vi.waitFor(() => { expect(getResult().status).toBe("idle"); }); }); }); describe("configurable thresholds", () => { it("should accept custom silence and interrupt thresholds", async () => { const { getResult } = await renderHook({ silenceThreshold: 0.05, silenceDurationMs: 1000, interruptThreshold: 0.1, interruptChunks: 5 }); await vi.waitFor(() => { expect(getResult().connected).toBe(true); }); expect(getResult().status).toBe("idle"); }); }); });