livekit-client 2.22.3 → 2.23.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/README.md +35 -0
- package/dist/livekit-client.e2ee.worker.js +1 -1
- package/dist/livekit-client.e2ee.worker.js.map +1 -1
- package/dist/livekit-client.e2ee.worker.mjs +71 -8
- package/dist/livekit-client.e2ee.worker.mjs.map +1 -1
- package/dist/livekit-client.esm.mjs +1746 -547
- package/dist/livekit-client.esm.mjs.map +1 -1
- package/dist/livekit-client.fm.worker.js +1 -1
- package/dist/livekit-client.fm.worker.js.map +1 -1
- package/dist/livekit-client.fm.worker.mjs +2 -1
- package/dist/livekit-client.fm.worker.mjs.map +1 -1
- package/dist/livekit-client.umd.js +1 -1
- package/dist/livekit-client.umd.js.map +1 -1
- package/dist/src/api/SignalClient.d.ts +4 -2
- package/dist/src/api/SignalClient.d.ts.map +1 -1
- package/dist/src/api/WebSocketStream.d.ts +1 -0
- package/dist/src/api/WebSocketStream.d.ts.map +1 -1
- package/dist/src/api/utils.d.ts +1 -1
- package/dist/src/api/utils.d.ts.map +1 -1
- package/dist/src/e2ee/E2eeManager.d.ts.map +1 -1
- package/dist/src/e2ee/worker/FrameCryptor.d.ts.map +1 -1
- package/dist/src/e2ee/worker/naluUtils.d.ts +12 -1
- package/dist/src/e2ee/worker/naluUtils.d.ts.map +1 -1
- package/dist/src/index.d.ts +2 -2
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/options.d.ts +14 -0
- package/dist/src/options.d.ts.map +1 -1
- package/dist/src/room/DefaultReconnectPolicy.d.ts.map +1 -1
- package/dist/src/room/PCTransport.d.ts +100 -7
- package/dist/src/room/PCTransport.d.ts.map +1 -1
- package/dist/src/room/PCTransportManager.d.ts.map +1 -1
- package/dist/src/room/RTCEngine.d.ts +16 -0
- package/dist/src/room/RTCEngine.d.ts.map +1 -1
- package/dist/src/room/Room.d.ts +54 -1
- package/dist/src/room/Room.d.ts.map +1 -1
- package/dist/src/room/data-channel/DataChannelManager.d.ts +9 -8
- package/dist/src/room/data-channel/DataChannelManager.d.ts.map +1 -1
- package/dist/src/room/data-channel/FlowControlledDataChannel.d.ts +25 -12
- package/dist/src/room/data-channel/FlowControlledDataChannel.d.ts.map +1 -1
- package/dist/src/room/data-channel/LossyDataChannel.d.ts.map +1 -1
- package/dist/src/room/data-channel/ReliableDataChannel.d.ts.map +1 -1
- package/dist/src/room/data-stream/incoming/IncomingDataStreamManager.d.ts.map +1 -1
- package/dist/src/room/defaults.d.ts.map +1 -1
- package/dist/src/room/errors.d.ts +18 -0
- package/dist/src/room/errors.d.ts.map +1 -1
- package/dist/src/room/events.d.ts +3 -2
- package/dist/src/room/events.d.ts.map +1 -1
- package/dist/src/room/participant/LocalParticipant.d.ts +4 -0
- package/dist/src/room/participant/LocalParticipant.d.ts.map +1 -1
- package/dist/src/room/participant/Participant.d.ts +1 -1
- package/dist/src/room/participant/Participant.d.ts.map +1 -1
- package/dist/src/room/participant/publishUtils.d.ts.map +1 -1
- package/dist/src/room/rpc/client/RpcClientManager.d.ts.map +1 -1
- package/dist/src/room/rpc/utils.d.ts.map +1 -1
- package/dist/src/room/track/LocalAudioTrack.d.ts +24 -1
- package/dist/src/room/track/LocalAudioTrack.d.ts.map +1 -1
- package/dist/src/room/track/LocalTrack.d.ts +18 -0
- package/dist/src/room/track/LocalTrack.d.ts.map +1 -1
- package/dist/src/room/track/LocalVideoTrack.d.ts +9 -0
- package/dist/src/room/track/LocalVideoTrack.d.ts.map +1 -1
- package/dist/src/room/track/RemoteAudioTrack.d.ts +6 -0
- package/dist/src/room/track/RemoteAudioTrack.d.ts.map +1 -1
- package/dist/src/room/track/processor/types.d.ts +11 -0
- package/dist/src/room/track/processor/types.d.ts.map +1 -1
- package/dist/src/room/track/utils.d.ts +13 -0
- package/dist/src/room/track/utils.d.ts.map +1 -1
- package/dist/src/room/utils.d.ts +60 -1
- package/dist/src/room/utils.d.ts.map +1 -1
- package/dist/src/test/compressionStreamStubs.d.ts +12 -0
- package/dist/src/test/compressionStreamStubs.d.ts.map +1 -0
- package/dist/ts4.2/api/SignalClient.d.ts +187 -185
- package/dist/ts4.2/api/SignalClientStateMachine.d.ts +85 -85
- package/dist/ts4.2/api/WebSocketStream.d.ts +34 -33
- package/dist/ts4.2/api/utils.d.ts +9 -9
- package/dist/ts4.2/connectionHelper/ConnectionCheck.d.ts +30 -30
- package/dist/ts4.2/connectionHelper/checks/Checker.d.ts +62 -62
- package/dist/ts4.2/connectionHelper/checks/cloudRegion.d.ts +18 -18
- package/dist/ts4.2/connectionHelper/checks/connectionProtocol.d.ts +20 -20
- package/dist/ts4.2/connectionHelper/checks/publishAudio.d.ts +6 -6
- package/dist/ts4.2/connectionHelper/checks/publishVideo.d.ts +7 -7
- package/dist/ts4.2/connectionHelper/checks/reconnect.d.ts +6 -6
- package/dist/ts4.2/connectionHelper/checks/turn.d.ts +6 -6
- package/dist/ts4.2/connectionHelper/checks/webrtc.d.ts +6 -6
- package/dist/ts4.2/connectionHelper/checks/websocket.d.ts +6 -6
- package/dist/ts4.2/e2ee/E2eeManager.d.ts +111 -111
- package/dist/ts4.2/e2ee/KeyProvider.d.ts +54 -54
- package/dist/ts4.2/e2ee/constants.d.ts +21 -21
- package/dist/ts4.2/e2ee/errors.d.ts +12 -12
- package/dist/ts4.2/e2ee/events.d.ts +39 -39
- package/dist/ts4.2/e2ee/index.d.ts +6 -6
- package/dist/ts4.2/e2ee/types.d.ts +231 -231
- package/dist/ts4.2/e2ee/utils.d.ts +32 -32
- package/dist/ts4.2/e2ee/worker/DataCryptor.d.ts +16 -16
- package/dist/ts4.2/e2ee/worker/ErrorRateLimiter.d.ts +21 -21
- package/dist/ts4.2/e2ee/worker/FrameCryptor.d.ts +209 -209
- package/dist/ts4.2/e2ee/worker/ParticipantKeyHandler.d.ts +81 -84
- package/dist/ts4.2/e2ee/worker/e2ee.worker.d.ts +2 -2
- package/dist/ts4.2/e2ee/worker/naluUtils.d.ts +39 -28
- package/dist/ts4.2/e2ee/worker/sifPayload.d.ts +23 -23
- package/dist/ts4.2/frameMetadata/FrameMetadataManager.d.ts +53 -53
- package/dist/ts4.2/frameMetadata/frameMetadata.d.ts +33 -33
- package/dist/ts4.2/frameMetadata/types.d.ts +62 -62
- package/dist/ts4.2/frameMetadata/utils.d.ts +9 -9
- package/dist/ts4.2/frameMetadata/worker/frameMetadata.worker.d.ts +2 -2
- package/dist/ts4.2/index.d.ts +76 -76
- package/dist/ts4.2/logger.d.ts +66 -66
- package/dist/ts4.2/options.d.ts +163 -149
- package/dist/ts4.2/room/BackOffStrategy.d.ts +27 -27
- package/dist/ts4.2/room/DefaultReconnectPolicy.d.ts +8 -8
- package/dist/ts4.2/room/DeviceManager.d.ts +12 -12
- package/dist/ts4.2/room/PCTransport.d.ts +268 -175
- package/dist/ts4.2/room/PCTransportManager.d.ts +77 -77
- package/dist/ts4.2/room/RTCEngine.d.ts +277 -262
- package/dist/ts4.2/room/ReconnectPolicy.d.ts +24 -24
- package/dist/ts4.2/room/RegionUrlProvider.d.ts +36 -36
- package/dist/ts4.2/room/Room.d.ts +407 -365
- package/dist/ts4.2/room/attribute-typings.d.ts +35 -35
- package/dist/ts4.2/room/data-channel/DataChannelManager.d.ts +63 -62
- package/dist/ts4.2/room/data-channel/FlowControlledDataChannel.d.ts +107 -95
- package/dist/ts4.2/room/data-channel/LossyDataChannel.d.ts +41 -41
- package/dist/ts4.2/room/data-channel/ReliableDataChannel.d.ts +57 -57
- package/dist/ts4.2/room/data-channel/types.d.ts +12 -12
- package/dist/ts4.2/room/data-stream/compression.d.ts +49 -49
- package/dist/ts4.2/room/data-stream/constants.d.ts +18 -18
- package/dist/ts4.2/room/data-stream/incoming/IncomingDataStreamManager.d.ts +29 -29
- package/dist/ts4.2/room/data-stream/incoming/StreamReader.d.ts +80 -80
- package/dist/ts4.2/room/data-stream/outgoing/OutgoingDataStreamManager.d.ts +73 -73
- package/dist/ts4.2/room/data-stream/outgoing/StreamWriter.d.ts +16 -16
- package/dist/ts4.2/room/data-stream/outgoing/header-utils.d.ts +18 -18
- package/dist/ts4.2/room/data-track/LocalDataTrack.d.ts +79 -80
- package/dist/ts4.2/room/data-track/RemoteDataTrack.d.ts +50 -50
- package/dist/ts4.2/room/data-track/depacketizer.d.ts +59 -59
- package/dist/ts4.2/room/data-track/frame.d.ts +22 -22
- package/dist/ts4.2/room/data-track/handle.d.ts +27 -27
- package/dist/ts4.2/room/data-track/incoming/IncomingDataTrackManager.d.ts +124 -124
- package/dist/ts4.2/room/data-track/incoming/errors.d.ts +24 -24
- package/dist/ts4.2/room/data-track/incoming/pipeline.d.ts +41 -41
- package/dist/ts4.2/room/data-track/incoming/types.d.ts +20 -20
- package/dist/ts4.2/room/data-track/outgoing/OutgoingDataTrackManager.d.ts +126 -126
- package/dist/ts4.2/room/data-track/outgoing/errors.d.ts +74 -74
- package/dist/ts4.2/room/data-track/outgoing/pipeline.d.ts +23 -23
- package/dist/ts4.2/room/data-track/outgoing/types.d.ts +48 -48
- package/dist/ts4.2/room/data-track/packet/constants.d.ts +20 -20
- package/dist/ts4.2/room/data-track/packet/errors.d.ts +40 -40
- package/dist/ts4.2/room/data-track/packet/extensions.d.ts +69 -69
- package/dist/ts4.2/room/data-track/packet/index.d.ts +98 -98
- package/dist/ts4.2/room/data-track/packet/serializable.d.ts +13 -13
- package/dist/ts4.2/room/data-track/packetizer.d.ts +43 -43
- package/dist/ts4.2/room/data-track/track-interfaces.d.ts +23 -23
- package/dist/ts4.2/room/data-track/types.d.ts +22 -22
- package/dist/ts4.2/room/data-track/utils.d.ts +60 -60
- package/dist/ts4.2/room/debounce.d.ts +11 -11
- package/dist/ts4.2/room/defaults.d.ts +9 -9
- package/dist/ts4.2/room/errors.d.ts +180 -162
- package/dist/ts4.2/room/events.d.ts +638 -637
- package/dist/ts4.2/room/participant/LocalParticipant.d.ts +291 -287
- package/dist/ts4.2/room/participant/Participant.d.ts +142 -144
- package/dist/ts4.2/room/participant/ParticipantTrackPermission.d.ts +26 -26
- package/dist/ts4.2/room/participant/RemoteParticipant.d.ts +85 -86
- package/dist/ts4.2/room/participant/publishUtils.d.ts +53 -53
- package/dist/ts4.2/room/rpc/client/RpcClientManager.d.ts +43 -43
- package/dist/ts4.2/room/rpc/client/events.d.ts +8 -8
- package/dist/ts4.2/room/rpc/index.d.ts +7 -7
- package/dist/ts4.2/room/rpc/server/RpcServerManager.d.ts +44 -44
- package/dist/ts4.2/room/rpc/server/events.d.ts +8 -8
- package/dist/ts4.2/room/rpc/utils.d.ts +131 -131
- package/dist/ts4.2/room/stats.d.ts +71 -71
- package/dist/ts4.2/room/statsSummary.d.ts +13 -13
- package/dist/ts4.2/room/timers.d.ts +12 -12
- package/dist/ts4.2/room/token-source/TokenSource.d.ts +82 -82
- package/dist/ts4.2/room/token-source/types.d.ts +70 -70
- package/dist/ts4.2/room/token-source/utils.d.ts +8 -8
- package/dist/ts4.2/room/track/FrameMetadataExtractor.d.ts +19 -19
- package/dist/ts4.2/room/track/LocalAudioTrack.d.ts +62 -42
- package/dist/ts4.2/room/track/LocalTrack.d.ts +143 -125
- package/dist/ts4.2/room/track/LocalTrackPublication.d.ts +42 -42
- package/dist/ts4.2/room/track/LocalVideoTrack.d.ts +109 -100
- package/dist/ts4.2/room/track/RemoteAudioTrack.d.ts +61 -55
- package/dist/ts4.2/room/track/RemoteTrack.d.ts +38 -38
- package/dist/ts4.2/room/track/RemoteTrackPublication.d.ts +72 -75
- package/dist/ts4.2/room/track/RemoteVideoTrack.d.ts +73 -76
- package/dist/ts4.2/room/track/Track.d.ts +156 -166
- package/dist/ts4.2/room/track/TrackPublication.d.ts +75 -81
- package/dist/ts4.2/room/track/create.d.ts +25 -25
- package/dist/ts4.2/room/track/facingMode.d.ts +41 -41
- package/dist/ts4.2/room/track/options.d.ts +403 -403
- package/dist/ts4.2/room/track/processor/types.d.ts +42 -31
- package/dist/ts4.2/room/track/record.d.ts +13 -13
- package/dist/ts4.2/room/track/types.d.ts +28 -28
- package/dist/ts4.2/room/track/utils.d.ts +60 -47
- package/dist/ts4.2/room/types.d.ts +127 -127
- package/dist/ts4.2/room/utils.d.ts +273 -214
- package/dist/ts4.2/test/MockMediaStreamTrack.d.ts +26 -26
- package/dist/ts4.2/test/compressionStreamStubs.d.ts +12 -0
- package/dist/ts4.2/test/mocks.d.ts +12 -12
- package/dist/ts4.2/test/promiseState.d.ts +12 -12
- package/dist/ts4.2/test/signalToken.d.ts +26 -26
- package/dist/ts4.2/type-polyfills/non-shared-typed-arrays.d.ts +2 -2
- package/dist/ts4.2/utils/AsyncQueue.d.ts +23 -23
- package/dist/ts4.2/utils/TypedPromise.d.ts +17 -17
- package/dist/ts4.2/utils/abort-signal-polyfill.d.ts +13 -13
- package/dist/ts4.2/utils/browserParser.d.ts +13 -13
- package/dist/ts4.2/utils/camelToSnakeCase.d.ts +8 -8
- package/dist/ts4.2/utils/cloneDeep.d.ts +2 -2
- package/dist/ts4.2/utils/dataPacketBuffer.d.ts +34 -34
- package/dist/ts4.2/utils/deferrable-map.d.ts +32 -32
- package/dist/ts4.2/utils/machineInspector.d.ts +54 -54
- package/dist/ts4.2/utils/serializer.d.ts +48 -48
- package/dist/ts4.2/utils/subscribeToEvents.d.ts +15 -15
- package/dist/ts4.2/utils/ttlmap.d.ts +20 -20
- package/dist/ts4.2/version.d.ts +15 -15
- package/package.json +5 -4
- package/src/api/SignalClient.e2e.test.ts +1 -3
- package/src/api/SignalClient.test.ts +394 -10
- package/src/api/SignalClient.ts +132 -76
- package/src/api/WebSocketStream.test.ts +11 -0
- package/src/api/WebSocketStream.ts +22 -1
- package/src/api/utils.ts +1 -1
- package/src/e2ee/E2eeManager.test.ts +43 -0
- package/src/e2ee/E2eeManager.ts +6 -2
- package/src/e2ee/worker/FrameCryptor.test.ts +212 -90
- package/src/e2ee/worker/FrameCryptor.ts +21 -4
- package/src/e2ee/worker/naluUtils.test.ts +123 -0
- package/src/e2ee/worker/naluUtils.ts +57 -2
- package/src/index.ts +2 -0
- package/src/options.ts +16 -0
- package/src/room/DefaultReconnectPolicy.test.ts +58 -0
- package/src/room/DefaultReconnectPolicy.ts +14 -2
- package/src/room/PCTransport.test.ts +327 -9
- package/src/room/PCTransport.ts +339 -36
- package/src/room/PCTransportManager.ts +3 -1
- package/src/room/RTCEngine.test.ts +318 -2
- package/src/room/RTCEngine.ts +126 -12
- package/src/room/Room.test.ts +148 -6
- package/src/room/Room.ts +312 -80
- package/src/room/data-channel/DataChannelManager.test.ts +24 -7
- package/src/room/data-channel/DataChannelManager.ts +43 -38
- package/src/room/data-channel/FlowControlledDataChannel.test.ts +60 -18
- package/src/room/data-channel/FlowControlledDataChannel.ts +40 -22
- package/src/room/data-channel/LossyDataChannel.test.ts +38 -0
- package/src/room/data-channel/LossyDataChannel.ts +10 -1
- package/src/room/data-channel/ReliableDataChannel.test.ts +72 -0
- package/src/room/data-channel/ReliableDataChannel.ts +16 -5
- package/src/room/data-stream/incoming/IncomingDataStreamManager.test.ts +47 -20
- package/src/room/data-stream/incoming/IncomingDataStreamManager.ts +16 -5
- package/src/room/data-stream/outgoing/OutgoingDataStreamManager.test.ts +60 -39
- package/src/room/data-stream/outgoing/OutgoingDataStreamManager.ts +4 -4
- package/src/room/defaults.ts +1 -0
- package/src/room/errors.test.ts +36 -0
- package/src/room/errors.ts +27 -0
- package/src/room/events.ts +2 -1
- package/src/room/participant/LocalParticipant.test.ts +136 -1
- package/src/room/participant/LocalParticipant.ts +66 -17
- package/src/room/participant/Participant.ts +6 -3
- package/src/room/participant/publishUtils.ts +0 -19
- package/src/room/participant/restartOnDefaultAudioDevice.test.ts +177 -0
- package/src/room/rpc/client/RpcClientManager.test.ts +51 -0
- package/src/room/rpc/client/RpcClientManager.ts +32 -15
- package/src/room/rpc/utils.test.ts +32 -0
- package/src/room/rpc/utils.ts +7 -0
- package/src/room/track/LocalAudioTrack.ts +101 -10
- package/src/room/track/LocalTrack.ts +87 -19
- package/src/room/track/LocalVideoTrack.ts +17 -3
- package/src/room/track/RemoteAudioTrack.ts +59 -14
- package/src/room/track/RemoteVideoTrack.test.ts +210 -0
- package/src/room/track/RemoteVideoTrack.ts +29 -3
- package/src/room/track/processor/types.ts +11 -0
- package/src/room/track/utils.test.ts +206 -2
- package/src/room/track/utils.ts +117 -5
- package/src/room/utils.test.ts +250 -0
- package/src/room/utils.ts +152 -19
- package/src/test/compressionStreamStubs.ts +53 -0
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import DefaultReconnectPolicy from './DefaultReconnectPolicy';
|
|
3
|
+
|
|
4
|
+
const delayFor = (policy: DefaultReconnectPolicy, retryCount: number, random: number) => {
|
|
5
|
+
vi.spyOn(Math, 'random').mockReturnValue(random);
|
|
6
|
+
return policy.nextRetryDelayInMs({ retryCount, elapsedMs: 0 });
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
describe('DefaultReconnectPolicy', () => {
|
|
10
|
+
afterEach(() => {
|
|
11
|
+
vi.restoreAllMocks();
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('spreads the first retry over a window instead of retrying immediately', () => {
|
|
15
|
+
const policy = new DefaultReconnectPolicy();
|
|
16
|
+
expect(delayFor(policy, 0, 0)).toBe(0);
|
|
17
|
+
expect(delayFor(policy, 0, 0.5)).toBe(250);
|
|
18
|
+
expect(delayFor(policy, 0, 0.999)).toBeGreaterThan(450);
|
|
19
|
+
expect(delayFor(policy, 0, 0.999)).toBeLessThan(500);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('scales the jitter of later retries with the delay', () => {
|
|
23
|
+
const policy = new DefaultReconnectPolicy();
|
|
24
|
+
expect(delayFor(policy, 1, 0)).toBe(150);
|
|
25
|
+
expect(delayFor(policy, 1, 0.5)).toBe(300);
|
|
26
|
+
expect(delayFor(policy, 2, 0)).toBe(600);
|
|
27
|
+
expect(delayFor(policy, 2, 0.5)).toBe(1200);
|
|
28
|
+
expect(delayFor(policy, 9, 0)).toBe(3500);
|
|
29
|
+
expect(delayFor(policy, 9, 0.999)).toBeLessThan(10500);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('returns a different delay for each client on every retry', () => {
|
|
33
|
+
const policy = new DefaultReconnectPolicy();
|
|
34
|
+
for (let retryCount = 0; retryCount < 10; retryCount++) {
|
|
35
|
+
expect(delayFor(policy, retryCount, 0.1)).not.toBe(delayFor(policy, retryCount, 0.9));
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('returns null once the retries are exhausted', () => {
|
|
40
|
+
const policy = new DefaultReconnectPolicy([0, 100]);
|
|
41
|
+
expect(policy.nextRetryDelayInMs({ retryCount: 2, elapsedMs: 0 })).toBeNull();
|
|
42
|
+
expect(
|
|
43
|
+
new DefaultReconnectPolicy().nextRetryDelayInMs({ retryCount: 10, elapsedMs: 0 }),
|
|
44
|
+
).toBeNull();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('applies jitter to custom delays', () => {
|
|
48
|
+
const policy = new DefaultReconnectPolicy([1000]);
|
|
49
|
+
expect(delayFor(policy, 0, 0.5)).toBe(1000);
|
|
50
|
+
expect(delayFor(policy, 0, 0)).toBe(500);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('keeps a zero delay at a later index immediate', () => {
|
|
54
|
+
const policy = new DefaultReconnectPolicy([100, 0]);
|
|
55
|
+
expect(delayFor(policy, 1, 0.5)).toBe(0);
|
|
56
|
+
expect(delayFor(policy, 1, 0.999)).toBe(0);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
@@ -2,6 +2,12 @@ import type { ReconnectContext, ReconnectPolicy } from './ReconnectPolicy';
|
|
|
2
2
|
|
|
3
3
|
const maxRetryDelay = 7000;
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* Upper bound of the random delay added to the first retry, whose base delay is 0. It keeps a
|
|
7
|
+
* brief network blip recovering quickly while spreading out clients that disconnect together.
|
|
8
|
+
*/
|
|
9
|
+
const maxFirstRetryJitterInMs = 500;
|
|
10
|
+
|
|
5
11
|
const DEFAULT_RETRY_DELAYS_IN_MS = [
|
|
6
12
|
0,
|
|
7
13
|
300,
|
|
@@ -26,9 +32,15 @@ class DefaultReconnectPolicy implements ReconnectPolicy {
|
|
|
26
32
|
if (context.retryCount >= this._retryDelays.length) return null;
|
|
27
33
|
|
|
28
34
|
const retryDelay = this._retryDelays[context.retryCount];
|
|
29
|
-
if (context.retryCount <= 1) return retryDelay;
|
|
30
35
|
|
|
31
|
-
|
|
36
|
+
// The first retry has no base delay to scale, so it gets a fixed window instead. A zero at a
|
|
37
|
+
// later index is an explicit request for an immediate retry and passes through the formula as zero.
|
|
38
|
+
if (context.retryCount === 0 && retryDelay === 0) {
|
|
39
|
+
return Math.random() * maxFirstRetryJitterInMs;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Jitter scales with the delay (+/-50%) so clients spread out further on later retries.
|
|
43
|
+
return Math.round(retryDelay * (0.5 + Math.random()));
|
|
32
44
|
}
|
|
33
45
|
}
|
|
34
46
|
|
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
import { type MediaDescription, parse } from 'sdp-transform';
|
|
2
|
-
import { describe, expect, it } from 'vitest';
|
|
3
|
-
import {
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import PCTransport, {
|
|
4
4
|
applyVideoStartBitrate,
|
|
5
|
+
computeConnectionStartBitrate,
|
|
6
|
+
computeTrackStartBitrate,
|
|
5
7
|
conformBundledCodecFmtp,
|
|
6
8
|
ensureAudioNackAndStereo,
|
|
7
9
|
ensureVideoDDExtension,
|
|
8
10
|
extractStereoAndNackAudioFromOffer,
|
|
11
|
+
findTrackCodecPayload,
|
|
9
12
|
fmtpConfigHasParam,
|
|
13
|
+
isDuplicateAnswer,
|
|
10
14
|
placeholderMidsFromTransceivers,
|
|
11
15
|
} from './PCTransport';
|
|
12
16
|
import { ddExtensionURI } from './utils';
|
|
@@ -61,9 +65,7 @@ a=recvonly
|
|
|
61
65
|
a=rtpmap:49 H265/90000
|
|
62
66
|
a=fmtp:49 level-id=180;profile-id=1;tier-flag=0;tx-mode=SRST`;
|
|
63
67
|
|
|
64
|
-
|
|
65
|
-
it('applies the bitrate only to the section whose msid track ID matches the cid', () => {
|
|
66
|
-
const { media } = parse(`v=0
|
|
68
|
+
const TWO_VIDEO_SECTIONS = `v=0
|
|
67
69
|
o=- 0 0 IN IP4 127.0.0.1
|
|
68
70
|
s=-
|
|
69
71
|
t=0 0
|
|
@@ -79,15 +81,301 @@ c=IN IP4 0.0.0.0
|
|
|
79
81
|
a=mid:1
|
|
80
82
|
a=sendonly
|
|
81
83
|
a=msid:PA_remote|camera camera-cid
|
|
82
|
-
a=rtpmap:96 VP8/90000
|
|
84
|
+
a=rtpmap:96 VP8/90000`;
|
|
85
|
+
|
|
86
|
+
// The same bundle after the screen share is unpublished: `unpublishTrack` sets the
|
|
87
|
+
// transceiver to `inactive`, but the section keeps its `a=msid`, so it still matches the
|
|
88
|
+
// append-only trackBitrates entry. Only the camera is still sending.
|
|
89
|
+
const UNPUBLISHED_SCREEN_SHARE = `v=0
|
|
90
|
+
o=- 0 0 IN IP4 127.0.0.1
|
|
91
|
+
s=-
|
|
92
|
+
t=0 0
|
|
93
|
+
a=group:BUNDLE 0 1
|
|
94
|
+
m=video 9 UDP/TLS/RTP/SAVPF 96
|
|
95
|
+
c=IN IP4 0.0.0.0
|
|
96
|
+
a=mid:0
|
|
97
|
+
a=inactive
|
|
98
|
+
a=msid:PA_remote|camera other-track
|
|
99
|
+
a=rtpmap:96 VP8/90000
|
|
100
|
+
m=video 9 UDP/TLS/RTP/SAVPF 96
|
|
101
|
+
c=IN IP4 0.0.0.0
|
|
102
|
+
a=mid:1
|
|
103
|
+
a=sendonly
|
|
104
|
+
a=msid:PA_remote|camera camera-cid
|
|
105
|
+
a=rtpmap:96 VP8/90000`;
|
|
106
|
+
|
|
107
|
+
// The legacy `addTrack` fallback (no `addTransceiver` support) reuses a transceiver instead
|
|
108
|
+
// of creating a sendonly one, so the published camera lands on a `sendrecv` section. Mid 1
|
|
109
|
+
// omits the direction attribute entirely, which SDP also defaults to sendrecv. Both send.
|
|
110
|
+
const LEGACY_ADD_TRACK_SECTIONS = `v=0
|
|
111
|
+
o=- 0 0 IN IP4 127.0.0.1
|
|
112
|
+
s=-
|
|
113
|
+
t=0 0
|
|
114
|
+
a=group:BUNDLE 0 1
|
|
115
|
+
m=video 9 UDP/TLS/RTP/SAVPF 96
|
|
116
|
+
c=IN IP4 0.0.0.0
|
|
117
|
+
a=mid:0
|
|
118
|
+
a=sendrecv
|
|
119
|
+
a=msid:PA_remote|camera camera-cid
|
|
120
|
+
a=rtpmap:96 VP8/90000
|
|
121
|
+
m=video 9 UDP/TLS/RTP/SAVPF 96
|
|
122
|
+
c=IN IP4 0.0.0.0
|
|
123
|
+
a=mid:1
|
|
124
|
+
a=msid:PA_remote|camera other-track
|
|
125
|
+
a=rtpmap:96 VP8/90000`;
|
|
126
|
+
|
|
127
|
+
describe('video start bitrate', () => {
|
|
128
|
+
it('matches only the section whose msid track ID matches the cid', () => {
|
|
129
|
+
const { media } = parse(TWO_VIDEO_SECTIONS);
|
|
130
|
+
|
|
131
|
+
expect(findTrackCodecPayload(media[0], 'camera-cid', 'VP8')).toBeUndefined();
|
|
132
|
+
expect(findTrackCodecPayload(media[1], 'camera-cid', 'VP8')).toBe(96);
|
|
133
|
+
// Section belongs to the track but does not offer the codec.
|
|
134
|
+
expect(findTrackCodecPayload(media[1], 'camera-cid', 'AV1')).toBe(0);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('applies the bitrate only to the section it is given', () => {
|
|
138
|
+
const { media } = parse(TWO_VIDEO_SECTIONS);
|
|
83
139
|
|
|
84
|
-
|
|
85
|
-
applyVideoStartBitrate(section, 'camera-cid', 'VP8', 1_000);
|
|
86
|
-
}
|
|
140
|
+
applyVideoStartBitrate(media[1], 96, 900);
|
|
87
141
|
|
|
88
142
|
expect(fmtpOf(media, '0', 96)).toBeUndefined();
|
|
89
143
|
expect(paramSet(fmtpOf(media, '1', 96)!)).toContain('x-google-start-bitrate=900');
|
|
90
144
|
});
|
|
145
|
+
|
|
146
|
+
it('caps camera at 1 Mbps but leaves screen share uncapped', () => {
|
|
147
|
+
const camera = { cid: 'c', codec: 'VP8', maxbr: 3_000 };
|
|
148
|
+
const screenShare = { ...camera, isScreenShare: true };
|
|
149
|
+
|
|
150
|
+
expect(computeTrackStartBitrate(camera)).toBe(1_000);
|
|
151
|
+
expect(computeTrackStartBitrate(screenShare)).toBe(2_700);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('gives no hint below the 300 kbps target floor', () => {
|
|
155
|
+
expect(computeTrackStartBitrate({ cid: 'c', codec: 'VP8', maxbr: 299 })).toBeUndefined();
|
|
156
|
+
expect(computeTrackStartBitrate({ cid: 'c', codec: 'VP8', maxbr: 300 })).toBe(270);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('ramps the cap down with connection setup time', () => {
|
|
160
|
+
// A 3 Mbps camera target, so only the cap moves.
|
|
161
|
+
const camera = (ms: number) =>
|
|
162
|
+
computeTrackStartBitrate({ cid: 'c', codec: 'VP8', maxbr: 3_000 }, ms);
|
|
163
|
+
|
|
164
|
+
expect(camera(0)).toBe(1_000);
|
|
165
|
+
expect(camera(471)).toBe(1_000); // unshaped baseline
|
|
166
|
+
expect(camera(1_273)).toBe(1_000); // 1 Mbps link median
|
|
167
|
+
expect(camera(1_500)).toBe(1_000); // the fast anchor
|
|
168
|
+
expect(camera(1_724)).toBe(922); // 1 Mbps link slow attempt
|
|
169
|
+
expect(camera(2_334)).toBe(708); // 500 kbps link median
|
|
170
|
+
expect(camera(2_500)).toBe(650); // midpoint of the ramp
|
|
171
|
+
expect(camera(3_093)).toBe(442); // 300 kbps link fastest attempt
|
|
172
|
+
expect(camera(3_500)).toBe(300); // the slow anchor
|
|
173
|
+
expect(camera(18_131)).toBe(300); // anything slower stays at the floor
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('keeps the 1 Mbps ceiling without a setup time', () => {
|
|
177
|
+
expect(computeTrackStartBitrate({ cid: 'c', codec: 'VP8', maxbr: 3_000 }, undefined)).toBe(
|
|
178
|
+
1_000,
|
|
179
|
+
);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('still applies 90% of the target under the ramp, and hints at the floor', () => {
|
|
183
|
+
expect(computeTrackStartBitrate({ cid: 'c', codec: 'VP8', maxbr: 500 }, 2_500)).toBe(450);
|
|
184
|
+
expect(computeTrackStartBitrate({ cid: 'c', codec: 'VP8', maxbr: 300 }, 3_500)).toBe(270);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('leaves screen share uncapped however slow the setup', () => {
|
|
188
|
+
const screenShare = (ms: number) =>
|
|
189
|
+
computeTrackStartBitrate({ cid: 's', codec: 'VP8', maxbr: 3_000, isScreenShare: true }, ms);
|
|
190
|
+
|
|
191
|
+
expect(screenShare(1_273)).toBe(2_700);
|
|
192
|
+
expect(screenShare(2_500)).toBe(2_700);
|
|
193
|
+
expect(screenShare(4_061)).toBe(2_700);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('applies the setup time to the connection-level value', () => {
|
|
197
|
+
const { media } = parse(TWO_VIDEO_SECTIONS);
|
|
198
|
+
const camera = { cid: 'camera-cid', codec: 'VP8', maxbr: 3_000 };
|
|
199
|
+
const screenShare = { cid: 'other-track', codec: 'VP8', maxbr: 3_000, isScreenShare: true };
|
|
200
|
+
|
|
201
|
+
expect(computeConnectionStartBitrate(media, [camera], 1_000)).toBe(1_000);
|
|
202
|
+
expect(computeConnectionStartBitrate(media, [camera], 2_500)).toBe(650);
|
|
203
|
+
// The uncapped screen share still wins the connection-level max on a slow setup.
|
|
204
|
+
expect(computeConnectionStartBitrate(media, [camera, screenShare], 2_500)).toBe(2_700);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
describe('browser effective connection type', () => {
|
|
208
|
+
const withEffectiveType = (effectiveType: string | undefined) =>
|
|
209
|
+
vi.stubGlobal('navigator', { connection: effectiveType ? { effectiveType } : undefined });
|
|
210
|
+
|
|
211
|
+
afterEach(() => {
|
|
212
|
+
vi.unstubAllGlobals();
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
// A 3 Mbps camera target, so only the cap moves.
|
|
216
|
+
const camera = (setupTimeMs?: number) =>
|
|
217
|
+
computeTrackStartBitrate({ cid: 'c', codec: 'VP8', maxbr: 3_000 }, setupTimeMs);
|
|
218
|
+
|
|
219
|
+
it('caps 2g and slow-2g at 300 kbps and 3g at 500 kbps', () => {
|
|
220
|
+
withEffectiveType('slow-2g');
|
|
221
|
+
expect(camera(500)).toBe(300);
|
|
222
|
+
withEffectiveType('2g');
|
|
223
|
+
expect(camera(500)).toBe(300);
|
|
224
|
+
withEffectiveType('3g');
|
|
225
|
+
expect(camera(500)).toBe(500);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it('gives no cap for 4g or without the API', () => {
|
|
229
|
+
withEffectiveType('4g');
|
|
230
|
+
expect(camera(500)).toBe(1_000);
|
|
231
|
+
withEffectiveType(undefined);
|
|
232
|
+
expect(camera(500)).toBe(1_000);
|
|
233
|
+
vi.stubGlobal('navigator', undefined);
|
|
234
|
+
expect(camera(500)).toBe(1_000);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it('takes the lower of the setup time cap and the effective type cap', () => {
|
|
238
|
+
withEffectiveType('3g');
|
|
239
|
+
expect(camera(2_500)).toBe(500); // setup time alone allows 650
|
|
240
|
+
expect(camera(3_093)).toBe(442); // setup time is lower
|
|
241
|
+
expect(camera()).toBe(500);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it('still applies 90% of a target below the cap', () => {
|
|
245
|
+
withEffectiveType('3g');
|
|
246
|
+
expect(computeTrackStartBitrate({ cid: 'c', codec: 'VP8', maxbr: 450 }, 500)).toBe(405);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it('leaves screen share uncapped', () => {
|
|
250
|
+
withEffectiveType('slow-2g');
|
|
251
|
+
expect(
|
|
252
|
+
computeTrackStartBitrate(
|
|
253
|
+
{ cid: 's', codec: 'VP8', maxbr: 3_000, isScreenShare: true },
|
|
254
|
+
500,
|
|
255
|
+
),
|
|
256
|
+
).toBe(2_700);
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
describe('connection setup time while still connecting', () => {
|
|
261
|
+
beforeEach(() => {
|
|
262
|
+
// The constructor creates its peer connection eagerly; only its event handlers are set.
|
|
263
|
+
vi.stubGlobal('RTCPeerConnection', class {});
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
afterEach(() => {
|
|
267
|
+
vi.restoreAllMocks();
|
|
268
|
+
vi.unstubAllGlobals();
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
const estimate = (transport: PCTransport) =>
|
|
272
|
+
(
|
|
273
|
+
transport as unknown as { connectionSetupTimeEstimateMs(): number | undefined }
|
|
274
|
+
).connectionSetupTimeEstimateMs();
|
|
275
|
+
|
|
276
|
+
it('uses the time elapsed since the join started before the setup time is measured', () => {
|
|
277
|
+
const transport = new PCTransport();
|
|
278
|
+
vi.spyOn(performance, 'now').mockReturnValue(10_000);
|
|
279
|
+
transport.setConnectionStartedAt(7_500);
|
|
280
|
+
|
|
281
|
+
expect(estimate(transport)).toBe(2_500);
|
|
282
|
+
expect(
|
|
283
|
+
computeTrackStartBitrate({ cid: 'c', codec: 'VP8', maxbr: 3_000 }, estimate(transport)),
|
|
284
|
+
).toBe(650);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it('prefers the measured setup time once known', () => {
|
|
288
|
+
const transport = new PCTransport();
|
|
289
|
+
vi.spyOn(performance, 'now').mockReturnValue(10_000);
|
|
290
|
+
transport.setConnectionStartedAt(7_500);
|
|
291
|
+
transport.setConnectionSetupTime(3_600);
|
|
292
|
+
|
|
293
|
+
expect(estimate(transport)).toBe(3_600);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it('has no estimate without any timing', () => {
|
|
297
|
+
expect(estimate(new PCTransport())).toBeUndefined();
|
|
298
|
+
});
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it('uses one connection-level value: the largest hint across video sections', () => {
|
|
302
|
+
const { media } = parse(TWO_VIDEO_SECTIONS);
|
|
303
|
+
|
|
304
|
+
const startBitrate = computeConnectionStartBitrate(media, [
|
|
305
|
+
{ cid: 'camera-cid', codec: 'VP8', maxbr: 1_000 },
|
|
306
|
+
{ cid: 'other-track', codec: 'VP8', maxbr: 3_000, isScreenShare: true },
|
|
307
|
+
]);
|
|
308
|
+
|
|
309
|
+
expect(startBitrate).toBe(2_700);
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it('ignores registered tracks with no section in the current SDP', () => {
|
|
313
|
+
const { media } = parse(TWO_VIDEO_SECTIONS);
|
|
314
|
+
|
|
315
|
+
const startBitrate = computeConnectionStartBitrate(media, [
|
|
316
|
+
{ cid: 'camera-cid', codec: 'VP8', maxbr: 1_000 },
|
|
317
|
+
// Stale entry: trackBitrates is append-only and outlives an unpublish.
|
|
318
|
+
{ cid: 'unpublished-cid', codec: 'VP8', maxbr: 8_000, isScreenShare: true },
|
|
319
|
+
]);
|
|
320
|
+
|
|
321
|
+
expect(startBitrate).toBe(900);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
it('ignores a section that stopped sending but kept its msid', () => {
|
|
325
|
+
const trackBitrates = [
|
|
326
|
+
{ cid: 'camera-cid', codec: 'VP8', maxbr: 1_000 },
|
|
327
|
+
{ cid: 'other-track', codec: 'VP8', maxbr: 8_000, isScreenShare: true },
|
|
328
|
+
];
|
|
329
|
+
|
|
330
|
+
// While both send, the uncapped screen share wins the connection-level max.
|
|
331
|
+
expect(computeConnectionStartBitrate(parse(TWO_VIDEO_SECTIONS).media, trackBitrates)).toBe(
|
|
332
|
+
7_200,
|
|
333
|
+
);
|
|
334
|
+
// Once it is unpublished its entry is stale, so only the capped camera counts. Both
|
|
335
|
+
// directions a removed sender can land on are excluded: `inactive` from a sendonly
|
|
336
|
+
// transceiver, `recvonly` from the sendrecv one the addTrack fallback reuses.
|
|
337
|
+
expect(
|
|
338
|
+
computeConnectionStartBitrate(parse(UNPUBLISHED_SCREEN_SHARE).media, trackBitrates),
|
|
339
|
+
).toBe(900);
|
|
340
|
+
expect(
|
|
341
|
+
computeConnectionStartBitrate(
|
|
342
|
+
parse(UNPUBLISHED_SCREEN_SHARE.replace('a=inactive', 'a=recvonly')).media,
|
|
343
|
+
trackBitrates,
|
|
344
|
+
),
|
|
345
|
+
).toBe(900);
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
it('gives no connection value when no section maps to a published track', () => {
|
|
349
|
+
const { media } = parse(TWO_VIDEO_SECTIONS);
|
|
350
|
+
|
|
351
|
+
expect(computeConnectionStartBitrate(media, [])).toBeUndefined();
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
it('counts sendrecv and direction-less sections, which still send local media', () => {
|
|
355
|
+
// The legacy addTrack fallback never produces `sendonly`, so a strict match on it would
|
|
356
|
+
// leave those clients with no hint at all.
|
|
357
|
+
const { media } = parse(LEGACY_ADD_TRACK_SECTIONS);
|
|
358
|
+
|
|
359
|
+
expect(
|
|
360
|
+
computeConnectionStartBitrate(media, [{ cid: 'camera-cid', codec: 'VP8', maxbr: 1_000 }]),
|
|
361
|
+
).toBe(900);
|
|
362
|
+
expect(
|
|
363
|
+
computeConnectionStartBitrate(media, [
|
|
364
|
+
{ cid: 'other-track', codec: 'VP8', maxbr: 2_000, isScreenShare: true },
|
|
365
|
+
]),
|
|
366
|
+
).toBe(1_800);
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
it('leaves the hint unset when only non-sending sections match', () => {
|
|
370
|
+
// Nothing is published, so the one-shot hint must not be consumed on a dead section.
|
|
371
|
+
const { media } = parse(UNPUBLISHED_SCREEN_SHARE);
|
|
372
|
+
|
|
373
|
+
expect(
|
|
374
|
+
computeConnectionStartBitrate(media, [
|
|
375
|
+
{ cid: 'other-track', codec: 'VP8', maxbr: 8_000, isScreenShare: true },
|
|
376
|
+
]),
|
|
377
|
+
).toBeUndefined();
|
|
378
|
+
});
|
|
91
379
|
});
|
|
92
380
|
|
|
93
381
|
describe('placeholderMidsFromTransceivers', () => {
|
|
@@ -390,3 +678,33 @@ a=extmap:3 ${ddExtensionURI}`);
|
|
|
390
678
|
expect(ddOf(sdp.media, '1')).toBe(12);
|
|
391
679
|
});
|
|
392
680
|
});
|
|
681
|
+
|
|
682
|
+
describe('isDuplicateAnswer', () => {
|
|
683
|
+
it('drops an answer while the same offer is still being answered', () => {
|
|
684
|
+
expect(isDuplicateAnswer(3, 3, 0, 'have-local-offer', false)).toBe(true);
|
|
685
|
+
});
|
|
686
|
+
|
|
687
|
+
it('drops a repeat of an unnumbered answer while the first is still applying', () => {
|
|
688
|
+
expect(isDuplicateAnswer(0, 0, 0, 'have-local-offer', false)).toBe(true);
|
|
689
|
+
});
|
|
690
|
+
|
|
691
|
+
it('drops an answer for an offer whose answer was already applied', () => {
|
|
692
|
+
expect(isDuplicateAnswer(3, undefined, 3, 'stable', false)).toBe(true);
|
|
693
|
+
});
|
|
694
|
+
|
|
695
|
+
it('accepts a retry after a failed apply: nothing in flight, nothing applied', () => {
|
|
696
|
+
expect(isDuplicateAnswer(3, undefined, 2, 'have-local-offer', false)).toBe(false);
|
|
697
|
+
});
|
|
698
|
+
|
|
699
|
+
it('accepts the answer for a new offer', () => {
|
|
700
|
+
expect(isDuplicateAnswer(4, undefined, 3, 'have-local-offer', false)).toBe(false);
|
|
701
|
+
});
|
|
702
|
+
|
|
703
|
+
it('without an offerId, drops an answer in stable', () => {
|
|
704
|
+
expect(isDuplicateAnswer(0, undefined, 0, 'stable', false)).toBe(true);
|
|
705
|
+
});
|
|
706
|
+
|
|
707
|
+
it('without an offerId, accepts the answer to a deferred initial offer', () => {
|
|
708
|
+
expect(isDuplicateAnswer(0, undefined, 0, 'stable', true)).toBe(false);
|
|
709
|
+
});
|
|
710
|
+
});
|