@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.
Files changed (86) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/dist/index.browser.es.js +1497 -677
  3. package/dist/index.browser.es.js.map +1 -1
  4. package/dist/index.cjs.js +1497 -677
  5. package/dist/index.cjs.js.map +1 -1
  6. package/dist/index.es.js +1497 -677
  7. package/dist/index.es.js.map +1 -1
  8. package/dist/src/Call.d.ts +77 -4
  9. package/dist/src/StreamSfuClient.d.ts +8 -1
  10. package/dist/src/coordinator/connection/client.d.ts +1 -1
  11. package/dist/src/coordinator/connection/connection.d.ts +31 -25
  12. package/dist/src/coordinator/connection/types.d.ts +14 -0
  13. package/dist/src/coordinator/connection/utils.d.ts +1 -0
  14. package/dist/src/devices/DeviceManager.d.ts +3 -0
  15. package/dist/src/devices/DeviceManagerState.d.ts +13 -1
  16. package/dist/src/gen/video/sfu/event/events.d.ts +5 -1
  17. package/dist/src/gen/video/sfu/models/models.d.ts +34 -0
  18. package/dist/src/helpers/AudioBindingsWatchdog.d.ts +3 -3
  19. package/dist/src/helpers/BlockedAudioTracker.d.ts +30 -0
  20. package/dist/src/helpers/DynascaleManager.d.ts +8 -86
  21. package/dist/src/helpers/MediaPlaybackWatchdog.d.ts +32 -0
  22. package/dist/src/helpers/SlidingWindowRateLimiter.d.ts +28 -0
  23. package/dist/src/helpers/TrackSubscriptionManager.d.ts +114 -0
  24. package/dist/src/helpers/ViewportTracker.d.ts +11 -17
  25. package/dist/src/helpers/browsers.d.ts +13 -0
  26. package/dist/src/helpers/concurrency.d.ts +6 -4
  27. package/dist/src/rtc/BasePeerConnection.d.ts +11 -2
  28. package/dist/src/rtc/Publisher.d.ts +17 -0
  29. package/dist/src/rtc/Subscriber.d.ts +1 -0
  30. package/dist/src/rtc/helpers/degradationPreference.d.ts +2 -0
  31. package/dist/src/rtc/index.d.ts +1 -0
  32. package/dist/src/rtc/types.d.ts +33 -1
  33. package/dist/src/stats/rtc/types.d.ts +1 -1
  34. package/dist/src/store/rxUtils.d.ts +9 -0
  35. package/dist/src/types.d.ts +18 -0
  36. package/package.json +2 -2
  37. package/src/Call.ts +268 -40
  38. package/src/StreamSfuClient.ts +75 -12
  39. package/src/__tests__/Call.lifecycle.test.ts +67 -0
  40. package/src/__tests__/Call.publishing.test.ts +103 -0
  41. package/src/__tests__/StreamSfuClient.test.ts +275 -0
  42. package/src/coordinator/connection/__tests__/connection.test.ts +482 -0
  43. package/src/coordinator/connection/client.ts +1 -1
  44. package/src/coordinator/connection/connection.ts +149 -96
  45. package/src/coordinator/connection/types.ts +15 -0
  46. package/src/coordinator/connection/utils.ts +15 -0
  47. package/src/devices/DeviceManager.ts +92 -32
  48. package/src/devices/DeviceManagerState.ts +20 -1
  49. package/src/devices/__tests__/DeviceManager.test.ts +283 -0
  50. package/src/devices/__tests__/ScreenShareManager.test.ts +0 -2
  51. package/src/devices/__tests__/mocks.ts +2 -0
  52. package/src/devices/devices.ts +2 -1
  53. package/src/gen/video/sfu/event/events.ts +15 -0
  54. package/src/gen/video/sfu/models/models.ts +44 -0
  55. package/src/helpers/AudioBindingsWatchdog.ts +10 -7
  56. package/src/helpers/BlockedAudioTracker.ts +74 -0
  57. package/src/helpers/DynascaleManager.ts +46 -337
  58. package/src/helpers/MediaPlaybackWatchdog.ts +121 -0
  59. package/src/helpers/SlidingWindowRateLimiter.ts +49 -0
  60. package/src/helpers/TrackSubscriptionManager.ts +243 -0
  61. package/src/helpers/ViewportTracker.ts +74 -19
  62. package/src/helpers/__tests__/BlockedAudioTracker.test.ts +114 -0
  63. package/src/helpers/__tests__/DynascaleManager.test.ts +175 -122
  64. package/src/helpers/__tests__/MediaPlaybackWatchdog.test.ts +180 -0
  65. package/src/helpers/__tests__/SlidingWindowRateLimiter.test.ts +43 -0
  66. package/src/helpers/__tests__/TrackSubscriptionManager.test.ts +310 -0
  67. package/src/helpers/__tests__/ViewportTracker.test.ts +83 -0
  68. package/src/helpers/__tests__/browsers.test.ts +85 -1
  69. package/src/helpers/browsers.ts +24 -0
  70. package/src/helpers/concurrency.ts +9 -10
  71. package/src/rpc/retryable.ts +0 -1
  72. package/src/rtc/BasePeerConnection.ts +96 -6
  73. package/src/rtc/Publisher.ts +49 -2
  74. package/src/rtc/Subscriber.ts +42 -14
  75. package/src/rtc/__tests__/Call.reconnect.test.ts +761 -0
  76. package/src/rtc/__tests__/Publisher.test.ts +332 -10
  77. package/src/rtc/__tests__/Subscriber.test.ts +202 -1
  78. package/src/rtc/__tests__/mocks/webrtc.mocks.ts +13 -2
  79. package/src/rtc/helpers/__tests__/degradationPreference.test.ts +23 -0
  80. package/src/rtc/helpers/degradationPreference.ts +22 -0
  81. package/src/rtc/index.ts +1 -0
  82. package/src/rtc/types.ts +38 -1
  83. package/src/stats/rtc/types.ts +1 -0
  84. package/src/store/__tests__/rxUtils.test.ts +276 -0
  85. package/src/store/rxUtils.ts +19 -0
  86. package/src/types.ts +19 -0
