@stream-io/video-client 1.58.0 → 1.59.1

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 (57) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/dist/index.browser.es.js +367 -76
  3. package/dist/index.browser.es.js.map +1 -1
  4. package/dist/index.cjs.js +378 -74
  5. package/dist/index.cjs.js.map +1 -1
  6. package/dist/index.d.ts +2 -0
  7. package/dist/index.es.js +367 -76
  8. package/dist/index.es.js.map +1 -1
  9. package/dist/src/Call.d.ts +15 -4
  10. package/dist/src/StreamVideoClient.d.ts +9 -1
  11. package/dist/src/coordinator/connection/utils.d.ts +10 -0
  12. package/dist/src/devices/CameraManager.d.ts +2 -1
  13. package/dist/src/devices/DeviceManager.d.ts +1 -1
  14. package/dist/src/devices/MicrophoneManager.d.ts +2 -1
  15. package/dist/src/gen/coordinator/index.d.ts +26 -0
  16. package/dist/src/helpers/MediaStreamRecorder.d.ts +26 -0
  17. package/dist/src/helpers/concurrency.d.ts +8 -0
  18. package/dist/src/helpers/loopback.d.ts +55 -0
  19. package/dist/src/reporting/ClientEventReporter.d.ts +0 -2
  20. package/dist/src/rtc/Subscriber.d.ts +5 -0
  21. package/dist/src/sorting/participants.d.ts +13 -0
  22. package/index.ts +2 -0
  23. package/package.json +1 -1
  24. package/src/Call.ts +156 -134
  25. package/src/StreamVideoClient.ts +35 -8
  26. package/src/__tests__/Call.lifecycle.test.ts +33 -2
  27. package/src/__tests__/Call.publishing.test.ts +42 -1
  28. package/src/__tests__/StreamVideoClient.api.test.ts +33 -0
  29. package/src/__tests__/StreamVideoClient.rewatch.test.ts +213 -0
  30. package/src/__tests__/StreamVideoClient.ringing.test.ts +43 -24
  31. package/src/__tests__/StreamVideoClient.test.ts +14 -12
  32. package/src/coordinator/connection/__tests__/eventListenerIsolation.test.ts +115 -0
  33. package/src/coordinator/connection/client.ts +3 -2
  34. package/src/coordinator/connection/utils.ts +28 -0
  35. package/src/devices/CameraManager.ts +16 -4
  36. package/src/devices/DeviceManager.ts +7 -4
  37. package/src/devices/MicrophoneManager.ts +11 -3
  38. package/src/devices/__tests__/CameraManager.test.ts +27 -1
  39. package/src/devices/__tests__/DeviceManager.test.ts +49 -1
  40. package/src/devices/__tests__/MicrophoneManager.test.ts +23 -1
  41. package/src/events/call.ts +6 -3
  42. package/src/gen/coordinator/index.ts +26 -0
  43. package/src/helpers/MediaStreamRecorder.ts +104 -0
  44. package/src/helpers/__tests__/MediaStreamRecorder.test.ts +324 -0
  45. package/src/helpers/__tests__/concurrency.test.ts +74 -0
  46. package/src/helpers/__tests__/loopback.test.ts +367 -0
  47. package/src/helpers/concurrency.ts +24 -0
  48. package/src/helpers/loopback.ts +200 -0
  49. package/src/reporting/ClientEventReporter.ts +15 -28
  50. package/src/reporting/__tests__/ClientEventReporter.test.ts +3 -7
  51. package/src/rtc/Subscriber.ts +8 -0
  52. package/src/rtc/__tests__/Subscriber.test.ts +101 -0
  53. package/src/sorting/__tests__/presets.test.ts +188 -3
  54. package/src/sorting/__tests__/sorting.test.ts +22 -1
  55. package/src/sorting/participants.ts +20 -0
  56. package/src/sorting/presets.ts +4 -0
  57. package/src/store/__tests__/CallState.test.ts +2 -2
@@ -842,7 +842,10 @@ export abstract class DeviceManager<
842
842
  writePreferences(currentDevice, deviceKey, muted, storageKey);
