Skip to content
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
18 changes: 18 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

25 changes: 16 additions & 9 deletions src/api/SignalClient.e2e.test.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -15,8 +17,6 @@ const defaultOpts = (): SignalOptions => ({
websocketTimeout: 5_000,
});

const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));

function withTimeout<T>(p: Promise<T>, ms: number, label: string): Promise<T> {
return Promise.race([
p,
Expand Down Expand Up @@ -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);
});

Expand Down Expand Up @@ -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 () => {
Expand All @@ -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 -------------------------------
Expand Down
23 changes: 23 additions & 0 deletions src/test/promiseState.ts
Original file line number Diff line number Diff line change
@@ -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<unknown>, ms = 0): Promise<boolean> {
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<typeof pending>((resolve) => {
setTimeout(() => resolve(pending), ms);
});
return (await Promise.race([settled, timer])) === pending;
}
3 changes: 2 additions & 1 deletion src/test/signalServerSetup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand Down Expand Up @@ -47,7 +48,7 @@ async function waitReady(serverUrl: string): Promise<boolean> {
} catch {
// not up yet
}
await new Promise((r) => setTimeout(r, 200));
await sleep(200);
}
return false;
}
Expand Down
30 changes: 17 additions & 13 deletions src/test/signalToken.ts
Original file line number Diff line number Diff line change
@@ -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).
Expand Down Expand Up @@ -33,24 +33,28 @@ export async function createToken(opts: TokenOptions = {}): Promise<string> {
secret = 'secret',
ttlSeconds = 600,
} = opts;
const key = new TextEncoder().encode(secret);
const payload: Record<string, unknown> = {
video: { room, roomJoin: true, canPublish: true, canSubscribe: true, canPublishData: true },
};
const attributes: Record<string, string> = {};
if (signal) {
const control: Record<string, unknown> = { 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. */
Expand Down
10 changes: 8 additions & 2 deletions tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down
Loading