feat(load-test) make it possible to start multiple load-test clients from the same tab

* Refactor load-test into an object.

* Convert to class syntax.

* Bind member function callbacks.

* More binding and thisage.

* More thisage.

* More tweaks

* Rename numParticipants as remoteParticipants.

* Change back.

* Fix userLeft.

* Add members for event listeners, to be able to remove them.

* Add numClients parameter that allows multiple clients to be started.

* Clear clients array on unload.

* Add latency between starting clients.
This commit is contained in:
Jonathan Lennox 2022-05-05 03:12:18 -04:00 committed by GitHub
parent 3ab47ff96c
commit 5b86182f94
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
1 changed files with 435 additions and 389 deletions

View File

@ -14,7 +14,9 @@ const {
remoteVideo = isHuman, remoteVideo = isHuman,
remoteAudio = isHuman, remoteAudio = isHuman,
autoPlayVideo = config.testing.noAutoPlayVideo !== true, autoPlayVideo = config.testing.noAutoPlayVideo !== true,
stageView = config.disableTileView stageView = config.disableTileView,
numClients = 1,
clientInterval = 100 // ms
} = params; } = params;
let { let {
@ -23,42 +25,430 @@ let {
const { room: roomName } = parseURIString(window.location.toString()); const { room: roomName } = parseURIString(window.location.toString());
let connection = null; class LoadTestClient {
constructor(id) {
this.id = id;
this.connection = null;
this.connected = false;
this.room = null;
this.numParticipants = 1;
this.localTracks = [];
this.remoteTracks = {};
this.maxFrameHeight = 0;
this.selectedParticipant = null;
}
let connected = false; /**
* Simple emulation of jitsi-meet's screen layout behavior
*/
updateMaxFrameHeight() {
if (!this.connected) {
return;
}
let room = null; let newMaxFrameHeight;
let numParticipants = 1; if (stageView) {
newMaxFrameHeight = 2160;
}
else {
if (this.numParticipants <= 2) {
newMaxFrameHeight = 720;
} else if (this.numParticipants <= 4) {
newMaxFrameHeight = 360;
} else {
this.newMaxFrameHeight = 180;
}
}
let localTracks = []; if (this.room && this.maxFrameHeight !== newMaxFrameHeight) {
const remoteTracks = {}; this.maxFrameHeight = newMaxFrameHeight;
this.room.setReceiverVideoConstraint(this.maxFrameHeight);
}
}
let maxFrameHeight = 0; /**
* Simple emulation of jitsi-meet's lastN behavior
*/
updateLastN() {
if (!this.connected) {
return;
}
let selectedParticipant = null; let lastN = typeof config.channelLastN === 'undefined' ? -1 : config.channelLastN;
const limitedLastN = limitLastN(this.numParticipants, validateLastNLimits(config.lastNLimits));
if (limitedLastN !== undefined) {
lastN = lastN === -1 ? limitedLastN : Math.min(limitedLastN, lastN);
}
if (lastN === this.room.getLastN()) {
return;
}
this.room.setLastN(lastN);
}
/**
* Helper function to query whether a participant ID is a valid ID
* for stage view.
*/
isValidStageViewParticipant(id) {
return (id !== room.myUserId() && room.getParticipantById(id));
}
/**
* Simple emulation of jitsi-meet's stage view participant selection behavior.
* Doesn't take into account pinning or screen sharing, and the initial behavior
* is slightly different.
* @returns Whether the selected participant changed.
*/
selectStageViewParticipant(selected, previous) {
let newSelectedParticipant;
if (this.isValidStageViewParticipant(selected)) {
newSelectedParticipant = selected;
}
else {
newSelectedParticipant = previous.find(isValidStageViewParticipant);
}
if (newSelectedParticipant && newSelectedParticipant !== this.selectedParticipant) {
this.selectedParticipant = newSelectedParticipant;
return true;
}
return false;
}
/**
* Simple emulation of jitsi-meet's selectParticipants behavior
*/
selectParticipants() {
if (!this.connected) {
return;
}
if (stageView) {
if (this.selectedParticipant) {
this.room.selectParticipants([this.selectedParticipant]);
}
}
else {
/* jitsi-meet's current Tile View behavior. */
const ids = this.room.getParticipants().map(participant => participant.getId());
this.room.selectParticipants(ids);
}
}
/**
* Called when number of participants changes.
*/
setNumberOfParticipants() {
if (this.id === 0) {
$('#participants').text(this.numParticipants);
}
if (!stageView) {
this.selectParticipants();
this.updateMaxFrameHeight();
}
this.updateLastN();
}
/**
* Called when ICE connects
*/
onConnectionEstablished() {
this.connected = true;
this.selectParticipants();
this.updateMaxFrameHeight();
this.updateLastN();
}
/**
* Handles dominant speaker changed.
* @param id
*/
onDominantSpeakerChanged(selected, previous) {
if (this.selectStageViewParticipant(selected, previous)) {
this.selectParticipants();
}
this.updateMaxFrameHeight();
}
/**
* Handles local tracks.
* @param tracks Array with JitsiTrack objects
*/
onLocalTracks(tracks = []) {
this.localTracks = tracks;
for (let i = 0; i < this.localTracks.length; i++) {
if (this.localTracks[i].getType() === 'video') {
if (this.id === 0) {
$('body').append(`<video ${autoPlayVideo ? 'autoplay="1" ' : ''}id='localVideo${i}' />`);
this.localTracks[i].attach($(`#localVideo${i}`)[0]);
}
this.room.addTrack(this.localTracks[i]);
} else {
if (localAudio) {
this.room.addTrack(this.localTracks[i]);
} else {
this.localTracks[i].mute();
}
if (this.id === 0) {
$('body').append(
`<audio autoplay='1' muted='true' id='localAudio${i}' />`);
this.localTracks[i].attach($(`#localAudio${i}`)[0]);
}
}
}
}
/**
* Handles remote tracks
* @param track JitsiTrack object
*/
onRemoteTrack(track) {
if (track.isLocal()
|| (track.getType() === 'video' && !remoteVideo) || (track.getType() === 'audio' && !remoteAudio)) {
return;
}
const participant = track.getParticipantId();
if (!this.remoteTracks[participant]) {
this.remoteTracks[participant] = [];
}
if (this.id !== 0) {
return;
}
const idx = this.remoteTracks[participant].push(track);
const id = participant + track.getType() + idx;
if (track.getType() === 'video') {
$('body').append(`<video autoplay='1' id='${id}' />`);
} else {
$('body').append(`<audio autoplay='1' id='${id}' />`);
}
track.attach($(`#${id}`)[0]);
}
/**
* That function is executed when the conference is joined
*/
onConferenceJoined() {
console.log(`Participant ${this.id} Conference joined`);
}
/**
* Handles start muted events, when audio and/or video are muted due to
* startAudioMuted or startVideoMuted policy.
*/
onStartMuted() {
// Give it some time, as it may be currently in the process of muting
setTimeout(() => {
const localAudioTrack = this.room.getLocalAudioTrack();
if (localAudio && localAudioTrack && localAudioTrack.isMuted()) {
localAudioTrack.unmute();
}
const localVideoTrack = this.room.getLocalVideoTrack();
if (localVideo && localVideoTrack && localVideoTrack.isMuted()) {
localVideoTrack.unmute();
}
}, 2000);
}
/**
*
* @param id
*/
onUserJoined(id) {
this.numParticipants++;
this.setNumberOfParticipants();
this.remoteTracks[id] = [];
}
/**
*
* @param id
*/
onUserLeft(id) {
this.numParticipants--;
this.setNumberOfParticipants();
if (!this.remoteTracks[id]) {
return;
}
if (this.id !== 0) {
return;
}
const tracks = this.remoteTracks[id];
for (let i = 0; i < tracks.length; i++) {
const container = $(`#${id}${tracks[i].getType()}${i + 1}`)[0];
if (container) {
tracks[i].detach(container);
container.parentElement.removeChild(container);
}
}
}
/**
* Handles private messages.
*
* @param {string} id - The sender ID.
* @param {string} text - The message.
* @returns {void}
*/
onPrivateMessage(id, text) {
switch (text) {
case 'video on':
this.onVideoOnMessage();
break;
}
}
/**
* Handles 'video on' private messages.
*
* @returns {void}
*/
onVideoOnMessage() {
console.debug(`Participant ${this.id}: Turning my video on!`);
const localVideoTrack = this.room.getLocalVideoTrack();
if (localVideoTrack && localVideoTrack.isMuted()) {
console.debug(`Participant ${this.id}: Unmuting existing video track.`);
localVideoTrack.unmute();
} else if (!localVideoTrack) {
JitsiMeetJS.createLocalTracks({ devices: ['video'] })
.then(([videoTrack]) => videoTrack)
.catch(console.error)
.then(videoTrack => {
return this.room.replaceTrack(null, videoTrack);
})
.then(() => {
console.debug(`Participant ${this.id}: Successfully added a new video track for unmute.`);
});
} else {
console.log(`Participant ${this.id}: No-op! We are already video unmuted!`);
}
}
/**
* This function is called to connect.
*/
connect() {
this._onConnectionSuccess = this.onConnectionSuccess.bind(this)
this._onConnectionFailed = this.onConnectionFailed.bind(this)
this._disconnect = this.disconnect.bind(this)
this.connection = new JitsiMeetJS.JitsiConnection(null, null, config);
this.connection.addEventListener(JitsiMeetJS.events.connection.CONNECTION_ESTABLISHED, this._onConnectionSuccess);
this.connection.addEventListener(JitsiMeetJS.events.connection.CONNECTION_FAILED, this._onConnectionFailed);
this.connection.addEventListener(JitsiMeetJS.events.connection.CONNECTION_DISCONNECTED, this._disconnect);
this.connection.connect();
}
/**
* That function is called when connection is established successfully
*/
onConnectionSuccess() {
this.room = this.connection.initJitsiConference(roomName.toLowerCase(), config);
this.room.on(JitsiMeetJS.events.conference.STARTED_MUTED, this.onStartMuted.bind(this));
this.room.on(JitsiMeetJS.events.conference.TRACK_ADDED, this.onRemoteTrack.bind(this));
this.room.on(JitsiMeetJS.events.conference.CONFERENCE_JOINED, this.onConferenceJoined.bind(this));
this.room.on(JitsiMeetJS.events.conference.CONNECTION_ESTABLISHED, this.onConnectionEstablished.bind(this));
this.room.on(JitsiMeetJS.events.conference.USER_JOINED, this.onUserJoined.bind(this));
this.room.on(JitsiMeetJS.events.conference.USER_LEFT, this.onUserLeft.bind(this));
this.room.on(JitsiMeetJS.events.conference.PRIVATE_MESSAGE_RECEIVED, this.onPrivateMessage.bind(this));
if (stageView) {
this.room.on(JitsiMeetJS.events.conference.DOMINANT_SPEAKER_CHANGED, this.onDominantSpeakerChanged.bind(this));
}
const devices = [];
if (localVideo) {
devices.push('video');
}
// we always create audio local tracks
devices.push('audio');
if (devices.length > 0) {
JitsiMeetJS.createLocalTracks({ devices })
.then(this.onLocalTracks.bind(this))
.then(() => {
this.room.join();
})
.catch(error => {
throw error;
});
} else {
this.room.join();
}
this.updateMaxFrameHeight();
}
/**
* This function is called when the connection fail.
*/
onConnectionFailed() {
console.error(`Participant ${this.id}: Connection Failed!`);
}
/**
* This function is called when we disconnect.
*/
disconnect() {
console.log('disconnect!');
this.connection.removeEventListener(
JitsiMeetJS.events.connection.CONNECTION_ESTABLISHED,
this._onConnectionSuccess);
this.connection.removeEventListener(
JitsiMeetJS.events.connection.CONNECTION_FAILED,
this._onConnectionFailed);
this.connection.removeEventListener(
JitsiMeetJS.events.connection.CONNECTION_DISCONNECTED,
this._disconnect);
}
}
let clients = [];
window.APP = { window.APP = {
conference: { conference: {
getStats() { getStats() {
return room.connectionQuality.getStats(); return clients[0]?.room?.connectionQuality.getStats();
}, },
getConnectionState() { getConnectionState() {
return room && room.getConnectionState(); return clients[0] && clients[0].room && room.getConnectionState();
}, },
muteAudio(mute) { muteAudio(mute) {
localAudio = mute; localAudio = mute;
for (let i = 0; i < localTracks.length; i++) { for (let j = 0; j < clients.length; j++) {
if (localTracks[i].getType() === 'audio') { for (let i = 0; i < clients[j].localTracks.length; i++) {
if (mute) { if (clients[j].localTracks[i].getType() === 'audio') {
localTracks[i].mute(); if (mute) {
} clients[j].localTracks[i].mute();
else { }
localTracks[i].unmute(); else {
clients[j].localTracks[i].unmute();
// if track was not added we need to add it to the peerconnection
if (!room.getLocalAudioTrack()) { // if track was not added we need to add it to the peerconnection
room.replaceTrack(null, localTracks[i]); if (!clients[j].room.getLocalAudioTrack()) {
clients[j].room.replaceTrack(null, clients[j].localTracks[i]);
}
} }
} }
} }
@ -67,19 +457,19 @@ window.APP = {
}, },
get room() { get room() {
return room; return clients[0]?.room;
}, },
get connection() { get connection() {
return connection; return clients[0]?.connection;
}, },
get numParticipants() { get numParticipants() {
return numParticipants; return clients[0]?.remoteParticipants;
}, },
get localTracks() { get localTracks() {
return localTracks; return clients[0]?.localTracks;
}, },
get remoteTracks() { get remoteTracks() {
return remoteTracks; return clients[0]?.remoteTracks;
}, },
get params() { get params() {
return { return {
@ -94,368 +484,18 @@ window.APP = {
} }
}; };
/**
* Simple emulation of jitsi-meet's screen layout behavior
*/
function updateMaxFrameHeight() {
if (!connected) {
return;
}
let newMaxFrameHeight;
if (stageView) {
newMaxFrameHeight = 2160;
}
else {
if (numParticipants <= 2) {
newMaxFrameHeight = 720;
} else if (numParticipants <= 4) {
newMaxFrameHeight = 360;
} else {
newMaxFrameHeight = 180;
}
}
if (room && maxFrameHeight !== newMaxFrameHeight) {
maxFrameHeight = newMaxFrameHeight;
room.setReceiverVideoConstraint(maxFrameHeight);
}
}
/**
* Simple emulation of jitsi-meet's lastN behavior
*/
function updateLastN() {
if (!connected) {
return;
}
let lastN = typeof config.channelLastN === 'undefined' ? -1 : config.channelLastN;
const limitedLastN = limitLastN(numParticipants, validateLastNLimits(config.lastNLimits));
if (limitedLastN !== undefined) {
lastN = lastN === -1 ? limitedLastN : Math.min(limitedLastN, lastN);
}
if (lastN === room.getLastN()) {
return;
}
room.setLastN(lastN);
}
/**
* Helper function to query whether a participant ID is a valid ID
* for stage view.
*/
function isValidStageViewParticipant(id) {
return (id !== room.myUserId() && room.getParticipantById(id));
}
/**
* Simple emulation of jitsi-meet's stage view participant selection behavior.
* Doesn't take into account pinning or screen sharing, and the initial behavior
* is slightly different.
* @returns Whether the selected participant changed.
*/
function selectStageViewParticipant(selected, previous) {
let newSelectedParticipant;
if (isValidStageViewParticipant(selected)) {
newSelectedParticipant = selected;
}
else {
newSelectedParticipant = previous.find(isValidStageViewParticipant);
}
if (newSelectedParticipant && newSelectedParticipant !== selectedParticipant) {
selectedParticipant = newSelectedParticipant;
return true;
}
return false;
}
/**
* Simple emulation of jitsi-meet's selectParticipants behavior
*/
function selectParticipants() {
if (!connected) {
return;
}
if (stageView) {
if (selectedParticipant) {
room.selectParticipants([selectedParticipant]);
}
}
else {
/* jitsi-meet's current Tile View behavior. */
const ids = room.getParticipants().map(participant => participant.getId());
room.selectParticipants(ids);
}
}
/**
* Called when number of participants changes.
*/
function setNumberOfParticipants() {
$('#participants').text(numParticipants);
if (!stageView) {
selectParticipants();
updateMaxFrameHeight();
}
updateLastN();
}
/**
* Called when ICE connects
*/
function onConnectionEstablished() {
connected = true;
selectParticipants();
updateMaxFrameHeight();
updateLastN();
}
/**
* Handles dominant speaker changed.
* @param id
*/
function onDominantSpeakerChanged(selected, previous) {
if (selectStageViewParticipant(selected, previous)) {
selectParticipants();
}
updateMaxFrameHeight();
}
/**
* Handles local tracks.
* @param tracks Array with JitsiTrack objects
*/
function onLocalTracks(tracks = []) {
localTracks = tracks;
for (let i = 0; i < localTracks.length; i++) {
if (localTracks[i].getType() === 'video') {
$('body').append(`<video ${autoPlayVideo ? 'autoplay="1" ' : ''}id='localVideo${i}' />`);
localTracks[i].attach($(`#localVideo${i}`)[0]);
room.addTrack(localTracks[i]);
} else {
if (localAudio) {
room.addTrack(localTracks[i]);
} else {
localTracks[i].mute();
}
$('body').append(
`<audio autoplay='1' muted='true' id='localAudio${i}' />`);
localTracks[i].attach($(`#localAudio${i}`)[0]);
}
}
}
/**
* Handles remote tracks
* @param track JitsiTrack object
*/
function onRemoteTrack(track) {
if (track.isLocal()
|| (track.getType() === 'video' && !remoteVideo) || (track.getType() === 'audio' && !remoteAudio)) {
return;
}
const participant = track.getParticipantId();
if (!remoteTracks[participant]) {
remoteTracks[participant] = [];
}
const idx = remoteTracks[participant].push(track);
const id = participant + track.getType() + idx;
if (track.getType() === 'video') {
$('body').append(`<video autoplay='1' id='${id}' />`);
} else {
$('body').append(`<audio autoplay='1' id='${id}' />`);
}
track.attach($(`#${id}`)[0]);
}
/**
* That function is executed when the conference is joined
*/
function onConferenceJoined() {
console.log('Conference joined');
}
/**
* Handles start muted events, when audio and/or video are muted due to
* startAudioMuted or startVideoMuted policy.
*/
function onStartMuted() {
// Give it some time, as it may be currently in the process of muting
setTimeout(() => {
const localAudioTrack = room.getLocalAudioTrack();
if (localAudio && localAudioTrack && localAudioTrack.isMuted()) {
localAudioTrack.unmute();
}
const localVideoTrack = room.getLocalVideoTrack();
if (localVideo && localVideoTrack && localVideoTrack.isMuted()) {
localVideoTrack.unmute();
}
}, 2000);
}
/**
*
* @param id
*/
function onUserJoined(id) {
numParticipants++;
setNumberOfParticipants();
remoteTracks[id] = [];
}
/**
*
* @param id
*/
function onUserLeft(id) {
numParticipants--;
setNumberOfParticipants();
if (!remoteTracks[id]) {
return;
}
const tracks = remoteTracks[id];
for (let i = 0; i < tracks.length; i++) {
const container = $(`#${id}${tracks[i].getType()}${i + 1}`)[0];
if (container) {
tracks[i].detach(container);
container.parentElement.removeChild(container);
}
}
}
/**
* Handles private messages.
*
* @param {string} id - The sender ID.
* @param {string} text - The message.
* @returns {void}
*/
function onPrivateMessage(id, text) {
switch(text) {
case 'video on':
onVideoOnMessage();
break;
}
}
/**
* Handles 'video on' private messages.
*
* @returns {void}
*/
function onVideoOnMessage() {
console.debug('Turning my video on!');
const localVideoTrack = room.getLocalVideoTrack();
if (localVideoTrack && localVideoTrack.isMuted()) {
console.debug('Unmuting existing video track.');
localVideoTrack.unmute();
} else if (!localVideoTrack) {
JitsiMeetJS.createLocalTracks({ devices: [ 'video' ] })
.then(([ videoTrack ]) => videoTrack)
.catch(console.error)
.then(videoTrack => {
return room.replaceTrack(null, videoTrack);
})
.then(() => {
console.debug('Successfully added a new video track for unmute.');
});
} else {
console.log('No-op! We are already video unmuted!');
}
}
/**
* That function is called when connection is established successfully
*/
function onConnectionSuccess() {
room = connection.initJitsiConference(roomName.toLowerCase(), config);
room.on(JitsiMeetJS.events.conference.STARTED_MUTED, onStartMuted);
room.on(JitsiMeetJS.events.conference.TRACK_ADDED, onRemoteTrack);
room.on(JitsiMeetJS.events.conference.CONFERENCE_JOINED, onConferenceJoined);
room.on(JitsiMeetJS.events.conference.CONNECTION_ESTABLISHED, onConnectionEstablished);
room.on(JitsiMeetJS.events.conference.USER_JOINED, onUserJoined);
room.on(JitsiMeetJS.events.conference.USER_LEFT, onUserLeft);
room.on(JitsiMeetJS.events.conference.PRIVATE_MESSAGE_RECEIVED, onPrivateMessage);
if (stageView) {
room.on(JitsiMeetJS.events.conference.DOMINANT_SPEAKER_CHANGED, onDominantSpeakerChanged);
}
const devices = [];
if (localVideo) {
devices.push('video');
}
// we always create audio local tracks
devices.push('audio');
if (devices.length > 0) {
JitsiMeetJS.createLocalTracks({ devices })
.then(onLocalTracks)
.then(() => {
room.join();
})
.catch(error => {
throw error;
});
} else {
room.join();
}
updateMaxFrameHeight();
}
/**
* This function is called when the connection fail.
*/
function onConnectionFailed() {
console.error('Connection Failed!');
}
/**
* This function is called when we disconnect.
*/
function disconnect() {
console.log('disconnect!');
connection.removeEventListener(
JitsiMeetJS.events.connection.CONNECTION_ESTABLISHED,
onConnectionSuccess);
connection.removeEventListener(
JitsiMeetJS.events.connection.CONNECTION_FAILED,
onConnectionFailed);
connection.removeEventListener(
JitsiMeetJS.events.connection.CONNECTION_DISCONNECTED,
disconnect);
}
/** /**
* *
*/ */
function unload() { function unload() {
for (let i = 0; i < localTracks.length; i++) { for (let j = 0; j < clients.length; j++) {
localTracks[i].dispose(); for (let i = 0; i < clients[j].localTracks.length; i++) {
clients[j].localTracks[i].dispose();
}
clients[j].room.leave();
clients[j].connection.disconnect();
} }
room.leave(); clients = [];
connection.disconnect();
} }
$(window).bind('beforeunload', unload); $(window).bind('beforeunload', unload);
@ -470,8 +510,14 @@ if (config.websocketKeepAliveUrl) {
config.websocketKeepAliveUrl += `?room=${roomName.toLowerCase()}`; config.websocketKeepAliveUrl += `?room=${roomName.toLowerCase()}`;
} }
connection = new JitsiMeetJS.JitsiConnection(null, null, config); function startClient(i) {
connection.addEventListener(JitsiMeetJS.events.connection.CONNECTION_ESTABLISHED, onConnectionSuccess); clients[i] = new LoadTestClient(i);
connection.addEventListener(JitsiMeetJS.events.connection.CONNECTION_FAILED, onConnectionFailed); clients[i].connect();
connection.addEventListener(JitsiMeetJS.events.connection.CONNECTION_DISCONNECTED, disconnect); if (i + 1 < numClients) {
connection.connect(); setTimeout(() => { startClient(i+1) }, clientInterval)
}
}
if (numClients > 0) {
startClient(0)
}