import { ClientInfo_Capability } from '@livekit/protocol'; import { describe, expect, it, vi } from 'vitest'; import { ddExtensionURI, extractMaxAgeFromRequestHeaders, getClientInfo, negotiateDependencyDescriptor, splitUtf8, toWebsocketUrl, } from './utils'; describe('toWebsocketUrl', () => { it('leaves wss urls alone', () => { expect(toWebsocketUrl('ws://mywebsite.com')).toEqual('ws://mywebsite.com'); }); it('converts https to wss', () => { expect(toWebsocketUrl('https://mywebsite.com')).toEqual('wss://mywebsite.com'); }); it('does not convert other parts of URL', () => { expect(toWebsocketUrl('https://httpsmywebsite.com')).toEqual('wss://httpsmywebsite.com'); }); }); describe('getClientInfo', () => { it('does not advertise packet trailer capability by default', () => { expect(getClientInfo().capabilities).toEqual([]); }); it('advertises packet trailer capability when provided', () => { expect(getClientInfo([ClientInfo_Capability.CAP_PACKET_TRAILER]).capabilities).toEqual([ ClientInfo_Capability.CAP_PACKET_TRAILER, ]); }); }); describe('splitUtf8', () => { it('splits a string into chunks of the given size', () => { expect(splitUtf8('hello world', 5)).toEqual([ new TextEncoder().encode('hello'), new TextEncoder().encode(' worl'), new TextEncoder().encode('d'), ]); }); it('splits a string with special characters into chunks of the given size', () => { expect(splitUtf8('héllo wörld', 5)).toEqual([ new TextEncoder().encode('héll'), new TextEncoder().encode('o wö'), new TextEncoder().encode('rld'), ]); }); it('splits a string with multi-byte utf8 characters correctly', () => { expect(splitUtf8('こんにちは世界', 5)).toEqual([ new TextEncoder().encode('こ'), new TextEncoder().encode('ん'), new TextEncoder().encode('に'), new TextEncoder().encode('ち'), new TextEncoder().encode('は'), new TextEncoder().encode('世'), new TextEncoder().encode('界'), ]); }); it('handles a string with a single multi-byte utf8 character', () => { expect(splitUtf8('😊', 5)).toEqual([new TextEncoder().encode('😊')]); }); it('handles a string with mixed single and multi-byte utf8 characters', () => { expect(splitUtf8('a😊b', 4)).toEqual([ new TextEncoder().encode('a'), new TextEncoder().encode('😊'), new TextEncoder().encode('b'), ]); }); it('handles an empty string', () => { expect(splitUtf8('', 5)).toEqual([]); }); }); describe('extractMaxAgeFromRequestHeaders', () => { it('extracts max-age from valid Cache-Control header', () => { const headers = new Headers(); headers.set('Cache-Control', 'max-age=3600'); expect(extractMaxAgeFromRequestHeaders(headers)).toBe(3600); }); it('extracts max-age from Cache-Control header with multiple directives', () => { const headers = new Headers(); headers.set('Cache-Control', 'public, max-age=7200, must-revalidate'); expect(extractMaxAgeFromRequestHeaders(headers)).toBe(7200); }); it('extracts max-age when it appears at the beginning', () => { const headers = new Headers(); headers.set('Cache-Control', 'max-age=1800, public, no-cache'); expect(extractMaxAgeFromRequestHeaders(headers)).toBe(1800); }); it('extracts max-age when it appears in the middle', () => { const headers = new Headers(); headers.set('Cache-Control', 'public, max-age=900, no-store'); expect(extractMaxAgeFromRequestHeaders(headers)).toBe(900); }); it('handles max-age with value 0', () => { const headers = new Headers(); headers.set('Cache-Control', 'max-age=0'); expect(extractMaxAgeFromRequestHeaders(headers)).toBe(0); }); it('handles large max-age values', () => { const headers = new Headers(); headers.set('Cache-Control', 'max-age=31536000'); // 1 year expect(extractMaxAgeFromRequestHeaders(headers)).toBe(31536000); }); it('returns undefined when Cache-Control header is missing', () => { const headers = new Headers(); expect(extractMaxAgeFromRequestHeaders(headers)).toBeUndefined(); }); it('returns undefined when Cache-Control header exists but has no max-age', () => { const headers = new Headers(); headers.set('Cache-Control', 'public, no-cache, must-revalidate'); expect(extractMaxAgeFromRequestHeaders(headers)).toBeUndefined(); }); it('returns undefined when Cache-Control header is empty', () => { const headers = new Headers(); headers.set('Cache-Control', ''); expect(extractMaxAgeFromRequestHeaders(headers)).toBeUndefined(); }); it('handles Cache-Control header with extra whitespace', () => { const headers = new Headers(); headers.set('Cache-Control', ' public, max-age=1200 , no-cache '); expect(extractMaxAgeFromRequestHeaders(headers)).toBe(1200); }); it('returns undefined for malformed max-age values', () => { const headers = new Headers(); headers.set('Cache-Control', 'max-age=abc, public'); expect(extractMaxAgeFromRequestHeaders(headers)).toBeUndefined(); }); it('handles max-age with leading zeros', () => { const headers = new Headers(); headers.set('Cache-Control', 'max-age=0003600'); expect(extractMaxAgeFromRequestHeaders(headers)).toBe(3600); }); it('takes the first max-age value when multiple are present', () => { const headers = new Headers(); headers.set('Cache-Control', 'max-age=1800, public, max-age=3600'); expect(extractMaxAgeFromRequestHeaders(headers)).toBe(1800); }); it('handles case-insensitive Cache-Control header name', () => { const headers = new Headers(); headers.set('cache-control', 'max-age=2400'); expect(extractMaxAgeFromRequestHeaders(headers)).toBe(2400); }); it('handles Cache-Control with s-maxage (should ignore s-maxage)', () => { const headers = new Headers(); headers.set('Cache-Control', 's-maxage=1800, max-age=3600'); expect(extractMaxAgeFromRequestHeaders(headers)).toBe(3600); }); it('returns undefined when only s-maxage is present (no max-age)', () => { const headers = new Headers(); headers.set('Cache-Control', 's-maxage=1800, public'); expect(extractMaxAgeFromRequestHeaders(headers)).toBeUndefined(); }); it('returns undefined for negative max-age values (regex only matches positive digits)', () => { const headers = new Headers(); headers.set('Cache-Control', 'max-age=-100'); expect(extractMaxAgeFromRequestHeaders(headers)).toBeUndefined(); }); it('ignores non standard cache control custom-max-age values', () => { const headers = new Headers(); headers.set('Cache-Control', 'custom-max-age=7200'); expect(extractMaxAgeFromRequestHeaders(headers)).toBeUndefined(); }); it('still works with comma separated non standard cache control custom-max-age values', () => { const headers = new Headers(); headers.set('Cache-Control', 'custom-max-age=7200,max-age=3600'); expect(extractMaxAgeFromRequestHeaders(headers)).toBe(3600); }); }); describe('negotiateDependencyDescriptor', () => { /** A transceiver whose header extension control offers `extensions`, or none at all. */ const transceiverWith = (extensions?: RTCRtpHeaderExtensionCapability[], throws = false) => { const set = vi.fn((updated: RTCRtpHeaderExtensionCapability[]) => { if (throws) { throw new Error('InvalidModificationError'); } extensions = updated; }); return { transceiver: (extensions ? { getHeaderExtensionsToNegotiate: () => extensions!, setHeaderExtensionsToNegotiate: set, } : {}) as unknown as RTCRtpTransceiver, set, current: () => extensions, }; }; it('turns the extension on for a transceiver that has it stopped', () => { const { transceiver, set, current } = transceiverWith([ { uri: 'urn:ietf:params:rtp-hdrext:sdes:mid', direction: 'sendrecv' }, { uri: ddExtensionURI, direction: 'stopped' }, ]); expect(negotiateDependencyDescriptor(transceiver)).toBe(true); expect(set).toHaveBeenCalledOnce(); // sendrecv keeps it a plain a=extmap line, with no direction suffix for the server to parse expect(current()?.find((ext) => ext.uri === ddExtensionURI)?.direction).toBe('sendrecv'); }); it('leaves an extension the browser already negotiates alone', () => { const { transceiver, set } = transceiverWith([{ uri: ddExtensionURI, direction: 'recvonly' }]); expect(negotiateDependencyDescriptor(transceiver)).toBe(true); expect(set).not.toHaveBeenCalled(); }); it('reports no negotiation where the browser does not know the extension', () => { const { transceiver, set } = transceiverWith([ { uri: 'urn:ietf:params:rtp-hdrext:sdes:mid', direction: 'sendrecv' }, ]); expect(negotiateDependencyDescriptor(transceiver)).toBe(false); expect(set).not.toHaveBeenCalled(); }); it('reports no negotiation where the browser has no such control', () => { const { transceiver } = transceiverWith(); expect(negotiateDependencyDescriptor(transceiver)).toBe(false); }); it('swallows a rejected direction rather than failing the connection', () => { const { transceiver } = transceiverWith([{ uri: ddExtensionURI, direction: 'stopped' }], true); expect(negotiateDependencyDescriptor(transceiver)).toBe(false); }); });