@@ -11,8 +11,10 @@ import {
11
11
  ErrorCode,
12
12
  PeerType,
13
13
  TrackType,
14
+ WebsocketReconnectStrategy,
14
15
  } from '../../gen/video/sfu/models/models';
15
16
  import { NegotiationError } from '../NegotiationError';
17
+ import { ReconnectReason } from '../types';
16
18
  import { IceTrickleBuffer } from '../IceTrickleBuffer';
17
19
  import { StreamClient } from '../../coordinator/connection/client';
18
20
 
@@ -26,10 +28,11 @@ vi.mock('../../StreamSfuClient', () => {
26
28
  describe('Subscriber', () => {
27
29
  let sfuClient: StreamSfuClient;
28
30
  let subscriber: Subscriber;
29
- const state = new CallState();
31
+ let state: CallState;
30
32
  let dispatcher: Dispatcher;
31
33
 
32
34
  beforeEach(() => {
35
+ state = new CallState();
33
36
  dispatcher = new Dispatcher();
34
37
  sfuClient = new StreamSfuClient({
35
38
  dispatcher,
@@ -62,6 +65,7 @@ describe('Subscriber', () => {
62
65
  });
63
66
 
64
67
  afterEach(() => {
68
+ vi.useRealTimers();
65
69
  vi.clearAllMocks();
66
70
  vi.resetModules();
67
71
  subscriber.dispose();
@@ -97,7 +101,14 @@ describe('Subscriber', () => {
97
101
  });
98
102
  });
99
103
 
104
+ const simulatePriorIceConnected = () => {
105
+ // @ts-expect-error - private field
106
+ subscriber['pc'].iceConnectionState = 'connected';
107
+ subscriber['onIceConnectionStateChange']();
108
+ };
109
+
100
110
  it(`should perform ICE restart when connection state changes to 'failed'`, () => {
111
+ simulatePriorIceConnected();
101
112
  vi.spyOn(subscriber, 'restartIce').mockResolvedValue();
102
113
  // @ts-expect-error - private field
103
114
  subscriber['pc'].iceConnectionState = 'failed';
@@ -106,6 +117,7 @@ describe('Subscriber', () => {
106
117
  });
107
118
 
108
119
  it(`should perform ICE restart when connection state changes to 'disconnected'`, () => {
120
+ simulatePriorIceConnected();
109
121
  vi.spyOn(subscriber, 'restartIce').mockResolvedValue();
110
122
  vi.useFakeTimers();
111
123
  // @ts-expect-error - private field
@@ -115,6 +127,51 @@ describe('Subscriber', () => {
115
127
  expect(subscriber.restartIce).toHaveBeenCalled();
116
128
  });
117
129
 
130
+ it(`does NOT perform ICE restart when ICE never connected and state goes to 'failed' — emits REJOIN with 'ice_never_connected'`, () => {
131
+ vi.spyOn(subscriber, 'restartIce').mockResolvedValue();
132
+ subscriber['onReconnectionNeeded'] = vi.fn();
133
+ // @ts-expect-error - private field
134
+ subscriber['pc'].iceConnectionState = 'failed';
135
+ subscriber['onIceConnectionStateChange']();
136
+ expect(subscriber.restartIce).not.toHaveBeenCalled();
137
+ expect(subscriber['onReconnectionNeeded']).toHaveBeenCalledWith(
138
+ WebsocketReconnectStrategy.REJOIN,
139
+ ReconnectReason.ICE_NEVER_CONNECTED,
140
+ PeerType.SUBSCRIBER,
141
+ );
142
+ });
143
+
144
+ it(`isStable() returns true only when ICE is connected/completed and connectionState is connected`, () => {
145
+ // @ts-expect-error - private field
146
+ subscriber['pc'].iceConnectionState = 'connected';
147
+ // @ts-expect-error - private field
148
+ subscriber['pc'].connectionState = 'connected';
149
+ expect(subscriber.isStable()).toBe(true);
150
+
151
+ // @ts-expect-error - private field
152
+ subscriber['pc'].iceConnectionState = 'completed';
153
+ expect(subscriber.isStable()).toBe(true);
154
+
155
+ // @ts-expect-error - private field
156
+ subscriber['pc'].iceConnectionState = 'disconnected';
157
+ expect(subscriber.isStable()).toBe(false);
158
+
159
+ // @ts-expect-error - private field
160
+ subscriber['pc'].iceConnectionState = 'new';
161
+ expect(subscriber.isStable()).toBe(false);
162
+ });
163
+
164
+ it(`iceHasEverConnected tracks lifetime connectivity`, () => {
165
+ expect(subscriber['iceHasEverConnected']).toBe(false);
166
+ simulatePriorIceConnected();
167
+ expect(subscriber['iceHasEverConnected']).toBe(true);
168
+ // going disconnected does not reset the flag
169
+ // @ts-expect-error - private field
170
+ subscriber['pc'].iceConnectionState = 'disconnected';
171
+ subscriber['onIceConnectionStateChange']();
172
+ expect(subscriber['iceHasEverConnected']).toBe(true);
173
+ });
174
+
118
175
  it(`should throw NegotiationError when SFU returns an error`, async () => {
119
176
  sfuClient.iceRestart = vi.fn().mockResolvedValue({
120
177
  response: {
@@ -216,6 +273,150 @@ describe('Subscriber', () => {
216
273
  });
217
274
  });
218
275
 
276
+ describe('interruptedTracks', () => {
277
+ const setup = ({ muted = false }: { muted?: boolean } = {}) => {
278
+ const mediaStream = new MediaStream();
279
+ const track = new MediaStreamTrack();
280
+ // @ts-expect-error - mock
281
+ mediaStream.id = 'lookup:TRACK_TYPE_AUDIO';
282
+ // @ts-expect-error - mock
283
+ track.kind = 'audio';
284
+ Object.defineProperty(track, 'muted', {
285
+ configurable: true,
286
+ get: () => muted,
287
+ });
288
+ // @ts-expect-error - incomplete mock
289
+ state.updateOrAddParticipant('session-id', {
290
+ sessionId: 'session-id',
291
+ trackLookupPrefix: 'lookup',
292
+ });
293
+
294
+ const onTrack = subscriber['handleOnTrack'];
295
+ // @ts-expect-error - incomplete mock
296
+ onTrack({ streams: [mediaStream], track });
297
+
298
+ const calls = (track.addEventListener as ReturnType<typeof vi.fn>).mock
299
+ .calls;
300
+ const handlers: Record<string, () => void> = {};
301
+ for (const [event, handler] of calls) {
302
+ handlers[event] = handler as () => void;
303
+ }
304
+ return { track, handlers };
305
+ };
306
+
307
+ const interruptedFor = (sessionId: string) =>
308
+ state.participants.find((p) => p.sessionId === sessionId)
309
+ ?.interruptedTracks ?? [];
310
+
311
+ it('adds the track type when the mute handler fires', () => {
312
+ const { handlers } = setup();
313
+ expect(interruptedFor('session-id')).toEqual([]);
314
+
315
+ handlers['mute']();
316
+
317
+ expect(interruptedFor('session-id')).toEqual([TrackType.AUDIO]);
318
+ });
319
+
320
+ it('removes the track type when the unmute handler fires', () => {
321
+ const { handlers } = setup();
322
+ handlers['mute']();
323
+ expect(interruptedFor('session-id')).toEqual([TrackType.AUDIO]);
324
+
325
+ handlers['unmute']();
326
+
327
+ expect(interruptedFor('session-id')).toEqual([]);
328
+ });
329
+
330
+ it('seeds the track type when the track arrives already muted', () => {
331
+ setup({ muted: true });
332
+
333
+ expect(interruptedFor('session-id')).toEqual([TrackType.AUDIO]);
334
+ });
335
+
336
+ it('clears the track type when the track ends', () => {
337
+ const { handlers } = setup();
338
+ handlers['mute']();
339
+ expect(interruptedFor('session-id')).toEqual([TrackType.AUDIO]);
340
+
341
+ handlers['ended']();
342
+
343
+ expect(interruptedFor('session-id')).toEqual([]);
344
+ });
345
+
346
+ it('ignores non-audio remote tracks to avoid Dynascale false positives', () => {
347
+ // Remote video track.muted is dominated by viewport-driven
348
+ // SFU unsubscriptions, so we deliberately only track audio
349
+ // interruption on remote participants.
350
+ const mediaStream = new MediaStream();
351
+ const track = new MediaStreamTrack();
352
+ // @ts-expect-error - mock
353
+ mediaStream.id = 'video-lookup:TRACK_TYPE_VIDEO';
354
+ // @ts-expect-error - mock
355
+ track.kind = 'video';
356
+ Object.defineProperty(track, 'muted', {
357
+ configurable: true,
358
+ get: () => true,
359
+ });
360
+ // @ts-expect-error - incomplete mock
361
+ state.updateOrAddParticipant('video-session', {
362
+ sessionId: 'video-session',
363
+ trackLookupPrefix: 'video-lookup',
364
+ });
365
+
366
+ const onTrack = subscriber['handleOnTrack'];
367
+ // @ts-expect-error - incomplete mock
368
+ onTrack({ streams: [mediaStream], track });
369
+
370
+ // Seeded muted track is ignored.
371
+ expect(interruptedFor('video-session')).toEqual([]);
372
+
373
+ // Subsequent mute / unmute events are ignored too.
374
+ const calls = (track.addEventListener as ReturnType<typeof vi.fn>).mock
375
+ .calls;
376
+ const handlers: Record<string, () => void> = {};
377
+ for (const [event, handler] of calls) {
378
+ handlers[event] = handler as () => void;
379
+ }
380
+ handlers['mute']();
381
+ handlers['unmute']();
382
+ expect(interruptedFor('video-session')).toEqual([]);
383
+ });
384
+
385
+ it('does not mutate state for orphaned tracks until associated', () => {
386
+ const mediaStream = new MediaStream();
387
+ const track = new MediaStreamTrack();
388
+ // @ts-expect-error - mock
389
+ mediaStream.id = 'orphan:TRACK_TYPE_AUDIO';
390
+ // @ts-expect-error - mock
391
+ track.kind = 'audio';
392
+
393
+ const onTrack = subscriber['handleOnTrack'];
394
+ // @ts-expect-error - incomplete mock
395
+ onTrack({ streams: [mediaStream], track });
396
+
397
+ const calls = (track.addEventListener as ReturnType<typeof vi.fn>).mock
398
+ .calls;
399
+ const handlers: Record<string, () => void> = {};
400
+ for (const [event, handler] of calls) {
401
+ handlers[event] = handler as () => void;
402
+ }
403
+
404
+ // Orphan: handler fires before the participant exists.
405
+ handlers['mute']();
406
+ expect(state.participants).toEqual([]);
407
+
408
+ // Once the participant is registered, the next event lands.
409
+ // @ts-expect-error - incomplete mock
410
+ state.updateOrAddParticipant('orphan-session', {
411
+ sessionId: 'orphan-session',
412
+ trackLookupPrefix: 'orphan',
413
+ });
414
+ handlers['mute']();
415
+
416
+ expect(interruptedFor('orphan-session')).toEqual([TrackType.AUDIO]);
417
+ });
418
+ });
419
+
219
420
  describe('Negotiation', () => {
220
421
  it('negotiates with the SFU', async () => {
221
422
  sfuClient.sendAnswer = vi.fn();
@@ -16,8 +16,8 @@ const RTCPeerConnectionMock = vi.fn((): Partial<RTCPeerConnection> => {
16
16
  close: vi.fn(),
17
17
  connectionState: 'connected',
18
18
  signalingState: 'stable',
19
- getReceivers: vi.fn(),
20
- getSenders: vi.fn(),
19
+ getReceivers: vi.fn().mockReturnValue([]),
20
+ getSenders: vi.fn().mockReturnValue([]),
21
21
  removeTrack: vi.fn(),
22
22
  };
23
23
  });
@@ -109,6 +109,16 @@ const AudioContextMock = vi.fn((): Partial<AudioContext> => {
109
109
  gain: { value: 1 },
110
110
  } as unknown as GainNode;
111
111
  }),
112
+ // Silent keep-alive node used by DynascaleManager's probe AudioContext.
113
+ createConstantSource: vi.fn(() => {
114
+ return {
115
+ offset: { value: 0 },
116
+ connect: vi.fn((v) => v),
117
+ disconnect: vi.fn(),
118
+ start: vi.fn(),
119
+ stop: vi.fn(),
120
+ } as unknown as ConstantSourceNode;
121
+ }),
112
122
  close: vi.fn(async function () {
113
123
  this.state = 'closed';
114
124
  }),
@@ -119,6 +129,7 @@ const AudioContextMock = vi.fn((): Partial<AudioContext> => {
119
129
  this.sinkId = sinkId;
120
130
  }),
121
131
  addEventListener: vi.fn(),
132
+ removeEventListener: vi.fn(),
122
133
  };
123
134
  });
124
135
  vi.stubGlobal('AudioContext', AudioContextMock);
@@ -0,0 +1,23 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { DegradationPreference } from '../../../gen/video/sfu/models/models';
3
+ import { toRTCDegradationPreference } from '../degradationPreference';
4
+
5
+ describe('toRTCDegradationPreference', () => {
6
+ it.each([
7
+ [DegradationPreference.BALANCED, 'balanced'],
8
+ [DegradationPreference.MAINTAIN_FRAMERATE, 'maintain-framerate'],
9
+ [DegradationPreference.MAINTAIN_RESOLUTION, 'maintain-resolution'],
10
+ [
11
+ DegradationPreference.MAINTAIN_FRAMERATE_AND_RESOLUTION,
12
+ 'maintain-framerate-and-resolution',
13
+ ],
14
+ ])('maps %s to "%s"', (preference, expected) => {
15
+ expect(toRTCDegradationPreference(preference)).toBe(expected);
16
+ });
17
+
18
+ it('returns undefined for UNSPECIFIED', () => {
19
+ expect(
20
+ toRTCDegradationPreference(DegradationPreference.UNSPECIFIED),
21
+ ).toBeUndefined();
22
+ });
23
+ });
@@ -0,0 +1,22 @@
1
+ import { DegradationPreference } from '../../gen/video/sfu/models/models';
2
+ import { ensureExhausted } from '../../helpers/ensureExhausted';
3
+
4
+ export const toRTCDegradationPreference = (
5
+ preference: DegradationPreference,
6
+ ): RTCDegradationPreference | undefined => {
7
+ switch (preference) {
8
+ case DegradationPreference.BALANCED:
9
+ return 'balanced';
10
+ case DegradationPreference.MAINTAIN_FRAMERATE:
11
+ return 'maintain-framerate';
12
+ case DegradationPreference.MAINTAIN_RESOLUTION:
13
+ return 'maintain-resolution';
14
+ case DegradationPreference.MAINTAIN_FRAMERATE_AND_RESOLUTION:
15
+ // @ts-expect-error not in the typedefs yet
16
+ return 'maintain-framerate-and-resolution';
17
+ case DegradationPreference.UNSPECIFIED:
18
+ return undefined;
19
+ default:
20
+ ensureExhausted(preference, 'Unknown degradation preference');
21
+ }
22
+ };
package/src/rtc/index.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  export * from './codecs';
2
2
  export * from './Dispatcher';
3
+ export * from './NegotiationError';
3
4
  export * from './IceTrickleBuffer';
4
5
  export * from './Publisher';
5
6
  export * from './Subscriber';
package/src/rtc/types.ts CHANGED
@@ -10,18 +10,55 @@ import { Dispatcher } from './Dispatcher';
10
10
  import type { OptimalVideoLayer } from './layers';
11
11
  import type { ClientPublishOptions } from '../types';
12
12
 
13
+ /**
14
+ * Canonical reasons the SDK uses to trigger a reconnection. Free-form strings
15
+ * are still accepted at the callback boundary (e.g. when forwarding an SFU
16
+ * error message), but only the members below influence reconnect-loop
17
+ * behavior. In particular, `Call.reconnect` programmatically inspects
18
+ * `ICE_NEVER_CONNECTED` to drive the unsupported-network detector — pass a
19
+ * canonical member when you want the SDK to react to the reason; pass a
20
+ * free-form string when the value is purely diagnostic.
21
+ */
22
+ export const ReconnectReason = {
23
+ /** ICE never reached `connected`/`completed`, escalate to REJOIN. */
24
+ ICE_NEVER_CONNECTED: 'ice_never_connected',
25
+ /** RTCPeerConnection.connectionState became `failed`. */
26
+ CONNECTION_FAILED: 'connection_failed',
27
+ /** `restartIce()` rejected. */
28
+ RESTART_ICE_FAILED: 'restart_ice_failed',
29
+ /** SFU `goAway` event, migrate to a new SFU. */
30
+ GO_AWAY: 'go_away',
31
+ /** Network came back online after going offline. */
32
+ NETWORK_BACK_ONLINE: 'network_back_online',
33
+ /** SFU error event with no descriptive message. */
34
+ SFU_ERROR: 'sfu_error',
35
+ } as const;
36
+
37
+ export type ReconnectReason =
38
+ | (typeof ReconnectReason)[keyof typeof ReconnectReason]
39
+ | (string & {});
40
+
13
41
  export type OnReconnectionNeeded = (
14
42
  kind: WebsocketReconnectStrategy,
15
- reason: string,
43
+ reason: ReconnectReason,
16
44
  peerType: PeerType,
17
45
  ) => void;
18
46
 
47
+ /**
48
+ * Fires the first time a peer connection's ICE transport reaches
49
+ * `connected` or `completed` during its lifetime. Used by `Call` to reset
50
+ * the "ICE never connected" failure counter only when WebRTC has actually
51
+ * recovered, not merely when the SFU join handshake succeeded.
52
+ */
53
+ export type OnIceConnected = (peerType: PeerType) => void;
54
+
19
55
  export type BasePeerConnectionOpts = {
20
56
  sfuClient: StreamSfuClient;
21
57
  state: CallState;
22
58
  connectionConfig?: RTCConfiguration;
23
59
  dispatcher: Dispatcher;
24
60
  onReconnectionNeeded?: OnReconnectionNeeded;
61
+ onIceConnected?: OnIceConnected;
25
62
  tag: string;
26
63
  enableTracing: boolean;
27
64
  iceRestartDelay?: number;
@@ -16,6 +16,7 @@ export type RTCStatsDataType =
16
16
  | RTCSessionDescriptionInit
17
17
  | (RTCIceCandidateInit | RTCIceCandidate) // addIceCandidate
18
18
  | object
19
+ | number
19
20
  | null
20
21
  | undefined;
21
22
 
@@ -0,0 +1,276 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { BehaviorSubject, Subject, throwError } from 'rxjs';
3
+ import { promiseWithResolvers } from '../../helpers/promise';
4
+ import {
5
+ createSafeAsyncSubscription,
6
+ createSubscription,
7
+ getCurrentValue,
8
+ setCurrentValue,
9
+ setCurrentValueAsync,
10
+ updateValue,
11
+ } from '../rxUtils';
12
+
13
+ describe('getCurrentValue', () => {
14
+ it('returns the current value of a BehaviorSubject', () => {
15
+ const subject = new BehaviorSubject(42);
16
+ expect(getCurrentValue(subject)).toBe(42);
17
+ });
18
+
19
+ it('reflects subsequent emissions', () => {
20
+ const subject = new BehaviorSubject('a');
21
+ subject.next('b');
22
+ expect(getCurrentValue(subject)).toBe('b');
23
+ });
24
+
25
+ it('rethrows errors emitted by the observable', () => {
26
+ const err = new Error('observable failed');
27
+ expect(() => getCurrentValue(throwError(() => err))).toThrow(err);
28
+ });
29
+ });
30
+
31
+ describe('setCurrentValue', () => {
32
+ it('sets a plain value and returns it', () => {
33
+ const subject = new BehaviorSubject(1);
34
+ const result = setCurrentValue(subject, 5);
35
+ expect(result).toBe(5);
36
+ expect(subject.getValue()).toBe(5);
37
+ });
38
+
39
+ it('applies a function patch using the current value', () => {
40
+ const subject = new BehaviorSubject(10);
41
+ const result = setCurrentValue(subject, (n) => n * 2);
42
+ expect(result).toBe(20);
43
+ expect(subject.getValue()).toBe(20);
44
+ });
45
+
46
+ it('emits the new value to subscribers', () => {
47
+ const subject = new BehaviorSubject(0);
48
+ const seen: number[] = [];
49
+ const sub = subject.subscribe((v) => seen.push(v));
50
+ setCurrentValue(subject, 1);
51
+ setCurrentValue(subject, (n) => n + 1);
52
+ sub.unsubscribe();
53
+ expect(seen).toEqual([0, 1, 2]);
54
+ });
55
+ });
56
+
57
+ describe('setCurrentValueAsync', () => {
58
+ it('passes the current value to the update fn and emits the resolved value', async () => {
59
+ const subject = new BehaviorSubject(1);
60
+ const update = vi.fn(async (n: number) => n + 1);
61
+
62
+ const result = await setCurrentValueAsync(subject, update);
63
+
64
+ expect(update).toHaveBeenCalledWith(1);
65
+ expect(result).toBe(2);
66
+ expect(subject.getValue()).toBe(2);
67
+ });
68
+
69
+ it('serializes concurrent calls on the same subject so each sees the previous result', async () => {
70
+ const subject = new BehaviorSubject(0);
71
+ const observed: number[] = [];
72
+
73
+ const append = (delay: number) =>
74
+ setCurrentValueAsync(subject, async (n) => {
75
+ observed.push(n);
76
+ await new Promise((r) => setTimeout(r, delay));
77
+ return n + 1;
78
+ });
79
+
80
+ const [a, b, c] = await Promise.all([append(10), append(0), append(0)]);
81
+
82
+ expect(observed).toEqual([0, 1, 2]);
83
+ expect([a, b, c]).toEqual([1, 2, 3]);
84
+ expect(subject.getValue()).toBe(3);
85
+ });
86
+
87
+ it('does not block updates on a different subject', async () => {
88
+ const a = new BehaviorSubject('a-0');
89
+ const b = new BehaviorSubject('b-0');
90
+
91
+ const gate = promiseWithResolvers();
92
+
93
+ const aPending = setCurrentValueAsync(a, async (v) => {
94
+ await gate.promise;
95
+ return `${v}-done`;
96
+ });
97
+ const bDone = await setCurrentValueAsync(b, async (v) => `${v}-done`);
98
+
99
+ expect(bDone).toBe('b-0-done');
100
+ expect(b.getValue()).toBe('b-0-done');
101
+
102
+ gate.resolve();
103
+ await expect(aPending).resolves.toBe('a-0-done');
104
+ expect(a.getValue()).toBe('a-0-done');
105
+ });
106
+
107
+ it('propagates rejections without emitting and keeps the prior value', async () => {
108
+ const subject = new BehaviorSubject(7);
109
+ const emitted: number[] = [];
110
+ const sub = subject.subscribe((v) => emitted.push(v));
111
+
112
+ const boom = new Error('boom');
113
+ await expect(
114
+ setCurrentValueAsync(subject, async () => {
115
+ throw boom;
116
+ }),
117
+ ).rejects.toBe(boom);
118
+
119
+ expect(subject.getValue()).toBe(7);
120
+ // Only the initial replay from the BehaviorSubject, no second emission.
121
+ expect(emitted).toEqual([7]);
122
+ sub.unsubscribe();
123
+ });
124
+
125
+ it('continues to process queued updates after a rejection', async () => {
126
+ const subject = new BehaviorSubject(0);
127
+
128
+ const failing = setCurrentValueAsync(subject, async () => {
129
+ throw new Error('nope');
130
+ });
131
+ const succeeding = setCurrentValueAsync(subject, async (n) => n + 5);
132
+
133
+ await expect(failing).rejects.toThrow('nope');
134
+ await expect(succeeding).resolves.toBe(5);
135
+ expect(subject.getValue()).toBe(5);
136
+ });
137
+ });
138
+
139
+ describe('updateValue', () => {
140
+ it('returns the previous and new values', () => {
141
+ const subject = new BehaviorSubject(1);
142
+ const { lastValue, value } = updateValue(subject, 2);
143
+ expect(lastValue).toBe(1);
144
+ expect(value).toBe(2);
145
+ expect(subject.getValue()).toBe(2);
146
+ });
147
+
148
+ it('rollback restores the previous value', () => {
149
+ const subject = new BehaviorSubject({ count: 3 });
150
+ const prior = subject.getValue();
151
+
152
+ const { rollback } = updateValue(subject, { count: 99 });
153
+ expect(subject.getValue()).toEqual({ count: 99 });
154
+
155
+ rollback();
156
+ expect(subject.getValue()).toBe(prior);
157
+ });
158
+
159
+ it('accepts a function patch', () => {
160
+ const subject = new BehaviorSubject(10);
161
+ const { value } = updateValue(subject, (n) => n + 5);
162
+ expect(value).toBe(15);
163
+ expect(subject.getValue()).toBe(15);
164
+ });
165
+ });
166
+
167
+ describe('createSubscription', () => {
168
+ it('invokes the handler with every emitted value', () => {
169
+ const subject = new Subject<number>();
170
+ const handler = vi.fn();
171
+ const unsubscribe = createSubscription(subject, handler);
172
+
173
+ subject.next(1);
174
+ subject.next(2);
175
+ unsubscribe();
176
+
177
+ expect(handler).toHaveBeenCalledTimes(2);
178
+ expect(handler).toHaveBeenNthCalledWith(1, 1);
179
+ expect(handler).toHaveBeenNthCalledWith(2, 2);
180
+ });
181
+
182
+ it('stops receiving values after unsubscribe is called', () => {
183
+ const subject = new Subject<number>();
184
+ const handler = vi.fn();
185
+ const unsubscribe = createSubscription(subject, handler);
186
+
187
+ subject.next(1);
188
+ unsubscribe();
189
+ subject.next(2);
190
+
191
+ expect(handler).toHaveBeenCalledTimes(1);
192
+ expect(handler).toHaveBeenCalledWith(1);
193
+ });
194
+
195
+ it('routes errors to the provided onError handler', () => {
196
+ const err = new Error('observable failed');
197
+ const onError = vi.fn();
198
+ createSubscription(
199
+ throwError(() => err),
200
+ vi.fn(),
201
+ onError,
202
+ );
203
+ expect(onError).toHaveBeenCalledWith(err);
204
+ });
205
+
206
+ it('swallows errors via the default onError when none is provided', () => {
207
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
208
+
209
+ expect(() =>
210
+ createSubscription(
211
+ throwError(() => new Error('boom')),
212
+ vi.fn(),
213
+ ),
214
+ ).not.toThrow();
215
+ expect(warn).toHaveBeenCalled();
216
+
217
+ warn.mockRestore();
218
+ });
219
+ });
220
+
221
+ describe('createSafeAsyncSubscription', () => {
222
+ it('runs the async handler for each emission', async () => {
223
+ const subject = new Subject<number>();
224
+ const handler = vi.fn(async () => {});
225
+ const unsubscribe = createSafeAsyncSubscription(subject, handler);
226
+
227
+ subject.next(1);
228
+ subject.next(2);
229
+ await new Promise((r) => setTimeout(r, 0));
230
+
231
+ expect(handler).toHaveBeenCalledTimes(2);
232
+ expect(handler).toHaveBeenNthCalledWith(1, 1);
233
+ expect(handler).toHaveBeenNthCalledWith(2, 2);
234
+ unsubscribe();
235
+ });
236
+
237
+ it('serializes handlers so a slow one blocks the next', async () => {
238
+ const subject = new Subject<number>();
239
+ const events: string[] = [];
240
+ const gate = promiseWithResolvers();
241
+
242
+ const unsubscribe = createSafeAsyncSubscription(subject, async (v) => {
243
+ events.push(`start:${v}`);
244
+ if (v === 1) await gate.promise;
245
+ events.push(`end:${v}`);
246
+ });
247
+
248
+ subject.next(1);
249
+ subject.next(2);
250
+ await new Promise((r) => setTimeout(r, 0));
251
+
252
+ // Second handler hasn't started yet because the first is still in-flight.
253
+ expect(events).toEqual(['start:1']);
254
+
255
+ gate.resolve();
256
+ await new Promise((r) => setTimeout(r, 0));
257
+ await new Promise((r) => setTimeout(r, 0));
258
+
259
+ expect(events).toEqual(['start:1', 'end:1', 'start:2', 'end:2']);
260
+ unsubscribe();
261
+ });
262
+
263
+ it('stops invoking the handler after unsubscribe', async () => {
264
+ const subject = new Subject<number>();
265
+ const handler = vi.fn(async () => {});
266
+ const unsubscribe = createSafeAsyncSubscription(subject, handler);
267
+
268
+ subject.next(1);
269
+ unsubscribe();
270
+ subject.next(2);
271
+ await new Promise((r) => setTimeout(r, 0));
272
+
273
+ expect(handler).toHaveBeenCalledTimes(1);
274
+ expect(handler).toHaveBeenCalledWith(1);
275
+ });
276
+ });