@livekit/rtc-node 0.13.29 → 0.13.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/dist/audio_stream-B4hGVcKz.d.cts +498 -0
  2. package/dist/audio_stream-DEG1JKge.d.ts +498 -0
  3. package/dist/audio_stream.cjs +21 -6
  4. package/dist/audio_stream.cjs.map +1 -1
  5. package/dist/audio_stream.d.cts +12 -22
  6. package/dist/audio_stream.d.ts +12 -22
  7. package/dist/audio_stream.d.ts.map +1 -1
  8. package/dist/audio_stream.js +19 -5
  9. package/dist/audio_stream.js.map +1 -1
  10. package/dist/data_streams/index.d.cts +1 -1
  11. package/dist/data_streams/index.d.ts +1 -1
  12. package/dist/data_streams/stream_reader.d.cts +1 -1
  13. package/dist/data_streams/stream_reader.d.ts +1 -1
  14. package/dist/data_streams/stream_writer.d.cts +1 -1
  15. package/dist/data_streams/stream_writer.d.ts +1 -1
  16. package/dist/data_streams/types.d.cts +1 -1
  17. package/dist/data_streams/types.d.ts +1 -1
  18. package/dist/frame_processor.cjs +6 -0
  19. package/dist/frame_processor.cjs.map +1 -1
  20. package/dist/frame_processor.d.cts +2 -0
  21. package/dist/frame_processor.d.ts +2 -0
  22. package/dist/frame_processor.d.ts.map +1 -1
  23. package/dist/frame_processor.js +6 -0
  24. package/dist/frame_processor.js.map +1 -1
  25. package/dist/index.d.cts +4 -7
  26. package/dist/index.d.ts +4 -7
  27. package/dist/participant.cjs +6 -0
  28. package/dist/participant.cjs.map +1 -1
  29. package/dist/participant.d.cts +14 -153
  30. package/dist/participant.d.ts +14 -153
  31. package/dist/participant.d.ts.map +1 -1
  32. package/dist/participant.js +6 -0
  33. package/dist/participant.js.map +1 -1
  34. package/dist/room.cjs +28 -0
  35. package/dist/room.cjs.map +1 -1
  36. package/dist/room.d.cts +12 -221
  37. package/dist/room.d.ts +12 -221
  38. package/dist/room.d.ts.map +1 -1
  39. package/dist/room.js +28 -0
  40. package/dist/room.js.map +1 -1
  41. package/dist/track.cjs +123 -0
  42. package/dist/track.cjs.map +1 -1
  43. package/dist/track.d.cts +15 -42
  44. package/dist/track.d.ts +15 -42
  45. package/dist/track.d.ts.map +1 -1
  46. package/dist/track.js +123 -0
  47. package/dist/track.js.map +1 -1
  48. package/dist/track_publication.d.cts +14 -47
  49. package/dist/track_publication.d.ts +14 -47
  50. package/dist/{stream_reader-CuG4b8Y-.d.cts → types-_PdPVish.d.cts} +40 -40
  51. package/dist/{stream_reader-CuG4b8Y-.d.ts → types-_PdPVish.d.ts} +40 -40
  52. package/dist/version.cjs +1 -1
  53. package/dist/version.cjs.map +1 -1
  54. package/dist/version.d.cts +1 -1
  55. package/dist/version.d.ts +1 -1
  56. package/dist/version.js +1 -1
  57. package/dist/version.js.map +1 -1
  58. package/dist/video_stream.d.cts +12 -2
  59. package/dist/video_stream.d.ts +12 -2
  60. package/package.json +3 -3
  61. package/src/audio_stream.ts +28 -4
  62. package/src/audio_stream_room_lifecycle.test.ts +753 -0
  63. package/src/bun_runtime.test.ts +27 -0
  64. package/src/frame_processor.ts +4 -0
  65. package/src/participant.ts +17 -0
  66. package/src/room.ts +44 -0
  67. package/src/track.ts +141 -0
  68. package/src/version.ts +1 -1
