@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.
- package/dist/audio_stream-B4hGVcKz.d.cts +498 -0
- package/dist/audio_stream-DEG1JKge.d.ts +498 -0
- package/dist/audio_stream.cjs +21 -6
- package/dist/audio_stream.cjs.map +1 -1
- package/dist/audio_stream.d.cts +12 -22
- package/dist/audio_stream.d.ts +12 -22
- package/dist/audio_stream.d.ts.map +1 -1
- package/dist/audio_stream.js +19 -5
- package/dist/audio_stream.js.map +1 -1
- package/dist/data_streams/index.d.cts +1 -1
- package/dist/data_streams/index.d.ts +1 -1
- package/dist/data_streams/stream_reader.d.cts +1 -1
- package/dist/data_streams/stream_reader.d.ts +1 -1
- package/dist/data_streams/stream_writer.d.cts +1 -1
- package/dist/data_streams/stream_writer.d.ts +1 -1
- package/dist/data_streams/types.d.cts +1 -1
- package/dist/data_streams/types.d.ts +1 -1
- package/dist/frame_processor.cjs +6 -0
- package/dist/frame_processor.cjs.map +1 -1
- package/dist/frame_processor.d.cts +2 -0
- package/dist/frame_processor.d.ts +2 -0
- package/dist/frame_processor.d.ts.map +1 -1
- package/dist/frame_processor.js +6 -0
- package/dist/frame_processor.js.map +1 -1
- package/dist/index.d.cts +4 -7
- package/dist/index.d.ts +4 -7
- package/dist/participant.cjs +6 -0
- package/dist/participant.cjs.map +1 -1
- package/dist/participant.d.cts +14 -153
- package/dist/participant.d.ts +14 -153
- package/dist/participant.d.ts.map +1 -1
- package/dist/participant.js +6 -0
- package/dist/participant.js.map +1 -1
- package/dist/room.cjs +28 -0
- package/dist/room.cjs.map +1 -1
- package/dist/room.d.cts +12 -221
- package/dist/room.d.ts +12 -221
- package/dist/room.d.ts.map +1 -1
- package/dist/room.js +28 -0
- package/dist/room.js.map +1 -1
- package/dist/track.cjs +123 -0
- package/dist/track.cjs.map +1 -1
- package/dist/track.d.cts +15 -42
- package/dist/track.d.ts +15 -42
- package/dist/track.d.ts.map +1 -1
- package/dist/track.js +123 -0
- package/dist/track.js.map +1 -1
- package/dist/track_publication.d.cts +14 -47
- package/dist/track_publication.d.ts +14 -47
- package/dist/{stream_reader-CuG4b8Y-.d.cts → types-_PdPVish.d.cts} +40 -40
- package/dist/{stream_reader-CuG4b8Y-.d.ts → types-_PdPVish.d.ts} +40 -40
- package/dist/version.cjs +1 -1
- package/dist/version.cjs.map +1 -1
- package/dist/version.d.cts +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/version.js.map +1 -1
- package/dist/video_stream.d.cts +12 -2
- package/dist/video_stream.d.ts +12 -2
- package/package.json +3 -3
- package/src/audio_stream.ts +28 -4
- package/src/audio_stream_room_lifecycle.test.ts +753 -0
- package/src/bun_runtime.test.ts +27 -0
- package/src/frame_processor.ts +4 -0
- package/src/participant.ts +17 -0
- package/src/room.ts +44 -0
- package/src/track.ts +141 -0
- 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
|
+
});
|