Unit tests for library (#156)

* add unit test coverage reports to gitignore

* add jest dependencies

* add jest config

* add Config unit tests

* add AFKController unit tests

* mock video codecs list

* test for PixelStreaming SettingsChangedEvent

* mock/unmock functions for RTCRtpReceiver

* mock WebSocket

* test for PixelStreaming.connect()

* unit test for disconnect

* unit tests for reconnect

* added eventemitter events in tests

* test that listStreamers is sent on WS connect

* mock RTCPeerConnection and RTCIceCandidate

* test webRtcConnected event

* test RTCPeerConnect close

* fixed variable name for consistency

* check for navigator.getGamepads before calling it

* mock addTransceiver, createAnswer, getTransceivers, setRemoteDescription

* test for receiving a connection offer

* add null checks for peerConnection since it might be null if connectiong closed

* mock addIceCandidate

* test for receiving ICE candidate message

* mock setLocalDescription, getStats

* test for statistics

* test webRtcDisconnected event

* mock RTCDataChannel and RTCDataChannelEvent

* test for dataChannelOpen event

* mock RTCTrackEvent

* mock MediaStream and MediaStreamTrack

* mock video element play()

* test playStream and playStreamRejected events

* mock video readyState

* mock WebRTC data channel send()

* test emitCommand

* test emitUIInteraction

* test emitConsoleCommand

* Jest mock for HTMLMediaElement.play()

* check boolean return value of all emit* commands

* test dataChannelClose event

* added TextEncoder and TextDecoder to Jest globals

* mock datachannel onmessage

* test UE -> browser data channel message

* clarified in test description that we are using a Response message

* extracted commonly used setup steps as util functions

* triggerSdpOffer -> triggerSdpOfferMessage for consistency

* run unit tests as a Github action

* Revert "break one of the tests to test the GH action"

This reverts commit 05f5742cf0.

* run tests only if files changed under Frontend/library

* added unit test run instructions to README.md
This commit is contained in:
hmuurine 2023-03-17 04:41:05 +02:00 committed by GitHub
parent dbc8dd1600
commit f58b32cae6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 7549 additions and 21 deletions

View File

@ -0,0 +1,36 @@
name: Run library unit tests
on:
push:
branches: ['master']
paths: ['Frontend/library/**']
pull_request:
branches: ['master']
paths: ['Frontend/library/**']
jobs:
build:
runs-on: ubuntu-latest
defaults:
run:
working-directory: ./
permissions:
contents: write
steps:
- name: "Checkout source code"
uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: '16.x'
registry-url: 'https://registry.npmjs.org'
- name: Install library deps
working-directory: ./Frontend/library
run: npm ci
- name: Run frontend lib tests
working-directory: ./Frontend/library
run: npm run test

3
.gitignore vendored
View File

@ -6,4 +6,5 @@ node_modules/
node.zip
SignallingWebServer/Public/
SignallingWebServer/certificates
.vscode
.vscode
coverage/

View File

@ -57,6 +57,12 @@ We recommend studying [/ui-library](/Frontend/ui-library) and [player.ts](/Front
- `cd implementation/your_implementation`
- `npm build-all`
## Unit tests
The [/library](/Frontend/library) project has unit tests that test the Pixel Streaming functionality against a mocked connection. To run the tests manually, run:
- `cd library`
- `npm install`
- `npm run test`
## Legal

View File

@ -0,0 +1,18 @@
module.exports = {
preset: "ts-jest/presets/js-with-ts",
testEnvironment: "jsdom",
transform: {
"^.+\\.tsx?$": [
"ts-jest",
{
tsconfig: "tsconfig.jest.json",
},
],
},
modulePathIgnorePatterns: ["<rootDir>/build/"],
testPathIgnorePatterns: ["<rootDir>/build/", "/node_modules/"],
globals: {
TextDecoder: TextDecoder,
TextEncoder: TextEncoder,
}
};

File diff suppressed because it is too large Load Diff

View File

@ -11,15 +11,20 @@
"build": "npx webpack --config webpack.prod.js",
"build-dev": "npx webpack --config webpack.dev.js",
"lint": "eslint . --ext .js,.jsx,.ts,.tsx",
"test": "jest --detectOpenHandles --coverage=true",
"spellcheck": "cspell \"{README.md,.github/*.md,src/**/*.ts}\""
},
"devDependencies": {
"@types/jest": "29.4.0",
"@types/webxr": "^0.5.1",
"@typescript-eslint/eslint-plugin": "^5.16.0",
"@typescript-eslint/parser": "^5.16.0",
"@types/webxr": "^0.5.1",
"cspell": "^4.1.0",
"eslint": "^8.11.0",
"jest": "^29.4.0",
"jest-environment-jsdom": "29.4.0",
"prettier": "2.8.3",
"ts-jest": "29.0.5",
"ts-loader": "^9.4.2",
"typedoc": "^0.23.24",
"typescript": "^4.9.4",

View File

@ -0,0 +1,162 @@
import { Config, Flags, NumericParameters } from '../Config/Config';
import { PixelStreaming } from '../PixelStreaming/PixelStreaming';
import { AfkTimedOutEvent, AfkWarningActivateEvent, AfkWarningUpdateEvent, AfkWarningDeactivateEvent } from '../Util/EventEmitter';
import { mockRTCRtpReceiver, unmockRTCRtpReceiver } from '../__test__/mockRTCRtpReceiver';
import {
AFKController
} from './AFKController';
describe('AFKController', () => {
let mockPixelStreaming: PixelStreaming;
beforeEach(() => {
mockRTCRtpReceiver();
jest.useFakeTimers();
mockPixelStreaming = {
dispatchEvent: jest.fn()
} as any as PixelStreaming;
});
afterEach(() => {
unmockRTCRtpReceiver();
jest.resetAllMocks();
});
it('should not activate AFK timer if it has been disabled from settings', () => {
const config = new Config({ initialSettings: { [Flags.AFKDetection]: false } });
const onDismissAfk = jest.fn();
const afkController = new AFKController(config, mockPixelStreaming, onDismissAfk);
afkController.startAfkWarningTimer();
expect(afkController.active).toBe(false);
jest.advanceTimersByTime(1000000 * 1000);
expect(mockPixelStreaming.dispatchEvent).not.toHaveBeenCalled();
});
it('should activate AFK timer and trigger it after specified delay if it has been enabled from settings', () => {
const timeoutSeconds = 100;
const config = new Config({ initialSettings: { [Flags.AFKDetection]: true, [NumericParameters.AFKTimeoutSecs]: timeoutSeconds} });
const onDismissAfk = jest.fn();
const afkController = new AFKController(config, mockPixelStreaming, onDismissAfk);
afkController.startAfkWarningTimer();
expect(afkController.active).toBe(true);
// Advance to 1 second before AFK event:
jest.advanceTimersByTime((timeoutSeconds - 1) * 1000);
expect(mockPixelStreaming.dispatchEvent).not.toHaveBeenCalled();
// advance 1 more second to trigger AFK warning
jest.advanceTimersByTime(1000);
expect(mockPixelStreaming.dispatchEvent).toHaveBeenCalledWith(new AfkWarningActivateEvent({
countDown: 0,
dismissAfk: expect.anything()
}));
expect(mockPixelStreaming.dispatchEvent).toHaveBeenCalledWith(new AfkWarningUpdateEvent({
countDown: 10,
}));
// advance 10 more seconds to trigger AFK countdown updates and eventually timeout
jest.advanceTimersByTime(10 * 1000);
expect(mockPixelStreaming.dispatchEvent).toHaveBeenCalledWith(new AfkWarningUpdateEvent({
countDown: 9,
}));
expect(mockPixelStreaming.dispatchEvent).toHaveBeenCalledWith(new AfkWarningUpdateEvent({
countDown: 8,
}));
expect(mockPixelStreaming.dispatchEvent).toHaveBeenCalledWith(new AfkWarningUpdateEvent({
countDown: 7,
}));
expect(mockPixelStreaming.dispatchEvent).toHaveBeenCalledWith(new AfkWarningUpdateEvent({
countDown: 6,
}));
expect(mockPixelStreaming.dispatchEvent).toHaveBeenCalledWith(new AfkWarningUpdateEvent({
countDown: 5,
}));
expect(mockPixelStreaming.dispatchEvent).toHaveBeenCalledWith(new AfkWarningUpdateEvent({
countDown: 4,
}));
expect(mockPixelStreaming.dispatchEvent).toHaveBeenCalledWith(new AfkWarningUpdateEvent({
countDown: 3,
}));
expect(mockPixelStreaming.dispatchEvent).toHaveBeenCalledWith(new AfkWarningUpdateEvent({
countDown: 2,
}));
expect(mockPixelStreaming.dispatchEvent).toHaveBeenCalledWith(new AfkWarningUpdateEvent({
countDown: 1,
}));
expect(mockPixelStreaming.dispatchEvent).toHaveBeenCalledWith(new AfkTimedOutEvent());
});
it('should postpone AFK activation each time resetAfkWarningTimer is called', () => {
const timeoutSeconds = 100;
const config = new Config({ initialSettings: { [Flags.AFKDetection]: true, [NumericParameters.AFKTimeoutSecs]: timeoutSeconds} });
const onDismissAfk = jest.fn();
const afkController = new AFKController(config, mockPixelStreaming, onDismissAfk);
afkController.startAfkWarningTimer();
// Advance to 1 second before AFK event:
jest.advanceTimersByTime((timeoutSeconds - 1) * 1000);
expect(mockPixelStreaming.dispatchEvent).not.toHaveBeenCalled();
afkController.resetAfkWarningTimer();
// advance 1 more second and ensure that AFK warning is not triggered since reset was called
jest.advanceTimersByTime(1000);
expect(mockPixelStreaming.dispatchEvent).not.toHaveBeenCalled();
// reset AFK timer once more and ensure it is triggered exactly after timeoutSeconds
afkController.resetAfkWarningTimer();
// Advance to 1 second before AFK event:
jest.advanceTimersByTime((timeoutSeconds - 1) * 1000);
expect(mockPixelStreaming.dispatchEvent).not.toHaveBeenCalled();
// advance 1 more second to trigger AFK warning
jest.advanceTimersByTime(1000);
expect(mockPixelStreaming.dispatchEvent).toHaveBeenCalledWith(new AfkWarningActivateEvent({
countDown: 0,
dismissAfk: expect.anything()
}));
});
it('should dismiss AFK warning countdown if onAfkClick is called', () => {
const timeoutSeconds = 100;
const config = new Config({ initialSettings: { [Flags.AFKDetection]: true, [NumericParameters.AFKTimeoutSecs]: timeoutSeconds} });
const onDismissAfk = jest.fn();
const afkController = new AFKController(config, mockPixelStreaming, onDismissAfk);
afkController.startAfkWarningTimer();
// Advance to AFK event:
jest.advanceTimersByTime(timeoutSeconds * 1000);
expect(mockPixelStreaming.dispatchEvent).toHaveBeenCalledWith(new AfkWarningActivateEvent({
countDown: 0,
dismissAfk: expect.anything()
}));
expect(mockPixelStreaming.dispatchEvent).toHaveBeenCalledWith(new AfkWarningUpdateEvent({
countDown: 10,
}));
// Advance one more second and call onAfkClick
jest.advanceTimersByTime(1000);
expect(mockPixelStreaming.dispatchEvent).toHaveBeenCalledWith(new AfkWarningUpdateEvent({
countDown: 9,
}));
afkController.onAfkClick();
expect(mockPixelStreaming.dispatchEvent).toHaveBeenCalledWith(new AfkWarningDeactivateEvent());
// advance 10 more seconds and ensure there are no more countdown/timeout events emitted
jest.advanceTimersByTime(10 * 1000);
expect(mockPixelStreaming.dispatchEvent).toHaveBeenCalledTimes(4);
});
});

View File

@ -0,0 +1,222 @@
import { mockRTCRtpReceiver, unmockRTCRtpReceiver } from '../__test__/mockRTCRtpReceiver';
import {
Config,
Flags,
FlagsKeys,
NumericParameters,
NumericParametersKeys,
OptionParameters,
OptionParametersKeys,
TextParameters,
TextParametersKeys
} from './Config';
const allFlags = Object.keys(Flags).map((key) => Flags[key as FlagsKeys]);
const allNumericParameters = Object.keys(NumericParameters).map(
(key) => NumericParameters[key as NumericParametersKeys]
);
const allTextParameters = Object.keys(TextParameters).map(
(key) => TextParameters[key as TextParametersKeys]
);
const allOptionParameters = Object.keys(OptionParameters).map(
(key) => OptionParameters[key as OptionParametersKeys]
);
const allParameters = [
...allFlags,
...allNumericParameters,
...allTextParameters,
...allOptionParameters
];
describe('Config', () => {
beforeEach(() => {
mockRTCRtpReceiver();
});
afterEach(() => {
unmockRTCRtpReceiver();
jest.resetAllMocks();
});
it('should populate initial values for all settings when initialized without parameters', () => {
const config = new Config();
const settings = config.getSettings();
expect(Object.keys(settings)).toEqual(
expect.arrayContaining(allParameters)
);
});
it('should populate given initial setting values', () => {
const initialSettings = {
[Flags.AutoPlayVideo]: false,
[NumericParameters.WebRTCMaxBitrate]: 12345,
[TextParameters.SignallingServerUrl]: 'url'
};
const config = new Config({ initialSettings });
expect(config.isFlagEnabled(Flags.AutoPlayVideo)).toEqual(
initialSettings[Flags.AutoPlayVideo]
);
expect(
config.getNumericSettingValue(NumericParameters.WebRTCMaxBitrate)
).toEqual(initialSettings[NumericParameters.WebRTCMaxBitrate]);
expect(
config.getTextSettingValue(TextParameters.SignallingServerUrl)
).toEqual(initialSettings[TextParameters.SignallingServerUrl]);
});
it('should replace setting values when new settings are set with setSettings', () => {
const config = new Config();
const preferredCodecs = ['c1', 'c2', 'c3'];
config.setOptionSettingOptions(
OptionParameters.PreferredCodec,
preferredCodecs
);
const changedSettings = {
[Flags.AutoPlayVideo]: false,
[NumericParameters.WebRTCMaxBitrate]: 54321,
[TextParameters.SignallingServerUrl]: 'signalling-url',
[OptionParameters.PreferredCodec]: 'c2'
};
config.setSettings(changedSettings);
expect(config.isFlagEnabled(Flags.AutoPlayVideo)).toEqual(
changedSettings[Flags.AutoPlayVideo]
);
expect(
config.getNumericSettingValue(NumericParameters.WebRTCMaxBitrate)
).toEqual(changedSettings[NumericParameters.WebRTCMaxBitrate]);
expect(
config.getTextSettingValue(TextParameters.SignallingServerUrl)
).toEqual(changedSettings[TextParameters.SignallingServerUrl]);
expect(
config.getSettingOption(OptionParameters.PreferredCodec).selected
).toEqual(changedSettings[OptionParameters.PreferredCodec]);
});
it('should replace setting values when new settings are set with set* setters', () => {
const config = new Config();
const preferredCodecs = ['c1', 'c2', 'c3'];
config.setOptionSettingOptions(
OptionParameters.PreferredCodec,
preferredCodecs
);
const changedSettings = {
[Flags.AutoPlayVideo]: false,
[NumericParameters.WebRTCMaxBitrate]: 54321,
[TextParameters.SignallingServerUrl]: 'signalling-url',
[OptionParameters.PreferredCodec]: 'c2'
};
config.setFlagEnabled(
Flags.AutoPlayVideo,
changedSettings[Flags.AutoPlayVideo]
);
config.setNumericSetting(
NumericParameters.WebRTCMaxBitrate,
changedSettings[NumericParameters.WebRTCMaxBitrate]
);
config.setTextSetting(
TextParameters.SignallingServerUrl,
changedSettings[TextParameters.SignallingServerUrl]
);
config.setOptionSettingValue(
OptionParameters.PreferredCodec,
changedSettings[OptionParameters.PreferredCodec]
);
expect(config.isFlagEnabled(Flags.AutoPlayVideo)).toEqual(
changedSettings[Flags.AutoPlayVideo]
);
expect(
config.getNumericSettingValue(NumericParameters.WebRTCMaxBitrate)
).toEqual(changedSettings[NumericParameters.WebRTCMaxBitrate]);
expect(
config.getTextSettingValue(TextParameters.SignallingServerUrl)
).toEqual(changedSettings[TextParameters.SignallingServerUrl]);
expect(
config.getSettingOption(OptionParameters.PreferredCodec).selected
).toEqual(changedSettings[OptionParameters.PreferredCodec]);
});
it('should persist config changes to window.location URL when updateURLParams() is called', () => {
const config = new Config({ useUrlParams: true });
const preferredCodecs = ['c1', 'c2', 'c3'];
config.setOptionSettingOptions(
OptionParameters.PreferredCodec,
preferredCodecs
);
const changedSettings = {
[Flags.AutoPlayVideo]: false,
[NumericParameters.WebRTCMaxBitrate]: 54321,
[TextParameters.SignallingServerUrl]: 'signalling-url',
[OptionParameters.PreferredCodec]: 'c2'
};
config.setSettings(changedSettings);
config
.getFlags()
.find((setting) => setting.id === Flags.AutoPlayVideo)
?.updateURLParams();
config
.getNumericSettings()
.find(
(setting) => setting.id === NumericParameters.WebRTCMaxBitrate
)
?.updateURLParams();
config
.getTextSettings()
.find(
(setting) => setting.id === TextParameters.SignallingServerUrl
)
?.updateURLParams();
config
.getOptionSettings()
.find((setting) => setting.id === OptionParameters.PreferredCodec)
?.updateURLParams();
const urlParams = new URLSearchParams(window.location.search);
expect(urlParams.get(Flags.AutoPlayVideo)).toEqual(
changedSettings[Flags.AutoPlayVideo].toString()
);
expect(urlParams.get(NumericParameters.WebRTCMaxBitrate)).toEqual(
changedSettings[NumericParameters.WebRTCMaxBitrate].toString()
);
expect(urlParams.get(TextParameters.SignallingServerUrl)).toEqual(
changedSettings[TextParameters.SignallingServerUrl].toString()
);
expect(urlParams.get(OptionParameters.PreferredCodec)).toEqual(
changedSettings[OptionParameters.PreferredCodec].toString()
);
});
it('should read initial config from window.location URL if initialized with useUrlParams: true', () => {
window.history.replaceState(
{},
'',
'http://localhost/?AutoPlayVideo=false&WebRTCMaxBitrate=43210&ss=signalling-url-from-url-param'
);
const config = new Config({ useUrlParams: true });
expect(config.isFlagEnabled(Flags.AutoPlayVideo)).toEqual(false);
expect(
config.getNumericSettingValue(NumericParameters.WebRTCMaxBitrate)
).toEqual(43210);
expect(
config.getTextSettingValue(TextParameters.SignallingServerUrl)
).toEqual('signalling-url-from-url-param');
});
});

View File

@ -54,9 +54,11 @@ export class GamePadController {
);
}
this.controllers = [];
for (const gamepad of navigator.getGamepads()) {
if (gamepad) {
this.gamePadConnectHandler(new GamepadEvent('gamepadconnected', { gamepad }));
if (navigator.getGamepads) {
for (const gamepad of navigator.getGamepads()) {
if (gamepad) {
this.gamePadConnectHandler(new GamepadEvent('gamepadconnected', { gamepad }));
}
}
}
}

View File

@ -84,11 +84,11 @@ export class PeerConnectionController {
this.setupTransceiversAsync(useMic).finally(() => {
this.peerConnection
.createOffer(offerOptions)
?.createOffer(offerOptions)
.then((offer: RTCSessionDescriptionInit) => {
this.showTextOverlayConnecting();
offer.sdp = this.mungeSDP(offer.sdp, useMic);
this.peerConnection.setLocalDescription(offer);
this.peerConnection?.setLocalDescription(offer);
this.onSendWebRTCOffer(offer);
})
.catch(() => {
@ -103,7 +103,7 @@ export class PeerConnectionController {
async receiveOffer(offer: RTCSessionDescriptionInit, config: Config) {
Logger.Log(Logger.GetStackTrace(), 'Receive Offer', 6);
this.peerConnection.setRemoteDescription(offer).then(() => {
this.peerConnection?.setRemoteDescription(offer).then(() => {
const isLocalhostConnection =
location.hostname === 'localhost' ||
location.hostname === '127.0.0.1';
@ -123,14 +123,14 @@ export class PeerConnectionController {
this.setupTransceiversAsync(useMic).finally(() => {
this.peerConnection
.createAnswer()
?.createAnswer()
.then((Answer: RTCSessionDescriptionInit) => {
Answer.sdp = this.mungeSDP(Answer.sdp, useMic);
return this.peerConnection.setLocalDescription(Answer);
return this.peerConnection?.setLocalDescription(Answer);
})
.then(() => {
this.onSendWebRTCAnswer(
this.peerConnection.currentLocalDescription
this.peerConnection?.currentLocalDescription
);
})
.catch(() => {
@ -158,7 +158,7 @@ export class PeerConnectionController {
* @param answer - RTC Session Descriptor from the Signaling Server
*/
receiveAnswer(answer: RTCSessionDescriptionInit) {
this.peerConnection.setRemoteDescription(answer);
this.peerConnection?.setRemoteDescription(answer);
// Ugly syntax, but this achieves the intersection of the browser supported list and the UE supported list
this.config.setOptionSettingOptions(
OptionParameters.PreferredCodec,
@ -174,7 +174,7 @@ export class PeerConnectionController {
* Generate Aggregated Stats and then fire a onVideo Stats event
*/
generateStats() {
this.peerConnection.getStats(null).then((StatsData: RTCStatsReport) => {
this.peerConnection?.getStats(null).then((StatsData: RTCStatsReport) => {
this.aggregatedStats.processStats(StatsData);
this.onVideoStats(this.aggregatedStats);
@ -257,7 +257,7 @@ export class PeerConnectionController {
}
}
this.peerConnection.addIceCandidate(iceCandidate);
this.peerConnection?.addIceCandidate(iceCandidate);
}
/**
@ -363,14 +363,14 @@ export class PeerConnectionController {
*/
async setupTransceiversAsync(useMic: boolean) {
const hasTransceivers =
this.peerConnection.getTransceivers().length > 0;
this.peerConnection?.getTransceivers().length > 0;
// Setup a transceiver for getting UE video
this.peerConnection.addTransceiver('video', { direction: 'recvonly' });
this.peerConnection?.addTransceiver('video', { direction: 'recvonly' });
// We can only set preferrec codec on Chrome
if (RTCRtpReceiver.getCapabilities && this.preferredCodec != '') {
for (const transceiver of this.peerConnection.getTransceivers()) {
for (const transceiver of this.peerConnection?.getTransceivers() ?? []) {
if (
transceiver &&
transceiver.receiver &&
@ -421,7 +421,7 @@ export class PeerConnectionController {
// Setup a transceiver for sending mic audio to UE and receiving audio from UE
if (!useMic) {
this.peerConnection.addTransceiver('audio', {
this.peerConnection?.addTransceiver('audio', {
direction: 'recvonly'
});
} else {
@ -451,7 +451,7 @@ export class PeerConnectionController {
);
if (stream) {
if (hasTransceivers) {
for (const transceiver of this.peerConnection.getTransceivers()) {
for (const transceiver of this.peerConnection?.getTransceivers() ?? []) {
if (
transceiver &&
transceiver.receiver &&
@ -469,14 +469,14 @@ export class PeerConnectionController {
} else {
for (const track of stream.getTracks()) {
if (track.kind && track.kind == 'audio') {
this.peerConnection.addTransceiver(track, {
this.peerConnection?.addTransceiver(track, {
direction: 'sendrecv'
});
}
}
}
} else {
this.peerConnection.addTransceiver('audio', {
this.peerConnection?.addTransceiver('audio', {
direction: 'recvonly'
});
}

View File

@ -0,0 +1,524 @@
import { mockRTCRtpReceiver, unmockRTCRtpReceiver } from '../__test__/mockRTCRtpReceiver';
import {
Config,
NumericParameters,
} from '../Config/Config';
import { PixelStreaming } from './PixelStreaming';
import { SettingsChangedEvent, StreamerListMessageEvent, WebRtcConnectedEvent, WebRtcSdpEvent } from '../Util/EventEmitter';
import { mockWebSocket, MockWebSocketSpyFunctions, MockWebSocketTriggerFunctions, unmockWebSocket } from '../__test__/mockWebSocket';
import { MessageRecvTypes } from '../WebSockets/MessageReceive';
import { mockRTCPeerConnection, MockRTCPeerConnectionSpyFunctions, MockRTCPeerConnectionTriggerFunctions, unmockRTCPeerConnection } from '../__test__/mockRTCPeerConnection';
import { mockHTMLMediaElement, mockMediaStream, unmockMediaStream } from '../__test__/mockMediaStream';
import { InitialSettings } from '../DataChannel/InitialSettings';
const flushPromises = () => new Promise(jest.requireActual("timers").setImmediate);
describe('PixelStreaming', () => {
let webSocketSpyFunctions: MockWebSocketSpyFunctions;
let webSocketTriggerFunctions: MockWebSocketTriggerFunctions;
let rtcPeerConnectionSpyFunctions: MockRTCPeerConnectionSpyFunctions;
let rtcPeerConnectionTriggerFunctions: MockRTCPeerConnectionTriggerFunctions;
const mockSignallingUrl = 'ws://localhost:24680/';
const streamerId = "MOCK_PIXEL_STREAMING";
const streamerIdList = [streamerId];
const sdp = "v=0\r\no=- 974006863270230083 2 IN IP4 127.0.0.1\r\ns=-\r\nt=0 0\r\na=group:BUNDLE 0 1 2\r\na=extmap-allow-mixed\r\na=msid-semantic: WMS pixelstreaming_audio_stream_id pixelstreaming_video_stream_id\r\nm=video 9 UDP/TLS/RTP/SAVPF 96 97 98 99 100 101 102\r\nc=IN IP4 0.0.0.0\r\na=rtcp:9 IN IP4 0.0.0.0\r\na=ice-ufrag:+JE1\r\na=ice-pwd:R2dKmHqM47E++7TRKKkHMyHj\r\na=ice-options:trickle\r\na=fingerprint:sha-256 20:EE:85:F0:DA:F4:90:F3:0D:13:2E:A9:1E:36:8C:81:E1:BD:38:78:20:AA:38:F3:FC:65:3F:8E:06:1D:A7:53\r\na=setup:actpass\r\na=mid:0\r\na=extmap:1 urn:ietf:params:rtp-hdrext:toffset\r\na=extmap:2 http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time\r\na=extmap:3 urn:3gpp:video-orientation\r\na=extmap:4 http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01\r\na=extmap:5 http://www.webrtc.org/experiments/rtp-hdrext/playout-delay\r\na=extmap:6 http://www.webrtc.org/experiments/rtp-hdrext/video-content-type\r\na=extmap:7 http://www.webrtc.org/experiments/rtp-hdrext/video-timing\r\na=extmap:8 http://www.webrtc.org/experiments/rtp-hdrext/color-space\r\na=extmap:9 urn:ietf:params:rtp-hdrext:sdes:mid\r\na=extmap:10 urn:ietf:params:rtp-hdrext:sdes:rtp-stream-id\r\na=extmap:11 urn:ietf:params:rtp-hdrext:sdes:repaired-rtp-stream-id\r\na=sendonly\r\na=msid:pixelstreaming_video_stream_id pixelstreaming_video_track_label\r\na=rtcp-mux\r\na=rtcp-rsize\r\na=rtpmap:96 H264/90000\r\na=rtcp-fb:96 goog-remb\r\na=rtcp-fb:96 transport-cc\r\na=rtcp-fb:96 ccm fir\r\na=rtcp-fb:96 nack\r\na=rtcp-fb:96 nack pli\r\na=fmtp:96 level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f\r\na=rtpmap:97 rtx/90000\r\na=fmtp:97 apt=96\r\na=rtpmap:98 H264/90000\r\na=rtcp-fb:98 goog-remb\r\na=rtcp-fb:98 transport-cc\r\na=rtcp-fb:98 ccm fir\r\na=rtcp-fb:98 nack\r\na=rtcp-fb:98 nack pli\r\na=fmtp:98 level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42001f\r\na=rtpmap:99 rtx/90000\r\na=fmtp:99 apt=98\r\na=rtpmap:100 red/90000\r\na=rtpmap:101 rtx/90000\r\na=fmtp:101 apt=100\r\na=rtpmap:102 ulpfec/90000\r\na=ssrc-group:FID 3702690738 1574960745\r\na=ssrc:3702690738 cname:I/iLZxsY4mZ0aoNG\r\na=ssrc:3702690738 msid:pixelstreaming_video_stream_id pixelstreaming_video_track_label\r\na=ssrc:3702690738 mslabel:pixelstreaming_video_stream_id\r\na=ssrc:3702690738 label:pixelstreaming_video_track_label\r\na=ssrc:1574960745 cname:I/iLZxsY4mZ0aoNG\r\na=ssrc:1574960745 msid:pixelstreaming_video_stream_id pixelstreaming_video_track_label\r\na=ssrc:1574960745 mslabel:pixelstreaming_video_stream_id\r\na=ssrc:1574960745 label:pixelstreaming_video_track_label\r\nm=audio 9 UDP/TLS/RTP/SAVPF 111 63 110\r\nc=IN IP4 0.0.0.0\r\na=rtcp:9 IN IP4 0.0.0.0\r\na=ice-ufrag:+JE1\r\na=ice-pwd:R2dKmHqM47E++7TRKKkHMyHj\r\na=ice-options:trickle\r\na=fingerprint:sha-256 20:EE:85:F0:DA:F4:90:F3:0D:13:2E:A9:1E:36:8C:81:E1:BD:38:78:20:AA:38:F3:FC:65:3F:8E:06:1D:A7:53\r\na=setup:actpass\r\na=mid:1\r\na=extmap:14 urn:ietf:params:rtp-hdrext:ssrc-audio-level\r\na=extmap:2 http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time\r\na=extmap:4 http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01\r\na=extmap:9 urn:ietf:params:rtp-hdrext:sdes:mid\r\na=extmap:10 urn:ietf:params:rtp-hdrext:sdes:rtp-stream-id\r\na=extmap:11 urn:ietf:params:rtp-hdrext:sdes:repaired-rtp-stream-id\r\na=sendrecv\r\na=msid:pixelstreaming_audio_stream_id pixelstreaming_audio_track_label\r\na=rtcp-mux\r\na=rtpmap:111 opus/48000/2\r\na=rtcp-fb:111 transport-cc\r\na=fmtp:111 maxaveragebitrate=510000;maxplaybackrate=48000;minptime=3;sprop-stereo=1;stereo=1;usedtx=0;useinbandfec=1\r\na=rtpmap:63 red/48000/2\r\na=fmtp:63 111/111\r\na=rtpmap:110 telephone-event/48000\r\na=maxptime:120\r\na=ptime:20\r\na=ssrc:2587776314 cname:I/iLZxsY4mZ0aoNG\r\na=ssrc:2587776314 msid:pixelstreaming_audio_stream_id pixelstreaming_audio_track_label\r\na=ssrc:2587776314 mslabel:pixelstreaming_audio_stream_id\r\na=ssrc:2587776314 label:pixelstreaming_audio_track_label\r\nm=application 9 UDP/DTLS/SCTP webrtc-datachannel\r\nc=IN IP4 0.0.0.0\r\na=ice-ufrag:+JE1\r\na=ice-pwd:R2dKmHqM47E++7TRKKkHMyHj\r\na=ice-options:trickle\r\na=fingerprint:sha-256 20:EE:85:F0:DA:F4:90:F3:0D:13:2E:A9:1E:36:8C:81:E1:BD:38:78:20:AA:38:F3:FC:65:3F:8E:06:1D:A7:53\r\na=setup:actpass\r\na=mid:2\r\na=sctp-port:5000\r\na=max-message-size:262144\r\n";
const iceCandidate: RTCIceCandidateInit = {
sdpMid: "0",
sdpMLineIndex: null,
usernameFragment: null,
candidate:"candidate:2199032595 1 udp 2122260223 192.168.1.89 64674 typ host generation 0 ufrag +JE1 network-id 1"
};
const triggerWebSocketOpen = () =>
webSocketTriggerFunctions.triggerOnOpen?.();
const triggerConfigMessage = () =>
webSocketTriggerFunctions.triggerOnMessage?.({
type: MessageRecvTypes.CONFIG,
peerConnectionOptions: {}
});
const triggerStreamerListMessage = (streamerIdList: string[]) =>
webSocketTriggerFunctions.triggerOnMessage?.({
type: MessageRecvTypes.STREAMER_LIST,
ids: streamerIdList
});
const triggerSdpOfferMessage = () =>
webSocketTriggerFunctions.triggerOnMessage?.({
type: MessageRecvTypes.OFFER,
sdp
});
const triggerIceCandidateMessage = () =>
webSocketTriggerFunctions.triggerOnMessage?.({
type: MessageRecvTypes.ICE_CANDIDATE,
candidate: iceCandidate
});
const triggerIceConnectionState = (state: RTCIceConnectionState) =>
rtcPeerConnectionTriggerFunctions.triggerIceConnectionStateChange?.(
state
);
const triggerAddTrack = () => {
const stream = new MediaStream();
const track = new MediaStreamTrack();
rtcPeerConnectionTriggerFunctions.triggerOnTrack?.({
track,
streams: [stream]
} as RTCTrackEventInit);
return { stream, track };
};
const triggerOpenDataChannel = () => {
const channel = new RTCDataChannel();
rtcPeerConnectionTriggerFunctions.triggerOnDataChannel?.({
channel
});
channel.onopen?.(new Event('open'));
return { channel };
};
const establishMockedPixelStreamingConnection = (
streamerIds = streamerIdList,
iceConnectionState: RTCIceConnectionState = 'connected'
) => {
triggerWebSocketOpen();
triggerConfigMessage();
triggerStreamerListMessage(streamerIds);
triggerSdpOfferMessage();
triggerIceCandidateMessage();
triggerIceConnectionState(iceConnectionState);
const { stream, track } = triggerAddTrack();
const { channel } = triggerOpenDataChannel();
return { channel, stream, track };
};
beforeEach(() => {
mockRTCRtpReceiver();
mockMediaStream();
[webSocketSpyFunctions, webSocketTriggerFunctions] = mockWebSocket();
[rtcPeerConnectionSpyFunctions, rtcPeerConnectionTriggerFunctions] = mockRTCPeerConnection();
mockHTMLMediaElement({ ableToPlay: true });
jest.useFakeTimers();
});
afterEach(() => {
unmockRTCRtpReceiver();
unmockMediaStream();
unmockWebSocket();
unmockRTCPeerConnection();
jest.resetAllMocks();
});
it('should emit settingsChanged events when the configuration is updated', () => {
const config = new Config();
const pixelStreaming = new PixelStreaming(config);
const settingsChangedSpy = jest.fn();
pixelStreaming.addEventListener("settingsChanged", settingsChangedSpy);
expect(settingsChangedSpy).not.toHaveBeenCalled();
config.setNumericSetting(NumericParameters.WebRTCMaxBitrate, 123);
expect(settingsChangedSpy).toHaveBeenCalledWith(new SettingsChangedEvent({
id: NumericParameters.WebRTCMaxBitrate,
target: config.getNumericSettings().find((setting) => setting.id === NumericParameters.WebRTCMaxBitrate)!,
type: 'number',
value: 123,
}));
});
it('should connect to signalling server when connect is called', () => {
const config = new Config({ initialSettings: {ss: mockSignallingUrl}});
const pixelStreaming = new PixelStreaming(config);
expect(webSocketSpyFunctions.constructorSpy).not.toHaveBeenCalled();
pixelStreaming.connect();
expect(webSocketSpyFunctions.constructorSpy).toHaveBeenCalledWith(mockSignallingUrl);
});
it('should autoconnect to signalling server if autoconnect setting is enabled', () => {
const config = new Config({ initialSettings: {ss: mockSignallingUrl, AutoConnect: true}});
expect(webSocketSpyFunctions.constructorSpy).not.toHaveBeenCalled();
const pixelStreaming = new PixelStreaming(config);
expect(webSocketSpyFunctions.constructorSpy).toHaveBeenCalledWith(mockSignallingUrl);
});
it('should disconnect from signalling server if disconnect is called', () => {
const config = new Config({ initialSettings: {ss: mockSignallingUrl, AutoConnect: true}});
const disconnectedSpy = jest.fn();
expect(webSocketSpyFunctions.constructorSpy).not.toHaveBeenCalled();
const pixelStreaming = new PixelStreaming(config);
pixelStreaming.addEventListener("webRtcDisconnected", disconnectedSpy);
expect(webSocketSpyFunctions.constructorSpy).toHaveBeenCalledWith(mockSignallingUrl);
expect(webSocketSpyFunctions.closeSpy).not.toHaveBeenCalled();
pixelStreaming.disconnect();
expect(webSocketSpyFunctions.closeSpy).toHaveBeenCalled();
expect(disconnectedSpy).toHaveBeenCalled();
});
it('should connect immediately to signalling server if reconnect is called and connection is not up', () => {
const config = new Config({ initialSettings: {ss: mockSignallingUrl}});
const pixelStreaming = new PixelStreaming(config);
expect(webSocketSpyFunctions.constructorSpy).not.toHaveBeenCalled();
pixelStreaming.reconnect();
expect(webSocketSpyFunctions.closeSpy).not.toHaveBeenCalled();
expect(webSocketSpyFunctions.constructorSpy).toHaveBeenCalledWith(mockSignallingUrl);
});
it('should disconnect and reconnect to signalling server if reconnect is called and connection is up', () => {
const config = new Config({ initialSettings: {ss: mockSignallingUrl, AutoConnect: true}});
const autoconnectedSpy = jest.fn();
const pixelStreaming = new PixelStreaming(config);
pixelStreaming.addEventListener("webRtcAutoConnect", autoconnectedSpy);
expect(webSocketSpyFunctions.constructorSpy).toHaveBeenCalledWith(mockSignallingUrl);
expect(webSocketSpyFunctions.constructorSpy).toHaveBeenCalledTimes(1);
expect(webSocketSpyFunctions.closeSpy).not.toHaveBeenCalled();
pixelStreaming.reconnect();
expect(webSocketSpyFunctions.closeSpy).toHaveBeenCalled();
// delayed reconnect after 3 seconds
jest.advanceTimersByTime(3000);
expect(webSocketSpyFunctions.constructorSpy).toHaveBeenCalledWith(mockSignallingUrl);
expect(webSocketSpyFunctions.constructorSpy).toHaveBeenCalledTimes(2);
expect(autoconnectedSpy).toHaveBeenCalled();
});
it('should request streamer list when connected to the signalling server', () => {
const config = new Config({ initialSettings: {ss: mockSignallingUrl, AutoConnect: true}});
const pixelStreaming = new PixelStreaming(config);
triggerWebSocketOpen();
expect(webSocketSpyFunctions.sendSpy).toHaveBeenCalledWith(
expect.stringMatching(/"type":"listStreamers"/)
);
});
it('should autoselect a streamer if receiving only one streamer in streamerList message', () => {
const config = new Config({ initialSettings: {ss: mockSignallingUrl, AutoConnect: true}});
const streamerListSpy = jest.fn();
const pixelStreaming = new PixelStreaming(config);
pixelStreaming.addEventListener("streamerListMessage", streamerListSpy);
triggerWebSocketOpen();
triggerConfigMessage();
triggerStreamerListMessage(streamerIdList);
expect(streamerListSpy).toHaveBeenCalledWith(new StreamerListMessageEvent({
messageStreamerList: expect.objectContaining({
type: MessageRecvTypes.STREAMER_LIST,
ids: streamerIdList
}),
autoSelectedStreamerId: streamerId
}));
expect(webSocketSpyFunctions.sendSpy).toHaveBeenCalledWith(
expect.stringMatching(/"type":"subscribe".*MOCK_PIXEL_STREAMING/)
);
});
it('should not autoselect a streamer if receiving multiple streamers in streamerList message', () => {
const config = new Config({ initialSettings: {ss: mockSignallingUrl, AutoConnect: true}});
const streamerId2 = "MOCK_2_PIXEL_STREAMING";
const extendedStreamerIdList = [streamerId, streamerId2];
const streamerListSpy = jest.fn();
const pixelStreaming = new PixelStreaming(config);
pixelStreaming.addEventListener("streamerListMessage", streamerListSpy);
triggerWebSocketOpen();
triggerConfigMessage();
triggerStreamerListMessage(extendedStreamerIdList);
expect(streamerListSpy).toHaveBeenCalledWith(new StreamerListMessageEvent({
messageStreamerList: expect.objectContaining({
type: MessageRecvTypes.STREAMER_LIST,
ids: extendedStreamerIdList
}),
autoSelectedStreamerId: null
}));
expect(webSocketSpyFunctions.sendSpy).not.toHaveBeenCalledWith(
expect.stringMatching(/"type":"subscribe"/)
);
});
it('should set remoteDescription and emit webRtcSdp event when an offer is received', () => {
const config = new Config({ initialSettings: {ss: mockSignallingUrl, AutoConnect: true}});
const eventSpy = jest.fn();
const pixelStreaming = new PixelStreaming(config);
pixelStreaming.addEventListener("webRtcSdp", eventSpy);
triggerWebSocketOpen();
triggerConfigMessage();
triggerStreamerListMessage(streamerIdList);
expect(eventSpy).not.toHaveBeenCalled();
triggerSdpOfferMessage();
expect(rtcPeerConnectionSpyFunctions.setRemoteDescriptionSpy).toHaveBeenCalledWith(expect.objectContaining({
sdp
}));
expect(eventSpy).toHaveBeenCalledWith(new WebRtcSdpEvent());
});
it('should add an ICE candidate when receiving a iceCandidate message', () => {
const config = new Config({ initialSettings: {ss: mockSignallingUrl, AutoConnect: true}});
const pixelStreaming = new PixelStreaming(config);
triggerWebSocketOpen();
triggerConfigMessage();
triggerStreamerListMessage(streamerIdList);
triggerSdpOfferMessage();
triggerIceCandidateMessage();
expect(rtcPeerConnectionSpyFunctions.addIceCandidateSpy).toHaveBeenCalledWith(iceCandidate)
});
it('should emit webRtcConnected event when ICE connection state is connected', () => {
const config = new Config({ initialSettings: {ss: mockSignallingUrl, AutoConnect: true}});
const connectedSpy = jest.fn();
const pixelStreaming = new PixelStreaming(config);
pixelStreaming.addEventListener("webRtcConnected", connectedSpy);
triggerWebSocketOpen();
expect(rtcPeerConnectionSpyFunctions.constructorSpy).not.toHaveBeenCalled();
triggerConfigMessage();
expect(rtcPeerConnectionSpyFunctions.constructorSpy).toHaveBeenCalled();
triggerIceCandidateMessage();
triggerIceConnectionState('connected')
expect(connectedSpy).toHaveBeenCalledWith(new WebRtcConnectedEvent());
});
it('should call RTCPeerConnection close and emit webRtcDisconnected when disconnect is called', () => {
const config = new Config({ initialSettings: {ss: mockSignallingUrl}});
const disconnectedSpy = jest.fn();
const dataChannelSpy = jest.fn();
const pixelStreaming = new PixelStreaming(config);
pixelStreaming.addEventListener("webRtcDisconnected", disconnectedSpy);
pixelStreaming.addEventListener("dataChannelClose", dataChannelSpy);
pixelStreaming.connect();
establishMockedPixelStreamingConnection();
pixelStreaming.disconnect();
expect(rtcPeerConnectionSpyFunctions.closeSpy).toHaveBeenCalled();
expect(disconnectedSpy).toHaveBeenCalled();
expect(dataChannelSpy).toHaveBeenCalled();
});
it('should emit statistics when connected', async () => {
const config = new Config({ initialSettings: {ss: mockSignallingUrl, AutoConnect: true}});
const statsSpy = jest.fn();
const pixelStreaming = new PixelStreaming(config);
pixelStreaming.addEventListener("statsReceived", statsSpy);
establishMockedPixelStreamingConnection();
expect(statsSpy).not.toHaveBeenCalled();
// New stats sent at 1s intervals
jest.advanceTimersByTime(1000);
await flushPromises();
expect(statsSpy).toHaveBeenCalledTimes(1);
expect(statsSpy).toHaveBeenCalledWith(
expect.objectContaining({
data: {
aggregatedStats: expect.objectContaining({
candidatePair: expect.objectContaining({
bytesReceived: 123
}),
localCandidates: [
expect.objectContaining({ address: 'mock-address' })
]
})
}
})
);
jest.advanceTimersByTime(1000);
await flushPromises();
expect(statsSpy).toHaveBeenCalledTimes(2);
});
it('should emit dataChannelOpen when data channel is opened', () => {
const config = new Config({ initialSettings: {ss: mockSignallingUrl}});
const dataChannelSpy = jest.fn();
const pixelStreaming = new PixelStreaming(config);
pixelStreaming.addEventListener("dataChannelOpen", dataChannelSpy);
pixelStreaming.connect();
establishMockedPixelStreamingConnection();
expect(dataChannelSpy).toHaveBeenCalled();
});
it('should emit playStream when video play is called', () => {
const config = new Config({ initialSettings: {ss: mockSignallingUrl}});
const streamSpy = jest.fn();
const pixelStreaming = new PixelStreaming(config);
pixelStreaming.addEventListener("playStream", streamSpy);
pixelStreaming.connect();
establishMockedPixelStreamingConnection();
pixelStreaming.play();
expect(streamSpy).toHaveBeenCalled();
});
it('should emit playStreamRejected if video play is rejected', async () => {
mockHTMLMediaElement({ ableToPlay: false });
const config = new Config({ initialSettings: {ss: mockSignallingUrl}});
const streamRejectedSpy = jest.fn();
const pixelStreaming = new PixelStreaming(config);
pixelStreaming.addEventListener("playStreamRejected", streamRejectedSpy);
pixelStreaming.connect();
establishMockedPixelStreamingConnection();
pixelStreaming.play();
await flushPromises();
expect(streamRejectedSpy).toHaveBeenCalled();
});
it('should send data through the data channel when emitCommand is called', () => {
mockHTMLMediaElement({ ableToPlay: true, readyState: 2 });
const config = new Config({ initialSettings: {ss: mockSignallingUrl}});
const pixelStreaming = new PixelStreaming(config);
pixelStreaming.connect();
establishMockedPixelStreamingConnection();
pixelStreaming.play();
expect(rtcPeerConnectionSpyFunctions.sendDataSpy).not.toHaveBeenCalled();
const commandSent = pixelStreaming.emitCommand({
'Resolution.Width': 123,
'Resolution.Height': 456
});
expect(commandSent).toEqual(true);
expect(rtcPeerConnectionSpyFunctions.sendDataSpy).toHaveBeenCalled();
});
it('should prevent sending console commands unless permitted by streamer', () => {
mockHTMLMediaElement({ ableToPlay: true, readyState: 2 });
const config = new Config({ initialSettings: {ss: mockSignallingUrl}});
const pixelStreaming = new PixelStreaming(config);
pixelStreaming.connect();
establishMockedPixelStreamingConnection();
pixelStreaming.play();
expect(rtcPeerConnectionSpyFunctions.sendDataSpy).not.toHaveBeenCalled();
const commandSent = pixelStreaming.emitConsoleCommand("console command");
expect(commandSent).toEqual(false);
expect(rtcPeerConnectionSpyFunctions.sendDataSpy).not.toHaveBeenCalled();
});
it('should allow sending console commands if permitted by streamer', () => {
mockHTMLMediaElement({ ableToPlay: true, readyState: 2 });
const config = new Config({ initialSettings: {ss: mockSignallingUrl}});
const initialSettingsSpy = jest.fn();
const pixelStreaming = new PixelStreaming(config);
pixelStreaming.addEventListener("initialSettings", initialSettingsSpy);
pixelStreaming.connect();
establishMockedPixelStreamingConnection();
pixelStreaming.play();
expect(rtcPeerConnectionSpyFunctions.sendDataSpy).not.toHaveBeenCalled();
expect(initialSettingsSpy).not.toHaveBeenCalled();
const initialSettings = new InitialSettings();
initialSettings.PixelStreamingSettings.AllowPixelStreamingCommands = true;
pixelStreaming._onInitialSettings(initialSettings);
expect(initialSettingsSpy).toHaveBeenCalled();
const commandSent = pixelStreaming.emitConsoleCommand("console command");
expect(commandSent).toEqual(true);
expect(rtcPeerConnectionSpyFunctions.sendDataSpy).toHaveBeenCalled();
});
it('should send data through the data channel when emitUIInteraction is called', () => {
mockHTMLMediaElement({ ableToPlay: true, readyState: 2 });
const config = new Config({ initialSettings: {ss: mockSignallingUrl}});
const pixelStreaming = new PixelStreaming(config);
pixelStreaming.connect();
establishMockedPixelStreamingConnection();
pixelStreaming.play();
expect(rtcPeerConnectionSpyFunctions.sendDataSpy).not.toHaveBeenCalled();
const commandSent = pixelStreaming.emitUIInteraction({ custom: "descriptor" });
expect(commandSent).toEqual(true);
expect(rtcPeerConnectionSpyFunctions.sendDataSpy).toHaveBeenCalled();
});
it('should call user-provided callback if receiving a data channel Response message from the streamer', () => {
mockHTMLMediaElement({ ableToPlay: true, readyState: 2 });
const config = new Config({ initialSettings: {ss: mockSignallingUrl}});
const responseListenerSpy = jest.fn();
const pixelStreaming = new PixelStreaming(config);
pixelStreaming.addResponseEventListener('responseListener', responseListenerSpy);
pixelStreaming.connect();
const { channel } = establishMockedPixelStreamingConnection();
pixelStreaming.play();
expect(responseListenerSpy).not.toHaveBeenCalled();
const testMessageContents = JSON.stringify({ test: "mock-data" });
const data = new DataView(new ArrayBuffer(1 + 2 * testMessageContents.length));
data.setUint8(0, 1); // type 1 == Response
let byteIdx = 1;
for (let i = 0; i < testMessageContents.length; i++) {
data.setUint16(byteIdx, testMessageContents.charCodeAt(i), true);
byteIdx += 2;
}
channel.dispatchEvent(new MessageEvent('message', { data: data.buffer }));
expect(responseListenerSpy).toHaveBeenCalledWith(testMessageContents);
});
});

View File

@ -0,0 +1,124 @@
export class MockMediaStreamImpl implements MediaStream {
active: boolean;
id: string;
constructor(data?: MediaStream | MediaStreamTrack[]) {
//
}
onaddtrack: ((this: MediaStream, ev: MediaStreamTrackEvent) => any) | null;
onremovetrack: ((this: MediaStream, ev: MediaStreamTrackEvent) => any) | null;
addTrack(track: MediaStreamTrack): void {
throw new Error("Method not implemented.");
}
clone(): MediaStream {
throw new Error("Method not implemented.");
}
getAudioTracks(): MediaStreamTrack[] {
throw new Error("Method not implemented.");
}
getTrackById(trackId: string): MediaStreamTrack | null {
throw new Error("Method not implemented.");
}
getTracks(): MediaStreamTrack[] {
throw new Error("Method not implemented.");
}
getVideoTracks(): MediaStreamTrack[] {
throw new Error("Method not implemented.");
}
removeTrack(track: MediaStreamTrack): void {
throw new Error("Method not implemented.");
}
addEventListener<K extends keyof MediaStreamEventMap>(type: K, listener: (this: MediaStream, ev: MediaStreamEventMap[K]) => any, options?: boolean | AddEventListenerOptions | undefined): void;
addEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions | undefined): void;
addEventListener(type: unknown, listener: unknown, options?: unknown): void {
throw new Error("Method not implemented.");
}
removeEventListener<K extends keyof MediaStreamEventMap>(type: K, listener: (this: MediaStream, ev: MediaStreamEventMap[K]) => any, options?: boolean | EventListenerOptions | undefined): void;
removeEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | EventListenerOptions | undefined): void;
removeEventListener(type: unknown, listener: unknown, options?: unknown): void {
throw new Error("Method not implemented.");
}
dispatchEvent(event: Event): boolean {
throw new Error("Method not implemented.");
}
}
export class MockMediaStreamTrackImpl implements MediaStreamTrack {
contentHint: string;
enabled: boolean;
id: string;
kind: string;
label: string;
muted: boolean;
readyState: MediaStreamTrackState;
constructor() {
this.kind = 'video';
this.readyState = 'live';
}
onended: ((this: MediaStreamTrack, ev: Event) => any) | null;
onmute: ((this: MediaStreamTrack, ev: Event) => any) | null;
onunmute: ((this: MediaStreamTrack, ev: Event) => any) | null;
applyConstraints(constraints?: MediaTrackConstraints | undefined): Promise<void> {
throw new Error("Method not implemented.");
}
clone(): MediaStreamTrack {
throw new Error("Method not implemented.");
}
getCapabilities(): MediaTrackCapabilities {
throw new Error("Method not implemented.");
}
getConstraints(): MediaTrackConstraints {
throw new Error("Method not implemented.");
}
getSettings(): MediaTrackSettings {
throw new Error("Method not implemented.");
}
stop(): void {
throw new Error("Method not implemented.");
}
addEventListener<K extends keyof MediaStreamTrackEventMap>(type: K, listener: (this: MediaStreamTrack, ev: MediaStreamTrackEventMap[K]) => any, options?: boolean | AddEventListenerOptions | undefined): void;
addEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions | undefined): void;
addEventListener(type: unknown, listener: unknown, options?: unknown): void {
throw new Error("Method not implemented.");
}
removeEventListener<K extends keyof MediaStreamTrackEventMap>(type: K, listener: (this: MediaStreamTrack, ev: MediaStreamTrackEventMap[K]) => any, options?: boolean | EventListenerOptions | undefined): void;
removeEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | EventListenerOptions | undefined): void;
removeEventListener(type: unknown, listener: unknown, options?: unknown): void {
throw new Error("Method not implemented.");
}
dispatchEvent(event: Event): boolean {
throw new Error("Method not implemented.");
}
}
const mockHTMLMediaElementPlay = (ableToPlay: boolean) => {
if (ableToPlay) {
return Promise.resolve();
}
return Promise.reject("mock cancel");
};
const originalMediaStream = global.MediaStream;
const originalMediaStreamTrack = global.MediaStreamTrack;
export const mockMediaStream = () => {
global.MediaStream = MockMediaStreamImpl;
global.MediaStreamTrack = MockMediaStreamTrackImpl;
}
export const unmockMediaStream = () => {
global.MediaStream = originalMediaStream;
global.MediaStreamTrack = originalMediaStreamTrack;
}
export const mockHTMLMediaElement = (options: { ableToPlay: boolean, readyState?: number }) => {
const { ableToPlay, readyState } = options;
jest.spyOn(HTMLMediaElement.prototype, 'play').mockReturnValue(mockHTMLMediaElementPlay(ableToPlay));
if (readyState !== undefined) {
jest.spyOn(HTMLMediaElement.prototype, 'readyState', 'get').mockReturnValue(readyState);
}
}

View File

@ -0,0 +1,347 @@
export interface MockRTCPeerConnectionSpyFunctions {
constructorSpy: null | ((config: RTCConfiguration) => void);
closeSpy: null | (() => void);
setRemoteDescriptionSpy: null | ((description: RTCSessionDescriptionInit) => void);
setLocalDescriptionSpy: null | ((description: RTCLocalSessionDescriptionInit) => void);
createAnswerSpy: null | (() => void);
addTransceiverSpy: null | ((trackOrKind: string | MediaStreamTrack, init?: RTCRtpTransceiverInit | undefined) => void);
addIceCandidateSpy: null | ((candidate: RTCIceCandidateInit) => void);
sendDataSpy: null | ((data: ArrayBuffer) => void);
}
export interface MockRTCPeerConnectionTriggerFunctions {
triggerIceConnectionStateChange: null | ((state: RTCIceConnectionState) => void);
triggerOnTrack: null | ((data: RTCTrackEventInit) => void);
triggerOnIceCandidate: null | ((data: RTCPeerConnectionIceEventInit) => void);
triggerOnDataChannel: null | ((data: RTCDataChannelEventInit) => void);
}
const spyFunctions: MockRTCPeerConnectionSpyFunctions = {
constructorSpy: null,
closeSpy: null,
setRemoteDescriptionSpy: null,
setLocalDescriptionSpy: null,
createAnswerSpy: null,
addTransceiverSpy: null,
addIceCandidateSpy: null,
sendDataSpy: null,
};
const triggerFunctions: MockRTCPeerConnectionTriggerFunctions = {
triggerIceConnectionStateChange: null,
triggerOnTrack: null,
triggerOnIceCandidate: null,
triggerOnDataChannel: null
};
export class MockRTCPeerConnectionImpl implements RTCPeerConnection {
canTrickleIceCandidates: boolean | null;
connectionState: RTCPeerConnectionState;
currentLocalDescription: RTCSessionDescription | null;
currentRemoteDescription: RTCSessionDescription | null;
iceConnectionState: RTCIceConnectionState;
iceGatheringState: RTCIceGatheringState;
localDescription: RTCSessionDescription | null;
pendingLocalDescription: RTCSessionDescription | null;
pendingRemoteDescription: RTCSessionDescription | null;
remoteDescription: RTCSessionDescription | null;
sctp: RTCSctpTransport | null;
signalingState: RTCSignalingState;
_dataChannels: RTCDataChannel[] = [];
constructor(config: RTCConfiguration) {
this.connectionState = "new";
this.iceConnectionState = "new";
spyFunctions.constructorSpy?.(config);
triggerFunctions.triggerIceConnectionStateChange = this.triggerIceConnectionStateChange.bind(this);
triggerFunctions.triggerOnTrack = this.triggerOnTrack.bind(this);
triggerFunctions.triggerOnIceCandidate = this.triggerOnIceCandidate.bind(this);
triggerFunctions.triggerOnDataChannel =
this.triggerOnDataChannel.bind(this);
}
onconnectionstatechange: ((this: RTCPeerConnection, ev: Event) => any) | null;
ondatachannel: ((this: RTCPeerConnection, ev: RTCDataChannelEvent) => any) | null;
onicecandidate: ((this: RTCPeerConnection, ev: RTCPeerConnectionIceEvent) => any) | null;
onicecandidateerror: ((this: RTCPeerConnection, ev: Event) => any) | null;
oniceconnectionstatechange: ((this: RTCPeerConnection, ev: Event) => any) | null;
onicegatheringstatechange: ((this: RTCPeerConnection, ev: Event) => any) | null;
onnegotiationneeded: ((this: RTCPeerConnection, ev: Event) => any) | null;
onsignalingstatechange: ((this: RTCPeerConnection, ev: Event) => any) | null;
ontrack: ((this: RTCPeerConnection, ev: RTCTrackEvent) => any) | null;
addIceCandidate(candidate?: RTCIceCandidateInit | undefined): Promise<void>;
addIceCandidate(candidate: RTCIceCandidateInit, successCallback: VoidFunction, failureCallback: RTCPeerConnectionErrorCallback): Promise<void>;
addIceCandidate(candidate?: unknown, successCallback?: unknown, failureCallback?: unknown): Promise<void> {
if (this.iceConnectionState !== "connected" && this.iceConnectionState !== "completed") {
this.iceConnectionState = "checking";
}
this.oniceconnectionstatechange?.(new Event("iceconnectionstatechange"));
spyFunctions.addIceCandidateSpy?.(candidate as RTCIceCandidateInit);
return Promise.resolve();
}
addTrack(track: MediaStreamTrack, ...streams: MediaStream[]): RTCRtpSender {
throw new Error("Method not implemented.");
}
addTransceiver(trackOrKind: string | MediaStreamTrack, init?: RTCRtpTransceiverInit | undefined): RTCRtpTransceiver {
spyFunctions.addTransceiverSpy?.(trackOrKind, init);
return {} as RTCRtpTransceiver;
}
createAnswer(options?: RTCAnswerOptions | undefined): Promise<RTCSessionDescriptionInit>;
createAnswer(successCallback: RTCSessionDescriptionCallback, failureCallback: RTCPeerConnectionErrorCallback): Promise<void>;
createAnswer(successCallback?: unknown, failureCallback?: unknown): Promise<void> | Promise<RTCSessionDescriptionInit> {
spyFunctions.createAnswerSpy?.();
const res: RTCSessionDescriptionInit = {
type: "answer",
sdp: "v=0\r\no=- 5791786663981007547 2 IN IP4 127.0.0.1\r\ns=-\r\nt=0 0\r\na=group:BUNDLE 0 1 2\r\na=extmap-allow-mixed\r\na=msid-semantic: WMS\r\nm=video 9 UDP/TLS/RTP/SAVPF 96 98\r\nc=IN IP4 0.0.0.0\r\na=rtcp:9 IN IP4 0.0.0.0\r\na=ice-ufrag:z0li\r\na=ice-pwd:DkbG5Q3dFSIygDc47cms4TGA\r\na=ice-options:trickle\r\na=fingerprint:sha-256 F9:5B:3C:AB:89:88:0E:1B:2E:63:B3:D2:B8:92:59:E2:3A:46:B6:85:09:F4:50:0E:72:4F:9F:70:6D:5F:BD:1A\r\na=setup:active\r\na=mid:0\r\na=extmap:1 urn:ietf:params:rtp-hdrext:toffset\r\na=extmap:2 http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time\r\na=extmap:3 urn:3gpp:video-orientation\r\na=extmap:4 http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01\r\na=extmap:5 http://www.webrtc.org/experiments/rtp-hdrext/playout-delay\r\na=extmap:6 http://www.webrtc.org/experiments/rtp-hdrext/video-content-type\r\na=extmap:7 http://www.webrtc.org/experiments/rtp-hdrext/video-timing\r\na=extmap:8 http://www.webrtc.org/experiments/rtp-hdrext/color-space\r\na=extmap:9 urn:ietf:params:rtp-hdrext:sdes:mid\r\na=extmap:10 urn:ietf:params:rtp-hdrext:sdes:rtp-stream-id\r\na=extmap:11 urn:ietf:params:rtp-hdrext:sdes:repaired-rtp-stream-id\r\na=recvonly\r\na=rtcp-mux\r\na=rtcp-rsize\r\na=rtpmap:96 H264/90000\r\na=rtcp-fb:96 goog-remb\r\na=rtcp-fb:96 transport-cc\r\na=rtcp-fb:96 ccm fir\r\na=rtcp-fb:96 nack\r\na=rtcp-fb:96 nack pli\r\na=fmtp:96 level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f\r\na=rtpmap:98 H264/90000\r\na=rtcp-fb:98 goog-remb\r\na=rtcp-fb:98 transport-cc\r\na=rtcp-fb:98 ccm fir\r\na=rtcp-fb:98 nack\r\na=rtcp-fb:98 nack pli\r\na=fmtp:98 level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42001f\r\nm=audio 9 UDP/TLS/RTP/SAVPF 111 63 110\r\nc=IN IP4 0.0.0.0\r\na=rtcp:9 IN IP4 0.0.0.0\r\na=ice-ufrag:z0li\r\na=ice-pwd:DkbG5Q3dFSIygDc47cms4TGA\r\na=ice-options:trickle\r\na=fingerprint:sha-256 F9:5B:3C:AB:89:88:0E:1B:2E:63:B3:D2:B8:92:59:E2:3A:46:B6:85:09:F4:50:0E:72:4F:9F:70:6D:5F:BD:1A\r\na=setup:active\r\na=mid:1\r\na=extmap:14 urn:ietf:params:rtp-hdrext:ssrc-audio-level\r\na=extmap:2 http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time\r\na=extmap:4 http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01\r\na=extmap:9 urn:ietf:params:rtp-hdrext:sdes:mid\r\na=recvonly\r\na=rtcp-mux\r\na=rtpmap:111 opus/48000/2\r\na=rtcp-fb:111 transport-cc\r\na=fmtp:111 minptime=10;useinbandfec=1\r\na=rtpmap:63 red/48000/2\r\na=fmtp:63 111/111\r\na=rtpmap:110 telephone-event/48000\r\nm=application 9 UDP/DTLS/SCTP webrtc-datachannel\r\nc=IN IP4 0.0.0.0\r\na=ice-ufrag:z0li\r\na=ice-pwd:DkbG5Q3dFSIygDc47cms4TGA\r\na=ice-options:trickle\r\na=fingerprint:sha-256 F9:5B:3C:AB:89:88:0E:1B:2E:63:B3:D2:B8:92:59:E2:3A:46:B6:85:09:F4:50:0E:72:4F:9F:70:6D:5F:BD:1A\r\na=setup:active\r\na=mid:2\r\na=sctp-port:5000\r\na=max-message-size:262144\r\n"
};
return Promise.resolve(res);
}
createDataChannel(label: string, dataChannelDict?: RTCDataChannelInit | undefined): RTCDataChannel {
const dataChannel = new RTCDataChannel();
this._dataChannels.push(dataChannel);
return dataChannel;
}
createOffer(options?: RTCOfferOptions | undefined): Promise<RTCSessionDescriptionInit>;
createOffer(successCallback: RTCSessionDescriptionCallback, failureCallback: RTCPeerConnectionErrorCallback, options?: RTCOfferOptions | undefined): Promise<void>;
createOffer(successCallback?: unknown, failureCallback?: unknown, options?: unknown): Promise<void> | Promise<RTCSessionDescriptionInit> {
throw new Error("Method not implemented.");
}
getConfiguration(): RTCConfiguration {
throw new Error("Method not implemented.");
}
getReceivers(): RTCRtpReceiver[] {
throw new Error("Method not implemented.");
}
getSenders(): RTCRtpSender[] {
throw new Error("Method not implemented.");
}
getStats(selector?: MediaStreamTrack | null | undefined): Promise<RTCStatsReport> {
const stats = {
forEach: function (callbackfn: (value: any) => void): void {
callbackfn({
type: 'candidate-pair',
bytesReceived: 123,
});
callbackfn({
type: 'local-candidate',
address: 'mock-address',
});
},
};
return Promise.resolve(stats as RTCStatsReport);
}
getTransceivers(): RTCRtpTransceiver[] {
return [];
}
removeTrack(sender: RTCRtpSender): void {
throw new Error("Method not implemented.");
}
restartIce(): void {
throw new Error("Method not implemented.");
}
setConfiguration(configuration?: RTCConfiguration | undefined): void {
throw new Error("Method not implemented.");
}
setLocalDescription(description?: RTCLocalSessionDescriptionInit | undefined): Promise<void>;
setLocalDescription(description: RTCLocalSessionDescriptionInit, successCallback: VoidFunction, failureCallback: RTCPeerConnectionErrorCallback): Promise<void>;
setLocalDescription(description?: unknown, successCallback?: unknown, failureCallback?: unknown): Promise<void> {
spyFunctions.setLocalDescriptionSpy?.(description as RTCLocalSessionDescriptionInit);
return Promise.resolve();
}
setRemoteDescription(description: RTCSessionDescriptionInit): Promise<void>;
setRemoteDescription(description: RTCSessionDescriptionInit, successCallback: VoidFunction, failureCallback: RTCPeerConnectionErrorCallback): Promise<void>;
setRemoteDescription(description: unknown, successCallback?: unknown, failureCallback?: unknown): Promise<void> {
spyFunctions.setRemoteDescriptionSpy?.(description as RTCSessionDescriptionInit);
return Promise.resolve();
}
addEventListener<K extends keyof RTCPeerConnectionEventMap>(type: K, listener: (this: RTCPeerConnection, ev: RTCPeerConnectionEventMap[K]) => any, options?: boolean | AddEventListenerOptions | undefined): void;
addEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions | undefined): void;
addEventListener(type: unknown, listener: unknown, options?: unknown): void {
throw new Error("Method not implemented.");
}
removeEventListener<K extends keyof RTCPeerConnectionEventMap>(type: K, listener: (this: RTCPeerConnection, ev: RTCPeerConnectionEventMap[K]) => any, options?: boolean | EventListenerOptions | undefined): void;
removeEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | EventListenerOptions | undefined): void;
removeEventListener(type: unknown, listener: unknown, options?: unknown): void {
throw new Error("Method not implemented.");
}
dispatchEvent(event: Event): boolean {
throw new Error("Method not implemented.");
}
static generateCertificate(keygenAlgorithm: AlgorithmIdentifier): Promise<RTCCertificate> {
throw new Error("Method not implemented.");
}
close(): void {
this.connectionState = "closed";
this.iceConnectionState = "closed";
this.onconnectionstatechange?.(new Event(this.connectionState));
this.oniceconnectionstatechange?.(new Event(this.iceConnectionState));
this._dataChannels.forEach((channel) => channel.close());
spyFunctions.closeSpy?.();
}
triggerIceConnectionStateChange(state: RTCIceConnectionState) {
this.iceConnectionState = state;
const event = new Event(state);
this.oniceconnectionstatechange?.(event);
}
triggerOnTrack(data: RTCTrackEventInit) {
const event = new RTCTrackEvent('track', data);
this.ontrack?.(event);
}
triggerOnIceCandidate(data: RTCPeerConnectionIceEventInit) {
const event = new RTCPeerConnectionIceEvent('icecandidate', data);
this.onicecandidate?.(event);
}
triggerOnDataChannel(data: RTCDataChannelEventInit) {
this._dataChannels.push(data.channel);
const event = new RTCDataChannelEvent('datachannel', data);
this.ondatachannel?.(event);
}
}
export class MockRTCIceCandidateImpl implements RTCIceCandidate {
address: string | null;
candidate: string;
component: RTCIceComponent | null;
foundation: string | null;
port: number | null;
priority: number | null;
protocol: RTCIceProtocol | null;
relatedAddress: string | null;
relatedPort: number | null;
sdpMLineIndex: number | null;
sdpMid: string | null;
tcpType: RTCIceTcpCandidateType | null;
type: RTCIceCandidateType | null;
usernameFragment: string | null;
constructor(options?: RTCIceCandidateInit) {
this.candidate = options?.candidate || "";
this.sdpMid = options?.sdpMid || null;
this.sdpMLineIndex = options?.sdpMLineIndex || null;
this.usernameFragment = options?.usernameFragment || null;
}
toJSON(): RTCIceCandidateInit {
throw new Error("Method not implemented.");
}
}
export class MockRTCDataChannelImpl implements RTCDataChannel {
binaryType: BinaryType;
bufferedAmount: number;
bufferedAmountLowThreshold: number;
id: number | null;
label: string;
maxPacketLifeTime: number | null;
maxRetransmits: number | null;
negotiated: boolean;
ordered: boolean;
protocol: string;
readyState: RTCDataChannelState;
constructor() {
this.readyState = "open";
}
onbufferedamountlow: ((this: RTCDataChannel, ev: Event) => any) | null;
onclose: ((this: RTCDataChannel, ev: Event) => any) | null;
onclosing: ((this: RTCDataChannel, ev: Event) => any) | null;
onerror: ((this: RTCDataChannel, ev: Event) => any) | null;
onmessage: ((this: RTCDataChannel, ev: MessageEvent<any>) => any) | null;
onopen: ((this: RTCDataChannel, ev: Event) => any) | null;
close(): void {
this.onclose?.(new Event('close'));
}
send(data: string): void;
send(data: Blob): void;
send(data: ArrayBuffer): void;
send(data: ArrayBufferView): void;
send(data: unknown): void {
spyFunctions.sendDataSpy?.(data as ArrayBuffer);
}
addEventListener<K extends keyof RTCDataChannelEventMap>(type: K, listener: (this: RTCDataChannel, ev: RTCDataChannelEventMap[K]) => any, options?: boolean | AddEventListenerOptions | undefined): void;
addEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions | undefined): void;
addEventListener(type: unknown, listener: unknown, options?: unknown): void {
throw new Error("Method not implemented.");
}
removeEventListener<K extends keyof RTCDataChannelEventMap>(type: K, listener: (this: RTCDataChannel, ev: RTCDataChannelEventMap[K]) => any, options?: boolean | EventListenerOptions | undefined): void;
removeEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | EventListenerOptions | undefined): void;
removeEventListener(type: unknown, listener: unknown, options?: unknown): void {
throw new Error("Method not implemented.");
}
dispatchEvent(event: Event): boolean {
if (event.type === 'message') {
this.onmessage?.(event as MessageEvent);
}
return true;
}
}
export class MockRTCDataChannelEventImpl extends Event implements RTCDataChannelEvent {
channel: RTCDataChannel;
constructor(name: string, data: RTCDataChannelEventInit) {
super(name, data);
this.channel = data.channel;
}
}
export class MockRTCTrackEventImpl extends Event implements RTCTrackEvent {
receiver: RTCRtpReceiver;
streams: readonly MediaStream[];
track: MediaStreamTrack;
transceiver: RTCRtpTransceiver;
constructor(name: string, data: RTCTrackEventInit) {
super(name, data);
this.receiver = data.receiver;
this.streams = data.streams || [];
this.track = data.track;
this.transceiver = data.transceiver;
}
}
const originalRTCPeerConnection = global.RTCPeerConnection;
const originalRTCIceCandidate = global.RTCIceCandidate;
const originalRTCDataChannel = global.RTCDataChannel;
const originalRTCDataChannelEvent = global.RTCDataChannelEvent;
const originalRTCTrackEvent = global.RTCTrackEvent;
export const mockRTCPeerConnection = (): [
MockRTCPeerConnectionSpyFunctions,
MockRTCPeerConnectionTriggerFunctions
] => {
spyFunctions.constructorSpy = jest.fn();
spyFunctions.closeSpy = jest.fn();
spyFunctions.setRemoteDescriptionSpy = jest.fn();
spyFunctions.setLocalDescriptionSpy = jest.fn();
spyFunctions.createAnswerSpy = jest.fn();
spyFunctions.addTransceiverSpy = jest.fn();
spyFunctions.addIceCandidateSpy = jest.fn();
spyFunctions.sendDataSpy = jest.fn();
global.RTCPeerConnection = MockRTCPeerConnectionImpl;
global.RTCIceCandidate = MockRTCIceCandidateImpl;
global.RTCDataChannel = MockRTCDataChannelImpl;
global.RTCDataChannelEvent = MockRTCDataChannelEventImpl;
global.RTCTrackEvent = MockRTCTrackEventImpl;
return [spyFunctions, triggerFunctions];
};
export const unmockRTCPeerConnection = () => {
global.RTCPeerConnection = originalRTCPeerConnection;
global.RTCIceCandidate = originalRTCIceCandidate;
global.RTCDataChannel = originalRTCDataChannel;
global.RTCDataChannelEvent = originalRTCDataChannelEvent;
global.RTCTrackEvent = originalRTCTrackEvent;
spyFunctions.constructorSpy = null;
spyFunctions.closeSpy = null;
spyFunctions.setRemoteDescriptionSpy = null;
spyFunctions.setLocalDescriptionSpy = null;
spyFunctions.createAnswerSpy = null;
spyFunctions.addTransceiverSpy = null;
spyFunctions.addIceCandidateSpy = null;
spyFunctions.sendDataSpy = null;
};

View File

@ -0,0 +1,22 @@
export const mockRTCRtpReceiverImpl = {
prototype: jest.fn(),
getCapabilities: () => ({
codecs: [
{
clockRate: 60,
mimeType: "testMimeType",
sdpFmtpLine: "AV1"
}
] as RTCRtpCodecCapability[],
headerExtensions: [] as RTCRtpHeaderExtensionCapability[]
})
} as any as typeof global.RTCRtpReceiver;
const originalRTCRtpReceiver = global.RTCRtpReceiver;
export const mockRTCRtpReceiver = () => {
global.RTCRtpReceiver = mockRTCRtpReceiverImpl;
}
export const unmockRTCRtpReceiver = () => {
global.RTCRtpReceiver = originalRTCRtpReceiver;
}

View File

@ -0,0 +1,130 @@
export interface MockWebSocketSpyFunctions {
constructorSpy: null | ((url: string) => void);
openSpy: null | ((event: Event) => void);
errorSpy: null | ((event: Event) => void);
closeSpy: null | ((event: CloseEvent) => void);
messageSpy: null | ((event: MessageEvent) => void);
messageBinarySpy: null | ((event: MessageEvent) => void);
sendSpy: null | ((data: string | Blob | ArrayBufferView | ArrayBufferLike) => void);
}
export interface MockWebSocketTriggerFunctions {
triggerOnOpen: null | (() => void);
triggerOnError: null | (() => void);
triggerOnClose: null | ((closeReason?: CloseEventInit) => void);
triggerOnMessage: null | ((message?: object) => void);
triggerOnMessageBinary: null | ((message?: Blob) => void);
}
const spyFunctions: MockWebSocketSpyFunctions = {
constructorSpy: null,
openSpy: null,
errorSpy: null,
closeSpy: null,
messageSpy: null,
messageBinarySpy: null,
sendSpy: null
};
const triggerFunctions: MockWebSocketTriggerFunctions = {
triggerOnOpen: null,
triggerOnError: null,
triggerOnClose: null,
triggerOnMessage: null,
triggerOnMessageBinary: null
};
export class MockWebSocketImpl extends WebSocket {
_readyState: number;
constructor(url: string | URL, protocols?: string | string[]) {
super(url, protocols);
this._readyState = this.OPEN;
spyFunctions.constructorSpy?.(this.url);
triggerFunctions.triggerOnOpen = this.triggerOnOpen.bind(this);
triggerFunctions.triggerOnError = this.triggerOnError.bind(this);
triggerFunctions.triggerOnClose = this.triggerOnClose.bind(this);
triggerFunctions.triggerOnMessage = this.triggerOnMessage.bind(this);
triggerFunctions.triggerOnMessageBinary =
this.triggerOnMessageBinary.bind(this);
}
get readyState() {
return this._readyState;
}
close(code?: number | undefined, reason?: string | undefined): void {
super.close(code, reason);
this._readyState = this.CLOSED;
this.triggerOnClose({ code, reason });
}
send(data: string | Blob | ArrayBufferView | ArrayBufferLike): void {
spyFunctions.sendSpy?.(data);
}
triggerOnOpen() {
const event = new Event('open');
this.onopen?.(event);
spyFunctions.openSpy?.(event);
}
triggerOnError() {
const event = new Event('error');
this.onerror?.(event);
spyFunctions.errorSpy?.(event);
}
triggerOnClose(closeReason?: CloseEventInit) {
const reason = closeReason ?? { code: 1, reason: 'mock reason' };
const event = new CloseEvent('close', reason);
this.onclose?.(event);
spyFunctions.closeSpy?.(event);
}
triggerOnMessage(message?: object) {
const data = message
? JSON.stringify(message)
: JSON.stringify({ type: 'test' });
const event = new MessageEvent('message', { data });
this.onmessage?.(event);
spyFunctions.messageSpy?.(event);
}
triggerOnMessageBinary(message?: Blob) {
const data =
message ??
new Blob([JSON.stringify({ type: 'test' })], {
type: 'application/json'
});
const event = new MessageEvent('messagebinary', { data });
this.onmessagebinary?.(event);
spyFunctions.messageBinarySpy?.(event);
}
}
const originalWebSocket = WebSocket;
export const mockWebSocket = (): [
MockWebSocketSpyFunctions,
MockWebSocketTriggerFunctions
] => {
spyFunctions.constructorSpy = jest.fn();
spyFunctions.openSpy = jest.fn();
spyFunctions.errorSpy = jest.fn();
spyFunctions.closeSpy = jest.fn();
spyFunctions.messageSpy = jest.fn();
spyFunctions.messageBinarySpy = jest.fn();
spyFunctions.sendSpy = jest.fn();
global.WebSocket = MockWebSocketImpl;
return [spyFunctions, triggerFunctions];
};
export const unmockWebSocket = () => {
global.WebSocket = originalWebSocket;
spyFunctions.constructorSpy = null;
spyFunctions.openSpy = null;
spyFunctions.errorSpy = null;
spyFunctions.closeSpy = null;
spyFunctions.messageSpy = null;
spyFunctions.messageBinarySpy = null;
};

View File

@ -0,0 +1,8 @@
{
"extends": "./tsconfig.json",
"include": ["./src/**/*.ts"],
"exclude": [],
"compilerOptions": {
"module": "CommonJS"
}
}

View File

@ -12,6 +12,7 @@
},
"lib": ["es2015"],
"include": ["./src/*.ts"],
"exclude": ["./src/**/*.test.ts"],
"typedocOptions": {
"exclude": "src/index.*",
"entryPoints": ["src/pixelstreamingfrontend.ts"],