@@ -0,0 +1,753 @@
1
+ // SPDX-FileCopyrightText: 2026 LiveKit, Inc.
2
+ //
3
+ // SPDX-License-Identifier: Apache-2.0
4
+ import type { OwnedTrack } from '@livekit/rtc-ffi-bindings';
5
+ import { TrackPublishOptions } from '@livekit/rtc-ffi-bindings';
6
+ import { describe, expect, it, vi } from 'vitest';
7
+ import type { AudioFrame } from './audio_frame.js';
8
+ import type { AudioStreamSource } from './audio_stream.js';
9
+ import { FfiClient } from './ffi_client.js';
10
+ import {
11
+ FrameProcessor,
12
+ type FrameProcessorCredentials,
13
+ type FrameProcessorStreamInfo,
14
+ } from './frame_processor.js';
15
+ import { LocalParticipant } from './participant.js';
16
+ import { Room } from './room.js';
17
+ import { LocalAudioTrack, RemoteAudioTrack, type Track } from './track.js';
18
+ import { LocalTrackPublication } from './track_publication.js';
19
+
20
+ // These tests fabricate Tracks with synthetic (invalid) FFI handle ids to avoid
21
+ // touching the real FFI server. The native FfiHandle has a Rust-side drop that
22
+ // runs when the JS wrapper is garbage-collected; dropping an unallocated handle
23
+ // throws "trying to drop an invalid handle" as an uncaught exception at GC time
24
+ // (intermittent locally, reliably on CI). Replace FfiHandle with an inert stub
25
+ // so no native drop is ever scheduled; everything else in the bindings stays real.
26
+ vi.mock('@livekit/rtc-ffi-bindings', async () => {
27
+ const actual = await vi.importActual<typeof import('@livekit/rtc-ffi-bindings')>(
28
+ '@livekit/rtc-ffi-bindings',
29
+ );
30
+ class FakeFfiHandle {
31
+ private _handle: bigint;
32
+ constructor(handle: bigint) {
33
+ this._handle = handle;
34
+ }
35
+ dispose(): void {}
36
+ get handle(): bigint {
37
+ return this._handle;
38
+ }
39
+ }
40
+ return { ...actual, FfiHandle: FakeFfiHandle };
41
+ });
42
+
43
+ class RecordingProcessor extends FrameProcessor<AudioFrame> {
44
+ enabled = false;
45
+ streamInfoCalls: Array<FrameProcessorStreamInfo> = [];
46
+ credentialsCalls: Array<FrameProcessorCredentials> = [];
47
+ streamInfoClearedCount = 0;
48
+ credentialsClearedCount = 0;
49
+ closeCount = 0;
50
+ isEnabled(): boolean {
51
+ return this.enabled;
52
+ }
53
+ setEnabled(v: boolean): void {
54
+ this.enabled = v;
55
+ }
56
+ onStreamInfoUpdated(info: FrameProcessorStreamInfo): void {
57
+ this.streamInfoCalls.push(info);
58
+ }
59
+ onStreamInfoCleared(): void {
60
+ this.streamInfoClearedCount += 1;
61
+ }
62
+ onCredentialsUpdated(c: FrameProcessorCredentials): void {
63
+ this.credentialsCalls.push(c);
64
+ }
65
+ onCredentialsCleared(): void {
66
+ this.credentialsClearedCount += 1;
67
+ }
68
+ process(f: AudioFrame): AudioFrame {
69
+ return f;
70
+ }
71
+ close(): void {
72
+ this.closeCount += 1;
73
+ }
74
+ }
75
+
76
+ function makeRoom(opts: { name: string; token?: string; serverUrl?: string }): Room {
77
+ const room = new Room();
78
+ // The Room constructor doesn't accept name/token/url; for unit tests we set them directly
79
+ // on the private backing fields, the same way the FFI message handler would.
80
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
81
+ const r = room as any;
82
+ r.info = { name: opts.name };
83
+ r._token = opts.token;
84
+ r._serverUrl = opts.serverUrl;
85
+ return room;
86
+ }
87
+
88
+ interface StubParticipant {
89
+ identity: string;
90
+ trackPublications: Map<string, { sid: string }>;
91
+ }
92
+
93
+ function attachRemoteParticipant(
94
+ room: Room,
95
+ identity: string,
96
+ publications: Array<{ publicationSid: string; trackSid: string }>,
97
+ ): void {
98
+ const map = new Map<string, { sid: string }>();
99
+ for (const pub of publications) {
100
+ map.set(pub.trackSid, { sid: pub.publicationSid });
101
+ }
102
+ const participant: StubParticipant = { identity, trackPublications: map };
103
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
104
+ room.remoteParticipants.set(identity, participant as any);
105
+ }
106
+
107
+ function makeTrack(sid: string): RemoteAudioTrack {
108
+ const owned = {
109
+ info: { sid },
110
+ handle: { id: BigInt(0) },
111
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
112
+ } as any as OwnedTrack;
113
+ return new RemoteAudioTrack(owned);
114
+ }
115
+
116
+ function makeLocalAudioTrack(sid: string): LocalAudioTrack {
117
+ // Safe under the FfiHandle mock — no native handle is created.
118
+ const owned = {
119
+ info: { sid },
120
+ handle: { id: BigInt(0) },
121
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
122
+ } as any as OwnedTrack;
123
+ return new LocalAudioTrack(owned);
124
+ }
125
+
126
+ function makeStream(processor: FrameProcessor<AudioFrame> | null): AudioStreamSource {
127
+ // Minimal stub exercising only the surface the Track touches: the `processor`
128
+ // getter and a no-op `cancel()`. Keeping cancel inert isolates the
129
+ // metadata-push assertions from the real teardown path, which is covered
130
+ // separately via simulateStreamClose.
131
+ return { processor, cancel: () => {} } as unknown as AudioStreamSource;
132
+ }
133
+
134
+ function makeLocalParticipant(identity: string): LocalParticipant {
135
+ // Bypass the FFI-touching constructor; set only the fields the lifecycle
136
+ // paths read (identity getter + trackPublications map).
137
+ const p = Object.create(LocalParticipant.prototype) as LocalParticipant;
138
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
139
+ (p as any).info = { identity };
140
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
141
+ (p as any).trackPublications = new Map();
142
+ return p;
143
+ }
144
+
145
+ function makeLocalPublication(sid: string, track: Track | undefined): LocalTrackPublication {
146
+ const pub = Object.create(LocalTrackPublication.prototype) as LocalTrackPublication;
147
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
148
+ (pub as any).info = { sid };
149
+ pub.track = track;
150
+ return pub;
151
+ }
152
+
153
+ /**
154
+ * Attach a real Room's localParticipant holding one local publication whose
155
+ * `track` is the given Track (mirrors publish_track: track.sid == publication.sid).
156
+ */
157
+ function attachLocalTrack(room: Room, identity: string, sid: string, track: Track): void {
158
+ const local = makeLocalParticipant(identity);
159
+ local.trackPublications.set(sid, makeLocalPublication(sid, track));
160
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
161
+ (room as any).localParticipant = local;
162
+ }
163
+
164
+ /**
165
+ * Drive Room.processFfiEvent with a synthetic room event, satisfying the
166
+ * roomHandle / connected guards via injected private fields. Mirrors the
167
+ * Python tests dispatching through `room._on_room_event`.
168
+ */
169
+ async function dispatchRoomEvent(
170
+ room: Room,
171
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
172
+ message: { case: string; value: any },
173
+ ): Promise<void> {
174
+ const handle = BigInt(1);
175
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
176
+ const r = room as any;
177
+ r.ffiHandle = { handle };
178
+ if (!r.localParticipant) {
179
+ r.localParticipant = makeLocalParticipant('agent');
180
+ }
181
+ await r.processFfiEvent({
182
+ message: { case: 'roomEvent', value: { roomHandle: handle, message } },
183
+ });
184
+ }
185
+
186
+ /**
187
+ * Simulates the cleanup path that AudioStreamSource runs on `cancel()` / `eos`:
188
+ * unregister from track, then close the processor when `autoClose` is set.
189
+ * Mirrors the Python tests' `_make_closeable_stream` helper.
190
+ */
191
+ function simulateStreamClose(
192
+ track: RemoteAudioTrack,
193
+ stream: AudioStreamSource,
194
+ autoClose: boolean,
195
+ ): void {
196
+ track.unregisterAudioStream(stream);
197
+ if (stream.processor && autoClose) {
198
+ stream.processor.close();
199
+ }
200
+ }
201
+
202
+ const TRACK_SID = 'TR_1';
203
+
204
+ describe('AudioStream room lifecycle', () => {
205
+ it('processor receives lifecycle callbacks on room attach', () => {
206
+ const room = makeRoom({ name: 'room-a', token: 'tok-a', serverUrl: 'wss://a' });
207
+ attachRemoteParticipant(room, 'alice', [{ publicationSid: 'PUB_1', trackSid: TRACK_SID }]);
208
+ const track = makeTrack(TRACK_SID);
209
+ const proc = new RecordingProcessor();
210
+ const stream = makeStream(proc);
211
+
212
+ track.registerAudioStream(stream);
213
+ track.setRoom(room);
214
+
215
+ expect(proc.streamInfoCalls).toEqual([
216
+ { roomName: 'room-a', participantIdentity: 'alice', publicationSid: 'PUB_1' },
217
+ ]);
218
+ expect(proc.credentialsCalls).toEqual([{ token: 'tok-a', url: 'wss://a' }]);
219
+ expect(proc.streamInfoClearedCount).toBe(0);
220
+ expect(proc.credentialsClearedCount).toBe(0);
221
+ });
222
+
223
+ it('processor callbacks refire on track room change', () => {
224
+ const roomA = makeRoom({ name: 'room-a', token: 'tok-a', serverUrl: 'wss://a' });
225
+ attachRemoteParticipant(roomA, 'alice', [{ publicationSid: 'PUB_1', trackSid: TRACK_SID }]);
226
+ const roomB = makeRoom({ name: 'room-b', token: 'tok-b', serverUrl: 'wss://b' });
227
+ attachRemoteParticipant(roomB, 'bob', [{ publicationSid: 'PUB_2', trackSid: TRACK_SID }]);
228
+
229
+ const track = makeTrack(TRACK_SID);
230
+ const proc = new RecordingProcessor();
231
+ track.registerAudioStream(makeStream(proc));
232
+
233
+ track.setRoom(roomA);
234
+ track.setRoom(roomB);
235
+
236
+ expect(proc.streamInfoCalls.length).toBe(2);
237
+ expect(proc.streamInfoCalls[1]).toEqual({
238
+ roomName: 'room-b',
239
+ participantIdentity: 'bob',
240
+ publicationSid: 'PUB_2',
241
+ });
242
+ expect(proc.credentialsCalls.length).toBe(2);
243
+ expect(proc.credentialsCalls[1]).toEqual({ token: 'tok-b', url: 'wss://b' });
244
+ });
245
+
246
+ it('token refresh propagates to processor', () => {
247
+ const room = makeRoom({ name: 'room-a', token: 'tok-a', serverUrl: 'wss://a' });
248
+ attachRemoteParticipant(room, 'alice', [{ publicationSid: 'PUB_1', trackSid: TRACK_SID }]);
249
+ const track = makeTrack(TRACK_SID);
250
+ const proc = new RecordingProcessor();
251
+ track.registerAudioStream(makeStream(proc));
252
+ track.setRoom(room);
253
+
254
+ expect(proc.credentialsCalls.length).toBe(1);
255
+
256
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
257
+ (room as any)._token = 'tok-a-refreshed';
258
+ room.emit('tokenRefreshed');
259
+
260
+ expect(proc.credentialsCalls.length).toBe(2);
261
+ expect(proc.credentialsCalls[1]).toEqual({ token: 'tok-a-refreshed', url: 'wss://a' });
262
+ });
263
+
264
+ it('repeated setRoom with same room does not double-register listener', () => {
265
+ const room = makeRoom({ name: 'room-a', token: 'tok-a', serverUrl: 'wss://a' });
266
+ const track = makeTrack(TRACK_SID);
267
+
268
+ track.setRoom(room);
269
+ track.setRoom(room);
270
+ track.setRoom(room);
271
+
272
+ expect(room.listenerCount('tokenRefreshed')).toBe(1);
273
+ });
274
+
275
+ it('setRoom re-registers listener after Room.disconnect strips it', () => {
276
+ // Room.disconnect() calls removeAllListeners(), which silently drops the
277
+ // tokenRefreshed listener. If the same Room object is reused (e.g. on
278
+ // reconnect), setRoom(room) needs to re-register the listener rather than
279
+ // short-circuit on the oldRoom === room identity check.
280
+ const room = makeRoom({ name: 'room-a', token: 'tok-a', serverUrl: 'wss://a' });
281
+ const track = makeTrack(TRACK_SID);
282
+
283
+ track.setRoom(room);
284
+ expect(room.listenerCount('tokenRefreshed')).toBe(1);
285
+
286
+ // Simulate Room.disconnect() side-effect.
287
+ room.removeAllListeners();
288
+ expect(room.listenerCount('tokenRefreshed')).toBe(0);
289
+
290
+ track.setRoom(room);
291
+ expect(room.listenerCount('tokenRefreshed')).toBe(1);
292
+ });
293
+
294
+ it('setRoom swaps listener to new room', () => {
295
+ const roomA = makeRoom({ name: 'room-a', token: 'tok-a', serverUrl: 'wss://a' });
296
+ const roomB = makeRoom({ name: 'room-b', token: 'tok-b', serverUrl: 'wss://b' });
297
+ const track = makeTrack(TRACK_SID);
298
+ const proc = new RecordingProcessor();
299
+ track.registerAudioStream(makeStream(proc));
300
+
301
+ track.setRoom(roomA);
302
+ track.setRoom(roomB);
303
+
304
+ expect(roomA.listenerCount('tokenRefreshed')).toBe(0);
305
+ expect(roomB.listenerCount('tokenRefreshed')).toBe(1);
306
+
307
+ const beforeCredCount = proc.credentialsCalls.length;
308
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
309
+ (roomA as any)._token = 'tok-a-refreshed';
310
+ roomA.emit('tokenRefreshed');
311
+ expect(proc.credentialsCalls.length).toBe(beforeCredCount);
312
+
313
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
314
+ (roomB as any)._token = 'tok-b-refreshed';
315
+ roomB.emit('tokenRefreshed');
316
+ expect(proc.credentialsCalls.length).toBe(beforeCredCount + 1);
317
+ expect(proc.credentialsCalls[proc.credentialsCalls.length - 1]).toEqual({
318
+ token: 'tok-b-refreshed',
319
+ url: 'wss://b',
320
+ });
321
+ });
322
+
323
+ it('unregisterAudioStream stops metadata pushes', () => {
324
+ const roomA = makeRoom({ name: 'room-a', token: 'tok-a', serverUrl: 'wss://a' });
325
+ const roomB = makeRoom({ name: 'room-b', token: 'tok-b', serverUrl: 'wss://b' });
326
+ attachRemoteParticipant(roomA, 'alice', [{ publicationSid: 'PUB_1', trackSid: TRACK_SID }]);
327
+ attachRemoteParticipant(roomB, 'bob', [{ publicationSid: 'PUB_2', trackSid: TRACK_SID }]);
328
+ const track = makeTrack(TRACK_SID);
329
+ const proc = new RecordingProcessor();
330
+ const stream = makeStream(proc);
331
+
332
+ track.registerAudioStream(stream);
333
+ track.setRoom(roomA);
334
+ expect(proc.streamInfoCalls.length).toBe(1);
335
+
336
+ track.unregisterAudioStream(stream);
337
+ track.setRoom(roomB);
338
+
339
+ expect(proc.streamInfoCalls.length).toBe(1);
340
+ expect(proc.credentialsCalls.length).toBe(1);
341
+ });
342
+
343
+ it('track leaving room clears processor metadata', () => {
344
+ const room = makeRoom({ name: 'room-a', token: 'tok-a', serverUrl: 'wss://a' });
345
+ attachRemoteParticipant(room, 'alice', [{ publicationSid: 'PUB_1', trackSid: TRACK_SID }]);
346
+ const track = makeTrack(TRACK_SID);
347
+ const proc = new RecordingProcessor();
348
+ track.registerAudioStream(makeStream(proc));
349
+
350
+ track.setRoom(room);
351
+ expect(proc.streamInfoCalls.length).toBe(1);
352
+ expect(proc.credentialsCalls.length).toBe(1);
353
+
354
+ track.setRoom(null);
355
+
356
+ expect(proc.streamInfoClearedCount).toBe(1);
357
+ expect(proc.credentialsClearedCount).toBe(1);
358
+ expect(proc.streamInfoCalls.length).toBe(1);
359
+ expect(proc.credentialsCalls.length).toBe(1);
360
+ });
361
+
362
+ it('fanout to multiple registered streams', () => {
363
+ const room = makeRoom({ name: 'room-a', token: 'tok-a', serverUrl: 'wss://a' });
364
+ attachRemoteParticipant(room, 'alice', [{ publicationSid: 'PUB_1', trackSid: TRACK_SID }]);
365
+ const track = makeTrack(TRACK_SID);
366
+ const proc1 = new RecordingProcessor();
367
+ const proc2 = new RecordingProcessor();
368
+ track.registerAudioStream(makeStream(proc1));
369
+ track.registerAudioStream(makeStream(proc2));
370
+
371
+ track.setRoom(room);
372
+
373
+ for (const proc of [proc1, proc2]) {
374
+ expect(proc.streamInfoCalls).toEqual([
375
+ { roomName: 'room-a', participantIdentity: 'alice', publicationSid: 'PUB_1' },
376
+ ]);
377
+ expect(proc.credentialsCalls).toEqual([{ token: 'tok-a', url: 'wss://a' }]);
378
+ }
379
+ });
380
+
381
+ it('registerAudioStream before track enters room', () => {
382
+ const track = makeTrack(TRACK_SID);
383
+ const proc = new RecordingProcessor();
384
+ track.registerAudioStream(makeStream(proc));
385
+
386
+ expect(proc.streamInfoCalls.length).toBe(0);
387
+ expect(proc.credentialsCalls.length).toBe(0);
388
+
389
+ const room = makeRoom({ name: 'room-a', token: 'tok-a', serverUrl: 'wss://a' });
390
+ attachRemoteParticipant(room, 'alice', [{ publicationSid: 'PUB_1', trackSid: TRACK_SID }]);
391
+ track.setRoom(room);
392
+
393
+ expect(proc.streamInfoCalls).toEqual([
394
+ { roomName: 'room-a', participantIdentity: 'alice', publicationSid: 'PUB_1' },
395
+ ]);
396
+ expect(proc.credentialsCalls).toEqual([{ token: 'tok-a', url: 'wss://a' }]);
397
+ });
398
+
399
+ it('track room cycle attach detach reattach', () => {
400
+ const roomA = makeRoom({ name: 'room-a', token: 'tok-a', serverUrl: 'wss://a' });
401
+ attachRemoteParticipant(roomA, 'alice', [{ publicationSid: 'PUB_1', trackSid: TRACK_SID }]);
402
+ const roomB = makeRoom({ name: 'room-b', token: 'tok-b', serverUrl: 'wss://b' });
403
+ attachRemoteParticipant(roomB, 'bob', [{ publicationSid: 'PUB_2', trackSid: TRACK_SID }]);
404
+ const track = makeTrack(TRACK_SID);
405
+ const proc = new RecordingProcessor();
406
+ track.registerAudioStream(makeStream(proc));
407
+
408
+ track.setRoom(roomA);
409
+ track.setRoom(null);
410
+ track.setRoom(roomB);
411
+
412
+ expect(proc.streamInfoCalls.length).toBe(2);
413
+ expect(proc.streamInfoCalls[0]).toEqual({
414
+ roomName: 'room-a',
415
+ participantIdentity: 'alice',
416
+ publicationSid: 'PUB_1',
417
+ });
418
+ expect(proc.streamInfoCalls[1]).toEqual({
419
+ roomName: 'room-b',
420
+ participantIdentity: 'bob',
421
+ publicationSid: 'PUB_2',
422
+ });
423
+ expect(proc.streamInfoClearedCount).toBe(1);
424
+ expect(proc.credentialsClearedCount).toBe(1);
425
+ expect(roomA.listenerCount('tokenRefreshed')).toBe(0);
426
+ expect(roomB.listenerCount('tokenRefreshed')).toBe(1);
427
+ });
428
+
429
+ it('setRoom with no registered streams is safe', () => {
430
+ const room = makeRoom({ name: 'room-a', token: 'tok-a', serverUrl: 'wss://a' });
431
+ attachRemoteParticipant(room, 'alice', [{ publicationSid: 'PUB_1', trackSid: TRACK_SID }]);
432
+ const track = makeTrack(TRACK_SID);
433
+
434
+ expect(() => track.setRoom(room)).not.toThrow();
435
+
436
+ const proc = new RecordingProcessor();
437
+ track.registerAudioStream(makeStream(proc));
438
+
439
+ expect(proc.streamInfoCalls).toEqual([
440
+ { roomName: 'room-a', participantIdentity: 'alice', publicationSid: 'PUB_1' },
441
+ ]);
442
+ expect(proc.credentialsCalls).toEqual([{ token: 'tok-a', url: 'wss://a' }]);
443
+ });
444
+
445
+ it('unregister one of many streams only fans out to remaining', () => {
446
+ const roomA = makeRoom({ name: 'room-a', token: 'tok-a', serverUrl: 'wss://a' });
447
+ attachRemoteParticipant(roomA, 'alice', [{ publicationSid: 'PUB_1', trackSid: TRACK_SID }]);
448
+ const roomB = makeRoom({ name: 'room-b', token: 'tok-b', serverUrl: 'wss://b' });
449
+ attachRemoteParticipant(roomB, 'bob', [{ publicationSid: 'PUB_2', trackSid: TRACK_SID }]);
450
+ const track = makeTrack(TRACK_SID);
451
+ const proc1 = new RecordingProcessor();
452
+ const proc2 = new RecordingProcessor();
453
+ const stream1 = makeStream(proc1);
454
+ const stream2 = makeStream(proc2);
455
+ track.registerAudioStream(stream1);
456
+ track.registerAudioStream(stream2);
457
+
458
+ track.setRoom(roomA);
459
+ track.unregisterAudioStream(stream1);
460
+ track.setRoom(roomB);
461
+
462
+ expect(proc1.streamInfoCalls.length).toBe(1);
463
+ expect(proc1.credentialsCalls.length).toBe(1);
464
+ expect(proc2.streamInfoCalls.length).toBe(2);
465
+ expect(proc2.streamInfoCalls[1]).toEqual({
466
+ roomName: 'room-b',
467
+ participantIdentity: 'bob',
468
+ publicationSid: 'PUB_2',
469
+ });
470
+ expect(proc2.credentialsCalls.length).toBe(2);
471
+ });
472
+
473
+ it('close path closes processor when autoClose true', () => {
474
+ const track = makeTrack(TRACK_SID);
475
+ const proc = new RecordingProcessor();
476
+ const stream = makeStream(proc);
477
+ track.registerAudioStream(stream);
478
+
479
+ simulateStreamClose(track, stream, true);
480
+
481
+ expect(proc.closeCount).toBe(1);
482
+
483
+ // Stream is unregistered: further room attaches don't reach the processor.
484
+ const room = makeRoom({ name: 'room-a', token: 'tok-a', serverUrl: 'wss://a' });
485
+ attachRemoteParticipant(room, 'alice', [{ publicationSid: 'PUB_1', trackSid: TRACK_SID }]);
486
+ track.setRoom(room);
487
+ expect(proc.streamInfoCalls.length).toBe(0);
488
+ });
489
+
490
+ it('close path leaves processor open when autoClose false', () => {
491
+ const track = makeTrack(TRACK_SID);
492
+ const proc = new RecordingProcessor();
493
+ const stream = makeStream(proc);
494
+ track.registerAudioStream(stream);
495
+
496
+ simulateStreamClose(track, stream, false);
497
+
498
+ expect(proc.closeCount).toBe(0);
499
+
500
+ // Still unregistered, even though we kept the processor open for reuse.
501
+ const room = makeRoom({ name: 'room-a', token: 'tok-a', serverUrl: 'wss://a' });
502
+ attachRemoteParticipant(room, 'alice', [{ publicationSid: 'PUB_1', trackSid: TRACK_SID }]);
503
+ track.setRoom(room);
504
+ expect(proc.streamInfoCalls.length).toBe(0);
505
+ });
506
+
507
+ it('setRoom(null) is idempotent for cleared callbacks', () => {
508
+ const room = makeRoom({ name: 'room-1', token: 'tok-1', serverUrl: 'wss://r' });
509
+ attachRemoteParticipant(room, 'alice', [{ publicationSid: 'PUB_1', trackSid: TRACK_SID }]);
510
+ const track = makeTrack(TRACK_SID);
511
+ track.setRoom(room);
512
+ const proc = new RecordingProcessor();
513
+ track.registerAudioStream(makeStream(proc));
514
+
515
+ track.setRoom(null); // first clear (e.g. room event handler)
516
+ track.setRoom(null); // second clear (e.g. unpublishTrack on the same track)
517
+
518
+ expect(proc.streamInfoClearedCount).toBe(1);
519
+ expect(proc.credentialsClearedCount).toBe(1);
520
+ });
521
+
522
+ it('tokenRefreshed listener only removed by setRoom(null)', () => {
523
+ const room = makeRoom({ name: 'room-1', token: 'tok-1', serverUrl: 'wss://r' });
524
+ const track = makeTrack(TRACK_SID);
525
+
526
+ track.setRoom(room);
527
+ expect(room.listenerCount('tokenRefreshed')).toBe(1);
528
+
529
+ // Only setRoom(null) detaches it.
530
+ track.setRoom(null);
531
+ expect(room.listenerCount('tokenRefreshed')).toBe(0);
532
+ });
533
+
534
+ it('localTrackRepublished updates track sid and repushes metadata', async () => {
535
+ // A full-reconnect republish re-issues the publication SID. The handler must
536
+ // keep the local-track invariant (track.sid == publication.sid) intact and
537
+ // re-push metadata so attached processors learn the new SID — otherwise the
538
+ // SID-based local lookup yields empty participant_identity / publication_sid.
539
+ const room = makeRoom({ name: 'room-1', token: 'tok-1', serverUrl: 'wss://r' });
540
+ const track = makeTrack('OLD');
541
+ attachLocalTrack(room, 'agent', 'OLD', track);
542
+ track.setRoom(room);
543
+
544
+ const proc = new RecordingProcessor();
545
+ track.registerAudioStream(makeStream(proc));
546
+ expect(proc.streamInfoCalls.at(-1)).toEqual({
547
+ roomName: 'room-1',
548
+ participantIdentity: 'agent',
549
+ publicationSid: 'OLD',
550
+ });
551
+
552
+ await dispatchRoomEvent(room, {
553
+ case: 'localTrackRepublished',
554
+ value: { previousSid: 'OLD', info: { sid: 'NEW' } },
555
+ });
556
+
557
+ // Invariant restored + map rekeyed.
558
+ expect(track.sid).toBe('NEW');
559
+ expect(room.localParticipant!.trackPublications.has('NEW')).toBe(true);
560
+ expect(room.localParticipant!.trackPublications.has('OLD')).toBe(false);
561
+
562
+ // Existing attached processor re-pushed with the NEW sid.
563
+ expect(proc.streamInfoCalls.at(-1)).toEqual({
564
+ roomName: 'room-1',
565
+ participantIdentity: 'agent',
566
+ publicationSid: 'NEW',
567
+ });
568
+
569
+ // Regression guard: a stream created AFTER republish also resolves NEW
570
+ // (stale track.sid would yield "").
571
+ const proc2 = new RecordingProcessor();
572
+ track.registerAudioStream(makeStream(proc2));
573
+ expect(proc2.streamInfoCalls.at(-1)).toEqual({
574
+ roomName: 'room-1',
575
+ participantIdentity: 'agent',
576
+ publicationSid: 'NEW',
577
+ });
578
+ });
579
+
580
+ it('localTrackUnpublished event nulls publication track', async () => {
581
+ const room = makeRoom({ name: 'room-1', token: 'tok-1', serverUrl: 'wss://r' });
582
+ const track = makeTrack(TRACK_SID);
583
+ attachLocalTrack(room, 'agent', TRACK_SID, track);
584
+ const publication = room.localParticipant!.trackPublications.get(TRACK_SID)!;
585
+ track.setRoom(room);
586
+
587
+ await dispatchRoomEvent(room, {
588
+ case: 'localTrackUnpublished',
589
+ value: { publicationSid: TRACK_SID },
590
+ });
591
+
592
+ expect(room.localParticipant!.trackPublications.has(TRACK_SID)).toBe(false);
593
+ // The publication's track reference was dropped, and the track left the room.
594
+ expect(publication.track).toBeUndefined();
595
+ expect(track.resolveRoom()).toBe(null);
596
+ });
597
+
598
+ it('localTrackUnpublished callback still sees track', async () => {
599
+ // Backwards-compat: publication.track is nulled AFTER the event is emitted,
600
+ // so a callback reading publication.track during the event still sees it.
601
+ const room = makeRoom({ name: 'room-1', token: 'tok-1', serverUrl: 'wss://r' });
602
+ const track = makeTrack(TRACK_SID);
603
+ attachLocalTrack(room, 'agent', TRACK_SID, track);
604
+ const publication = room.localParticipant!.trackPublications.get(TRACK_SID)!;
605
+ track.setRoom(room);
606
+
607
+ const seenTrack: Array<Track | undefined> = [];
608
+ room.on('localTrackUnpublished', (pub) => {
609
+ seenTrack.push(pub.track);
610
+ });
611
+
612
+ await dispatchRoomEvent(room, {
613
+ case: 'localTrackUnpublished',
614
+ value: { publicationSid: TRACK_SID },
615
+ });
616
+
617
+ // The callback saw the track (backwards-compatible payload) ...
618
+ expect(seenTrack).toEqual([track]);
619
+ // ... and the reference is dropped once the handler returns.
620
+ expect(publication.track).toBeUndefined();
621
+ });
622
+
623
+ it('unpublishTrack clears processor when it wins the event race', async () => {
624
+ // unpublishTrack races the localTrackUnpublished room event. When unpublish
625
+ // wins, the room-event handler later finds the publication gone and skips its
626
+ // setRoom(null). The unpublish path must therefore clear the processor itself.
627
+ const room = makeRoom({ name: 'room-1', token: 'tok-1', serverUrl: 'wss://r' });
628
+ const track = makeTrack(TRACK_SID);
629
+ const local = makeLocalParticipant('agent');
630
+ const publication = makeLocalPublication(TRACK_SID, track);
631
+ local.trackPublications.set(TRACK_SID, publication);
632
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
633
+ (room as any).localParticipant = local;
634
+ // Fields unpublishTrack reads beyond the FFI round-trip.
635
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
636
+ (local as any).ffiEventLock = { lock: async () => () => {} };
637
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
638
+ (local as any).ffi_handle = { handle: BigInt(1) };
639
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
640
+ (local as any).disconnectSignal = undefined;
641
+ track.setRoom(room);
642
+
643
+ const proc = new RecordingProcessor();
644
+ track.registerAudioStream(makeStream(proc));
645
+ const clearedInfoBefore = proc.streamInfoClearedCount;
646
+ const clearedCredsBefore = proc.credentialsClearedCount;
647
+
648
+ // Mock the FFI round-trip so unpublishTrack resolves without a real server.
649
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
650
+ const requestSpy = vi.spyOn(FfiClient.instance, 'request').mockReturnValue({
651
+ asyncId: BigInt(1),
652
+ } as never);
653
+ const waitForSpy = vi
654
+ .spyOn(FfiClient.instance, 'waitFor')
655
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
656
+ .mockResolvedValue({ error: undefined } as never);
657
+
658
+ try {
659
+ await local.unpublishTrack(TRACK_SID);
660
+ } finally {
661
+ requestSpy.mockRestore();
662
+ waitForSpy.mockRestore();
663
+ }
664
+
665
+ expect(local.trackPublications.has(TRACK_SID)).toBe(false);
666
+ expect(publication.track).toBeUndefined();
667
+ // The unpublish path cleared the processor's room context even though the
668
+ // room-event handler never ran.
669
+ expect(proc.streamInfoClearedCount).toBe(clearedInfoBefore + 1);
670
+ expect(proc.credentialsClearedCount).toBe(clearedCredsBefore + 1);
671
+ });
672
+
673
+ it('disconnect clears processors and detaches listeners', () => {
674
+ // On disconnect, cleanupOnDisconnect must walk every publication and detach
675
+ // its track so attached processors get cleared callbacks and the
676
+ // tokenRefreshed listener is removed — covers both the local and remote
677
+ // participant maps.
678
+ const room = makeRoom({ name: 'room-1', token: 'tok-1', serverUrl: 'wss://r' });
679
+
680
+ // Local track + processor.
681
+ const localTrack = makeTrack('TR_LOCAL');
682
+ attachLocalTrack(room, 'agent', 'TR_LOCAL', localTrack);
683
+ const localProc = new RecordingProcessor();
684
+ localTrack.registerAudioStream(makeStream(localProc));
685
+ localTrack.setRoom(room);
686
+
687
+ // Remote track + processor on the same room.
688
+ const remoteTrack = makeTrack('TR_REMOTE');
689
+ const remoteParticipant = {
690
+ identity: 'bob',
691
+ trackPublications: new Map([['TR_REMOTE', { sid: 'PUB_REMOTE', track: remoteTrack }]]),
692
+ };
693
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
694
+ room.remoteParticipants.set('bob', remoteParticipant as any);
695
+ const remoteProc = new RecordingProcessor();
696
+ remoteTrack.registerAudioStream(makeStream(remoteProc));
697
+ remoteTrack.setRoom(room);
698
+
699
+ expect(room.listenerCount('tokenRefreshed')).toBe(2);
700
+ expect(localProc.streamInfoCalls.length).toBe(1);
701
+ expect(remoteProc.streamInfoCalls.length).toBe(1);
702
+
703
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
704
+ (room as any).cleanupOnDisconnect();
705
+
706
+ for (const proc of [localProc, remoteProc]) {
707
+ expect(proc.streamInfoClearedCount).toBe(1);
708
+ expect(proc.credentialsClearedCount).toBe(1);
709
+ }
710
+ expect(room.listenerCount('tokenRefreshed')).toBe(0);
711
+ expect(localTrack.resolveRoom()).toBe(null);
712
+ expect(remoteTrack.resolveRoom()).toBe(null);
713
+ });
714
+
715
+ it('publishTrack stamps publication sid onto track', async () => {
716
+ // Mirrors Python publish_track: after the server assigns the publication SID,
717
+ // the track's own info.sid is updated to match so the local-publication
718
+ // lookup in pushProcessorMetadataToStream resolves it.
719
+ const local = makeLocalParticipant('agent');
720
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
721
+ (local as any).ffiEventLock = { lock: async () => () => {} };
722
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
723
+ (local as any).ffi_handle = { handle: BigInt(1) };
724
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
725
+ (local as any).disconnectSignal = undefined;
726
+
727
+ // Track starts with a provisional SID that differs from the publication SID.
728
+ const track = makeLocalAudioTrack('PROVISIONAL');
729
+
730
+ const requestSpy = vi.spyOn(FfiClient.instance, 'request').mockReturnValue({
731
+ asyncId: BigInt(1),
732
+ } as never);
733
+ const waitForSpy = vi.spyOn(FfiClient.instance, 'waitFor').mockResolvedValue({
734
+ message: {
735
+ case: 'publication',
736
+ value: { info: { sid: 'PUB_NEW' }, handle: { id: BigInt(0) } },
737
+ },
738
+ } as never);
739
+
740
+ let pub: LocalTrackPublication;
741
+ try {
742
+ pub = await local.publishTrack(track, new TrackPublishOptions());
743
+ } finally {
744
+ requestSpy.mockRestore();
745
+ waitForSpy.mockRestore();
746
+ }
747
+
748
+ expect(pub.sid).toBe('PUB_NEW');
749
+ // The invariant: the track's own SID now matches the publication SID.
750
+ expect(track.sid).toBe('PUB_NEW');
751
+ expect(local.trackPublications.has('PUB_NEW')).toBe(true);
752
+ });
753
+ });