Skip to content
Open
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
155 changes: 155 additions & 0 deletions __tests__/stopWhileConnecting.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import Vapi from "../vapi";

let mockDailyCall: any = null;
const mockCreateWebCall = jest.fn();

jest.mock("@daily-co/daily-js", () => {
return {
__esModule: true,
default: {
createCallObject: () => mockDailyCall,
},
};
});

jest.mock("../client", () => {
return {
client: {
baseUrl: "",
setSecurityData: () => {},
call: {
callControllerCreateWebCall: (...args: any[]) =>
mockCreateWebCall(...args),
},
},
};
});

function createMockDailyCall() {
return {
on: jest.fn(),
once: jest.fn(),
join: jest.fn().mockResolvedValue(undefined),
destroy: jest.fn().mockResolvedValue(undefined),
iframe: jest.fn().mockReturnValue(null),
setLocalAudio: jest.fn(),
localAudio: jest.fn(),
updateInputSettings: jest.fn().mockResolvedValue(undefined),
startRemoteParticipantsAudioLevelObserver: jest.fn(),
startLocalAudioLevelObserver: jest.fn(),
};
}

function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((res) => {
resolve = res;
});
return { promise, resolve };
}

const webCallResponse = {
data: { id: "call_test", webCallUrl: "https://example.daily.co/test" },
};

describe("Vapi stop() while start() is connecting", () => {
let vapi: Vapi;

beforeEach(() => {
mockCreateWebCall.mockReset();
mockDailyCall = createMockDailyCall();
vapi = new Vapi("dummy_token");
vapi.on("error", () => {});
});

it("does not join the call when stop() lands while the web call is being created", async () => {
const pending = deferred<typeof webCallResponse>();
mockCreateWebCall.mockReturnValue(pending.promise);

const started = vapi.start("assistant_id");
await vapi.stop();
pending.resolve(webCallResponse);

await expect(started).resolves.toBeNull();
expect(mockDailyCall.join).not.toHaveBeenCalled();
expect(vapi.getDailyCallObject()).toBeNull();
});

it("does not report a cancelled start as a failure", async () => {
const pending = deferred<typeof webCallResponse>();
mockCreateWebCall.mockReturnValue(pending.promise);
const failed = jest.fn();
vapi.on("call-start-failed", failed);
const success = jest.fn();
vapi.on("call-start-success", success);

const started = vapi.start("assistant_id");
await vapi.stop();
pending.resolve(webCallResponse);
await started;

expect(failed).not.toHaveBeenCalled();
expect(success).not.toHaveBeenCalled();
});

it("does not connect a cancelled start after a new start() has begun", async () => {
const first = deferred<typeof webCallResponse>();
const secondCall = createMockDailyCall();
mockCreateWebCall
.mockReturnValueOnce(first.promise)
.mockResolvedValueOnce(webCallResponse);

const firstStart = vapi.start("assistant_id");
await vapi.stop();
mockDailyCall = secondCall;
await vapi.start("assistant_id");
first.resolve(webCallResponse);

await expect(firstStart).resolves.toBeNull();
expect(secondCall.join).toHaveBeenCalledTimes(1);
expect(secondCall.destroy).not.toHaveBeenCalled();
expect(vapi.getDailyCallObject()).toBe(secondCall);
});

it("does not report a failure when stop() destroys the call while join() is pending", async () => {
const join = deferred<void>();
mockDailyCall.join.mockReturnValue(join.promise);
mockCreateWebCall.mockResolvedValue(webCallResponse);
const failed = jest.fn();
vapi.on("call-start-failed", failed);
const success = jest.fn();
vapi.on("call-start-success", success);

const started = vapi.start("assistant_id");
await new Promise((resolve) => setImmediate(resolve));
expect(mockDailyCall.join).toHaveBeenCalledTimes(1);
await vapi.stop();
join.resolve();

await expect(started).resolves.toBeNull();
expect(failed).not.toHaveBeenCalled();
expect(success).not.toHaveBeenCalled();
expect(vapi.getDailyCallObject()).toBeNull();
});

it("does not join when stop() lands during the mobile permission wait", async () => {
jest.spyOn(vapi as any, "isMobileDevice").mockReturnValue(true);
jest.useFakeTimers();
try {
mockCreateWebCall.mockResolvedValue(webCallResponse);
const failed = jest.fn();
vapi.on("call-start-failed", failed);

const started = vapi.start("assistant_id");
await jest.advanceTimersByTimeAsync(0);
await vapi.stop();
await jest.advanceTimersByTimeAsync(1000);

await expect(started).resolves.toBeNull();
expect(mockDailyCall.join).not.toHaveBeenCalled();
expect(failed).not.toHaveBeenCalled();
} finally {
jest.useRealTimers();
}
});
});
12 changes: 12 additions & 0 deletions vapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,8 @@ class VapiEventEmitter extends EventEmitter {

export default class Vapi extends VapiEventEmitter {
private started: boolean = false;
// Bumped by stop() so a start() still in flight can tell it was cancelled.
private startGeneration = 0;
private call: DailyCall | null = null;
private audioPlayer: HTMLAudioElement | null = null;
// Survives teardown so the caller's choice persists across calls.
Expand Down Expand Up @@ -497,6 +499,8 @@ export default class Vapi extends VapiEventEmitter {
});

this.started = true;
const generation = ++this.startGeneration;
const isCancelled = () => generation !== this.startGeneration;

try {
// Stage 1: Create web call
Expand All @@ -522,6 +526,9 @@ export default class Vapi extends VapiEventEmitter {
})
).data;

// stop() was called while the web call was being created.
if (isCancelled()) return null;

const webCallDuration = Date.now() - webCallStartTime;
this.emit('call-start-progress', {
stage: 'web-call-creation',
Expand Down Expand Up @@ -733,6 +740,7 @@ export default class Vapi extends VapiEventEmitter {
if (isMobile) {
const mobileWaitStartTime = Date.now();
await this.sleep(1000);
if (isCancelled()) return null;
const mobileWaitDuration = Date.now() - mobileWaitStartTime;
this.emit('call-start-progress', {
stage: 'mobile-permissions',
Expand Down Expand Up @@ -770,6 +778,8 @@ export default class Vapi extends VapiEventEmitter {
subscribeToTracksAutomatically: false,
});

if (isCancelled()) return null;

const joinDuration = Date.now() - joinStartTime;
this.emit('call-start-progress', {
stage: 'daily-call-join',
Expand All @@ -778,6 +788,7 @@ export default class Vapi extends VapiEventEmitter {
timestamp: new Date().toISOString()
});
} catch (error) {
if (isCancelled()) return null;
const joinDuration = Date.now() - joinStartTime;
const serializedError = serializeError(error);
this.emit('call-start-progress', {
Expand Down Expand Up @@ -1092,6 +1103,7 @@ export default class Vapi extends VapiEventEmitter {
* If `roomDeleteOnUserLeaveEnabled` is set to `true`, the Vapi call will also be destroyed, preventing any reconnections.
*/
async stop(): Promise<void> {
this.startGeneration++;
this.started = false;
if (this.call) {
await this.call.destroy();
Expand Down