diff --git a/__tests__/vapi.test.ts b/__tests__/vapi.test.ts index 63822586a..5385e2360 100644 --- a/__tests__/vapi.test.ts +++ b/__tests__/vapi.test.ts @@ -1,5 +1,100 @@ import Vapi from "../vapi"; // Adjust the import path based on your project structure +// The DailyCall object the mocked daily-js factory hands back. It is read when +// createCallObject() is called, so each test installs its own fake first. +let mockDailyCall: any = null; + +jest.mock("@daily-co/daily-js", () => { + return { + __esModule: true, + default: { + createCallObject: () => { + return mockDailyCall; + }, + }, + }; +}); + +jest.mock("../client", () => { + return { + client: { + baseUrl: "", + setSecurityData: () => {}, + call: { + callControllerCreateWebCall: () => { + return Promise.resolve({ + data: { + id: "call_test", + webCallUrl: "https://example.daily.co/test", + }, + }); + }, + }, + }, + }; +}); + +// A DailyCall stand-in with only the members start()/reconnect() touch. The +// registered event handlers are kept so tests can fire Daily's events. +function createMockDailyCall(updateInputSettings: jest.Mock) { + const handlers: Record void> = {}; + return { + handlers, + updateInputSettings, + on: jest.fn((event: string, handler: (event: any) => void) => { + handlers[event] = handler; + }), + join: jest.fn().mockResolvedValue(undefined), + destroy: jest.fn().mockResolvedValue(undefined), + iframe: jest.fn().mockReturnValue(null), + setLocalAudio: jest.fn(), + localAudio: jest.fn(), + startRemoteParticipantsAudioLevelObserver: jest.fn(), + startLocalAudioLevelObserver: jest.fn(), + }; +} + +type RejectionTracker = { handled: boolean }; + +// Jest runs test code in a vm sandbox that never delivers process-level +// "unhandledRejection" events, so an escaping rejection is asserted at its +// source instead: this wrapper records whether the SDK attaches a rejection +// handler anywhere on the chain it builds from the promise updateInputSettings() +// hands back. Without one, the rejection reaches the page unhandled. +function trackRejectionHandling( + promise: Promise, + tracker: RejectionTracker +): any { + return { + then(onFulfilled?: any, onRejected?: any) { + if (onRejected) { + tracker.handled = true; + } + return trackRejectionHandling( + promise.then(onFulfilled, onRejected), + tracker + ); + }, + catch(onRejected?: any) { + tracker.handled = true; + return trackRejectionHandling(promise.catch(onRejected), tracker); + }, + finally(onFinally?: any) { + return trackRejectionHandling(promise.finally(onFinally), tracker); + }, + }; +} + +// Let the rejection handlers the SDK attached run before asserting on them. +async function flushRejections() { + await new Promise((resolve) => { + setImmediate(resolve); + }); + await new Promise((resolve) => { + setImmediate(resolve); + }); +} + describe("Vapi", () => { let vapi: Vapi; let mockCall: any; @@ -54,3 +149,140 @@ describe("Vapi", () => { }); }); }); + +// Daily applies input settings through its remotely loaded call machine, so a +// failure (e.g. Krisp's "KrispInitError: Canceled") arrives as a rejected +// promise. It must not escape to the page as an unhandled rejection, and it must +// stay observable on the SDK's own "error" event. +describe("Vapi audio processing failures", () => { + const webCall = { + id: "call_test", + webCallUrl: "https://example.daily.co/test", + }; + let tracker: RejectionTracker; + let errors: any[]; + + beforeEach(() => { + tracker = { handled: false }; + errors = []; + }); + + afterEach(() => { + mockDailyCall = null; + }); + + it("reports a rejected noise cancellation setup during start()", async () => { + const updateInputSettings = jest.fn(() => { + return trackRejectionHandling( + Promise.reject(new Error("Canceled")), + tracker + ); + }); + mockDailyCall = createMockDailyCall(updateInputSettings as jest.Mock); + const vapi = new Vapi("dummy_token"); + vapi.on("error", (error) => { + errors.push(error); + }); + + const call = await vapi.start("dummy_assistant_id"); + await flushRejections(); + + expect(updateInputSettings).toHaveBeenCalledWith({ + audio: { processor: { type: "noise-cancellation" } }, + }); + // Non-fatal: the call still starts. + expect(call).not.toBeNull(); + expect(tracker.handled).toBe(true); + const emitted = errors.find((error) => { + return error?.type === "audio-processing-setup-error"; + }); + expect(emitted?.error?.message).toBe("Canceled"); + }); + + it("reports a rejected processor reset during start()", async () => { + const updateInputSettings = jest.fn((settings: any) => { + if (settings?.audio?.processor?.type === "none") { + return trackRejectionHandling( + Promise.reject(new Error("Canceled")), + tracker + ); + } + return Promise.resolve(); + }); + mockDailyCall = createMockDailyCall(updateInputSettings as jest.Mock); + const vapi = new Vapi("dummy_token"); + vapi.on("error", (error) => { + errors.push(error); + }); + + await vapi.start("dummy_assistant_id"); + mockDailyCall.handlers["nonfatal-error"]({ type: "audio-processor-error" }); + await flushRejections(); + + expect(updateInputSettings).toHaveBeenCalledWith({ + audio: { processor: { type: "none" } }, + }); + expect(tracker.handled).toBe(true); + const emitted = errors.find((error) => { + return error?.type === "audio-processor-recovery-error"; + }); + expect(emitted?.error?.message).toBe("Canceled"); + }); + + it("reports a rejected noise cancellation setup during reconnect()", async () => { + const updateInputSettings = jest.fn(() => { + return trackRejectionHandling( + Promise.reject(new Error("Canceled")), + tracker + ); + }); + mockDailyCall = createMockDailyCall(updateInputSettings as jest.Mock); + const vapi = new Vapi("dummy_token"); + vapi.on("error", (error) => { + errors.push(error); + }); + + // Non-fatal: reconnect() resolves rather than rethrowing. + await expect(vapi.reconnect(webCall)).resolves.toBeUndefined(); + await flushRejections(); + + expect(updateInputSettings).toHaveBeenCalledWith({ + audio: { processor: { type: "noise-cancellation" } }, + }); + expect(tracker.handled).toBe(true); + const emitted = errors.find((error) => { + return error?.type === "audio-processing-setup-error"; + }); + expect(emitted?.error?.message).toBe("Canceled"); + }); + + it("reports a rejected processor reset during reconnect()", async () => { + const updateInputSettings = jest.fn((settings: any) => { + if (settings?.audio?.processor?.type === "none") { + return trackRejectionHandling( + Promise.reject(new Error("Canceled")), + tracker + ); + } + return Promise.resolve(); + }); + mockDailyCall = createMockDailyCall(updateInputSettings as jest.Mock); + const vapi = new Vapi("dummy_token"); + vapi.on("error", (error) => { + errors.push(error); + }); + + await vapi.reconnect(webCall); + mockDailyCall.handlers["nonfatal-error"]({ type: "audio-processor-error" }); + await flushRejections(); + + expect(updateInputSettings).toHaveBeenCalledWith({ + audio: { processor: { type: "none" } }, + }); + expect(tracker.handled).toBe(true); + const emitted = errors.find((error) => { + return error?.type === "audio-processor-recovery-error"; + }); + expect(emitted?.error?.message).toBe("Canceled"); + }); +}); diff --git a/vapi.ts b/vapi.ts index f34de5b5b..aba01b8ab 100644 --- a/vapi.ts +++ b/vapi.ts @@ -368,6 +368,28 @@ export default class Vapi extends VapiEventEmitter { return new Promise((resolve) => setTimeout(resolve, ms)); } + /** + * Reports a failed audio processing update on the SDK's `error` event. + * + * Daily applies input settings inside its remotely loaded call machine, so a + * failure - Krisp's `KrispInitError: Canceled`, for example - arrives as a + * rejected promise, which a synchronous try/catch around + * `updateInputSettings()` cannot see. Without this the rejection escapes to + * the page as an unhandled rejection instead of reaching the listeners + * consumers register. Audio processing is non-critical, so the call continues. + */ + private emitAudioProcessingError( + stage: 'audio-processing-setup' | 'audio-processor-recovery', + error: unknown, + ) { + this.emit('error', { + type: `${stage}-error`, + stage, + error: serializeError(error), + timestamp: new Date().toISOString(), + }); + } + async start( assistant?: CreateAssistantDTO | string, assistantOverrides?: AssistantOverrides, @@ -828,6 +850,9 @@ export default class Vapi extends VapiEventEmitter { }) .then(() => { safeSetLocalAudio(this.call, true); + }) + .catch((error) => { + this.emitAudioProcessingError('audio-processor-recovery', error); }); } }); @@ -842,14 +867,18 @@ export default class Vapi extends VapiEventEmitter { const audioProcessingStartTime = Date.now(); try { - this.call.updateInputSettings({ - audio: { - processor: { - type: 'noise-cancellation', + this.call + .updateInputSettings({ + audio: { + processor: { + type: 'noise-cancellation', + }, }, - }, - }); - + }) + .catch((error) => { + this.emitAudioProcessingError('audio-processing-setup', error); + }); + const audioProcessingDuration = Date.now() - audioProcessingStartTime; this.emit('call-start-progress', { stage: 'audio-processing-setup', @@ -1352,6 +1381,9 @@ export default class Vapi extends VapiEventEmitter { }) .then(() => { safeSetLocalAudio(this.call, true); + }) + .catch((error) => { + this.emitAudioProcessingError('audio-processor-recovery', error); }); } }); @@ -1513,14 +1545,18 @@ export default class Vapi extends VapiEventEmitter { const audioProcessingStartTime = Date.now(); try { - this.call.updateInputSettings({ - audio: { - processor: { - type: 'noise-cancellation', + this.call + .updateInputSettings({ + audio: { + processor: { + type: 'noise-cancellation', + }, }, - }, - }); - + }) + .catch((error) => { + this.emitAudioProcessingError('audio-processing-setup', error); + }); + const audioProcessingDuration = Date.now() - audioProcessingStartTime; this.emit('call-start-progress', { stage: 'audio-processing-setup',