Skip to content

Commit 02cf89c

Browse files
committed
feat: add initial e2e tests
1 parent 7899ac9 commit 02cf89c

2 files changed

Lines changed: 337 additions & 1 deletion

File tree

‎packages/livekit-rtc/scripts/run-e2e.mjs‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ const PORT = 7880;
1616
const URL = `ws://${HOST}:${PORT}`;
1717
const API_KEY = 'devkey';
1818
const API_SECRET = 'secret';
19+
const TEST_FILES = ['src/tests/e2e.test.ts', 'src/tests/e2e_data_streams.test.ts'];
1920

2021
async function tcpReady(host, port, timeoutMs) {
2122
const deadline = Date.now() + timeoutMs;
@@ -77,7 +78,7 @@ process.on('SIGTERM', () => onSignal('SIGTERM'));
7778
try {
7879
await tcpReady(HOST, PORT, 15_000);
7980

80-
const args = ['exec', 'vitest', 'run', 'src/tests/e2e.test.ts', ...process.argv.slice(2)];
81+
const args = ['exec', 'vitest', 'run', ...TEST_FILES, ...process.argv.slice(2)];
8182
testProc = spawn('pnpm', args, {
8283
env: {
8384
...process.env,
Lines changed: 335 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,335 @@
1+
// SPDX-FileCopyrightText: 2026 LiveKit, Inc.
2+
//
3+
// SPDX-License-Identifier: Apache-2.0
4+
//
5+
// End-to-end tests for data streams (text/byte streams over the FFI-backed
6+
// rust implementation). Ported from rust-sdks `livekit/tests/data_stream_test.rs`
7+
// where the node API surface allows; see comments on individual tests for
8+
// intentional deviations.
9+
import { afterAll, expect, it as itRaw } from 'vitest';
10+
import { dispose } from '../index.js';
11+
import {
12+
concatUint8,
13+
connectTestRooms,
14+
describeE2E,
15+
testTimeoutMs,
16+
withTimeout,
17+
} from './e2e_common.js';
18+
19+
// use concurrent testing if available on the runner (currently not supported by bun's api)
20+
const it = typeof itRaw.concurrent === 'function' ? itRaw.concurrent : itRaw;
21+
22+
/** Deterministic pseudo-random lowercase text (mulberry32 PRNG).
23+
*
24+
* Counterpart of rust's `pseudo_random_text`: random lowercase carries
25+
* ~4.7 bits of entropy per 8-bit byte, so deflate compresses it well under
26+
* its raw size — exercising the chunked-compressed wire path. */
27+
function pseudoRandomText(length: number, seed = 0x5eed): string {
28+
let a = seed >>> 0;
29+
const next = () => {
30+
a = (a + 0x6d2b79f5) >>> 0;
31+
let t = a;
32+
t = Math.imul(t ^ (t >>> 15), t | 1);
33+
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
34+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
35+
};
36+
const chars = new Array<string>(length);
37+
for (let i = 0; i < length; i++) {
38+
chars[i] = String.fromCharCode(97 + Math.floor(next() * 26));
39+
}
40+
return chars.join('');
41+
}
42+
43+
/** Deterministic pseudo-random bytes (mulberry32 PRNG).
44+
*
45+
* Uniform random bytes are genuinely incompressible: deflate cannot shrink
46+
* them, so the send path must fall back to an uncompressed wire format.
47+
* Seeded for a deterministic, reproducible test. */
48+
function pseudoRandomBytes(length: number, seed = 0xc0ffee): Uint8Array {
49+
let a = seed >>> 0;
50+
const out = new Uint8Array(length);
51+
for (let i = 0; i < length; i++) {
52+
a = (a + 0x6d2b79f5) >>> 0;
53+
let t = a;
54+
t = Math.imul(t ^ (t >>> 15), t | 1);
55+
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
56+
out[i] = (t ^ (t >>> 14)) & 0xff;
57+
}
58+
return out;
59+
}
60+
61+
describeE2E('livekit-rtc data streams e2e', () => {
62+
afterAll(async () => {
63+
await dispose();
64+
});
65+
66+
// Port of rust `test_send_text`. The `is_compressed` / `is_inline` info
67+
// assertions are not portable: the FFI stream-info protos don't expose them.
68+
it(
69+
'sends and receives a small text stream',
70+
async () => {
71+
const { rooms } = await connectTestRooms(2);
72+
const [receivingRoom, sendingRoom] = rooms;
73+
const senderIdentity = sendingRoom!.localParticipant!.identity;
74+
75+
const topic = 'some-topic';
76+
const textToSend = 'some-text';
77+
78+
const receivedText = withTimeout(
79+
new Promise<string>((resolve) => {
80+
receivingRoom!.registerTextStreamHandler(topic, async (reader, sender) => {
81+
expect(sender.identity).toBe(senderIdentity);
82+
resolve(await reader.readAll());
83+
});
84+
}),
85+
testTimeoutMs,
86+
'Timed out waiting for text stream',
87+
);
88+
89+
const textInfo = await sendingRoom!.localParticipant!.sendText(textToSend, { topic });
90+
expect(textInfo.streamId).toBeTruthy();
91+
expect(Math.abs(textInfo.timestamp - Date.now())).toBeLessThanOrEqual(1_000);
92+
expect(textInfo.totalSize).toBe(textToSend.length);
93+
expect(textInfo.mimeType).toBe('text/plain');
94+
expect(textInfo.topic).toBe(topic);
95+
96+
expect(await receivedText).toBe(textToSend);
97+
98+
await Promise.all(rooms.map((r) => r.disconnect()));
99+
},
100+
testTimeoutMs,
101+
);
102+
103+
// Port of rust `test_send_bytes`. Node has no `sendBytes` (in-memory one-shot)
104+
// wrapper yet, so this uses the incremental `streamBytes` writer — which by
105+
// design is never inlined or compressed on the wire.
106+
it(
107+
'sends and receives a small byte stream',
108+
async () => {
109+
const { rooms } = await connectTestRooms(2);
110+
const [receivingRoom, sendingRoom] = rooms;
111+
const senderIdentity = sendingRoom!.localParticipant!.identity;
112+
113+
const topic = 'some-topic';
114+
const bytesToSend = new Uint8Array(16).fill(0xfa);
115+
116+
const receivedBytes = withTimeout(
117+
new Promise<Uint8Array>((resolve) => {
118+
receivingRoom!.registerByteStreamHandler(topic, async (reader, sender) => {
119+
expect(sender.identity).toBe(senderIdentity);
120+
const chunks = await reader.readAll();
121+
resolve(concatUint8(chunks));
122+
});
123+
}),
124+
testTimeoutMs,
125+
'Timed out waiting for byte stream',
126+
);
127+
128+
const writer = await sendingRoom!.localParticipant!.streamBytes({
129+
topic,
130+
totalSize: bytesToSend.byteLength,
131+
});
132+
await writer.write(bytesToSend);
133+
await writer.close();
134+
135+
const byteInfo = writer.info;
136+
expect(byteInfo.streamId).toBeTruthy();
137+
expect(Math.abs(byteInfo.timestamp - Date.now())).toBeLessThanOrEqual(1_000);
138+
expect(byteInfo.mimeType).toBe('application/octet-stream');
139+
expect(byteInfo.topic).toBe(topic);
140+
141+
expect(await receivedBytes).toEqual(bytesToSend);
142+
143+
await Promise.all(rooms.map((r) => r.disconnect()));
144+
},
145+
testTimeoutMs,
146+
);
147+
148+
// Port of rust `test_send_large_compressible_text`: ~50 KB of deterministic
149+
// pseudo-random lowercase is too big to inline and compresses well under its
150+
// raw size, so the sender emits chunked deflate-raw and the receiver
151+
// decompresses — validating the v2 compression path on the real wire.
152+
it(
153+
'round-trips a large compressible text (chunked + compressed wire path)',
154+
async () => {
155+
const { rooms } = await connectTestRooms(2);
156+
const [receivingRoom, sendingRoom] = rooms;
157+
158+
const topic = 'large-compressible-text';
159+
const text = pseudoRandomText(50_000);
160+
161+
const receivedText = withTimeout(
162+
new Promise<string>((resolve) => {
163+
receivingRoom!.registerTextStreamHandler(topic, async (reader) => {
164+
resolve(await reader.readAll());
165+
});
166+
}),
167+
testTimeoutMs,
168+
'Timed out waiting for large text stream',
169+
);
170+
171+
const info = await sendingRoom!.localParticipant!.sendText(text, { topic });
172+
expect(info.totalSize).toBe(text.length);
173+
174+
expect(await receivedText).toBe(text);
175+
176+
await Promise.all(rooms.map((r) => r.disconnect()));
177+
},
178+
testTimeoutMs,
179+
);
180+
181+
// Port of rust `test_send_large_incompressible_random_bytes`, adapted to the
182+
// incremental `streamBytes` writer (no `sendBytes` wrapper in node yet):
183+
// uniform random bytes exercise the chunked, uncompressed wire path with a
184+
// payload spanning many chunks.
185+
it(
186+
'round-trips large incompressible random bytes',
187+
async () => {
188+
const { rooms } = await connectTestRooms(2);
189+
const [receivingRoom, sendingRoom] = rooms;
190+
191+
const topic = 'large-random-bytes';
192+
const payload = pseudoRandomBytes(1_800_000);
193+
194+
const receivedBytes = withTimeout(
195+
new Promise<Uint8Array>((resolve) => {
196+
receivingRoom!.registerByteStreamHandler(topic, async (reader) => {
197+
resolve(concatUint8(await reader.readAll()));
198+
});
199+
}),
200+
testTimeoutMs * 2,
201+
'Timed out waiting for large byte stream',
202+
);
203+
204+
const writer = await sendingRoom!.localParticipant!.streamBytes({
205+
topic,
206+
totalSize: payload.byteLength,
207+
});
208+
const writeChunkSize = 64 * 1024;
209+
for (let offset = 0; offset < payload.byteLength; offset += writeChunkSize) {
210+
await writer.write(payload.subarray(offset, offset + writeChunkSize));
211+
}
212+
await writer.close();
213+
214+
const received = await receivedBytes;
215+
expect(received.byteLength).toBe(payload.byteLength);
216+
expect(received).toEqual(payload);
217+
218+
await Promise.all(rooms.map((r) => r.disconnect()));
219+
},
220+
testTimeoutMs * 3,
221+
);
222+
223+
// Port of rust `test_send_large_bytes`: a 50 KB patterned (compressible)
224+
// payload via the byte-stream path.
225+
it(
226+
'round-trips large patterned bytes',
227+
async () => {
228+
const { rooms } = await connectTestRooms(2);
229+
const [receivingRoom, sendingRoom] = rooms;
230+
231+
const topic = 'large-patterned-bytes';
232+
const payload = new Uint8Array(50_000);
233+
for (let i = 0; i < payload.length; i++) payload[i] = i % 251;
234+
235+
const receivedBytes = withTimeout(
236+
new Promise<Uint8Array>((resolve) => {
237+
receivingRoom!.registerByteStreamHandler(topic, async (reader) => {
238+
resolve(concatUint8(await reader.readAll()));
239+
});
240+
}),
241+
testTimeoutMs,
242+
'Timed out waiting for patterned byte stream',
243+
);
244+
245+
const writer = await sendingRoom!.localParticipant!.streamBytes({
246+
topic,
247+
totalSize: payload.byteLength,
248+
});
249+
await writer.write(payload);
250+
await writer.close();
251+
252+
expect(await receivedBytes).toEqual(payload);
253+
254+
await Promise.all(rooms.map((r) => r.disconnect()));
255+
},
256+
testTimeoutMs,
257+
);
258+
259+
// Incremental writer path for text (multi-write, unicode content): ensures
260+
// UTF-8-aware chunking on the sender doesn't split multi-byte characters.
261+
it(
262+
'round-trips an incremental text stream with multi-byte characters',
263+
async () => {
264+
const { rooms } = await connectTestRooms(2);
265+
const [receivingRoom, sendingRoom] = rooms;
266+
267+
const topic = 'incremental-unicode-text';
268+
const pieces = ['héllo wörld — ', '日本語のテキスト、', '🌍🚀 emoji tail'];
269+
const expected = pieces.join('');
270+
271+
const receivedText = withTimeout(
272+
new Promise<string>((resolve) => {
273+
receivingRoom!.registerTextStreamHandler(topic, async (reader) => {
274+
resolve(await reader.readAll());
275+
});
276+
}),
277+
testTimeoutMs,
278+
'Timed out waiting for incremental text stream',
279+
);
280+
281+
const writer = await sendingRoom!.localParticipant!.streamText({ topic });
282+
for (const piece of pieces) {
283+
await writer.write(piece);
284+
}
285+
await writer.close();
286+
287+
expect(await receivedText).toBe(expected);
288+
289+
await Promise.all(rooms.map((r) => r.disconnect()));
290+
},
291+
testTimeoutMs,
292+
);
293+
294+
// Moved from e2e.test.ts (kept alongside the other data-stream coverage).
295+
it(
296+
'cleans up stream controllers when disconnecting during an active stream',
297+
async () => {
298+
const { rooms } = await connectTestRooms(2);
299+
const [receivingRoom, sendingRoom] = rooms;
300+
const topic = 'cleanup-stream-topic';
301+
302+
// Register a handler on the receiving side that will intentionally
303+
// NOT fully consume the stream — simulating an abandoned transfer.
304+
let readerReceived = false;
305+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
306+
receivingRoom!.registerTextStreamHandler(topic, async (_reader, _sender) => {
307+
readerReceived = true;
308+
// Deliberately do not call reader.readAll() so the stream stays open
309+
});
310+
311+
// Start sending a text stream but don't close it
312+
const writer = await sendingRoom!.localParticipant!.streamText({ topic });
313+
await writer.write('partial data');
314+
315+
// Wait for the receiving side to get the stream header
316+
const deadline = Date.now() + 5000;
317+
while (!readerReceived && Date.now() < deadline) {
318+
await new Promise((r) => setTimeout(r, 50));
319+
}
320+
expect(readerReceived).toBe(true);
321+
322+
// Disconnect the receiving room while the stream is still open.
323+
// This should close the stream controller without throwing.
324+
await receivingRoom!.disconnect();
325+
326+
// Also close the writer and disconnect the sender
327+
await writer.close();
328+
await sendingRoom!.disconnect();
329+
330+
// If we got here without hanging or throwing, the stream controller
331+
// was properly cleaned up on disconnect.
332+
},
333+
testTimeoutMs,
334+
);
335+
});

0 commit comments

Comments
 (0)