import { type LeaveRequest, LeaveRequest_Action } from '@livekit/protocol'; import { afterEach, beforeEach, describe, expect, inject, it } from 'vitest'; import { ConnectionErrorReason } from '../room/errors'; import { sleep } from '../room/utils'; import { isPending } from '../test/promiseState'; import { createInvalidToken, createToken } from '../test/signalToken'; import { SignalClient, SignalConnectionState, type SignalOptions } from './SignalClient'; // Provided by src/test/signalServerSetup.ts (spawns the livekit-server test-server). const serverUrl = inject('serverUrl'); const unavailable = inject('e2eUnavailable'); const defaultOpts = (): SignalOptions => ({ autoSubscribe: true, maxRetries: 0, e2eeEnabled: false, websocketTimeout: 5_000, }); function withTimeout(p: Promise, ms: number, label: string): Promise { return Promise.race([ p, new Promise((_, reject) => setTimeout(() => reject(new Error(`timed out waiting for ${label}`)), ms), ), ]); } /** Resolves with the reason passed to onClose (register before the event can fire). */ function captureClose(client: SignalClient): Promise { return new Promise((resolve) => { client.onClose = (reason) => resolve(reason); }); } /** Resolves with the LeaveRequest passed to onLeave. */ function captureLeave(client: SignalClient): Promise { return new Promise((resolve) => { client.onLeave = (leave) => resolve(leave); }); } describe.skipIf(!!unavailable)('SignalClient e2e', () => { // Behaviors that depend on the signal path (v0 legacy vs v1). describe.each([ { label: 'v0', useV0: true }, { label: 'v1', useV0: false }, ])('$label path', ({ useV0 }) => { let client: SignalClient; beforeEach(() => { client = new SignalClient(false); }); afterEach(async () => { await client.close().catch(() => {}); }); /** Mint a token whose metadata selects the server behavior mode, then join. */ const join = (mode: string, opts: SignalOptions = defaultOpts(), abortSignal?: AbortSignal) => createToken({ signal: mode }).then((token) => client.join(serverUrl, token, opts, abortSignal, useV0), ); it('connects and reports CONNECTED with a join response', async () => { const res = await join('happy'); expect(res).toBeTruthy(); expect(res.pingInterval).toBeGreaterThan(0); expect(client.currentState).toBe(SignalConnectionState.CONNECTED); }); it('stays connected past the ping timeout while the server pongs', async () => { await join('happy'); const closed = captureClose(client); // Server ping timeout is 3s; a healthy pong loop must keep us alive. await sleep(4_000); expect(await isPending(closed)).toBe(true); expect(client.currentState).toBe(SignalConnectionState.CONNECTED); }); it('closes with a ping timeout when the server stops ponging', async () => { const closed = captureClose(client); await join('no_pong'); expect(client.currentState).toBe(SignalConnectionState.CONNECTED); const reason = await withTimeout(closed, 8_000, 'onClose (ping timeout)'); expect(reason).toContain('ping timeout'); expect(client.currentState).toBe(SignalConnectionState.DISCONNECTED); }); it('surfaces an unexpected transport close while connected', async () => { const closed = captureClose(client); await join('close_when_connected'); const reason = await withTimeout(closed, 5_000, 'onClose (transport closed)'); expect(reason).toBeTruthy(); expect(client.currentState).toBe(SignalConnectionState.DISCONNECTED); }); it('surfaces an abnormal socket drop (1006) while connected', async () => { // Server sends the join, then drops the TCP connection with no close // handshake -> the browser reports an abnormal 1006 closure, exercising // WebSocketStream's error->close path (distinct from the clean 1011 above). const closed = captureClose(client); await join('drop_when_connected'); expect(client.currentState).toBe(SignalConnectionState.CONNECTED); const reason = await withTimeout(closed, 5_000, 'onClose (abnormal drop)'); expect(reason).toBe('Unexpected WS error'); expect(client.currentState).toBe(SignalConnectionState.DISCONNECTED); }); it('rejects the join when the socket closes before the first message', async () => { // Upgrade succeeds, then the server closes the WS before sending a join. // The client is still establishing, so it rejects rather than hanging. const err = await join('close_before_join').then( () => undefined, (e) => e as Error, ); expect(err).toBeInstanceOf(Error); expect((err as { reason?: ConnectionErrorReason }).reason).toBe( ConnectionErrorReason.InternalError, ); }); it('delivers a server-initiated leave while connected', async () => { const left = captureLeave(client); await join('leave_when_connected'); const leave = await withTimeout(left, 5_000, 'onLeave'); expect(leave).toBeTruthy(); expect(leave.action).toBe(LeaveRequest_Action.DISCONNECT); }); it('forwards a non-DISCONNECT leave action verbatim', async () => { // SignalClient does not branch on the leave action (RTCEngine does); it // must pass it through unchanged. Guards the FSM swap's leave_received // effect, which carries leaveAction. Uses RECONNECT to prove it's not // defaulted to DISCONNECT. const left = captureLeave(client); const token = await createToken({ signal: 'leave_when_connected', leaveAction: LeaveRequest_Action.RECONNECT, }); await client.join(serverUrl, token, defaultOpts(), undefined, useV0); const leave = await withTimeout(left, 5_000, 'onLeave'); expect(leave.action).toBe(LeaveRequest_Action.RECONNECT); }); it('rejects the join when a leave arrives as the first message', async () => { const err = await join('leave_first_message').then( () => undefined, (e) => e as Error, ); expect(err).toBeInstanceOf(Error); expect((err as { reason?: ConnectionErrorReason }).reason).toBe( ConnectionErrorReason.LeaveRequest, ); expect((err as Error).message).toContain('Received leave request'); }); it('closes gracefully via close() without firing onClose', async () => { await join('happy'); const closed = captureClose(client); await client.close(); expect(client.isDisconnected).toBe(true); // Give any stray onClose a macrotask to land before asserting it stayed silent. expect(await isPending(closed, 100)).toBe(true); }); it('reconnects (resume) back to CONNECTED', async () => { await join('happy'); const token = await createToken({ signal: 'happy' }); await client.reconnect(serverUrl, token, 'RM_session'); expect(client.currentState).toBe(SignalConnectionState.CONNECTED); }); it('rejects a reconnect when a leave arrives as the first message', async () => { // Reconnecting into a room that sends leave-first exercises the client's // first-message validation while in RECONNECTING (path-independent: it // doesn't rely on the mock detecting reconnect, which v1 hides inside the // gzipped join_request the mock ignores). await join('happy'); const token = await createToken({ signal: 'leave_first_message' }); const err = await client.reconnect(serverUrl, token, 'RM_session').then( () => undefined, (e) => e as Error, ); expect(err).toBeInstanceOf(Error); expect((err as { reason?: ConnectionErrorReason }).reason).toBe( ConnectionErrorReason.LeaveRequest, ); expect((err as Error).message).toContain('Received leave request'); }); // --- validate-endpoint classification ------------------------------- it('classifies an invalid token as NotAllowed', async () => { const token = await createInvalidToken(); const err = await client .join(serverUrl, token, defaultOpts(), undefined, useV0) .catch((e) => e); expect(err.reason).toBe(ConnectionErrorReason.NotAllowed); }); it('classifies room-not-found as NotAllowed', async () => { const err = await join('room_not_found').catch((e) => e); expect(err.reason).toBe(ConnectionErrorReason.NotAllowed); }); it('classifies a wrong-path 404 as ServiceNotFound', async () => { const err = await join('validate_service_not_found').catch((e) => e); expect(err.reason).toBe(ConnectionErrorReason.ServiceNotFound); }); it('surfaces a 5xx validate as a WebSocket error (ws error shadows 5xx→internal)', async () => { // Current behavior: handleConnectionError only maps a 5xx to InternalError // when the ws rejection is NOT a ConnectionError. WebSocketStream always // rejects with one, so for a refused upgrade the WS error wins. Only // 401/403/404 (handled before that check) override it. const err = await join('validate_500').catch((e) => e); expect(err.reason).toBe(ConnectionErrorReason.WebSocket); }); }); // Path-independent transport-level failures (run once, via v0). describe('transport failures', () => { let client: SignalClient; beforeEach(() => { client = new SignalClient(false); }); afterEach(async () => { await client.close().catch(() => {}); }); it('classifies an unreachable server as ServerUnreachable', async () => { const token = await createToken({ signal: 'happy' }); // Nothing listening on this port -> ws fails, validate fetch fails. const err = await client .join('ws://127.0.0.1:59999', token, defaultOpts(), undefined, true) .catch((e) => e); expect(err.reason).toBe(ConnectionErrorReason.ServerUnreachable); }); it('cancels the connect when the AbortSignal fires mid-connection', async () => { const controller = new AbortController(); const token = await createToken({ signal: 'no_first_message' }); const p = client.join(serverUrl, token, defaultOpts(), controller.signal, true); setTimeout(() => controller.abort('user requested abort'), 300); const err = await p.catch((e) => e); expect(err.reason).toBe(ConnectionErrorReason.Cancelled); }); it('times out when the socket opens but no first message arrives', async () => { // wsTimeout only guards the WebSocket upgrade and is cleared once ws.opened // resolves. If the server accepts the socket then stays silent, the wait for // the first message is guarded by its own JOIN_RESPONSE_TIMEOUT (5s), so // join() rejects with Timeout instead of hanging. const token = await createToken({ signal: 'no_first_message' }); const joinPromise = client.join(serverUrl, token, defaultOpts(), undefined, true); const err = await withTimeout( joinPromise.then( () => undefined, (e) => e, ), 8_000, 'join to time out', ); expect(err.reason).toBe(ConnectionErrorReason.Timeout); }); }); });