@stream-io/video-client 1.48.0 → 1.50.0
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/CHANGELOG.md +25 -0
- package/dist/index.browser.es.js +1497 -677
- package/dist/index.browser.es.js.map +1 -1
- package/dist/index.cjs.js +1497 -677
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.es.js +1497 -677
- package/dist/index.es.js.map +1 -1
- package/dist/src/Call.d.ts +77 -4
- package/dist/src/StreamSfuClient.d.ts +8 -1
- package/dist/src/coordinator/connection/client.d.ts +1 -1
- package/dist/src/coordinator/connection/connection.d.ts +31 -25
- package/dist/src/coordinator/connection/types.d.ts +14 -0
- package/dist/src/coordinator/connection/utils.d.ts +1 -0
- package/dist/src/devices/DeviceManager.d.ts +3 -0
- package/dist/src/devices/DeviceManagerState.d.ts +13 -1
- package/dist/src/gen/video/sfu/event/events.d.ts +5 -1
- package/dist/src/gen/video/sfu/models/models.d.ts +34 -0
- package/dist/src/helpers/AudioBindingsWatchdog.d.ts +3 -3
- package/dist/src/helpers/BlockedAudioTracker.d.ts +30 -0
- package/dist/src/helpers/DynascaleManager.d.ts +8 -86
- package/dist/src/helpers/MediaPlaybackWatchdog.d.ts +32 -0
- package/dist/src/helpers/SlidingWindowRateLimiter.d.ts +28 -0
- package/dist/src/helpers/TrackSubscriptionManager.d.ts +114 -0
- package/dist/src/helpers/ViewportTracker.d.ts +11 -17
- package/dist/src/helpers/browsers.d.ts +13 -0
- package/dist/src/helpers/concurrency.d.ts +6 -4
- package/dist/src/rtc/BasePeerConnection.d.ts +11 -2
- package/dist/src/rtc/Publisher.d.ts +17 -0
- package/dist/src/rtc/Subscriber.d.ts +1 -0
- package/dist/src/rtc/helpers/degradationPreference.d.ts +2 -0
- package/dist/src/rtc/index.d.ts +1 -0
- package/dist/src/rtc/types.d.ts +33 -1
- package/dist/src/stats/rtc/types.d.ts +1 -1
- package/dist/src/store/rxUtils.d.ts +9 -0
- package/dist/src/types.d.ts +18 -0
- package/package.json +2 -2
- package/src/Call.ts +268 -40
- package/src/StreamSfuClient.ts +75 -12
- package/src/__tests__/Call.lifecycle.test.ts +67 -0
- package/src/__tests__/Call.publishing.test.ts +103 -0
- package/src/__tests__/StreamSfuClient.test.ts +275 -0
- package/src/coordinator/connection/__tests__/connection.test.ts +482 -0
- package/src/coordinator/connection/client.ts +1 -1
- package/src/coordinator/connection/connection.ts +149 -96
- package/src/coordinator/connection/types.ts +15 -0
- package/src/coordinator/connection/utils.ts +15 -0
- package/src/devices/DeviceManager.ts +92 -32
- package/src/devices/DeviceManagerState.ts +20 -1
- package/src/devices/__tests__/DeviceManager.test.ts +283 -0
- package/src/devices/__tests__/ScreenShareManager.test.ts +0 -2
- package/src/devices/__tests__/mocks.ts +2 -0
- package/src/devices/devices.ts +2 -1
- package/src/gen/video/sfu/event/events.ts +15 -0
- package/src/gen/video/sfu/models/models.ts +44 -0
- package/src/helpers/AudioBindingsWatchdog.ts +10 -7
- package/src/helpers/BlockedAudioTracker.ts +74 -0
- package/src/helpers/DynascaleManager.ts +46 -337
- package/src/helpers/MediaPlaybackWatchdog.ts +121 -0
- package/src/helpers/SlidingWindowRateLimiter.ts +49 -0
- package/src/helpers/TrackSubscriptionManager.ts +243 -0
- package/src/helpers/ViewportTracker.ts +74 -19
- package/src/helpers/__tests__/BlockedAudioTracker.test.ts +114 -0
- package/src/helpers/__tests__/DynascaleManager.test.ts +175 -122
- package/src/helpers/__tests__/MediaPlaybackWatchdog.test.ts +180 -0
- package/src/helpers/__tests__/SlidingWindowRateLimiter.test.ts +43 -0
- package/src/helpers/__tests__/TrackSubscriptionManager.test.ts +310 -0
- package/src/helpers/__tests__/ViewportTracker.test.ts +83 -0
- package/src/helpers/__tests__/browsers.test.ts +85 -1
- package/src/helpers/browsers.ts +24 -0
- package/src/helpers/concurrency.ts +9 -10
- package/src/rpc/retryable.ts +0 -1
- package/src/rtc/BasePeerConnection.ts +96 -6
- package/src/rtc/Publisher.ts +49 -2
- package/src/rtc/Subscriber.ts +42 -14
- package/src/rtc/__tests__/Call.reconnect.test.ts +761 -0
- package/src/rtc/__tests__/Publisher.test.ts +332 -10
- package/src/rtc/__tests__/Subscriber.test.ts +202 -1
- package/src/rtc/__tests__/mocks/webrtc.mocks.ts +13 -2
- package/src/rtc/helpers/__tests__/degradationPreference.test.ts +23 -0
- package/src/rtc/helpers/degradationPreference.ts +22 -0
- package/src/rtc/index.ts +1 -0
- package/src/rtc/types.ts +38 -1
- package/src/stats/rtc/types.ts +1 -0
- package/src/store/__tests__/rxUtils.test.ts +276 -0
- package/src/store/rxUtils.ts +19 -0
- package/src/types.ts +19 -0
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @vitest-environment happy-dom
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import '../rtc/__tests__/mocks/webrtc.mocks';
|
|
6
|
+
|
|
7
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
8
|
+
import { Call } from '../Call';
|
|
9
|
+
import { StreamClient } from '../coordinator/connection/client';
|
|
10
|
+
import { generateUUIDv4 } from '../coordinator/connection/utils';
|
|
11
|
+
import { StreamVideoWriteableStateStore } from '../store';
|
|
12
|
+
|
|
13
|
+
describe('Call lifecycle wiring', () => {
|
|
14
|
+
let call: Call;
|
|
15
|
+
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
call = new Call({
|
|
18
|
+
type: 'test',
|
|
19
|
+
id: generateUUIDv4(),
|
|
20
|
+
streamClient: new StreamClient('abc'),
|
|
21
|
+
clientStore: new StreamVideoWriteableStateStore(),
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
// Regression guard for the Call-owned helper teardown chain. Each of
|
|
26
|
+
// these helpers holds a resource (timer, listener, AudioContext) that
|
|
27
|
+
// leaks across calls if teardown is dropped during a refactor.
|
|
28
|
+
// Covers trackSubscriptionManager, audioBindingsWatchdog, and
|
|
29
|
+
// dynascaleManager. SFU-lifecycle disposables (publisher/subscriber/
|
|
30
|
+
// sfuStatsReporter) require a real join and are out of scope.
|
|
31
|
+
it('call.leave() tears down all Call-owned helpers exactly once', async () => {
|
|
32
|
+
const trackSubDispose = vi.spyOn(call.trackSubscriptionManager, 'dispose');
|
|
33
|
+
const audioBindingsDispose = vi.spyOn(
|
|
34
|
+
call.audioBindingsWatchdog!,
|
|
35
|
+
'dispose',
|
|
36
|
+
);
|
|
37
|
+
const dynascaleDispose = vi.spyOn(call.dynascaleManager!, 'dispose');
|
|
38
|
+
|
|
39
|
+
await call.leave();
|
|
40
|
+
|
|
41
|
+
expect(trackSubDispose).toHaveBeenCalledTimes(1);
|
|
42
|
+
expect(audioBindingsDispose).toHaveBeenCalledTimes(1);
|
|
43
|
+
expect(dynascaleDispose).toHaveBeenCalledTimes(1);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// Order matters: the SFU subscription pump must finish tearing down
|
|
47
|
+
// before DynascaleManager closes its AudioContext, otherwise helpers
|
|
48
|
+
// can run on a closed context (logged as warnings or thrown by
|
|
49
|
+
// happy-dom). This is the contract the leave() teardown chain encodes.
|
|
50
|
+
it('call.leave() tears down helpers in the documented order', async () => {
|
|
51
|
+
const trackSubDispose = vi.spyOn(call.trackSubscriptionManager, 'dispose');
|
|
52
|
+
const audioBindingsDispose = vi.spyOn(
|
|
53
|
+
call.audioBindingsWatchdog!,
|
|
54
|
+
'dispose',
|
|
55
|
+
);
|
|
56
|
+
const dynascaleDispose = vi.spyOn(call.dynascaleManager!, 'dispose');
|
|
57
|
+
|
|
58
|
+
await call.leave();
|
|
59
|
+
|
|
60
|
+
const trackSubOrder = trackSubDispose.mock.invocationCallOrder[0];
|
|
61
|
+
const audioBindingsOrder = audioBindingsDispose.mock.invocationCallOrder[0];
|
|
62
|
+
const dynascaleOrder = dynascaleDispose.mock.invocationCallOrder[0];
|
|
63
|
+
|
|
64
|
+
expect(trackSubOrder).toBeLessThan(audioBindingsOrder);
|
|
65
|
+
expect(audioBindingsOrder).toBeLessThan(dynascaleOrder);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
@@ -290,6 +290,109 @@ describe('Publishing and Unpublishing tracks', () => {
|
|
|
290
290
|
expect(participant!.screenShareStream).toBeUndefined();
|
|
291
291
|
expect(participant!.screenShareAudioStream).toBeUndefined();
|
|
292
292
|
});
|
|
293
|
+
|
|
294
|
+
it('does not throw if sfuClient is cleared while the mute-state RPC is in flight', async () => {
|
|
295
|
+
let releaseMuteUpdate!: () => void;
|
|
296
|
+
let signalMuteUpdateEntered!: () => void;
|
|
297
|
+
const muteUpdateEntered = new Promise<void>(
|
|
298
|
+
(resolve) => (signalMuteUpdateEntered = resolve),
|
|
299
|
+
);
|
|
300
|
+
sfuClient.updateMuteStates = vi.fn().mockImplementation(() => {
|
|
301
|
+
signalMuteUpdateEntered();
|
|
302
|
+
return new Promise<void>((resolve) => (releaseMuteUpdate = resolve));
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
const track = new MediaStreamTrack() as MediaStreamAudioTrack;
|
|
306
|
+
const mediaStream = new MediaStream();
|
|
307
|
+
vi.spyOn(mediaStream, 'getAudioTracks').mockReturnValue([track]);
|
|
308
|
+
|
|
309
|
+
const inflight = call.publish(mediaStream, TrackType.AUDIO);
|
|
310
|
+
|
|
311
|
+
await muteUpdateEntered;
|
|
312
|
+
|
|
313
|
+
call['sfuClient'] = undefined;
|
|
314
|
+
releaseMuteUpdate();
|
|
315
|
+
|
|
316
|
+
await inflight;
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it('updates local stream state when sfuClient is replaced with the same session id', async () => {
|
|
320
|
+
let releaseMuteUpdate!: () => void;
|
|
321
|
+
let signalMuteUpdateEntered!: () => void;
|
|
322
|
+
const muteUpdateEntered = new Promise<void>(
|
|
323
|
+
(resolve) => (signalMuteUpdateEntered = resolve),
|
|
324
|
+
);
|
|
325
|
+
sfuClient.updateMuteStates = vi.fn().mockImplementation(() => {
|
|
326
|
+
signalMuteUpdateEntered();
|
|
327
|
+
return new Promise<void>((resolve) => (releaseMuteUpdate = resolve));
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
const track = new MediaStreamTrack() as MediaStreamAudioTrack;
|
|
331
|
+
const mediaStream = new MediaStream();
|
|
332
|
+
vi.spyOn(mediaStream, 'getAudioTracks').mockReturnValue([track]);
|
|
333
|
+
|
|
334
|
+
const inflight = call.publish(mediaStream, TrackType.AUDIO);
|
|
335
|
+
|
|
336
|
+
await muteUpdateEntered;
|
|
337
|
+
|
|
338
|
+
const replacementSfuClient = vi.fn() as unknown as StreamSfuClient;
|
|
339
|
+
// @ts-expect-error sessionId is readonly
|
|
340
|
+
replacementSfuClient['sessionId'] = sessionId;
|
|
341
|
+
replacementSfuClient.updateMuteStates = vi.fn();
|
|
342
|
+
call['sfuClient'] = replacementSfuClient;
|
|
343
|
+
releaseMuteUpdate();
|
|
344
|
+
|
|
345
|
+
await inflight;
|
|
346
|
+
|
|
347
|
+
const participant = call.state.findParticipantBySessionId(sessionId);
|
|
348
|
+
expect(participant?.publishedTracks).toEqual([TrackType.AUDIO]);
|
|
349
|
+
expect(participant?.audioStream).toBe(mediaStream);
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
it('skips local stream state update when sfuClient is replaced with a new session id', async () => {
|
|
353
|
+
let releaseMuteUpdate!: () => void;
|
|
354
|
+
let signalMuteUpdateEntered!: () => void;
|
|
355
|
+
const muteUpdateEntered = new Promise<void>(
|
|
356
|
+
(resolve) => (signalMuteUpdateEntered = resolve),
|
|
357
|
+
);
|
|
358
|
+
sfuClient.updateMuteStates = vi.fn().mockImplementation(() => {
|
|
359
|
+
signalMuteUpdateEntered();
|
|
360
|
+
return new Promise<void>((resolve) => (releaseMuteUpdate = resolve));
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
const track = new MediaStreamTrack() as MediaStreamAudioTrack;
|
|
364
|
+
const mediaStream = new MediaStream();
|
|
365
|
+
vi.spyOn(mediaStream, 'getAudioTracks').mockReturnValue([track]);
|
|
366
|
+
|
|
367
|
+
const inflight = call.publish(mediaStream, TrackType.AUDIO);
|
|
368
|
+
|
|
369
|
+
await muteUpdateEntered;
|
|
370
|
+
|
|
371
|
+
const replacementSessionId = 'replacement-session-id';
|
|
372
|
+
// @ts-expect-error partial data
|
|
373
|
+
call.state.updateOrAddParticipant(replacementSessionId, {
|
|
374
|
+
sessionId: replacementSessionId,
|
|
375
|
+
publishedTracks: [],
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
const replacementSfuClient = vi.fn() as unknown as StreamSfuClient;
|
|
379
|
+
// @ts-expect-error sessionId is readonly
|
|
380
|
+
replacementSfuClient['sessionId'] = replacementSessionId;
|
|
381
|
+
replacementSfuClient.updateMuteStates = vi.fn();
|
|
382
|
+
call['sfuClient'] = replacementSfuClient;
|
|
383
|
+
releaseMuteUpdate();
|
|
384
|
+
|
|
385
|
+
await inflight;
|
|
386
|
+
|
|
387
|
+
const originalParticipant =
|
|
388
|
+
call.state.findParticipantBySessionId(sessionId);
|
|
389
|
+
const replacementParticipant =
|
|
390
|
+
call.state.findParticipantBySessionId(replacementSessionId);
|
|
391
|
+
expect(originalParticipant?.publishedTracks).toEqual([]);
|
|
392
|
+
expect(originalParticipant?.audioStream).toBeUndefined();
|
|
393
|
+
expect(replacementParticipant?.publishedTracks).toEqual([]);
|
|
394
|
+
expect(replacementParticipant?.audioStream).toBeUndefined();
|
|
395
|
+
});
|
|
293
396
|
});
|
|
294
397
|
|
|
295
398
|
describe('Deprecated methods', () => {
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { StreamSfuClient } from '../StreamSfuClient';
|
|
3
|
+
import { Dispatcher } from '../rtc';
|
|
4
|
+
import { StreamClient } from '../coordinator/connection/client';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Minimal `WebSocket` stub used to drive `StreamSfuClient.close()` while the
|
|
8
|
+
* underlying connection is still in `CONNECTING` state. The constructor
|
|
9
|
+
* leaves `readyState = CONNECTING`; `close()` records the call and flips
|
|
10
|
+
* to `CLOSED` so subsequent assertions can see what happened.
|
|
11
|
+
*/
|
|
12
|
+
class CapturingWebSocket {
|
|
13
|
+
static CONNECTING = 0;
|
|
14
|
+
static OPEN = 1;
|
|
15
|
+
static CLOSING = 2;
|
|
16
|
+
static CLOSED = 3;
|
|
17
|
+
static instances: CapturingWebSocket[] = [];
|
|
18
|
+
|
|
19
|
+
readyState = CapturingWebSocket.CONNECTING;
|
|
20
|
+
url: string;
|
|
21
|
+
binaryType = 'blob';
|
|
22
|
+
closeArgs: { code?: number; reason?: string } | undefined;
|
|
23
|
+
private listeners = new Map<string, Set<(e: unknown) => void>>();
|
|
24
|
+
|
|
25
|
+
constructor(url: string | URL) {
|
|
26
|
+
this.url = typeof url === 'string' ? url : url.toString();
|
|
27
|
+
CapturingWebSocket.instances.push(this);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
addEventListener(event: string, listener: (e: unknown) => void) {
|
|
31
|
+
if (!this.listeners.has(event)) this.listeners.set(event, new Set());
|
|
32
|
+
this.listeners.get(event)!.add(listener);
|
|
33
|
+
}
|
|
34
|
+
removeEventListener(event: string, listener: (e: unknown) => void) {
|
|
35
|
+
this.listeners.get(event)?.delete(listener);
|
|
36
|
+
}
|
|
37
|
+
close(code?: number, reason?: string) {
|
|
38
|
+
this.closeArgs = { code, reason };
|
|
39
|
+
this.readyState = CapturingWebSocket.CLOSED;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const buildSfuClient = () => {
|
|
44
|
+
const dispatcher = new Dispatcher();
|
|
45
|
+
const streamClient = new StreamClient('test-key');
|
|
46
|
+
return new StreamSfuClient({
|
|
47
|
+
dispatcher,
|
|
48
|
+
sessionId: 'session-id-test',
|
|
49
|
+
streamClient,
|
|
50
|
+
cid: 'default:test',
|
|
51
|
+
credentials: {
|
|
52
|
+
server: {
|
|
53
|
+
url: 'https://test.invalid',
|
|
54
|
+
ws_endpoint: 'wss://test.invalid/ws',
|
|
55
|
+
edge_name: 'sfu-test',
|
|
56
|
+
},
|
|
57
|
+
token: 'token',
|
|
58
|
+
ice_servers: [],
|
|
59
|
+
},
|
|
60
|
+
tag: 'test',
|
|
61
|
+
enableTracing: false,
|
|
62
|
+
});
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
describe('StreamSfuClient.close()', () => {
|
|
66
|
+
beforeEach(() => {
|
|
67
|
+
CapturingWebSocket.instances = [];
|
|
68
|
+
vi.stubGlobal('WebSocket', CapturingWebSocket);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
afterEach(() => {
|
|
72
|
+
vi.unstubAllGlobals();
|
|
73
|
+
vi.clearAllMocks();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('closes the WebSocket even when it is still in CONNECTING state', () => {
|
|
77
|
+
const sfuClient = buildSfuClient();
|
|
78
|
+
const ws = CapturingWebSocket.instances.at(-1)!;
|
|
79
|
+
expect(ws.readyState).toBe(CapturingWebSocket.CONNECTING);
|
|
80
|
+
|
|
81
|
+
sfuClient.close(1000, 'tearing down');
|
|
82
|
+
|
|
83
|
+
expect(ws.closeArgs).toBeDefined();
|
|
84
|
+
expect(ws.closeArgs?.code).toBe(1000);
|
|
85
|
+
expect(ws.readyState).toBe(CapturingWebSocket.CLOSED);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('rejects a pending joinResponseTask on close so awaiters do not hang', async () => {
|
|
89
|
+
const sfuClient = buildSfuClient();
|
|
90
|
+
const joinTask = sfuClient.joinTask;
|
|
91
|
+
|
|
92
|
+
sfuClient.close(1000, 'aborting');
|
|
93
|
+
|
|
94
|
+
await expect(joinTask).rejects.toThrow(/SFU client disposed/);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('does not blow up when the WebSocket is already CLOSED', () => {
|
|
98
|
+
const sfuClient = buildSfuClient();
|
|
99
|
+
const ws = CapturingWebSocket.instances.at(-1)!;
|
|
100
|
+
ws.readyState = CapturingWebSocket.CLOSED;
|
|
101
|
+
|
|
102
|
+
expect(() => sfuClient.close(1000, 'noop')).not.toThrow();
|
|
103
|
+
// close() should not be called twice
|
|
104
|
+
expect(ws.closeArgs).toBeUndefined();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('leaveAndClose returns within ~grace period when the SFU is silent (no hang)', async () => {
|
|
108
|
+
const sfuClient = buildSfuClient();
|
|
109
|
+
vi.spyOn(
|
|
110
|
+
sfuClient as unknown as {
|
|
111
|
+
notifyLeave: (reason: string) => Promise<void>;
|
|
112
|
+
},
|
|
113
|
+
'notifyLeave',
|
|
114
|
+
).mockResolvedValue(undefined);
|
|
115
|
+
|
|
116
|
+
// joinResponseTask stays pending forever — verify leaveAndClose still returns.
|
|
117
|
+
const start = Date.now();
|
|
118
|
+
await Promise.race([
|
|
119
|
+
sfuClient.leaveAndClose('silent-sfu'),
|
|
120
|
+
new Promise((_, reject) =>
|
|
121
|
+
setTimeout(
|
|
122
|
+
() => reject(new Error('leaveAndClose hung past 2x grace')),
|
|
123
|
+
StreamSfuClient.LEAVE_NOTIFY_GRACE_MS * 2,
|
|
124
|
+
),
|
|
125
|
+
),
|
|
126
|
+
]);
|
|
127
|
+
const elapsed = Date.now() - start;
|
|
128
|
+
expect(elapsed).toBeLessThan(StreamSfuClient.LEAVE_NOTIFY_GRACE_MS * 2);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('close() does NOT produce an unhandled rejection when nobody awaits joinTask', async () => {
|
|
132
|
+
// Capture unhandledrejection events that fire during this test. Without
|
|
133
|
+
// the safe-catch attached to `joinResponseTask.promise`, dispose-time
|
|
134
|
+
// reject would surface here.
|
|
135
|
+
const unhandled: PromiseRejectionEvent[] = [];
|
|
136
|
+
const onUnhandled = (e: PromiseRejectionEvent) => {
|
|
137
|
+
unhandled.push(e);
|
|
138
|
+
// mark as handled so it doesn't crash the test runner
|
|
139
|
+
e.preventDefault?.();
|
|
140
|
+
};
|
|
141
|
+
if (typeof window !== 'undefined') {
|
|
142
|
+
window.addEventListener('unhandledrejection', onUnhandled);
|
|
143
|
+
}
|
|
144
|
+
// Node-side fallback so the test passes regardless of test environment.
|
|
145
|
+
const onProcessUnhandled = (reason: unknown) => {
|
|
146
|
+
unhandled.push({ reason } as unknown as PromiseRejectionEvent);
|
|
147
|
+
};
|
|
148
|
+
process.on('unhandledRejection', onProcessUnhandled);
|
|
149
|
+
|
|
150
|
+
try {
|
|
151
|
+
const sfuClient = buildSfuClient();
|
|
152
|
+
// Intentionally do NOT touch joinTask anywhere — no .catch, no await.
|
|
153
|
+
sfuClient.close(1000, 'aborting before any join');
|
|
154
|
+
|
|
155
|
+
// give microtasks + a tick for any unhandledrejection event to fire
|
|
156
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
157
|
+
expect(unhandled).toHaveLength(0);
|
|
158
|
+
} finally {
|
|
159
|
+
if (typeof window !== 'undefined') {
|
|
160
|
+
window.removeEventListener('unhandledrejection', onUnhandled);
|
|
161
|
+
}
|
|
162
|
+
process.off('unhandledRejection', onProcessUnhandled);
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
describe('StreamSfuClient.leaveAndClose()', () => {
|
|
168
|
+
beforeEach(() => {
|
|
169
|
+
CapturingWebSocket.instances = [];
|
|
170
|
+
vi.stubGlobal('WebSocket', CapturingWebSocket);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
afterEach(() => {
|
|
174
|
+
vi.useRealTimers();
|
|
175
|
+
vi.unstubAllGlobals();
|
|
176
|
+
vi.clearAllMocks();
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
type JoinResponseTaskHandle = {
|
|
180
|
+
joinResponseTask: {
|
|
181
|
+
resolve: (v: unknown) => void;
|
|
182
|
+
reject: (err: unknown) => void;
|
|
183
|
+
};
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
it('notifies the SFU when joinResponseTask is already resolved', async () => {
|
|
187
|
+
const sfuClient = buildSfuClient();
|
|
188
|
+
(sfuClient as unknown as JoinResponseTaskHandle).joinResponseTask.resolve(
|
|
189
|
+
{},
|
|
190
|
+
);
|
|
191
|
+
const notifyLeaveSpy = vi
|
|
192
|
+
.spyOn(
|
|
193
|
+
sfuClient as unknown as {
|
|
194
|
+
notifyLeave: (reason: string) => Promise<void>;
|
|
195
|
+
},
|
|
196
|
+
'notifyLeave',
|
|
197
|
+
)
|
|
198
|
+
.mockResolvedValue(undefined);
|
|
199
|
+
|
|
200
|
+
await sfuClient.leaveAndClose('user-leaving');
|
|
201
|
+
|
|
202
|
+
expect(notifyLeaveSpy).toHaveBeenCalledWith('user-leaving');
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('waits for an in-flight join and notifies the SFU when it resolves within the grace period', async () => {
|
|
206
|
+
const sfuClient = buildSfuClient();
|
|
207
|
+
const notifyLeaveSpy = vi
|
|
208
|
+
.spyOn(
|
|
209
|
+
sfuClient as unknown as {
|
|
210
|
+
notifyLeave: (reason: string) => Promise<void>;
|
|
211
|
+
},
|
|
212
|
+
'notifyLeave',
|
|
213
|
+
)
|
|
214
|
+
.mockResolvedValue(undefined);
|
|
215
|
+
|
|
216
|
+
vi.useFakeTimers();
|
|
217
|
+
const leavePromise = sfuClient.leaveAndClose('user-leaving');
|
|
218
|
+
|
|
219
|
+
// simulate the SFU sending JoinResponse 50 ms in (well within the grace window)
|
|
220
|
+
await vi.advanceTimersByTimeAsync(50);
|
|
221
|
+
(sfuClient as unknown as JoinResponseTaskHandle).joinResponseTask.resolve(
|
|
222
|
+
{},
|
|
223
|
+
);
|
|
224
|
+
// flush remaining timers (the losing race branch and any microtasks)
|
|
225
|
+
await vi.runAllTimersAsync();
|
|
226
|
+
await leavePromise;
|
|
227
|
+
|
|
228
|
+
expect(notifyLeaveSpy).toHaveBeenCalledWith('user-leaving');
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it('skips notifyLeave when the join does not complete within the grace period', async () => {
|
|
232
|
+
const sfuClient = buildSfuClient();
|
|
233
|
+
const notifyLeaveSpy = vi
|
|
234
|
+
.spyOn(
|
|
235
|
+
sfuClient as unknown as {
|
|
236
|
+
notifyLeave: (reason: string) => Promise<void>;
|
|
237
|
+
},
|
|
238
|
+
'notifyLeave',
|
|
239
|
+
)
|
|
240
|
+
.mockResolvedValue(undefined);
|
|
241
|
+
|
|
242
|
+
vi.useFakeTimers();
|
|
243
|
+
const leavePromise = sfuClient.leaveAndClose('silent-sfu');
|
|
244
|
+
// run past the grace window — the task is never resolved
|
|
245
|
+
await vi.advanceTimersByTimeAsync(
|
|
246
|
+
StreamSfuClient.LEAVE_NOTIFY_GRACE_MS + 50,
|
|
247
|
+
);
|
|
248
|
+
await leavePromise;
|
|
249
|
+
|
|
250
|
+
expect(notifyLeaveSpy).not.toHaveBeenCalled();
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it('skips notifyLeave when joinResponseTask rejects within the grace period', async () => {
|
|
254
|
+
const sfuClient = buildSfuClient();
|
|
255
|
+
const notifyLeaveSpy = vi
|
|
256
|
+
.spyOn(
|
|
257
|
+
sfuClient as unknown as {
|
|
258
|
+
notifyLeave: (reason: string) => Promise<void>;
|
|
259
|
+
},
|
|
260
|
+
'notifyLeave',
|
|
261
|
+
)
|
|
262
|
+
.mockResolvedValue(undefined);
|
|
263
|
+
|
|
264
|
+
vi.useFakeTimers();
|
|
265
|
+
const leavePromise = sfuClient.leaveAndClose('rejected');
|
|
266
|
+
await vi.advanceTimersByTimeAsync(20);
|
|
267
|
+
(sfuClient as unknown as JoinResponseTaskHandle).joinResponseTask.reject(
|
|
268
|
+
new Error('SFU went away'),
|
|
269
|
+
);
|
|
270
|
+
await vi.runAllTimersAsync();
|
|
271
|
+
await leavePromise;
|
|
272
|
+
|
|
273
|
+
expect(notifyLeaveSpy).not.toHaveBeenCalled();
|
|
274
|
+
});
|
|
275
|
+
});
|