diff --git a/package.json b/package.json index 0886d83887..7d542e77b4 100644 --- a/package.json +++ b/package.json @@ -119,6 +119,7 @@ "glob": "^13.0.6", "happy-dom": "^20.0.0", "jsdom": "^26.1.0", + "livekit-server-sdk": "^2.17.0", "playwright": "^1.61.1", "prettier": "^3.4.2", "publint": "^0.3.21", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ab7fc902b0..4298abe138 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -150,6 +150,9 @@ importers: jsdom: specifier: ^26.1.0 version: 26.1.0 + livekit-server-sdk: + specifier: ^2.17.0 + version: 2.17.0 playwright: specifier: ^1.61.1 version: 1.61.1 @@ -2898,6 +2901,9 @@ packages: resolution: {integrity: sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==} hasBin: true + jose@5.10.0: + resolution: {integrity: sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==} + jose@6.2.3: resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==} @@ -2967,6 +2973,10 @@ packages: linkify-it@5.0.0: resolution: {integrity: sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==} + livekit-server-sdk@2.17.0: + resolution: {integrity: sha512-GWNEQrUD13UwZNrmosyTVFPgeBvSU2lFKGxdA4DaUI5F4sMb1cGeep/PPEE9vvRNELJYpxWS0ImS4gQmovKUDA==} + engines: {node: '>=18'} + loader-runner@4.3.2: resolution: {integrity: sha512-DFEqQ3ihfS9blba08cLfYf1NRAIEm+dDjic073DRDc3/JspI/8wYmtDsHwd3+4hwvdxSK7PGaElfTmm0awWJ4w==} engines: {node: '>=6.11.5'} @@ -7003,6 +7013,8 @@ snapshots: jiti@2.4.2: {} + jose@5.10.0: {} + jose@6.2.3: {} js-tokens@4.0.0: {} @@ -7085,6 +7097,12 @@ snapshots: dependencies: uc.micro: 2.1.0 + livekit-server-sdk@2.17.0: + dependencies: + '@bufbuild/protobuf': 1.10.1 + '@livekit/protocol': 1.50.4 + jose: 5.10.0 + loader-runner@4.3.2: {} locate-path@5.0.0: diff --git a/src/api/SignalClient.e2e.test.ts b/src/api/SignalClient.e2e.test.ts index 6d473cbaa9..d474100a32 100644 --- a/src/api/SignalClient.e2e.test.ts +++ b/src/api/SignalClient.e2e.test.ts @@ -1,6 +1,8 @@ import { type LeaveRequest, LeaveRequest_Action } from '@livekit/protocol'; -import { afterEach, beforeEach, describe, expect, inject, it, vi } from 'vitest'; +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'; @@ -15,8 +17,6 @@ const defaultOpts = (): SignalOptions => ({ websocketTimeout: 5_000, }); -const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); - function withTimeout(p: Promise, ms: number, label: string): Promise { return Promise.race([ p, @@ -70,12 +70,11 @@ describe.skipIf(!!unavailable)('SignalClient e2e', () => { }); it('stays connected past the ping timeout while the server pongs', async () => { - const onClose = vi.fn(); await join('happy'); - client.onClose = onClose; + const closed = captureClose(client); // Server ping timeout is 3s; a healthy pong loop must keep us alive. await sleep(4_000); - expect(onClose).not.toHaveBeenCalled(); + expect(await isPending(closed)).toBe(true); expect(client.currentState).toBe(SignalConnectionState.CONNECTED); }); @@ -151,15 +150,19 @@ describe.skipIf(!!unavailable)('SignalClient e2e', () => { (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 () => { - const onClose = vi.fn(); await join('happy'); - client.onClose = onClose; + const closed = captureClose(client); await client.close(); expect(client.isDisconnected).toBe(true); - expect(onClose).not.toHaveBeenCalled(); + // 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 () => { @@ -181,6 +184,10 @@ describe.skipIf(!!unavailable)('SignalClient e2e', () => { (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 ------------------------------- diff --git a/src/test/promiseState.ts b/src/test/promiseState.ts new file mode 100644 index 0000000000..e57a202d08 --- /dev/null +++ b/src/test/promiseState.ts @@ -0,0 +1,23 @@ +/** + * Test helper: resolves `true` if `promise` is still pending after `ms` + * (default: one macrotask), `false` if it has already settled — regardless of + * whether it resolved or rejected. + * + * Use to assert that something has *not* happened yet (e.g. an onClose callback + * that must stay silent), optionally within a timeout window. Preferable to + * `Promise.race([p, Promise.resolve(sentinel)])` when the "not yet" needs to + * hold for a duration rather than just the current microtask. + */ +export async function isPending(promise: Promise, ms = 0): Promise { + const pending = Symbol('pending'); + // Swallow settlement (incl. rejection) so racing it can't raise unhandled + // rejections when the timer wins. + const settled = promise.then( + () => 'settled' as const, + () => 'settled' as const, + ); + const timer = new Promise((resolve) => { + setTimeout(() => resolve(pending), ms); + }); + return (await Promise.race([settled, timer])) === pending; +} diff --git a/src/test/signalServerSetup.ts b/src/test/signalServerSetup.ts index 49bb767a04..af8a375ff3 100644 --- a/src/test/signalServerSetup.ts +++ b/src/test/signalServerSetup.ts @@ -2,6 +2,7 @@ import { type ChildProcess, execFileSync, spawn } from 'node:child_process'; import { existsSync } from 'node:fs'; import { Socket, createServer } from 'node:net'; import { join } from 'node:path'; +import { sleep } from '../room/utils'; import { createToken } from './signalToken'; /** @@ -47,7 +48,7 @@ async function waitReady(serverUrl: string): Promise { } catch { // not up yet } - await new Promise((r) => setTimeout(r, 200)); + await sleep(200); } return false; } diff --git a/src/test/signalToken.ts b/src/test/signalToken.ts index 5d89d34bef..efcc545843 100644 --- a/src/test/signalToken.ts +++ b/src/test/signalToken.ts @@ -1,4 +1,4 @@ -import { SignJWT } from 'jose'; +import { AccessToken } from 'livekit-server-sdk'; /** * Mint a LiveKit access token for the mock test-server (HS256, dev secret). @@ -33,24 +33,28 @@ export async function createToken(opts: TokenOptions = {}): Promise { secret = 'secret', ttlSeconds = 600, } = opts; - const key = new TextEncoder().encode(secret); - const payload: Record = { - video: { room, roomJoin: true, canPublish: true, canSubscribe: true, canPublishData: true }, - }; + const attributes: Record = {}; if (signal) { const control: Record = { signal }; if (leaveAction !== undefined) { control.leaveAction = leaveAction; } - payload.attributes = { 'lk.mock': JSON.stringify(control) }; + attributes['lk.mock'] = JSON.stringify(control); } - return new SignJWT(payload) - .setProtectedHeader({ alg: 'HS256' }) - .setIssuer(apiKey) - .setSubject(identity) - .setIssuedAt() - .setExpirationTime(`${ttlSeconds}s`) - .sign(key); + const token = new AccessToken(apiKey, secret, { + ttl: ttlSeconds, + identity, + attributes, + }); + token.addGrant({ + room, + roomJoin: true, + canPublish: true, + canSubscribe: true, + canPublishData: true, + }); + + return token.toJwt(); } /** A syntactically-valid token signed with the WRONG secret — for 401 tests. */ diff --git a/tsconfig.json b/tsconfig.json index edcc6cb45e..d44330a65e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -25,13 +25,19 @@ "skipLibCheck": true /* Skip type checking of declaration files. */, "noUnusedLocals": true, "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */, - "moduleResolution": "node", + "moduleResolution": "bundler", "resolveJsonModule": true, "verbatimModuleSyntax": true, "ignoreDeprecations": "5.0", "plugins": [{ "name": "@livekit/throws-transformer" }] }, - "exclude": ["dist", "**/*.test.ts", "test/**", "src/room/token-source/test-tokens.ts", "src/test/signalServerSetup.ts"], + "exclude": [ + "dist", + "**/*.test.ts", + "test/**", + "src/room/token-source/test-tokens.ts", + "src/test/signalServerSetup.ts" + ], "include": ["src/**/*.ts"], "typedocOptions": { "entryPoints": ["src/index.ts"],