Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
232 changes: 232 additions & 0 deletions __tests__/vapi.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, (event: any) => 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<unknown>,
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;
Expand Down Expand Up @@ -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");
});
});
64 changes: 50 additions & 14 deletions vapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -828,6 +850,9 @@ export default class Vapi extends VapiEventEmitter {
})
.then(() => {
safeSetLocalAudio(this.call, true);
})
.catch((error) => {
this.emitAudioProcessingError('audio-processor-recovery', error);
});
}
});
Expand All @@ -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',
Expand Down Expand Up @@ -1352,6 +1381,9 @@ export default class Vapi extends VapiEventEmitter {
})
.then(() => {
safeSetLocalAudio(this.call, true);
})
.catch((error) => {
this.emitAudioProcessingError('audio-processor-recovery', error);
});
}
});
Expand Down Expand Up @@ -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',
Expand Down
Loading