843
843
  }
844
844
 
845
- protected async applyPersistedPreferences(enabledInCallType: boolean) {
845
+ protected async applyPersistedPreferences(
846
+ enabledInCallType: boolean,
847
+ forceDisabled = false,
848
+ ) {
846
849
  const deviceKey: DevicePreferenceKey =
847
850
  this.trackType === TrackType.AUDIO ? 'microphone' : 'camera';
848
851
  const preferences = readPreferences(this.devicePersistence.storageKey);
@@ -875,15 +878,15 @@ export abstract class DeviceManager<
875
878
 
876
879
  const canPublish = this.call.permissionsContext.canPublish(this.trackType);
877
880
  if (typeof muted === 'boolean' && enabledInCallType && canPublish) {
878
- await this.applyMutedState(muted);
881
+ await this.applyMutedState(muted, forceDisabled);
879
882
  appliedMute = true;
880
883
  }
881
884
 
882
885
  return appliedDevice || appliedMute;
883
886
  }
884
887
 
885
- private async applyMutedState(muted: boolean) {
886
- if (this.state.status !== undefined) return;
888
+ private async applyMutedState(muted: boolean, forceDisabled: boolean) {
889
+ if (forceDisabled || this.state.status !== undefined) return;
887
890
  if (muted) {
888
891
  await this.disable();
889
892
  } else {
@@ -388,8 +388,13 @@ export class MicrophoneManager extends AudioDeviceManager<MicrophoneManagerState
388
388
  * Applies the audio settings to the microphone.
389
389
  * @param settings the audio settings to apply.
390
390
  * @param publish whether to publish the stream after applying the settings.
391
+ * @param forceDisabled whether to keep the mic disabled, regardless of server-side or local preferences.
391
392
  */
392
- async apply(settings: AudioSettingsResponse, publish: boolean) {
393
+ async apply(
394
+ settings: AudioSettingsResponse,
395
+ publish: boolean,
396
+ forceDisabled: boolean = false,
397
+ ) {
393
398
  // Wait for any in progress mic operation
394
399
  await this.statusChangeSettled();
395
400
 
@@ -407,12 +412,15 @@ export class MicrophoneManager extends AudioDeviceManager<MicrophoneManagerState
407
412
  this.devicePersistence.enabled &&
408
413
  permissionState === 'granted'
409
414
  ) {
410
- persistedPreferencesApplied = await this.applyPersistedPreferences(true);
415
+ persistedPreferencesApplied = await this.applyPersistedPreferences(
416
+ true,
417
+ forceDisabled,
418
+ );
411
419
  }
412
420
 
413
421
  const canPublish = this.call.permissionsContext.canPublish(this.trackType);
414
422
  if (shouldApplyDefaults && !persistedPreferencesApplied) {
415
- if (canPublish && settings.mic_default_on) {
423
+ if (!forceDisabled && canPublish && settings.mic_default_on) {
416
424
  await this.enable();
417
425
  }
418
426
  }
@@ -357,11 +357,37 @@ describe('CameraManager', () => {
357
357
  true,
358
358
  );
359
359
 
360
- expect(applySpy).toHaveBeenCalledWith(true);
360
+ expect(applySpy).toHaveBeenCalledWith(true, false);
361
361
  expect(selectDirectionSpy).not.toHaveBeenCalled();
362
362
  expect(enableSpy).not.toHaveBeenCalled();
363
363
  });
364
364
 
365
+ it('should apply persisted device preferences without enabling when forced disabled', async () => {
366
+ vi.spyOn(mockBrowserPermission, 'asStateObservable').mockReturnValue(
367
+ of('granted'),
368
+ );
369
+ const devicePersistence = { enabled: true, storageKey: '' };
370
+ const persistedManager = new CameraManager(call, devicePersistence);
371
+ const applySpy = vi
372
+ .spyOn(persistedManager as never, 'applyPersistedPreferences')
373
+ .mockResolvedValue(true);
374
+ const enableSpy = vi.spyOn(persistedManager, 'enable');
375
+
376
+ await persistedManager.apply(
377
+ fromPartial({
378
+ enabled: true,
379
+ target_resolution: { width: 640, height: 480 },
380
+ camera_facing: 'front',
381
+ camera_default_on: true,
382
+ }),
383
+ false,
384
+ true,
385
+ );
386
+
387
+ expect(applySpy).toHaveBeenCalledWith(true, true);
388
+ expect(enableSpy).not.toHaveBeenCalled();
389
+ });
390
+
365
391
  it('should skip persisted preferences when permission is not granted', async () => {
366
392
  vi.spyOn(mockBrowserPermission, 'asStateObservable').mockReturnValue(
367
393
  of('prompt'),
@@ -21,7 +21,7 @@ import { DeviceManagerState } from '../DeviceManagerState';
21
21
  import { firstValueFrom, of } from 'rxjs';
22
22
  import { TrackType } from '../../gen/video/sfu/models/models';
23
23
  import { PermissionsContext } from '../../permissions';
24
- import { readPreferences } from '../devicePersistence';
24
+ import { defaultDeviceId, readPreferences } from '../devicePersistence';
25
25
 
26
26
  vi.mock('../../reporting/ClientEventReporter', () => ({
27
27
  ClientEventReporter: vi.fn(function () {
@@ -1010,6 +1010,54 @@ describe('Device Manager', () => {
1010
1010
  expect(selectSpy).toHaveBeenCalledWith(mockVideoDevices[1].deviceId);
1011
1011
  });
1012
1012
 
1013
+ it('selects the device without unmuting when forced disabled', async () => {
1014
+ localStorageMock.setItem(
1015
+ storageKey,
1016
+ JSON.stringify({
1017
+ camera: [
1018
+ {
1019
+ selectedDeviceId: mockVideoDevices[0].deviceId,
1020
+ selectedDeviceLabel: mockVideoDevices[0].label,
1021
+ muted: false,
1022
+ },
1023
+ ],
1024
+ }),
1025
+ );
1026
+
1027
+ const selectSpy = vi.spyOn(manager, 'select');
1028
+ const enableSpy = vi.spyOn(manager, 'enable');
1029
+
1030
+ // @ts-expect-error - private API
1031
+ const result = await manager.applyPersistedPreferences(true, true);
1032
+
1033
+ expect(result).toBe(true);
1034
+ expect(selectSpy).toHaveBeenCalledWith(mockVideoDevices[0].deviceId);
1035
+ expect(enableSpy).not.toHaveBeenCalled();
1036
+ });
1037
+
1038
+ it('retains a default-device mute preference when forced disabled', async () => {
1039
+ localStorageMock.setItem(
1040
+ storageKey,
1041
+ JSON.stringify({
1042
+ camera: [
1043
+ {
1044
+ selectedDeviceId: defaultDeviceId,
1045
+ selectedDeviceLabel: '',
1046
+ muted: false,
1047
+ },
1048
+ ],
1049
+ }),
1050
+ );
1051
+
1052
+ const enableSpy = vi.spyOn(manager, 'enable');
1053
+
1054
+ // @ts-expect-error - private API
1055
+ const result = await manager.applyPersistedPreferences(true, true);
1056
+
1057
+ expect(result).toBe(true);
1058
+ expect(enableSpy).not.toHaveBeenCalled();
1059
+ });
1060
+
1013
1061
  it('applies muted state without selecting when default device is stored', async () => {
1014
1062
  localStorageMock.setItem(
1015
1063
  storageKey,
@@ -486,7 +486,29 @@ describe('MicrophoneManager', () => {
486
486
  // @ts-expect-error - partial data
487
487
  await persistedManager.apply({ mic_default_on: true }, true);
488
488
 
489
- expect(applySpy).toHaveBeenCalledWith(true);
489
+ expect(applySpy).toHaveBeenCalledWith(true, false);
490
+ expect(enableSpy).not.toHaveBeenCalled();
491
+ });
492
+
493
+ it('should apply persisted device preferences without enabling when forced disabled', async () => {
494
+ vi.spyOn(mockBrowserPermission, 'asStateObservable').mockReturnValue(
495
+ of('granted'),
496
+ );
497
+ const devicePersistence = { enabled: true, storageKey: '' };
498
+ const persistedManager = new MicrophoneManager(
499
+ call,
500
+ devicePersistence,
501
+ 'disable-tracks',
502
+ );
503
+ const applySpy = vi
504
+ .spyOn(persistedManager as never, 'applyPersistedPreferences')
505
+ .mockResolvedValue(true);
506
+ const enableSpy = vi.spyOn(persistedManager, 'enable');
507
+
508
+ // @ts-expect-error - partial data
509
+ await persistedManager.apply({ mic_default_on: true }, false, true);
510
+
511
+ expect(applySpy).toHaveBeenCalledWith(true, true);
490
512
  expect(enableSpy).not.toHaveBeenCalled();
491
513
  });
492
514
 
@@ -81,7 +81,6 @@ export const watchCallRejected = (call: Call) => {
81
81
  */
82
82
  export const watchCallEnded = (call: Call) => {
83
83
  return function onCallEnded() {
84
- globalThis.streamRNVideoSDK?.callingX?.endCall(call, 'remote');
85
84
  const { callingState } = call.state;
86
85
  if (
87
86
  callingState !== CallingState.IDLE &&
@@ -92,9 +91,13 @@ export const watchCallEnded = (call: Call) => {
92
91
  reason: 'call.ended event received',
93
92
  });
94
93
  call
95
- .leave({ message: 'call.ended event received', reject: false })
94
+ .leave({
95
+ message: 'call.ended event received',
96
+ reject: false,
97
+ reason: 'ended',
98
+ })
96
99
  .catch((err) => {
97
- call.logger.error('Failed to leave call after call.ended ', err);
100
+ call.logger.error('Failed to leave call after call.ended', err);
98
101
  });
99
102
  }
100
103
  };
@@ -8468,6 +8468,32 @@ export interface ReportByHistogramBucket {
8468
8468
  */
8469
8469
  upper_bound?: Bound;
8470
8470
  }
8471
+ /**
8472
+ * Reports a batch of client-side telemetry events. Each event is validated and processed independently; one invalid event does not block the rest of the batch.
8473
+ * @export
8474
+ * @interface ReportClientEventRequest
8475
+ */
8476
+ export interface ReportClientEventRequest {
8477
+ /**
8478
+ * Client-side events to report (1-100 per request)
8479
+ * @type {Array<ClientEvent>}
8480
+ * @memberof ReportClientEventRequest
8481
+ */
8482
+ events: Array<ClientEvent>;
8483
+ }
8484
+ /**
8485
+ * Response for reporting client-side telemetry events
8486
+ * @export
8487
+ * @interface ReportClientEventResponse
8488
+ */
8489
+ export interface ReportClientEventResponse {
8490
+ /**
8491
+ * Duration of the request in milliseconds
8492
+ * @type {string}
8493
+ * @memberof ReportClientEventResponse
8494
+ */
8495
+ duration: string;
8496
+ }
8471
8497
  /**
8472
8498
  *
8473
8499
  * @export
@@ -0,0 +1,104 @@
1
+ import { videoLoggerSystem } from '../logger';
2
+ import { promiseWithResolvers } from './promise';
3
+
4
+ export type MediaStreamRecorderOptions = {
5
+ /** Auto-stop after this many milliseconds. */
6
+ maxDurationMs?: number;
7
+ };
8
+
9
+ export type MediaRecordingResult = {
10
+ blob: Blob;
11
+ };
12
+
13
+ /** Single-use `MediaRecorder` wrapper that returns an in-memory blob. */
14
+ export class MediaStreamRecorder {
15
+ private recording = promiseWithResolvers<MediaRecordingResult | null>();
16
+ private logger = videoLoggerSystem.getLogger('MediaStreamRecorder');
17
+ private chunks: Blob[] = [];
18
+ private timeoutId: ReturnType<typeof setTimeout> | undefined;
19
+
20
+ private recorder: MediaRecorder;
21
+ private readonly tracks: MediaStreamTrack[];
22
+ private readonly maxDurationMs: number | undefined;
23
+
24
+ constructor(stream: MediaStream, options: MediaStreamRecorderOptions = {}) {
25
+ if (typeof MediaRecorder === 'undefined') {
26
+ throw new Error('MediaRecorder is not available in this environment');
27
+ }
28
+
29
+ this.tracks = stream.getTracks();
30
+ if (this.tracks.length === 0) {
31
+ throw new Error('cannot record a MediaStream that has no tracks');
32
+ }
33
+
34
+ this.maxDurationMs = options.maxDurationMs;
35
+ this.recorder = new MediaRecorder(new MediaStream(this.tracks));
36
+
37
+ this.recorder.ondataavailable = (event: BlobEvent) => {
38
+ if (event.data?.size) this.chunks.push(event.data);
39
+ };
40
+
41
+ this.recorder.onstop = this.handleStop;
42
+ this.recorder.onerror = this.handleError;
43
+ }
44
+
45
+ /** Starts recording and resolves when recording stops. */
46
+ start = (): Promise<MediaRecordingResult | null> => {
47
+ if (this.isSettled()) {
48
+ throw new Error(
49
+ 'MediaStreamRecorder is single-use: this instance has already ' +
50
+ 'recorded. Construct a new one.',
51
+ );
52
+ }
53
+
54
+ if (this.recorder.state === 'inactive') {
55
+ this.tracks.forEach((track) =>
56
+ track.addEventListener('ended', this.stop),
57
+ );
58
+ if (this.maxDurationMs !== undefined) {
59
+ this.timeoutId = setTimeout(this.stop, this.maxDurationMs);
60
+ }
61
+ this.recorder.start();
62
+ }
63
+
64
+ return this.recording.promise;
65
+ };
66
+
67
+ /** Stops recording early. */
68
+ stop = (): void => {
69
+ if (this.recorder.state !== 'inactive') this.recorder.stop();
70
+ };
71
+
72
+ private isSettled = () =>
73
+ this.recording.isResolved() || this.recording.isRejected();
74
+
75
+ private detach = () => {
76
+ clearTimeout(this.timeoutId);
77
+ this.tracks.forEach((track) =>
78
+ track.removeEventListener('ended', this.stop),
79
+ );
80
+ };
81
+
82
+ private handleStop = () => {
83
+ this.detach();
84
+
85
+ if (this.chunks.length === 0) {
86
+ this.logger.warn('MediaRecorder recording produced no data');
87
+ this.recording.resolve(null);
88
+ return;
89
+ }
90
+
91
+ const mimeType = this.chunks[0].type || this.recorder.mimeType;
92
+ this.recording.resolve({
93
+ blob: new Blob(this.chunks, { type: mimeType }),
94
+ });
95
+ };
96
+
97
+ private handleError = (event: Event) => {
98
+ if (this.isSettled()) return;
99
+ this.detach();
100
+ const error = (event as unknown as { error?: DOMException }).error;
101
+ this.logger.error('MediaRecorder error', error);
102
+ this.recording.reject(error ?? new Error('MediaRecorder failed'));
103
+ };
104
+ }
@@ -0,0 +1,324 @@
1
+ /**
2
+ * @vitest-environment happy-dom
3
+ */
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
5
+ import { fromPartial } from '@total-typescript/shoehorn';
6
+ import { MediaStreamRecorder } from '../MediaStreamRecorder';
7
+ import { videoLoggerSystem } from '../../logger';
8
+
9
+ const createTrack = (kind: 'audio' | 'video' = 'video') => {
10
+ const events = new EventTarget();
11
+ return fromPartial<MediaStreamTrack>({
12
+ kind,
13
+ id: `${kind}-track`,
14
+ readyState: 'live',
15
+ stop: vi.fn(),
16
+ addEventListener: events.addEventListener.bind(events),
17
+ removeEventListener: events.removeEventListener.bind(events),
18
+ dispatchEvent: events.dispatchEvent.bind(events),
19
+ });
20
+ };
21
+
22
+ class FakeMediaStream {
23
+ private tracks: MediaStreamTrack[];
24
+
25
+ constructor(tracks: MediaStreamTrack[] = []) {
26
+ this.tracks = [...tracks];
27
+ }
28
+
29
+ getTracks = () => [...this.tracks];
30
+ addTrack = (track: MediaStreamTrack) => this.tracks.push(track);
31
+ }
32
+
33
+ const createStream = (...tracks: MediaStreamTrack[]) =>
34
+ new FakeMediaStream(tracks) as unknown as MediaStream;
35
+
36
+ class FakeMediaRecorder {
37
+ static instances: FakeMediaRecorder[] = [];
38
+ static isTypeSupported = vi.fn(() => true);
39
+
40
+ stream: MediaStream;
41
+ options: MediaRecorderOptions | undefined;
42
+ state: 'inactive' | 'recording' | 'paused' = 'inactive';
43
+ mimeType: string;
44
+ ondataavailable: ((event: BlobEvent) => void) | null = null;
45
+ onstop: (() => void) | null = null;
46
+ onerror: ((event: Event) => void) | null = null;
47
+
48
+ finalChunks: Blob[] = [new Blob(['payload'], { type: 'video/webm' })];
49
+
50
+ constructor(stream: MediaStream, options?: MediaRecorderOptions) {
51
+ this.stream = stream;
52
+ this.options = options;
53
+ this.mimeType = options?.mimeType ?? 'video/webm;codecs=vp8,opus';
54
+ FakeMediaRecorder.instances.push(this);
55
+ }
56
+
57
+ start = vi.fn(() => {
58
+ this.state = 'recording';
59
+ });
60
+
61
+ stop = vi.fn(() => {
62
+ if (this.state === 'inactive') return;
63
+ this.state = 'inactive';
64
+ this.finalChunks.forEach((data) => this.emitData(data));
65
+ this.onstop?.();
66
+ });
67
+
68
+ emitData = (data: Blob) => {
69
+ this.ondataavailable?.(fromPartial<BlobEvent>({ data }));
70
+ };
71
+ }
72
+
73
+ const lastRecorder = () =>
74
+ FakeMediaRecorder.instances[FakeMediaRecorder.instances.length - 1];
75
+
76
+ describe('MediaStreamRecorder', () => {
77
+ let logger: { debug: any; warn: any; error: any };
78
+
79
+ beforeEach(() => {
80
+ FakeMediaRecorder.instances = [];
81
+ vi.stubGlobal('MediaRecorder', FakeMediaRecorder);
82
+ vi.stubGlobal('MediaStream', FakeMediaStream);
83
+ logger = { debug: vi.fn(), warn: vi.fn(), error: vi.fn() };
84
+ vi.spyOn(videoLoggerSystem, 'getLogger').mockReturnValue(
85
+ fromPartial(logger),
86
+ );
87
+ });
88
+
89
+ afterEach(() => {
90
+ vi.unstubAllGlobals();
91
+ vi.restoreAllMocks();
92
+ vi.useRealTimers();
93
+ });
94
+
95
+ describe('construction', () => {
96
+ it('throws when MediaRecorder is unavailable', () => {
97
+ vi.stubGlobal('MediaRecorder', undefined);
98
+ expect(
99
+ () => new MediaStreamRecorder(createStream(createTrack())),
100
+ ).toThrow(/MediaRecorder is not available/);
101
+ });
102
+
103
+ it('throws when the stream has no tracks', () => {
104
+ expect(() => new MediaStreamRecorder(createStream())).toThrow(
105
+ /has no tracks/,
106
+ );
107
+ });
108
+
109
+ it('lets the browser choose the recording MIME type', () => {
110
+ new MediaStreamRecorder(createStream(createTrack()));
111
+
112
+ expect(lastRecorder().options).toBeUndefined();
113
+ });
114
+
115
+ it('does not start recording until `start` is called', () => {
116
+ new MediaStreamRecorder(createStream(createTrack()));
117
+
118
+ expect(lastRecorder().start).not.toHaveBeenCalled();
119
+ });
120
+
121
+ it('creates no DOM elements of its own', () => {
122
+ const recorder = new MediaStreamRecorder(createStream(createTrack()));
123
+ recorder.start();
124
+ expect(document.querySelectorAll('video, audio')).toHaveLength(0);
125
+ });
126
+
127
+ it('records a snapshot, so mutating the caller stream cannot disturb it', () => {
128
+ const track = createTrack();
129
+ const stream = createStream(track);
130
+ new MediaStreamRecorder(stream);
131
+
132
+ stream.addTrack(createTrack('audio'));
133
+
134
+ expect(lastRecorder().stream).not.toBe(stream);
135
+ expect(lastRecorder().stream.getTracks()).toEqual([track]);
136
+ });
137
+ });
138
+
139
+ describe('recording', () => {
140
+ it('resolves with the recorded blob', async () => {
141
+ vi.useFakeTimers();
142
+ const recorder = new MediaStreamRecorder(createStream(createTrack()));
143
+
144
+ const recording = recorder.start();
145
+ vi.advanceTimersByTime(2500);
146
+ recorder.stop();
147
+
148
+ const result = await recording;
149
+
150
+ expect(result?.blob.size).toBeGreaterThan(0);
151
+ expect(result?.blob.type).toBe('video/webm');
152
+ });
153
+
154
+ it('hands every awaiter of `start` the same result', async () => {
155
+ const recorder = new MediaStreamRecorder(createStream(createTrack()));
156
+
157
+ const first = recorder.start();
158
+ const second = recorder.start();
159
+ recorder.stop();
160
+
161
+ expect(await first).toBe(await second);
162
+ expect(lastRecorder().start).toHaveBeenCalledTimes(1);
163
+ });
164
+
165
+ it('sets the blob type from the recorder when the chunk carries none', async () => {
166
+ const recorder = new MediaStreamRecorder(createStream(createTrack()));
167
+ lastRecorder().finalChunks = [new Blob(['payload'])];
168
+
169
+ const recording = recorder.start();
170
+ recorder.stop();
171
+
172
+ expect((await recording)?.blob.type).toBe('video/webm;codecs=vp8,opus');
173
+ });
174
+
175
+ it('keeps chunks that arrive before stop', async () => {
176
+ const recorder = new MediaStreamRecorder(createStream(createTrack()));
177
+ const recording = recorder.start();
178
+ lastRecorder().emitData(new Blob(['early'], { type: 'video/webm' }));
179
+
180
+ recorder.stop();
181
+
182
+ expect((await recording)?.blob.size).toBe(12);
183
+ });
184
+
185
+ it('ignores empty chunks', async () => {
186
+ const recorder = new MediaStreamRecorder(createStream(createTrack()));
187
+ const recording = recorder.start();
188
+ lastRecorder().emitData(new Blob([]));
189
+ lastRecorder().finalChunks = [];
190
+
191
+ recorder.stop();
192
+
193
+ await expect(recording).resolves.toBeNull();
194
+ });
195
+
196
+ it('resolves null and warns when nothing was captured', async () => {
197
+ const recorder = new MediaStreamRecorder(createStream(createTrack()));
198
+ lastRecorder().finalChunks = [];
199
+
200
+ const recording = recorder.start();
201
+ recorder.stop();
202
+
203
+ await expect(recording).resolves.toBeNull();
204
+ expect(logger.warn).toHaveBeenCalledWith(
205
+ expect.stringContaining('recording produced no data'),
206
+ );
207
+ });
208
+
209
+ it('never stops the tracks it was handed', async () => {
210
+ const track = createTrack();
211
+ const recorder = new MediaStreamRecorder(createStream(track));
212
+
213
+ const recording = recorder.start();
214
+ recorder.stop();
215
+ await recording;
216
+
217
+ expect(track.stop).not.toHaveBeenCalled();
218
+ });
219
+ });
220
+
221
+ describe('auto-stop', () => {
222
+ it('stops once maxDurationMs elapses', async () => {
223
+ vi.useFakeTimers();
224
+ const recorder = new MediaStreamRecorder(createStream(createTrack()), {
225
+ maxDurationMs: 5000,
226
+ });
227
+
228
+ const recording = recorder.start();
229
+ vi.advanceTimersByTime(5000);
230
+
231
+ await expect(recording).resolves.toMatchObject({
232
+ blob: expect.any(Blob),
233
+ });
234
+ });
235
+
236
+ it('runs indefinitely when maxDurationMs is omitted', async () => {
237
+ vi.useFakeTimers();
238
+ const recorder = new MediaStreamRecorder(createStream(createTrack()));
239
+
240
+ const recording = recorder.start();
241
+ vi.advanceTimersByTime(10 * 60 * 1000);
242
+
243
+ expect(lastRecorder().stop).not.toHaveBeenCalled();
244
+ recorder.stop();
245
+ await recording;
246
+ });
247
+
248
+ it('stops when any source track ends, keeping what was captured', async () => {
249
+ const audioTrack = createTrack('audio');
250
+ const recorder = new MediaStreamRecorder(
251
+ createStream(createTrack(), audioTrack),
252
+ );
253
+
254
+ const recording = recorder.start();
255
+ audioTrack.dispatchEvent(new Event('ended'));
256
+
257
+ await expect(recording).resolves.toMatchObject({
258
+ blob: expect.any(Blob),
259
+ });
260
+ });
261
+
262
+ it('resolves when the user agent stops the recorder on its own', async () => {
263
+ const recorder = new MediaStreamRecorder(createStream(createTrack()));
264
+ const recording = recorder.start();
265
+
266
+ lastRecorder().stop();
267
+
268
+ await expect(recording).resolves.toMatchObject({
269
+ blob: expect.any(Blob),
270
+ });
271
+ });
272
+ });
273
+
274
+ describe('lifecycle guards', () => {
275
+ it('is a no-op when stopped before it was started', () => {
276
+ const recorder = new MediaStreamRecorder(createStream(createTrack()));
277
+
278
+ expect(() => recorder.stop()).not.toThrow();
279
+ expect(lastRecorder().stop).not.toHaveBeenCalled();
280
+ expect(lastRecorder().start).not.toHaveBeenCalled();
281
+ });
282
+
283
+ it('throws when restarted after finishing', async () => {
284
+ const recorder = new MediaStreamRecorder(createStream(createTrack()));
285
+ const recording = recorder.start();
286
+ recorder.stop();
287
+ await recording;
288
+
289
+ expect(() => recorder.start()).toThrow(/single-use/);
290
+ });
291
+
292
+ it('is idempotent across repeated stops', async () => {
293
+ const recorder = new MediaStreamRecorder(createStream(createTrack()));
294
+ const recording = recorder.start();
295
+
296
+ recorder.stop();
297
+ recorder.stop();
298
+
299
+ await expect(recording).resolves.toMatchObject({
300
+ blob: expect.any(Blob),
301
+ });
302
+ expect(lastRecorder().stop).toHaveBeenCalledTimes(1);
303
+ });
304
+ });
305
+
306
+ it('leaves no timer or listener behind after finishing', async () => {
307
+ vi.useFakeTimers();
308
+ const track = createTrack();
309
+ const recorder = new MediaStreamRecorder(createStream(track), {
310
+ maxDurationMs: 5000,
311
+ });
312
+
313
+ const recording = recorder.start();
314
+ recorder.stop();
315
+ const result = await recording;
316
+
317
+ expect(() => {
318
+ vi.advanceTimersByTime(10_000);
319
+ track.dispatchEvent(new Event('ended'));
320
+ }).not.toThrow();
321
+ expect(lastRecorder().stop).toHaveBeenCalledTimes(1);
322
+ expect(result?.blob).toBeInstanceOf(Blob);
323
+ });
324
+ });