mediasoup-client
Version:
mediasoup client side TypeScript library
1,106 lines • 53.2 kB
JavaScript
"use strict";
/**
* This test runs in Node so no browser auto-detection is done. Instead, a
* FakeHandler device is used.
*/
Object.defineProperty(exports, "__esModule", { value: true });
const fake_mediastreamtrack_1 = require("fake-mediastreamtrack");
const mediasoupClient = require("../index");
const errors_1 = require("../errors");
const utils = require("../utils");
const FakeHandler_1 = require("../handlers/FakeHandler");
const fakeParameters = require("./fakeParameters");
const uaTestCases_1 = require("./uaTestCases");
const { Device, detectDevice, detectDeviceAsync, parseScalabilityMode, debug } = mediasoupClient;
const ctx = {};
beforeEach(async () => {
ctx.device = new Device({
handlerFactory: FakeHandler_1.FakeHandler.createFactory(fakeParameters),
});
ctx.loadedDevice = new Device({
handlerFactory: FakeHandler_1.FakeHandler.createFactory(fakeParameters),
});
const routerRtpCapabilities = fakeParameters.generateRouterRtpCapabilities();
// Only load loadedDevice.
await ctx.loadedDevice.load({ routerRtpCapabilities });
const { id, iceParameters, iceCandidates, dtlsParameters, sctpParameters } = fakeParameters.generateTransportRemoteParameters();
ctx.sendTransport = ctx.loadedDevice.createSendTransport({
id,
iceParameters,
iceCandidates,
dtlsParameters,
sctpParameters,
});
ctx.connectedSendTransport = ctx.loadedDevice.createSendTransport({
id,
iceParameters,
iceCandidates,
dtlsParameters,
sctpParameters,
});
ctx.connectedSendTransport.on('connect',
// eslint-disable-next-line no-shadow, @typescript-eslint/no-unused-vars
({ dtlsParameters }, callback /* errback */) => {
setTimeout(callback);
});
ctx.connectedSendTransport.on('produce',
// eslint-disable-next-line @typescript-eslint/no-unused-vars
({ kind, rtpParameters, appData }, callback /* errback */) => {
// eslint-disable-next-line no-shadow
const id = fakeParameters.generateProducerRemoteParameters().id;
setTimeout(() => callback({ id }));
});
ctx.connectedSendTransport.on('producedata', (
// eslint-disable-next-line @typescript-eslint/no-unused-vars
{ sctpStreamParameters, label, protocol, appData }, callback /* errback */) => {
// eslint-disable-next-line no-shadow
const id = fakeParameters.generateDataProducerRemoteParameters().id;
setTimeout(() => callback({ id }));
});
ctx.recvTransport = ctx.loadedDevice.createRecvTransport({
id,
iceParameters,
iceCandidates,
dtlsParameters,
sctpParameters,
});
ctx.connectedRecvTransport = ctx.loadedDevice.createRecvTransport({
id,
iceParameters,
iceCandidates,
dtlsParameters,
sctpParameters,
});
ctx.connectedRecvTransport.on('connect',
// eslint-disable-next-line no-shadow, @typescript-eslint/no-unused-vars
({ dtlsParameters }, callback /* errback */) => {
setTimeout(callback);
});
const audioTrack = new fake_mediastreamtrack_1.FakeMediaStreamTrack({ kind: 'audio' });
ctx.audioProducer = await ctx.connectedSendTransport.produce({
track: audioTrack,
stopTracks: false,
});
const videoTrack = new fake_mediastreamtrack_1.FakeMediaStreamTrack({ kind: 'video' });
ctx.videoProducer = await ctx.connectedSendTransport.produce({
track: videoTrack,
});
const audioConsumerRemoteParameters = fakeParameters.generateConsumerRemoteParameters({
codecMimeType: 'audio/opus',
});
ctx.audioConsumer = await ctx.connectedRecvTransport.consume({
id: audioConsumerRemoteParameters.id,
producerId: audioConsumerRemoteParameters.producerId,
kind: audioConsumerRemoteParameters.kind,
rtpParameters: audioConsumerRemoteParameters.rtpParameters,
});
const videoConsumerRemoteParameters = fakeParameters.generateConsumerRemoteParameters({
codecMimeType: 'video/VP8',
});
ctx.videoConsumer = await ctx.connectedRecvTransport.consume({
id: videoConsumerRemoteParameters.id,
producerId: videoConsumerRemoteParameters.producerId,
kind: videoConsumerRemoteParameters.kind,
rtpParameters: videoConsumerRemoteParameters.rtpParameters,
});
ctx.dataProducer = await ctx.connectedSendTransport.produceData({
ordered: false,
maxPacketLifeTime: 5555,
});
const dataConsumerRemoteParameters = fakeParameters.generateDataConsumerRemoteParameters();
ctx.dataConsumer = await ctx.connectedRecvTransport.consumeData({
id: dataConsumerRemoteParameters.id,
dataProducerId: dataConsumerRemoteParameters.dataProducerId,
sctpStreamParameters: dataConsumerRemoteParameters.sctpStreamParameters,
});
});
test('mediasoup-client exposes debug dependency', () => {
expect(typeof debug).toBe('function');
}, 500);
test('detectDevice() returns nothing in Node', () => {
expect(detectDevice()).toBe(undefined);
});
test('create a Device in Node without custom handlerName/handlerFactory throws UnsupportedError', () => {
expect(() => new Device()).toThrow(errors_1.UnsupportedError);
});
test('create a Device with an unknown handlerName string throws TypeError', () => {
// @ts-expect-error --- On purpose.
expect(() => new Device({ handlerName: 'FooBrowser666' })).toThrow(TypeError);
});
test('create a Device in Node with a valid handlerFactory succeeds', () => {
const device = new Device({
handlerFactory: FakeHandler_1.FakeHandler.createFactory(fakeParameters),
});
expect(typeof device).toBe('object');
expect(device.handlerName).toBe('FakeHandler');
expect(device.loaded).toBe(false);
});
test('device.rtpCapabilities (deprecated) getter throws InvalidStateError if not loaded', () => {
expect(() => ctx.device.rtpCapabilities).toThrow(errors_1.InvalidStateError);
});
test('device.recvRtpCapabilities getter throws InvalidStateError if not loaded', () => {
expect(() => ctx.device.recvRtpCapabilities).toThrow(errors_1.InvalidStateError);
});
test('device.sendRtpCapabilities getter throws InvalidStateError if not loaded', () => {
expect(() => ctx.device.sendRtpCapabilities).toThrow(errors_1.InvalidStateError);
});
test('device.sctpCapabilities getter throws InvalidStateError if not loaded', () => {
expect(() => ctx.device.sctpCapabilities).toThrow(errors_1.InvalidStateError);
});
test('device.canProduce() throws InvalidStateError if not loaded', () => {
expect(() => ctx.device.canProduce('audio')).toThrow(errors_1.InvalidStateError);
});
test('device.createSendTransport() throws InvalidStateError if not loaded', () => {
const { id, iceParameters, iceCandidates, dtlsParameters, sctpParameters } = fakeParameters.generateTransportRemoteParameters();
expect(() => ctx.device.createSendTransport({
id,
iceParameters,
iceCandidates,
dtlsParameters,
sctpParameters,
})).toThrow(errors_1.InvalidStateError);
});
test('device.load() without routerRtpCapabilities rejects with TypeError', async () => {
// @ts-expect-error --- On purpose.
await expect(ctx.device.load({})).rejects.toThrow(TypeError);
expect(ctx.device.loaded).toBe(false);
}, 500);
test('device.load() with invalid routerRtpCapabilities rejects with TypeError', async () => {
// Clone fake router RTP capabilities to make them invalid.
const routerRtpCapabilities = utils.clone(fakeParameters.generateRouterRtpCapabilities());
for (const codec of routerRtpCapabilities.codecs) {
// @ts-expect-error --- Removing mandatory field.
delete codec.mimeType;
}
await expect(ctx.device.load({ routerRtpCapabilities })).rejects.toThrow(TypeError);
expect(ctx.device.loaded).toBe(false);
}, 500);
test('device.load() succeeds', async () => {
// Assume we get the router RTP capabilities.
const routerRtpCapabilities = fakeParameters.generateRouterRtpCapabilities();
await expect(ctx.device.load({ routerRtpCapabilities })).resolves.toBe(undefined);
expect(ctx.device.loaded).toBe(true);
}, 500);
test('device.load() rejects with InvalidStateError if already loaded', async () => {
const routerRtpCapabilities = fakeParameters.generateRouterRtpCapabilities();
await expect(ctx.loadedDevice.load({ routerRtpCapabilities })).rejects.toThrow(errors_1.InvalidStateError);
expect(ctx.loadedDevice.loaded).toBe(true);
}, 500);
test('device.rtpCapabilities getter succeeds', () => {
expect(typeof ctx.loadedDevice.rtpCapabilities).toBe('object');
});
test('device.recvRtpCapabilities getter succeeds', () => {
expect(typeof ctx.loadedDevice.recvRtpCapabilities).toBe('object');
});
test('device.sendRtpCapabilities getter succeeds', () => {
expect(typeof ctx.loadedDevice.sendRtpCapabilities).toBe('object');
});
test('device.sctpCapabilities getter succeeds', () => {
expect(typeof ctx.loadedDevice.sctpCapabilities).toBe('object');
});
test('device.canProduce() with "audio"/"video" kind returns true', () => {
expect(ctx.loadedDevice.canProduce('audio')).toBe(true);
expect(ctx.loadedDevice.canProduce('video')).toBe(true);
});
test('device.canProduce() with invalid kind throws TypeError', () => {
// @ts-expect-error --- On purpose.
expect(() => ctx.loadedDevice.canProduce('chicken')).toThrow(TypeError);
});
test('device.createSendTransport() for sending media succeeds', () => {
// Assume we create a transport in the server and get its remote parameters.
const { id, iceParameters, iceCandidates, dtlsParameters, sctpParameters } = fakeParameters.generateTransportRemoteParameters();
const sendTransport = ctx.loadedDevice.createSendTransport({
id,
iceParameters,
iceCandidates,
dtlsParameters,
sctpParameters,
appData: { foo: 123 },
});
expect(typeof sendTransport).toBe('object');
expect(sendTransport.id).toBe(id);
expect(sendTransport.closed).toBe(false);
expect(sendTransport.direction).toBe('send');
expect(typeof sendTransport.handler).toBe('object');
expect(sendTransport.handler instanceof FakeHandler_1.FakeHandler).toBe(true);
expect(sendTransport.connectionState).toBe('new');
expect(sendTransport.appData).toEqual({ foo: 123 });
});
test('device.createRecvTransport() for receiving media succeeds', () => {
// Assume we create a transport in the server and get its remote parameters.
const { id, iceParameters, iceCandidates, dtlsParameters, sctpParameters } = fakeParameters.generateTransportRemoteParameters();
const recvTransport = ctx.loadedDevice.createRecvTransport({
id,
iceParameters,
iceCandidates,
dtlsParameters,
sctpParameters,
});
expect(typeof recvTransport).toBe('object');
expect(recvTransport.id).toBe(id);
expect(recvTransport.closed).toBe(false);
expect(recvTransport.direction).toBe('recv');
expect(typeof recvTransport.handler).toBe('object');
expect(recvTransport.handler instanceof FakeHandler_1.FakeHandler).toBe(true);
expect(recvTransport.connectionState).toBe('new');
expect(recvTransport.appData).toEqual({});
});
test('device.createSendTransport() with missing remote Transport parameters throws TypeError', () => {
// @ts-expect-error --- On purpose.
expect(() => ctx.loadedDevice.createSendTransport({ id: '1234' })).toThrow(TypeError);
expect(() =>
// @ts-expect-error --- On purpose.
ctx.loadedDevice.createSendTransport({ id: '1234', iceParameters: {} })).toThrow(TypeError);
expect(() => ctx.loadedDevice.createSendTransport({
id: '1234',
// @ts-expect-error --- On purpose.
iceParameters: {},
iceCandidates: [],
})).toThrow(TypeError);
});
test('device.createRecvTransport() with a non object appData throws TypeError', () => {
const { id, iceParameters, iceCandidates, dtlsParameters, sctpParameters } = fakeParameters.generateTransportRemoteParameters();
expect(() => ctx.loadedDevice.createRecvTransport({
id,
iceParameters,
iceCandidates,
dtlsParameters,
sctpParameters,
// @ts-expect-error --- On purpose.
appData: 1234,
})).toThrow(TypeError);
});
test('transport.produce() without "produce" listener rejects', async () => {
const audioTrack = new fake_mediastreamtrack_1.FakeMediaStreamTrack({ kind: 'audio' });
ctx.sendTransport.removeAllListeners('produce');
await expect(ctx.sendTransport.produce({ track: audioTrack })).rejects.toThrow(Error);
}, 500);
test('transport.produce() succeeds', async () => {
const audioTrack = new fake_mediastreamtrack_1.FakeMediaStreamTrack({ kind: 'audio' });
const videoTrack = new fake_mediastreamtrack_1.FakeMediaStreamTrack({ kind: 'video' });
let connectEventNumTimesCalled = 0;
let produceEventNumTimesCalled = 0;
let codecs;
let headerExtensions;
let encodings;
let rtcp;
// Pause the audio track before creating its Producer.
audioTrack.enabled = false;
ctx.connectedSendTransport.prependListener('connect', () => ++connectEventNumTimesCalled);
ctx.connectedSendTransport.prependListener('produce', () => ++produceEventNumTimesCalled);
// Use stopTracks: false.
const audioProducer = await ctx.connectedSendTransport.produce({
track: audioTrack,
stopTracks: false,
appData: { foo: 'FOO' },
});
// 'connect' event should not have been called since it was in beforeEach
// already.
expect(connectEventNumTimesCalled).toBe(0);
expect(produceEventNumTimesCalled).toBe(1);
expect(typeof audioProducer).toBe('object');
expect(typeof audioProducer.id).toBe('string');
expect(audioProducer.closed).toBe(false);
expect(audioProducer.kind).toBe('audio');
expect(audioProducer.track).toBe(audioTrack);
expect(typeof audioProducer.rtpParameters).toBe('object');
expect(typeof audioProducer.rtpParameters.mid).toBe('string');
expect(audioProducer.rtpParameters.codecs.length).toBe(1);
codecs = audioProducer.rtpParameters.codecs;
expect(codecs[0]).toEqual({
mimeType: 'audio/opus',
payloadType: 111,
clockRate: 48000,
channels: 2,
rtcpFeedback: [{ type: 'transport-cc', parameter: '' }],
parameters: {
minptime: 10,
useinbandfec: 1,
},
});
headerExtensions = audioProducer.rtpParameters.headerExtensions;
expect(headerExtensions).toEqual([
{
uri: 'urn:ietf:params:rtp-hdrext:sdes:mid',
id: 1,
encrypt: false,
parameters: {},
},
{
uri: 'urn:ietf:params:rtp-hdrext:ssrc-audio-level',
id: 10,
encrypt: false,
parameters: {},
},
]);
encodings = audioProducer.rtpParameters.encodings;
expect(Array.isArray(encodings)).toBe(true);
expect(encodings.length).toBe(1);
expect(typeof encodings[0]).toBe('object');
expect(Object.keys(encodings[0])).toEqual(['ssrc', 'dtx']);
expect(typeof encodings[0].ssrc).toBe('number');
rtcp = audioProducer.rtpParameters.rtcp;
expect(typeof rtcp).toBe('object');
expect(typeof rtcp?.cname).toBe('string');
expect(audioProducer.paused).toBe(true);
expect(audioProducer.maxSpatialLayer).toBe(undefined);
expect(audioProducer.appData).toEqual({ foo: 'FOO' });
// Reset the audio paused state.
audioProducer.resume();
const videoEncodings = [{ maxBitrate: 100000 }, { maxBitrate: 500000 }];
// Note that stopTracks is not given so it's true by default.
// Use disableTrackOnPause: false and zeroRtpOnPause: true
const videoProducer = await ctx.connectedSendTransport.produce({
track: videoTrack,
codec: ctx.loadedDevice.sendRtpCapabilities.codecs.find(codec => codec.mimeType.toLowerCase() === 'video/vp9'),
encodings: videoEncodings,
disableTrackOnPause: false,
zeroRtpOnPause: true,
});
expect(connectEventNumTimesCalled).toBe(0);
expect(produceEventNumTimesCalled).toBe(2);
expect(typeof videoProducer).toBe('object');
expect(typeof videoProducer.id).toBe('string');
expect(videoProducer.closed).toBe(false);
expect(videoProducer.kind).toBe('video');
expect(videoProducer.track).toBe(videoTrack);
expect(typeof videoProducer.rtpParameters).toBe('object');
expect(typeof videoProducer.rtpParameters.mid).toBe('string');
expect(videoProducer.rtpParameters.codecs.length).toBe(2);
codecs = videoProducer.rtpParameters.codecs;
expect(codecs[0]).toEqual({
mimeType: 'video/VP9',
payloadType: 98,
clockRate: 90000,
rtcpFeedback: [
{ type: 'goog-remb', parameter: '' },
{ type: 'transport-cc', parameter: '' },
{ type: 'ccm', parameter: 'fir' },
{ type: 'nack', parameter: '' },
{ type: 'nack', parameter: 'pli' },
],
parameters: {
'profile-id': 0,
},
});
expect(codecs[1]).toEqual({
mimeType: 'video/rtx',
payloadType: 99,
clockRate: 90000,
rtcpFeedback: [],
parameters: {
apt: 98,
},
});
headerExtensions = videoProducer.rtpParameters.headerExtensions;
expect(headerExtensions).toEqual([
{
uri: 'urn:ietf:params:rtp-hdrext:sdes:mid',
id: 1,
encrypt: false,
parameters: {},
},
{
uri: 'http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time',
id: 3,
encrypt: false,
parameters: {},
},
{
uri: 'http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01',
id: 5,
encrypt: false,
parameters: {},
},
{
uri: 'urn:3gpp:video-orientation',
id: 4,
encrypt: false,
parameters: {},
},
{
uri: 'urn:ietf:params:rtp-hdrext:toffset',
id: 2,
encrypt: false,
parameters: {},
},
]);
encodings = videoProducer.rtpParameters.encodings;
expect(Array.isArray(encodings)).toBe(true);
expect(encodings.length).toBe(2);
expect(typeof encodings[0]).toBe('object');
expect(typeof encodings[0].ssrc).toBe('number');
expect(typeof encodings[0].rtx).toBe('object');
expect(Object.keys(encodings[0].rtx)).toEqual(['ssrc']);
expect(typeof encodings[0].rtx.ssrc).toBe('number');
expect(typeof encodings[1]).toBe('object');
expect(typeof encodings[1].ssrc).toBe('number');
expect(typeof encodings[1].rtx).toBe('object');
expect(Object.keys(encodings[1].rtx)).toEqual(['ssrc']);
expect(typeof encodings[1].rtx.ssrc).toBe('number');
rtcp = videoProducer.rtpParameters.rtcp;
expect(typeof rtcp).toBe('object');
expect(typeof rtcp?.cname).toBe('string');
expect(videoProducer.paused).toBe(false);
expect(videoProducer.maxSpatialLayer).toBe(undefined);
expect(videoProducer.appData).toEqual({});
}, 500);
test('transport.produce() without track rejects with TypeError', async () => {
await expect(ctx.sendTransport.produce({})).rejects.toThrow(TypeError);
}, 500);
test('transport.produce() in a receiving Transport rejects with UnsupportedError', async () => {
const track = new fake_mediastreamtrack_1.FakeMediaStreamTrack({ kind: 'audio' });
await expect(ctx.recvTransport.produce({ track })).rejects.toThrow(errors_1.UnsupportedError);
}, 500);
test('transport.produce() with an ended track rejects with InvalidStateError', async () => {
const track = new fake_mediastreamtrack_1.FakeMediaStreamTrack({ kind: 'audio' });
track.stop();
await expect(ctx.sendTransport.produce({ track })).rejects.toThrow(errors_1.InvalidStateError);
}, 500);
test('transport.produce() with a non object appData rejects with TypeError', async () => {
const track = new fake_mediastreamtrack_1.FakeMediaStreamTrack({ kind: 'audio' });
await expect(
// @ts-expect-error --- On purpose.
ctx.sendTransport.produce({ track, appData: true })).rejects.toThrow(TypeError);
}, 500);
test('transport.consume() succeeds', async () => {
const audioConsumerRemoteParameters = fakeParameters.generateConsumerRemoteParameters({
codecMimeType: 'audio/opus',
});
const videoConsumerRemoteParameters = fakeParameters.generateConsumerRemoteParameters({
codecMimeType: 'video/VP8',
});
let connectEventNumTimesCalled = 0;
let codecs;
let headerExtensions;
let encodings;
let rtcp;
ctx.connectedRecvTransport.prependListener('connect', () => ++connectEventNumTimesCalled);
const audioConsumer = await ctx.connectedRecvTransport.consume({
id: audioConsumerRemoteParameters.id,
producerId: audioConsumerRemoteParameters.producerId,
kind: audioConsumerRemoteParameters.kind,
rtpParameters: audioConsumerRemoteParameters.rtpParameters,
appData: { bar: 'BAR' },
});
// 'connect' event should not have been called since it was in beforeEach
// already.
expect(connectEventNumTimesCalled).toBe(0);
expect(typeof audioConsumer).toBe('object');
expect(audioConsumer.id).toBe(audioConsumerRemoteParameters.id);
expect(audioConsumer.producerId).toBe(audioConsumerRemoteParameters.producerId);
expect(audioConsumer.closed).toBe(false);
expect(audioConsumer.kind).toBe('audio');
expect(typeof audioConsumer.track).toBe('object');
expect(typeof audioConsumer.rtpParameters).toBe('object');
expect(audioConsumer.rtpParameters.mid).toBe(undefined);
expect(audioConsumer.rtpParameters.codecs.length).toBe(1);
codecs = audioConsumer.rtpParameters.codecs;
expect(codecs[0]).toEqual({
mimeType: 'audio/opus',
payloadType: 100,
clockRate: 48000,
channels: 2,
rtcpFeedback: [{ type: 'transport-cc', parameter: '' }],
parameters: {
useinbandfec: 1,
foo: 'bar',
},
});
headerExtensions = audioConsumer.rtpParameters.headerExtensions;
expect(headerExtensions).toEqual([
{
uri: 'urn:ietf:params:rtp-hdrext:sdes:mid',
id: 1,
encrypt: false,
parameters: {},
},
{
uri: 'http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01',
id: 5,
encrypt: false,
parameters: {},
},
{
uri: 'urn:ietf:params:rtp-hdrext:ssrc-audio-level',
id: 10,
encrypt: false,
parameters: {},
},
]);
encodings = audioConsumer.rtpParameters.encodings;
expect(Array.isArray(encodings)).toBe(true);
expect(encodings.length).toBe(1);
expect(typeof encodings[0]).toBe('object');
expect(Object.keys(encodings[0])).toEqual(['ssrc', 'dtx']);
expect(typeof encodings[0].ssrc).toBe('number');
rtcp = audioConsumer.rtpParameters.rtcp;
expect(typeof rtcp).toBe('object');
expect(typeof rtcp?.cname).toBe('string');
expect(audioConsumer.paused).toBe(false);
expect(audioConsumer.appData).toEqual({ bar: 'BAR' });
const videoConsumer = await ctx.connectedRecvTransport.consume({
id: videoConsumerRemoteParameters.id,
producerId: videoConsumerRemoteParameters.producerId,
kind: videoConsumerRemoteParameters.kind,
rtpParameters: videoConsumerRemoteParameters.rtpParameters,
});
expect(connectEventNumTimesCalled).toBe(0);
expect(typeof videoConsumer).toBe('object');
expect(videoConsumer.id).toBe(videoConsumerRemoteParameters.id);
expect(videoConsumer.producerId).toBe(videoConsumerRemoteParameters.producerId);
expect(videoConsumer.closed).toBe(false);
expect(videoConsumer.kind).toBe('video');
expect(typeof videoConsumer.track).toBe('object');
expect(typeof videoConsumer.rtpParameters).toBe('object');
expect(videoConsumer.rtpParameters.mid).toBe(undefined);
expect(videoConsumer.rtpParameters.codecs.length).toBe(2);
codecs = videoConsumer.rtpParameters.codecs;
expect(codecs[0]).toEqual({
mimeType: 'video/VP8',
payloadType: 101,
clockRate: 90000,
rtcpFeedback: [
{ type: 'nack', parameter: '' },
{ type: 'nack', parameter: 'pli' },
{ type: 'ccm', parameter: 'fir' },
{ type: 'goog-remb', parameter: '' },
{ type: 'transport-cc', parameter: '' },
],
parameters: {
'x-google-start-bitrate': 1500,
},
});
expect(codecs[1]).toEqual({
mimeType: 'video/rtx',
payloadType: 102,
clockRate: 90000,
rtcpFeedback: [],
parameters: {
apt: 101,
},
});
headerExtensions = videoConsumer.rtpParameters.headerExtensions;
expect(headerExtensions).toEqual([
{
uri: 'urn:ietf:params:rtp-hdrext:sdes:mid',
id: 1,
encrypt: false,
parameters: {},
},
{
uri: 'http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time',
id: 4,
encrypt: false,
parameters: {},
},
{
uri: 'http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01',
id: 5,
encrypt: false,
parameters: {},
},
{
uri: 'urn:3gpp:video-orientation',
id: 11,
encrypt: false,
parameters: {},
},
{
uri: 'urn:ietf:params:rtp-hdrext:toffset',
id: 12,
encrypt: false,
parameters: {},
},
]);
encodings = videoConsumer.rtpParameters.encodings;
expect(Array.isArray(encodings)).toBe(true);
expect(encodings.length).toBe(1);
expect(typeof encodings[0]).toBe('object');
expect(Object.keys(encodings[0])).toEqual(['ssrc', 'rtx', 'dtx']);
expect(typeof encodings[0].ssrc).toBe('number');
expect(typeof encodings[0].rtx).toBe('object');
expect(Object.keys(encodings[0].rtx)).toEqual(['ssrc']);
expect(typeof encodings[0].rtx.ssrc).toBe('number');
rtcp = videoConsumer.rtpParameters.rtcp;
expect(typeof rtcp).toBe('object');
expect(typeof rtcp?.cname).toBe('string');
expect(videoConsumer.paused).toBe(false);
expect(videoConsumer.appData).toEqual({});
}, 500);
test('transport.consume() batches consumers created in same macrotask into the same task', async () => {
const videoConsumerRemoteParameters1 = fakeParameters.generateConsumerRemoteParameters({
codecMimeType: 'video/VP8',
});
const videoConsumerRemoteParameters2 = fakeParameters.generateConsumerRemoteParameters({
codecMimeType: 'video/VP8',
});
// @ts-expect-error --- On purpose.
const pushSpy = jest.spyOn(ctx.connectedRecvTransport._awaitQueue, 'push');
const waitForConsumer = (id) => {
return new Promise(resolve => {
ctx.connectedRecvTransport.observer.on('newconsumer', consumer => {
if (consumer.id === id) {
resolve();
}
});
});
};
const allConsumersCreated = Promise.all([
waitForConsumer(videoConsumerRemoteParameters1.id),
waitForConsumer(videoConsumerRemoteParameters2.id),
]);
await Promise.all([
ctx.connectedRecvTransport.consume({
id: videoConsumerRemoteParameters1.id,
producerId: videoConsumerRemoteParameters1.producerId,
kind: videoConsumerRemoteParameters1.kind,
rtpParameters: videoConsumerRemoteParameters1.rtpParameters,
}),
ctx.connectedRecvTransport.consume({
id: videoConsumerRemoteParameters2.id,
producerId: videoConsumerRemoteParameters2.producerId,
kind: videoConsumerRemoteParameters2.kind,
rtpParameters: videoConsumerRemoteParameters2.rtpParameters,
}),
]);
await allConsumersCreated;
expect(pushSpy).toHaveBeenCalledTimes(1);
}, 500);
test('transport.consume() without remote Consumer parameters rejects with TypeError', async () => {
// @ts-expect-error --- On purpose.
await expect(ctx.recvTransport.consume({})).rejects.toThrow(TypeError);
}, 500);
test('transport.consume() with missing remote Consumer parameters rejects with TypeError', async () => {
// @ts-expect-error --- On purpose.
await expect(ctx.recvTransport.consume({ id: '1234' })).rejects.toThrow(TypeError);
await expect(
// @ts-expect-error --- On purpose.
ctx.recvTransport.consume({ id: '1234', producerId: '4444' })).rejects.toThrow(TypeError);
await expect(ctx.recvTransport.consume(
// @ts-expect-error --- On purpose.
{
id: '1234',
producerId: '4444',
kind: 'audio',
})).rejects.toThrow(TypeError);
await expect(ctx.recvTransport.consume(
// @ts-expect-error --- On purpose.
{
id: '1234',
producerId: '4444',
kind: 'audio',
})).rejects.toThrow(TypeError);
}, 500);
test('transport.consume() in a sending Transport rejects with UnsupportedError', async () => {
const { id, producerId, kind, rtpParameters } = fakeParameters.generateConsumerRemoteParameters({
codecMimeType: 'audio/opus',
});
await expect(ctx.sendTransport.consume({
id,
producerId,
kind,
rtpParameters,
})).rejects.toThrow(errors_1.UnsupportedError);
}, 500);
test('transport.consume() with unsupported rtpParameters rejects with UnsupportedError', async () => {
const { id, producerId, kind, rtpParameters } = fakeParameters.generateConsumerRemoteParameters({
codecMimeType: 'audio/ISAC',
});
await expect(ctx.sendTransport.consume({
id,
producerId,
kind,
rtpParameters,
})).rejects.toThrow(errors_1.UnsupportedError);
}, 500);
test('transport.consume() with a non object appData rejects with TypeError', async () => {
const consumerRemoteParameters = fakeParameters.generateConsumerRemoteParameters({
codecMimeType: 'audio/opus',
});
await expect(
// @ts-expect-error --- On purpose.
ctx.recvTransport.consume({ consumerRemoteParameters, appData: true })).rejects.toThrow(TypeError);
}, 500);
test('transport.produceData() succeeds', async () => {
let produceDataEventNumTimesCalled = 0;
ctx.connectedSendTransport.prependListener('producedata', () => {
produceDataEventNumTimesCalled++;
});
const dataProducer = await ctx.connectedSendTransport.produceData({
ordered: false,
maxPacketLifeTime: 5555,
label: 'FOO',
protocol: 'BAR',
appData: { foo: 'FOO' },
});
expect(produceDataEventNumTimesCalled).toBe(1);
expect(typeof dataProducer).toBe('object');
expect(typeof dataProducer.id).toBe('string');
expect(dataProducer.closed).toBe(false);
expect(typeof dataProducer.sctpStreamParameters).toBe('object');
expect(typeof dataProducer.sctpStreamParameters.streamId).toBe('number');
expect(dataProducer.sctpStreamParameters.ordered).toBe(false);
expect(dataProducer.sctpStreamParameters.maxPacketLifeTime).toBe(5555);
expect(dataProducer.sctpStreamParameters.maxRetransmits).toBe(undefined);
expect(dataProducer.label).toBe('FOO');
expect(dataProducer.protocol).toBe('BAR');
}, 500);
test('transport.produceData() in a receiving Transport rejects with UnsupportedError', async () => {
await expect(ctx.recvTransport.produceData({})).rejects.toThrow(errors_1.UnsupportedError);
}, 500);
test('transport.produceData() with a non object appData rejects with TypeError', async () => {
await expect(
// @ts-expect-error --- On purpose.
ctx.sendTransport.produceData({ appData: true })).rejects.toThrow(TypeError);
}, 500);
test('transport.consumeData() succeeds', async () => {
const dataConsumerRemoteParameters = fakeParameters.generateDataConsumerRemoteParameters();
const dataConsumer = await ctx.connectedRecvTransport.consumeData({
id: dataConsumerRemoteParameters.id,
dataProducerId: dataConsumerRemoteParameters.dataProducerId,
sctpStreamParameters: dataConsumerRemoteParameters.sctpStreamParameters,
label: 'FOO',
protocol: 'BAR',
appData: { bar: 'BAR' },
});
expect(typeof dataConsumer).toBe('object');
expect(dataConsumer.id).toBe(dataConsumerRemoteParameters.id);
expect(dataConsumer.dataProducerId).toBe(dataConsumerRemoteParameters.dataProducerId);
expect(dataConsumer.closed).toBe(false);
expect(typeof dataConsumer.sctpStreamParameters).toBe('object');
expect(typeof dataConsumer.sctpStreamParameters.streamId).toBe('number');
expect(dataConsumer.label).toBe('FOO');
expect(dataConsumer.protocol).toBe('BAR');
}, 500);
test('transport.consumeData() without remote DataConsumer parameters rejects with TypeError', async () => {
// @ts-expect-error --- On purpose.
await expect(ctx.recvTransport.consumeData({})).rejects.toThrow(TypeError);
}, 500);
test('transport.consumeData() with missing remote DataConsumer parameters rejects with TypeError', async () => {
// @ts-expect-error --- On purpose.
await expect(ctx.recvTransport.consumeData({ id: '1234' })).rejects.toThrow(TypeError);
await expect(
// @ts-expect-error --- On purpose.
ctx.recvTransport.consumeData({ id: '1234', dataProducerId: '4444' })).rejects.toThrow(TypeError);
}, 500);
test('transport.consumeData() in a sending Transport rejects with UnsupportedError', async () => {
const { id, dataProducerId, sctpStreamParameters } = fakeParameters.generateDataConsumerRemoteParameters();
await expect(ctx.sendTransport.consumeData({
id,
dataProducerId,
sctpStreamParameters,
})).rejects.toThrow(errors_1.UnsupportedError);
}, 500);
test('transport.consumeData() with a non object appData rejects with TypeError', async () => {
const dataConsumerRemoteParameters = fakeParameters.generateDataConsumerRemoteParameters();
await expect(ctx.recvTransport.consumeData({
dataConsumerRemoteParameters,
// @ts-expect-error --- On purpose.
appData: true,
})).rejects.toThrow(TypeError);
}, 500);
test('transport.getStats() succeeds', async () => {
const stats = await ctx.sendTransport.getStats();
expect(typeof stats).toBe('object');
}, 500);
test('transport.restartIce() succeeds', async () => {
await expect(ctx.sendTransport.restartIce({
iceParameters: {
usernameFragment: 'foo',
password: 'xxx',
},
})).resolves.toBe(undefined);
}, 500);
test('transport.restartIce() without remote iceParameters rejects with TypeError', async () => {
// @ts-expect-error --- On purpose.
await expect(ctx.sendTransport.restartIce({})).rejects.toThrow(TypeError);
}, 500);
test('transport.updateIceServers() succeeds', async () => {
await expect(ctx.sendTransport.updateIceServers({ iceServers: [] })).resolves.toBe(undefined);
}, 500);
test('transport.updateIceServers() without iceServers rejects with TypeError', async () => {
await expect(ctx.sendTransport.updateIceServers({})).rejects.toThrow(TypeError);
}, 500);
test('ICE gathering state change fires "icegatheringstatechange" in live Transport', () => {
// NOTE: These tests are a bit flaky and we should isolate them. FakeHandler
// emits '@connectionstatechange' with value 'connecting' as soon as its
// private setupTransport() method is called (which has happens many times in
// tests above already). So here we have to reset it manually to test things.
// @ts-expect-error --- On purpose.
ctx.sendTransport.handler.setIceGatheringState('new');
// @ts-expect-error --- On purpose.
ctx.sendTransport.handler.setConnectionState('new');
let iceGatheringStateChangeEventNumTimesCalled = 0;
let connectionStateChangeEventNumTimesCalled = 0;
ctx.sendTransport.on('icegatheringstatechange', iceGatheringState => {
iceGatheringStateChangeEventNumTimesCalled++;
expect(iceGatheringState).toBe('complete');
expect(ctx.sendTransport.iceGatheringState).toBe('complete');
expect(ctx.sendTransport.connectionState).toBe('new');
});
// eslint-disable-next-line @typescript-eslint/no-unused-vars
ctx.sendTransport.on('connectionstatechange', connectionState => {
connectionStateChangeEventNumTimesCalled++;
});
// @ts-expect-error --- On purpose.
ctx.sendTransport.handler.setIceGatheringState('complete');
expect(iceGatheringStateChangeEventNumTimesCalled).toBe(1);
expect(connectionStateChangeEventNumTimesCalled).toBe(0);
expect(ctx.sendTransport.iceGatheringState).toBe('complete');
expect(ctx.sendTransport.connectionState).toBe('new');
});
test('connection state change fires "connectionstatechange" in live Transport', () => {
let connectionStateChangeEventNumTimesCalled = 0;
ctx.sendTransport.on('connectionstatechange', connectionState => {
connectionStateChangeEventNumTimesCalled++;
expect(connectionState).toBe('completed');
});
// @ts-expect-error --- On purpose.
ctx.sendTransport.handler.setConnectionState('completed');
expect(connectionStateChangeEventNumTimesCalled).toBe(1);
expect(ctx.sendTransport.connectionState).toBe('completed');
});
test('producer.pause() succeeds', () => {
ctx.videoProducer.pause();
expect(ctx.videoProducer.paused).toBe(true);
expect(ctx.videoProducer.track?.enabled).toBe(false);
});
test('producer.resume() succeeds', () => {
ctx.videoProducer.resume();
expect(ctx.videoProducer.paused).toBe(false);
expect(ctx.videoProducer.track?.enabled).toBe(true);
});
test('producer.replaceTrack() with a new track succeeds', async () => {
// Have the audio Producer paused.
ctx.audioProducer.pause();
const audioProducerPreviousTrack = ctx.audioProducer.track;
const newAudioTrack = new fake_mediastreamtrack_1.FakeMediaStreamTrack({ kind: 'audio' });
await expect(ctx.audioProducer.replaceTrack({ track: newAudioTrack })).resolves.toBe(undefined);
// Previous track must be 'live' due to stopTracks: false.
expect(audioProducerPreviousTrack?.readyState).toBe('live');
expect(ctx.audioProducer.track?.readyState).toBe('live');
expect(ctx.audioProducer.track).not.toBe(audioProducerPreviousTrack);
expect(ctx.audioProducer.track).toBe(newAudioTrack);
// Producer was already paused.
expect(ctx.audioProducer.paused).toBe(true);
// Reset the audio paused state.
ctx.audioProducer.resume();
const videoProducerPreviousTrack = ctx.videoProducer.track;
const newVideoTrack = new fake_mediastreamtrack_1.FakeMediaStreamTrack({ kind: 'video' });
await expect(ctx.videoProducer.replaceTrack({ track: newVideoTrack })).resolves.toBe(undefined);
// Previous track must be 'ended' due to stopTracks: true.
expect(videoProducerPreviousTrack?.readyState).toBe('ended');
expect(ctx.videoProducer.track).not.toBe(videoProducerPreviousTrack);
expect(ctx.videoProducer.track).toBe(newVideoTrack);
expect(ctx.videoProducer.paused).toBe(false);
}, 500);
test('producer.replaceTrack() with null succeeds', async () => {
// Have the audio Producer paused.
ctx.audioProducer.pause();
const audioProducerPreviousTrack = ctx.audioProducer.track;
await expect(ctx.audioProducer.replaceTrack({ track: null })).resolves.toBe(undefined);
// Previous track must be 'live' due to stopTracks: false.
expect(audioProducerPreviousTrack?.readyState).toBe('live');
expect(ctx.audioProducer.track).toBeNull();
// Producer was already paused.
expect(ctx.audioProducer.paused).toBe(true);
// Reset the audio paused state.
ctx.audioProducer.resume();
expect(ctx.audioProducer.paused).toBe(false);
// Manually "mute" the original audio track.
audioProducerPreviousTrack.enabled = false;
// Set the original audio track back.
await expect(ctx.audioProducer.replaceTrack({ track: audioProducerPreviousTrack })).resolves.toBe(undefined);
// The given audio track was muted but the Producer was not, so the track
// must not be muted now.
expect(ctx.audioProducer.paused).toBe(false);
expect(audioProducerPreviousTrack?.enabled).toBe(true);
// Reset the audio paused state.
ctx.audioProducer.resume();
}, 500);
test('producer.replaceTrack() with an ended track rejects with InvalidStateError', async () => {
const track = new fake_mediastreamtrack_1.FakeMediaStreamTrack({ kind: 'audio' });
track.stop();
await expect(ctx.videoProducer.replaceTrack({ track })).rejects.toThrow(errors_1.InvalidStateError);
expect(track.readyState).toBe('ended');
expect(ctx.videoProducer.track?.readyState).toBe('live');
}, 500);
test('producer.replaceTrack() with the same track succeeds', async () => {
await expect(ctx.audioProducer.replaceTrack({ track: ctx.audioProducer.track })).resolves.toBe(undefined);
expect(ctx.audioProducer.track?.readyState).toBe('live');
}, 500);
test('producer.setMaxSpatialLayer() succeeds', async () => {
await expect(ctx.videoProducer.setMaxSpatialLayer(0)).resolves.toBe(undefined);
expect(ctx.videoProducer.maxSpatialLayer).toBe(0);
}, 500);
test('producer.setMaxSpatialLayer() in an audio Producer rejects with UnsupportedError', async () => {
await expect(ctx.audioProducer.setMaxSpatialLayer(1)).rejects.toThrow(errors_1.UnsupportedError);
expect(ctx.audioProducer.maxSpatialLayer).toBe(undefined);
}, 500);
test('producer.setMaxSpatialLayer() with invalid spatialLayer rejects with TypeError', async () => {
await expect(
// @ts-expect-error --- On purpose.
ctx.videoProducer.setMaxSpatialLayer('chicken')).rejects.toThrow(TypeError);
}, 500);
test('producer.setMaxSpatialLayer() without spatialLayer rejects with TypeError', async () => {
// @ts-expect-error --- On purpose.
await expect(ctx.videoProducer.setMaxSpatialLayer()).rejects.toThrow(TypeError);
}, 500);
test('producer.setRtpEncodingParameters() succeeds', async () => {
await expect(ctx.videoProducer.setRtpEncodingParameters({ scaleResolutionDownBy: 2 })).resolves.toBe(undefined);
}, 500);
test('producer.getStats() succeeds', async () => {
const stats = await ctx.videoProducer.getStats();
expect(typeof stats).toBe('object');
}, 500);
test('consumer.resume() succeeds', () => {
ctx.videoConsumer.resume();
expect(ctx.videoConsumer.paused).toBe(false);
});
test('consumer.pause() succeeds', () => {
ctx.videoConsumer.pause();
expect(ctx.videoConsumer.paused).toBe(true);
});
test('consumer.getStats() succeeds', async () => {
const stats = await ctx.videoConsumer.getStats();
expect(typeof stats).toBe('object');
}, 500);
test('producer.close() succeed', () => {
ctx.audioProducer.close();
expect(ctx.audioProducer.closed).toBe(true);
// Track will be still 'live' due to stopTracks: false.
expect(ctx.audioProducer.track?.readyState).toBe('live');
});
test('producer.replaceTrack() rejects with InvalidStateError if closed', async () => {
const audioTrack = new fake_mediastreamtrack_1.FakeMediaStreamTrack({ kind: 'audio' });
ctx.audioProducer.close();
await expect(ctx.audioProducer.replaceTrack({ track: audioTrack })).rejects.toThrow(errors_1.InvalidStateError);
expect(audioTrack.readyState).toBe('live');
}, 500);
test('producer.getStats() rejects with InvalidStateError if closed', async () => {
ctx.audioProducer.close();
await expect(ctx.audioProducer.getStats()).rejects.toThrow(errors_1.InvalidStateError);
}, 500);
test('consumer.close() succeed', () => {
ctx.audioConsumer.close();
expect(ctx.audioConsumer.closed).toBe(true);
expect(ctx.audioConsumer.track.readyState).toBe('ended');
});
test('consumer.getStats() rejects with InvalidStateError if closed', async () => {
ctx.audioConsumer.close();
await expect(ctx.audioConsumer.getStats()).rejects.toThrow(errors_1.InvalidStateError);
}, 500);
test('dataProducer.close() succeed', () => {
ctx.dataProducer.close();
expect(ctx.dataProducer.closed).toBe(true);
});
test('dataConsumer.close() succeed', () => {
ctx.dataConsumer.close();
expect(ctx.dataConsumer.closed).toBe(true);
});
test('remotetely stopped track fires "trackended" in live Producers/Consumers', () => {
let audioProducerTrackendedEventCalled = false;
let videoProducerTrackendedEventCalled = false;
let audiosConsumerTrackendedEventCalled = false;
let videoConsumerTrackendedEventCalled = false;
ctx.audioProducer.on('trackended', () => {
audioProducerTrackendedEventCalled = true;
});
ctx.videoProducer.on('trackended', () => {
videoProducerTrackendedEventCalled = true;
});
ctx.audioConsumer.on('trackended', () => {
audiosConsumerTrackendedEventCalled = true;
});
ctx.videoConsumer.on('trackended', () => {
videoConsumerTrackendedEventCalled = true;
});
// @ts-expect-error --- On purpose.
ctx.audioProducer.track.remoteStop();
expect(audioProducerTrackendedEventCalled).toBe(true);
// Let's close the video producer.
ctx.videoProducer.close();
// @ts-expect-error --- On purpose.
ctx.videoProducer.track.remoteStop();
expect(videoProducerTrackendedEventCalled).toBe(false);
// @ts-expect-error --- On purpose.
ctx.audioConsumer.track.remoteStop();
expect(audiosConsumerTrackendedEventCalled).toBe(true);
// @ts-expect-error --- On purpose.
ctx.videoConsumer.track.remoteStop();
expect(videoConsumerTrackendedEventCalled).toBe(true);
});
test('transport.close() fires "transportclose" in live Producers/Consumers', () => {
let audioProducerTransportcloseEventCalled = false;
let videoProducerTransportcloseEventCalled = false;
let audioConsumerTransportcloseEventCalled = false;
let videoConsumerTransportcloseEventCalled = false;
ctx.audioProducer.on('transportclose', () => {
audioProducerTransportcloseEventCalled = true;
});
ctx.videoProducer.on('transportclose', () => {
videoProducerTransportcloseEventCalled = true;
});
ctx.audioConsumer.on('transportclose', () => {
audioConsumerTransportcloseEventCalled = true;
});
ctx.videoConsumer.on('transportclose', () => {
videoConsumerTransportcloseEventCalled = true;
});
ctx.connectedSendTransport.close();
expect(ctx.connectedSendTransport.closed).toBe(true);
expect(ctx.videoProducer.closed).toBe(true);
expect(audioProducerTransportcloseEventCalled).toBe(true);
expect(videoProducerTransportcloseEventCalled).toBe(true);
// Let's close the video consumer.
ctx.videoConsumer.close();
ctx.connectedRecvTransport.close();
expect(ctx.connectedRecvTransport.closed).toBe(true);
expect(ctx.videoConsumer.closed).toBe(true);
expect(audioConsumerTransportcloseEventCalled).toBe(true);
expect(videoConsumerTransportcloseEventCalled).toBe(false);
});
test('transport.produce() rejects with InvalidStateError if closed', async () => {
const track = new fake_mediastreamtrack_1.FakeMediaStreamTrack({ kind: 'audio' });
ctx.connectedSendTransport.close();
await expect(ctx.connectedSendTransport.produce({ track, stopTracks: false })).rejects.toThrow(errors_1.InvalidStateError);
// The track must be 'live' due to stopTracks: false.
expect(track.readyState).toBe('live');
}, 500);
test('transport.consume() rejects with InvalidStateError if closed', async () => {
ctx.connectedRecvTransport.close();
// @ts-expect-error --- On purpose.
await expect(ctx.connectedRecvTransport.consume({})).rejects.toThrow(errors_1.InvalidStateError);
}, 500);
test('transport.produceData() rejects with InvalidStateError if closed', async () => {
ctx.connectedSendTransport.close();
await expect(ctx.connectedSendTransport.produceData({})).rejects.toThrow(errors_1.InvalidStateError);
}, 500);
test('transport.consumeData() rejects with InvalidStateError if closed', async () => {
ctx.connectedRecvTransport.close();
// @ts-expect-error --- On purpose.
await expect(ctx.connectedRecvTransport.consumeData({})).rejects.toThrow(errors_1.InvalidStateError);
}, 500);
test('transport.getStats() rejects with InvalidStateError if closed', async () => {
ctx.connectedSendTransport.close();
await expect(ctx.connectedSendTransport.getStats()).rejects.toThrow(errors_1.InvalidStateError);
}, 500);
test('transport.restartIce() rejects with InvalidStateError if closed', async () => {
ctx.connectedSendTransport.close();
await expect(
// @ts-expect-error --- On purpose.
ctx.connectedSendTransport.restartIce({ ieParameters: {} })).rejects.toThrow(errors_1.InvalidStateError);
}, 500);
test('transport.updateIceServers() rejects with InvalidStateError if closed', async () => {
ctx.connectedSendTransport.close();
await expect(ctx.connectedSendTransport.updateIceServers({ iceServers: [] })).rejects.toThrow(errors_1.InvalidStateError);
}, 500);
test('connection state change does not fire "connectionstatechange" in closed Transport', () => {
let connectionStateChangeEventNumTimesCalled = 0;
ctx.connectedSendTransport.on('connectionstatechange', ( /* connectionState */) => {
connectionStateChangeEventNumTimesCalled++;
});
// @ts-expect-error --- On purpose.
ctx.connectedSendTransport.handler.setConnectionState('disconnected');
expect(con