snapshot of https://github.com/jitsi/jitsi-meet.git @ fd1749dd28f4138aa80234fc7f311b19aaffa0e7 (2026-09-15)
Some checks failed
Prosody Plugin Tests / test (push) Failing after 10m11s
Close stale issues and PRs / stale (push) Failing after 1s

This commit is contained in:
upstream-mirror
2026-09-15 15:56:32 +05:00
commit 1d2bfcac0c
3159 changed files with 464524 additions and 0 deletions

1193
tests/helpers/Participant.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,128 @@
/**
* An interface that tests can export (as a TEST_PROPERTIES property) to define what they require.
*/
export type ITestProperties = {
/**
* A more detailed description of what the test does, to be included in the Allure report.
*/
description?: string;
/** The test requires the webhook proxy to be available. */
requireWebhookProxy: boolean;
/** Whether the test should be retried. */
retry: boolean;
/** The test requires jaas, it should be skipped when the jaas configuration is not enabled. */
useJaas: boolean;
/** The test uses the webhook proxy if available. */
useWebhookProxy: boolean;
usesBrowsers: string[];
};
const defaultProperties: ITestProperties = {
useWebhookProxy: false,
requireWebhookProxy: false,
retry: false,
useJaas: false,
usesBrowsers: [ 'p1' ]
};
/**
* Maps a test filename to its registered properties.
*/
export const testProperties: Record<string, ITestProperties> = {};
/**
* Set properties for a test file. This was needed because I couldn't find a hook that executes with describe() before
* the code in wdio.conf.ts's before() hook. The intention is for tests to execute this directly. The properties don't
* change dynamically.
*
* @param filename the absolute path to the test file
* @param properties the properties to set for the test file, defaults will be applied for missing properties
*/
export function setTestProperties(filename: string, properties: Partial<ITestProperties>): void {
if (testProperties[filename]) {
console.warn(`Test properties for ${filename} are already set. Overwriting.`);
}
testProperties[filename] = { ...defaultProperties, ...properties };
}
let testFilesLoaded = false;
/**
* Loads test files to populate the testProperties registry. This function:
* 1. Mocks test framework globals to prevent test registration
* 2. require()s each file to trigger setTestProperties calls
* 3. Restores original test framework functions
*
* @param files - Array of file names to load
*/
export function loadTestFiles(files: string[]): void {
if (testFilesLoaded) {
return;
}
// Temporarily override test functions to prevent tests registering at this stage. We only want TestProperties to be
// loaded.
const originalTestFunctions: Record<string, any> = {};
const testGlobals = [ 'describe', 'it', 'test', 'expect', 'beforeEach', 'afterEach', 'before', 'after', 'beforeAll',
'afterAll', 'suite', 'setup', 'teardown' ];
testGlobals.forEach(fn => {
originalTestFunctions[fn] = (global as any)[fn];
const stub: any = () => {
// do nothing
};
// Tolerate any expect.extend({...}) calls a spec or helper might do at module load.
if (fn === 'expect') {
stub.extend = () => { /* no-op */ };
}
(global as any)[fn] = stub;
});
try {
// Load all spec files to trigger setTestProperties calls
files.forEach(file => {
try {
require(file);
if (!testProperties[file]) {
// If no properties were set, apply defaults
setTestProperties(file, { ...defaultProperties });
}
} catch (error) {
console.warn(`Warning: Could not analyze ${file}:`, (error as Error).message);
}
});
testFilesLoaded = true;
} finally {
// Restore original functions
testGlobals.forEach(fn => {
if (originalTestFunctions[fn] !== undefined) {
(global as any)[fn] = originalTestFunctions[fn];
} else {
delete (global as any)[fn];
}
});
// Clear require cache for analyzed files so they can be loaded fresh by WebDriverIO
files.forEach(file => {
delete require.cache[file];
});
}
}
/**
* @param testFilePath - The absolute path to the test file
* @returns Promise<ITestProperties> - The test properties with defaults applied
*/
export async function getTestProperties(testFilePath: string): Promise<ITestProperties> {
const properties = testProperties[testFilePath] || { ...defaultProperties };
if (properties.requireWebhookProxy && !properties.useWebhookProxy) {
properties.useWebhookProxy = true;
}
return properties;
}

View File

@@ -0,0 +1,76 @@
/**
* Global configuration that the tests are run with. Loaded from environment variables.
*/
export const config = {
/** Enable debug logging. Note this includes private information from .env */
debug: Boolean(process.env.JITSI_DEBUG?.trim()),
expectationsFile: process.env.EXPECTATIONS?.trim(),
jaas: {
customerId: (() => {
if (typeof process.env.JAAS_TENANT !== 'undefined') {
return process.env.JAAS_TENANT?.trim()?.replace('vpaas-magic-cookie-', '');
}
return process.env.IFRAME_TENANT?.trim()?.replace('vpaas-magic-cookie-', '');
})(),
/** Whether the configuration for JaaS specific tests is enabled. */
enabled: Boolean(
(process.env.JAAS_TENANT || process.env.IFRAME_TENANT)
&& (process.env.JAAS_PRIVATE_KEY_PATH || process.env.JWT_PRIVATE_KEY_PATH)
&& (process.env.JAAS_KID || process.env.JWT_KID)),
/** The JaaS key ID, used to sign the tokens. */
kid: (() => {
if (typeof process.env.JAAS_KID !== 'undefined') {
return process.env.JAAS_KID?.trim();
}
return process.env.JWT_KID?.trim();
})(),
/** The path to the JaaS private key, used to sign JaaS tokens. */
privateKeyPath: (() => {
if (typeof process.env.JAAS_PRIVATE_KEY_PATH != 'undefined') {
return process.env.JAAS_PRIVATE_KEY_PATH?.trim();
}
return process.env.JWT_PRIVATE_KEY_PATH?.trim();
})(),
/** The JaaS tenant (vpaas-magic-cookie-<ID>) . */
tenant: (() => {
if (typeof process.env.JAAS_TENANT !== 'undefined') {
return process.env.JAAS_TENANT?.trim();
}
return process.env.IFRAME_TENANT?.trim();
})()
},
jwt: {
kid: process.env.JWT_KID?.trim(),
/** A pre-configured token used by some tests. */
preconfiguredJwt: process.env.JWT_ACCESS_TOKEN?.trim(),
preconfiguredToken: (() => {
if (process.env.JWT_ACCESS_TOKEN) {
return { jwt: process.env.JWT_ACCESS_TOKEN?.trim() };
}
return undefined;
})(),
privateKeyPath: process.env.JWT_PRIVATE_KEY_PATH?.trim()
},
roomName: {
/** Optional fixed room name that overrides the generated one entirely. */
name: process.env.ROOM_NAME?.trim(),
/** Optional prefix for room names used for tests. */
prefix: process.env.ROOM_NAME_PREFIX?.trim(),
/** Optional suffix for room names used for tests. */
suffix: process.env.ROOM_NAME_SUFFIX?.trim()
},
webhooksProxy: {
enabled: Boolean(process.env.WEBHOOKS_PROXY_URL && process.env.WEBHOOKS_PROXY_SHARED_SECRET),
sharedSecret: process.env.WEBHOOKS_PROXY_SHARED_SECRET?.trim(),
url: process.env.WEBHOOKS_PROXY_URL?.trim(),
}
};
if (config.debug) {
console.log('TestsConfig:', JSON.stringify(config, null, 2));
}

View File

@@ -0,0 +1,241 @@
import fs from 'node:fs';
import WebSocket from 'ws';
/**
* Uses the webhook proxy service to proxy events to the testing clients.
*/
export default class WebhookProxy {
private readonly url;
private readonly secret;
private logFile;
private ws: WebSocket | undefined;
private cache = new Map<string, any[]>();
private listeners = new Map();
private consumers = new Map<string, Array<{
callback: (event: any) => void;
predicate?: (event: any) => boolean;
}>>();
private _defaultMeetingSettings: object | undefined;
/**
* Initializes the webhook proxy.
* @param url
* @param secret
* @param logFile
*/
constructor(url: string, secret: string, logFile: string) {
this.url = url;
this.secret = secret;
this.logFile = logFile;
}
/**
* Connects.
*/
connect() {
this.ws = new WebSocket(this.url, {
headers: {
Authorization: this.secret
}
});
this.ws.on('error', console.error);
this.ws.on('open', () => {
console.log('WebhookProxy connected');
this.logInfo('connected');
});
this.ws.on('message', (data: any) => {
const msg = JSON.parse(data.toString());
this.logInfo(`${msg.eventType} event: ${JSON.stringify(msg)}`);
if (msg.eventType) {
let processed = false;
if (this.consumers.has(msg.eventType)) {
const list = this.consumers.get(msg.eventType)!;
const idx = list.findIndex(c => !c.predicate || c.predicate(msg));
if (idx !== -1) {
const consumer = list[idx];
list.splice(idx, 1);
if (list.length === 0) {
this.consumers.delete(msg.eventType);
}
consumer.callback(msg);
processed = true;
}
}
if (!processed) {
if (!this.cache.has(msg.eventType)) {
this.cache.set(msg.eventType, []);
}
this.cache.get(msg.eventType)!.push(msg);
}
if (this.listeners.has(msg.eventType)) {
this.listeners.get(msg.eventType)(msg);
processed = true;
}
if (!processed && msg.eventType === 'SETTINGS_PROVISIONING') {
// just in case to not be empty
let response: any = { someField: 'someValue' };
if (this._defaultMeetingSettings) {
response = this._defaultMeetingSettings;
}
this.ws?.send(JSON.stringify(response));
}
}
});
}
/**
* Adds event consumer. Consumers receive the event single time and we remove them from the list of consumers.
* @param eventType
* @param callback
* @param predicate Optional filter; only events for which predicate returns true are consumed.
*/
addConsumer(eventType: string, callback: (event: any) => void, predicate?: (event: any) => boolean) {
if (this.cache.has(eventType)) {
const list = this.cache.get(eventType)!;
const idx = list.findIndex(e => !predicate || predicate(e));
if (idx !== -1) {
const event = list[idx];
list.splice(idx, 1);
if (list.length === 0) {
this.cache.delete(eventType);
}
callback(event);
return;
}
}
if (!this.consumers.has(eventType)) {
this.consumers.set(eventType, []);
}
this.consumers.get(eventType)!.push({ callback, predicate });
}
/**
* Clear any stored event.
*/
clearCache() {
this.logInfo('cache cleared');
this.cache.clear();
}
/**
* Waits for the event to be received.
* @param eventType
* @param predicateOrTimeout Optional predicate to filter events, or a timeout in ms.
* @param timeout
*/
async waitForEvent(
eventType: string,
predicateOrTimeout?: ((event: any) => boolean) | number,
timeout = 120000): Promise<any> {
let predicate: ((event: any) => boolean) | undefined;
let actualTimeout = timeout;
if (typeof predicateOrTimeout === 'function') {
predicate = predicateOrTimeout;
} else if (typeof predicateOrTimeout === 'number') {
actualTimeout = predicateOrTimeout;
}
// we create the error here so we have a meaningful stack trace
const error = new Error(`Timeout waiting for event:${eventType}`);
return new Promise((resolve, reject) => {
const waiter = setTimeout(() => {
this.logInfo(error.message);
return reject(error);
}, actualTimeout);
this.addConsumer(eventType, event => {
clearTimeout(waiter);
resolve(event);
}, predicate);
});
}
/**
* Adds a listener for the event type.
* @param eventType
* @param callback
*/
addListener(eventType: string, callback: (data: any) => void) {
this.listeners.set(eventType, callback);
}
/**
* Adds a listener for the event type.
* @param eventType
*/
removeListener(eventType: string) {
this.listeners.delete(eventType);
}
/**
* Disconnects the webhook proxy.
*/
disconnect() {
if (this.ws) {
this.ws.close();
console.log('WebhookProxy disconnected');
this.ws = undefined;
this.logInfo('disconnected');
}
}
/**
* Logs a message in the logfile.
*
* @param {string} message - The message to add.
* @returns {void}
*/
logInfo(message: string) {
try {
// @ts-ignore
fs.appendFileSync(this.logFile, `${new Date().toISOString()} ${message}\n`);
} catch (err) {
console.error(err);
}
}
/**
* Sets the settings provider.
* @param value
*/
set defaultMeetingSettings(value: {
asyncTranscriptions?: boolean;
autoAudioRecording?: boolean;
autoTranscriptions?: boolean;
autoVideoRecording?: boolean;
lobbyEnabled?: boolean;
lobbyType?: 'WAIT_FOR_APPROVAL' | 'WAIT_FOR_MODERATOR';
maxOccupants?: number;
outboundPhoneNo?: string;
participantsSoftLimit?: number;
passcode?: string;
transcriberType?: 'GOOGLE' | 'ORACLE_CLOUD_AI_SPEECH' | 'EGHT_WHISPER';
visitorsEnabled?: boolean;
visitorsLive?: boolean;
}) {
this._defaultMeetingSettings = value;
}
}

View File

@@ -0,0 +1,83 @@
import fs from 'node:fs';
/**
* A prefix to use for all messages we add to the console log.
*/
export const LOG_PREFIX = '[MeetTest] ';
/**
* Initialize logger for a driver.
*
* @param {WebdriverIO.Browser} driver - The driver.
* @param {string} fileName - The name of the file.
* @param {string} folder - The folder to save the file.
* @returns {void}
*/
export function initLogger(driver: WebdriverIO.Browser, fileName: string, folder: string) {
// @ts-ignore
driver.logFile = `${folder}/${fileName}.log`;
driver.sessionSubscribe({ events: [ 'log.entryAdded' ] });
driver.on('log.entryAdded', (entry: any) => {
try {
// @ts-ignore
fs.appendFileSync(driver.logFile, `${entry.text}\n`);
} catch (err) {
console.error(err);
}
});
}
/**
* Returns the content of the log file.
*
* @param {WebdriverIO.Browser} driver - The driver which log file is requested.
* @returns {string} The content of the log file.
*/
export function getLogs(driver: WebdriverIO.Browser) {
// @ts-ignore
if (!driver.logFile) {
return;
}
// @ts-ignore
return fs.readFileSync(driver.logFile, 'utf8');
}
/**
* Appends value to the log file.
* @param {WebdriverIO.Browser} driver - The driver which log file is requested.
* @param {string} value - The content to add to the file.
*/
export function saveLogs(driver: WebdriverIO.Browser, value: string) {
// @ts-ignore
if (!driver.logFile) {
return;
}
try {
// @ts-ignore
fs.appendFileSync(driver.logFile, value);
} catch (err) {
console.error(err);
}
}
/**
* Logs a message in the logfile.
*
* @param {WebdriverIO.Browser} driver - The participant in which log file to write.
* @param {string} message - The message to add.
* @returns {void}
*/
export async function logInfo(driver: WebdriverIO.Browser, message: string) {
// @ts-ignore
if (!driver.logFile) {
return;
}
return driver.execute((prefix, msg) =>
console.log(`${new Date().toISOString()} ${prefix} ${msg}\n`),
LOG_PREFIX, message);
}

View File

@@ -0,0 +1,88 @@
import fs from 'fs';
import { merge } from 'lodash-es';
import { config } from './TestsConfig';
const defaultExpectations = {
dialIn: {
/*
* The dial-in functionality is enabled.
* true -> assert the config is enabled, the UI elements are displayed, and the feature works.
* false -> assert the config is disabled and the UI elements are not displayed.
* null -> if the config is enabled, assert the UI elements are displayed and the feature works.
*/
enabled: null,
minPinLength: 8,
/* Whether to verify that the "more numbers" page is displayed. Note that only the positive verification is
performed: when set to 'false' we do not assert that the "more numbers" page is not displayed. */
moreNumbersPage: true
},
iframe: {
// Whether the iframe integration is enabled (the inverse of `disableIframeAPI` from config.js)
enabled: true
},
jaas: {
fileSharingEnabled: true,
liveStreamingEnabled: true,
recordingEnabled: true,
transcriptionEnabled: true,
transcription: {
// Expect "async" transcription to be enabled.
asyncTranscription: true,
// Expect the JaaS transcription webhooks to fire in "async" mode.
asyncTranscriptionWebhook: true,
// Expect (deprecated) jigasi-based transcription to be enabled.
jigasiTranscription: true
},
/**
* Whether the jaas account is configured with the account-level setting to allow unauthenticated users to join.
*/
unauthenticatedJoins: false,
visitors: true
},
moderation: {
// Everyone is a moderator.
allModerators: false,
// When a moderator leaves, another one is elected.
autoModerator: true,
// The first to join is a moderator.
firstModerator: true,
// The grantOwner function is available.
grantModerator: true,
// Whether the ability to set a password is available (there's a backend options which makes moderators unable
// to set a room password unless they also happen to have a token (any valid token?))
setPasswordAvailable: true
},
connectivity: {
jvb: {
direct: true,
turn: true
},
p2p: {
direct: false,
turn: true
}
},
// We can create conferences under any tenant.
useTenant: true
};
let overrides: any = {};
if (config.expectationsFile) {
try {
const str = fs.readFileSync(config.expectationsFile, 'utf8');
// Remove comments and multiline comments.
overrides = JSON.parse(str.replace(/\/\/.*|\/\*[\s\S]*?\*\//g, ''));
} catch (e) {
console.error('Error reading expectations file', e);
}
console.log('Loaded expectations from', config.expectationsFile);
}
export const expectations = merge(defaultExpectations, overrides);
if (!process.env.WDIO_WORKER_ID) {
console.log('Expectations:', expectations);
}

41
tests/helpers/jaas.ts Normal file
View File

@@ -0,0 +1,41 @@
import { Participant } from './Participant';
import { config } from './TestsConfig';
import { joinMuc } from './joinMuc';
import { IToken, ITokenOptions, generateToken } from './token';
import { IParticipantJoinOptions, IParticipantOptions } from './types';
export function generateJaasToken(options: ITokenOptions): IToken {
if (!config.jaas.enabled) {
throw new Error('JaaS is not configured.');
}
// Don't override the keyId and keyPath if they are already set in options, allow tests to set them.
return generateToken({
...options,
keyId: options.keyId || config.jaas.kid,
keyPath: options.keyPath || config.jaas.privateKeyPath
});
}
/**
* Creates a new Participant and joins the MUC with the given options. The jaas-specific properties must be set as
* environment variables (see env.example and TestsConfig.ts). If no room name is specified, the default room name
* from the context is used.
*
* @param participantOptions
* @param joinOptions options to use when joining the MUC.
* @returns {Promise<Participant>} The Participant that has joined the MUC.
*/
export async function joinJaasMuc(
participantOptions?: Partial<IParticipantOptions>,
joinOptions?: Partial<IParticipantJoinOptions>): Promise<Participant> {
if (!config.jaas.enabled) {
throw new Error('JaaS is not configured.');
}
return await joinMuc(participantOptions, {
...joinOptions,
tenant: joinOptions?.tenant || config.jaas.tenant
});
}

50
tests/helpers/joinMuc.ts Normal file
View File

@@ -0,0 +1,50 @@
import { Participant } from './Participant';
import { IParticipantJoinOptions, IParticipantOptions } from './types';
/**
* Creates a new Participant and joins the MUC with the given options. If no room name is specified, the default room
* name from the context is used.
*
* @param participantOptions
* @param joinOptions options to use when joining the MUC.
* @returns {Promise<Participant>} The Participant that has joined the MUC.
*/
export async function joinMuc(
participantOptions?: Partial<IParticipantOptions>,
joinOptions?: Partial<IParticipantJoinOptions>): Promise<Participant> {
const name = participantOptions?.name || 'p1';
// @ts-ignore
const p = ctx[name] as Participant;
if (p) {
// Load a blank page to make sure the page is reloaded (in case the new participant uses the same URL). Using
// 'about:blank' was causing problems in the past, if we notice any issues we can change to "base.html".
await p.driver.url('about:blank');
}
const newParticipant = new Participant({
iFrameApi: participantOptions?.iFrameApi || false,
name,
token: participantOptions?.token
});
// @ts-ignore
ctx[name] = newParticipant;
return await newParticipant.joinConference({
...joinOptions,
roomName: joinOptions?.roomName || ctx.roomName,
});
}
/**
* Wait until all participants have ICE connected and have sent and received data (their PC stats are ready).
* @param participants
*/
export async function waitForMedia(participants: Participant[]) {
await Promise.all(participants.map(p =>
p.waitForIceConnected().then(() => p.waitForSendReceiveData())
));
}

48
tests/helpers/matchers.ts Normal file
View File

@@ -0,0 +1,48 @@
// Register custom matchers on the global expect set up by @wdio/runner during _initSession.
// Must be called from a wdio config hook that runs after _initSession (e.g. `before`); calling
// at module load extends a stale `globalThis.expect` that gets replaced before tests run.
export function registerCustomMatchers(): void {
expect.extend({
toPartiallyMatch(received: string, other: string, receivedLabel = 'received', otherLabel = 'other') {
const pass = received.includes(other) || other.includes(received);
return {
pass,
message: () => pass
? `Expected ${receivedLabel} NOT to partially match ${otherLabel}.\n ${receivedLabel}: "${received}"\n ${otherLabel}: "${other}"`
: `Expected ${receivedLabel} to partially match ${otherLabel} (one should contain the other).\n ${receivedLabel}: "${received}"\n ${otherLabel}: "${other}"`
};
},
toStartWith(received: string, prefix: string, label = 'value') {
const pass = received.startsWith(prefix);
return {
pass,
message: () => pass
? `Expected ${label} NOT to start with "${prefix}".\n ${label}: "${received}"`
: `Expected ${label} to start with "${prefix}".\n ${label}: "${received}"`
};
},
toBeInteger(received: unknown, label = 'value') {
const pass = Number.isInteger(received);
return {
pass,
message: () => pass
? `Expected ${label} NOT to be an integer.\n ${label}: ${JSON.stringify(received)}`
: `Expected ${label} to be an integer.\n ${label}: ${JSON.stringify(received)}`
};
}
});
}
declare module 'expect' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface Matchers<R> {
toBeInteger: (label?: string) => R;
toPartiallyMatch: (other: string, receivedLabel?: string, otherLabel?: string) => R;
toStartWith: (prefix: string, label?: string) => R;
}
}

View File

@@ -0,0 +1,330 @@
import { P1, P2, P3, P4, P5, P6, Participant } from './Participant';
import { config } from './TestsConfig';
import { IJoinOptions, IParticipantOptions } from './types';
const SUBJECT_XPATH = '//div[starts-with(@class, "subject-text")]';
/**
* Ensure that there is on participant.
* Ensure that the first participant is moderator if there is such an option.
*
* @param {IJoinOptions} options - The options to use when joining the participant.
* @param participantOptions
* @returns {Promise<void>}
*/
export async function ensureOneParticipant(
options?: IJoinOptions, participantOptions?: IParticipantOptions): Promise<void> {
if (!participantOptions) {
participantOptions = { name: P1 };
}
participantOptions.name = P1;
if (!participantOptions.token) {
participantOptions.token = config.jwt.preconfiguredToken;
}
// make sure the first participant is moderator, if supported by deployment
await joinParticipant(participantOptions, options);
}
/**
* Ensure that there are three participants.
*
* @param {IJoinOptions} options - The options to use when joining the participant.
* @returns {Promise<void>}
*/
export async function ensureThreeParticipants(options?: IJoinOptions): Promise<void> {
await ensureOneParticipant(options);
// these need to be all, so we get the error when one fails
await Promise.all([
joinParticipant({ name: P2 }, options),
joinParticipant({ name: P3 }, options)
]);
if (options?.skipInMeetingChecks) {
return Promise.resolve();
}
await Promise.all([
ctx.p1.waitForIceConnected(),
ctx.p2.waitForIceConnected(),
ctx.p3.waitForIceConnected()
]);
await Promise.all([
ctx.p1.waitForSendReceiveData().then(() => ctx.p1.waitForRemoteStreams(1)),
ctx.p2.waitForSendReceiveData().then(() => ctx.p2.waitForRemoteStreams(1)),
ctx.p3.waitForSendReceiveData().then(() => ctx.p3.waitForRemoteStreams(1)),
]);
}
/**
* Creates the first participant instance or prepares one for re-joining.
*
* @param {IJoinOptions} options - The options to use when joining the participant.
* @returns {Promise<void>}
*/
export function joinFirstParticipant(options: IJoinOptions = { }): Promise<void> {
return ensureOneParticipant(options);
}
/**
* Creates the second participant instance or prepares one for re-joining.
*
* @param {IJoinOptions} options - The options to use when joining the participant.
* @returns {Promise<Participant>}
*/
export function joinSecondParticipant(options?: IJoinOptions): Promise<Participant> {
return joinParticipant({ name: P2 }, options);
}
/**
* Creates the third participant instance or prepares one for re-joining.
*
* @param {IJoinOptions} options - The options to use when joining the participant.
* @returns {Promise<Participant>}
*/
export function joinThirdParticipant(options?: IJoinOptions): Promise<Participant> {
return joinParticipant({ name: P3 }, options);
}
/**
* Ensure that there are four participants.
*
* @param {IJoinOptions} options - The options to use when joining the participant.
* @returns {Promise<void>}
*/
export async function ensureFourParticipants(options?: IJoinOptions): Promise<void> {
await ensureOneParticipant(options);
// these need to be all, so we get the error when one fails
await Promise.all([
joinParticipant({ name: P2 }, options),
joinParticipant({ name: P3 }, options),
joinParticipant({ name: P4 }, options)
]);
if (options?.skipInMeetingChecks) {
return Promise.resolve();
}
await Promise.all([
ctx.p1.waitForIceConnected(),
ctx.p2.waitForIceConnected(),
ctx.p3.waitForIceConnected(),
ctx.p4.waitForIceConnected()
]);
await Promise.all([
ctx.p1.waitForSendReceiveData().then(() => ctx.p1.waitForRemoteStreams(1)),
ctx.p2.waitForSendReceiveData().then(() => ctx.p2.waitForRemoteStreams(1)),
ctx.p3.waitForSendReceiveData().then(() => ctx.p3.waitForRemoteStreams(1)),
ctx.p4.waitForSendReceiveData().then(() => ctx.p4.waitForRemoteStreams(1)),
]);
}
/**
* Ensure that there are six participants.
*
* @param {IJoinOptions} options - The options to use when joining the participant.
* @returns {Promise<void>}
*/
export async function ensureSixParticipants(options?: IJoinOptions): Promise<void> {
await ensureOneParticipant(options);
// Join participants in batches
await Promise.all([
joinParticipant({ name: P2 }, options),
joinParticipant({ name: P3 }, options),
joinParticipant({ name: P4 }, options)
]);
await Promise.all([
joinParticipant({ name: P5 }, options),
joinParticipant({ name: P6 }, options)
]);
if (options?.skipInMeetingChecks) {
return Promise.resolve();
}
await Promise.all([
ctx.p1.waitForIceConnected(),
ctx.p2.waitForIceConnected(),
ctx.p3.waitForIceConnected(),
ctx.p4.waitForIceConnected(),
ctx.p5.waitForIceConnected(),
ctx.p6.waitForIceConnected()
]);
await Promise.all([
ctx.p1.waitForSendReceiveData().then(() => ctx.p1.waitForRemoteStreams(1)),
ctx.p2.waitForSendReceiveData().then(() => ctx.p2.waitForRemoteStreams(1)),
ctx.p3.waitForSendReceiveData().then(() => ctx.p3.waitForRemoteStreams(1)),
ctx.p4.waitForSendReceiveData().then(() => ctx.p4.waitForRemoteStreams(1)),
ctx.p5.waitForSendReceiveData().then(() => ctx.p5.waitForRemoteStreams(1)),
ctx.p6.waitForSendReceiveData().then(() => ctx.p6.waitForRemoteStreams(1))
]);
}
/**
* Ensure that there are two participants.
*
* @param {IJoinOptions} options - The options to join.
* @param participantOptions
*/
export async function ensureTwoParticipants(
options?: IJoinOptions, participantOptions?: IParticipantOptions): Promise<void> {
await ensureOneParticipant(options, participantOptions);
if (!participantOptions) {
participantOptions = { name: P2 };
}
participantOptions.name = P2;
await joinParticipant(participantOptions, options);
if (options?.skipInMeetingChecks) {
return Promise.resolve();
}
await Promise.all([
ctx.p1.waitForIceConnected(),
ctx.p2.waitForIceConnected()
]);
await Promise.all([
ctx.p1.waitForSendReceiveData().then(() => ctx.p1.waitForRemoteStreams(1)),
ctx.p2.waitForSendReceiveData().then(() => ctx.p2.waitForRemoteStreams(1))
]);
}
/**
* Creates a new participant instance, or returns an existing one if it is already joined.
* @param participantOptions - The participant options, with required name set.
* @param options - Join options.
* @returns {Promise<Participant>} - The participant instance.
*/
async function joinParticipant( // eslint-disable-line max-params
participantOptions: IParticipantOptions,
options?: IJoinOptions
): Promise<Participant> {
// @ts-ignore
const p = ctx[participantOptions.name] as Participant;
if (p) {
// Skip the isInMuc check (and the iframe context switch it requires) when the participant
// is already on base.html — they cannot be in the MUC and there is no iframe to switch into.
const alreadyOnBasePage = (await p.driver.getUrl()).endsWith('/base.html');
if (!alreadyOnBasePage) {
if (participantOptions.iFrameApi) {
await p.switchToIFrame();
}
if (await p.isInMuc()) {
return p;
}
if (participantOptions.iFrameApi) {
// when loading url make sure we are on the top page context or strange errors may occur
await p.switchToMainFrame();
}
// Change the page so we can reload same url if we need to, base.html is supposed to be empty or close to empty
await p.driver.url('/base.html');
}
}
const newParticipant = new Participant(participantOptions);
// set the new participant instance
// @ts-ignore
ctx[participantOptions.name] = newParticipant;
return await newParticipant.joinConference({
...options,
roomName: options?.roomName || ctx.roomName,
});
}
/**
* Parse a JID string.
* @param str the string to parse.
*/
export function parseJid(str: string): {
domain: string;
node: string;
resource: string | undefined;
} {
const parts = str.split('@');
const domainParts = parts[1].split('/');
return {
node: parts[0],
domain: domainParts[0],
resource: domainParts.length > 0 ? domainParts[1] : undefined
};
}
/**
* Check the subject of the participant.
* @param participant
* @param subject
*/
export async function checkSubject(participant: Participant, subject: string) {
const localTile = participant.driver.$(SUBJECT_XPATH);
await localTile.waitForExist();
await localTile.moveTo();
const txt = await localTile.getText();
expect(txt.startsWith(subject)).toBe(true);
}
/**
* Check if a screensharing tile is displayed on the observer.
* Expects there was already a video by this participant and screen sharing will be the second video `-v1`.
*/
export async function checkForScreensharingTile(sharer: Participant, observer: Participant, reverse = false) {
const selector = `//span[@id='participant_${await sharer.getEndpointId()}-v1']`;
// Re-runs the selector on every poll, unlike waitForDisplayed(), which keeps checking the element it
// matched first. Thumbnails get replaced by React while the wait is running, and losing the matched one
// is not something wdio recovers from here: over WebDriver BiDi the node handle is passed to the
// visibility check as a script argument, so Chrome rejects it with `invalid argument - Invalid input in
// "arguments"/0`, which wdio's refetch-on-stale handling does not recognize as staleness. Every
// remaining poll then reuses the dead handle, failing the wait while a tile is on screen.
await observer.driver.waitUntil(
async () => {
try {
const displayed = await observer.driver.$(selector).isDisplayed();
return displayed !== reverse;
} catch (e) {
// Look the element up again on the next poll.
return false;
}
},
{
timeout: 3_000,
timeoutMsg: `Screensharing tile of ${sharer.name} is ${
reverse ? 'still displayed' : 'not displayed'} on ${observer.name}`
}
);
}
/**
* Hangs up all participants (p1, p2, p3, p4, p5, and p6)
* @returns {Promise<void>}
*/
export function hangupAllParticipants() {
return Promise.all([
ctx.p1?.hangup(),
ctx.p2?.hangup(),
ctx.p3?.hangup(),
ctx.p4?.hangup(),
ctx.p5?.hangup(),
ctx.p6?.hangup()
].map(p => p ?? Promise.resolve()));
}

140
tests/helpers/token.ts Normal file
View File

@@ -0,0 +1,140 @@
import fs from 'fs';
import jwt from 'jsonwebtoken';
import { v4 as uuidv4 } from 'uuid';
import { config } from './TestsConfig';
export type ITokenOptions = {
displayName?: string;
/**
* The duration for which the token is valid, e.g. "1h" for one hour.
*/
exp?: string;
/**
* Additional JWT features to include (merged with the defaults).
* Keys are feature names (e.g. 'file-upload'), values are boolean or string.
*/
features?: Record<string, boolean | string>;
/**
* The key ID to use for the token.
* If not provided, the kid configured with environment variables will be used (see env.example).
*/
keyId?: string;
/**
* The path to the private key file used to sign the token.
* If not provided, the path configured with environment variables will be used (see env.example).
*/
keyPath?: string;
/**
* Whether to set the 'moderator' flag.
*/
moderator?: boolean;
/**
* The room for which the token is valid, or '*'. Defaults to '*'.
*/
room?: string;
sub?: string;
/**
* Whether to set the 'visitor' flag.
*/
visitor?: boolean;
};
export type IToken = {
/**
* The JWT headers, for easy reference.
*/
headers?: any;
/**
* The signed JWT.
*/
jwt: string;
/**
* The options used to generate the token.
*/
options?: ITokenOptions;
/**
* The token's payload, for easy reference.
*/
payload?: any;
};
export function generatePayload(options: ITokenOptions): any {
const payload = {
'aud': 'jitsi',
'iss': 'chat',
'sub': options?.sub || '',
'context': {
'user': {
'name': options.displayName,
'id': uuidv4(),
'avatar': 'https://avatars0.githubusercontent.com/u/3671647',
'email': 'john.doe@jitsi.org'
},
'group': uuidv4(),
'features': {
'outbound-call': 'true',
'transcription': 'true',
'recording': 'true',
'sip-outbound-call': true,
'livestreaming': true,
...options.features
},
},
'room': options.room || '*'
};
if (options.moderator) {
// @ts-ignore
payload.context.user.moderator = true;
} else if (options.visitor) {
// @ts-ignore
payload.context.user.role = 'visitor';
}
return payload;
}
/**
* Generate a signed token.
*/
export function generateToken(options: ITokenOptions): IToken {
const keyId = options.keyId || config.jwt.kid;
const keyPath = options.keyPath || config.jwt.privateKeyPath;
const headers = {
algorithm: 'RS256',
noTimestamp: true,
expiresIn: options.exp || '24h',
keyid: keyId,
};
if (!keyId) {
throw new Error('No keyId provided (JWT_KID is not set?)');
}
if (!keyPath) {
throw new Error('No keyPath provided (JWT_PRIVATE_KEY_PATH is not set?)');
}
const key = fs.readFileSync(keyPath);
const payload = generatePayload({
...options,
displayName: options?.displayName || '',
sub: keyId.substring(0, keyId.indexOf('/'))
});
return {
headers,
// @ts-ignore
jwt: jwt.sign(payload, key, headers),
options,
payload
};
}
/**
* Generated a signed token and return just the JWT string.
*/
export function generateJwt(options: ITokenOptions): string {
return generateToken(options).jwt;
}

142
tests/helpers/types.ts Normal file
View File

@@ -0,0 +1,142 @@
import { IConfig } from '../../react/features/base/config/configType';
import type { Participant } from './Participant';
import { ITestProperties } from './TestProperties';
import type WebhookProxy from './WebhookProxy';
import { IToken, ITokenOptions } from './token';
export type IContext = {
/**
* The name (describe block title) of the suite in which a test failed. Used to skip the remaining tests in that
* suite while still running tests in other describe blocks in the same file.
*/
failedSuite?: string;
/**
* The up-to-six browser instances provided by the framework. These can be initialized using
* ensureOneParticipant, ensureTwoParticipants, etc. from participants.ts.
**/
p1: Participant;
p2: Participant;
p3: Participant;
p4: Participant;
p5: Participant;
p6: Participant;
/** A room name automatically generated by the framework for convenience. */
roomName: string;
/**
* A flag that tests can set, which signals to the framework that the (rest of the) test suite should be skipped.
* A string value indicates the reason for the skipped (to be included in the Allure report).
*/
skipSuiteTests: boolean | string;
/**
* Test properties provided by the test file via TestProperties.setTestProperties. Used by the framework to
* set up the context appropriately.
**/
testProperties: ITestProperties;
times: any;
/**
* A WebhooksProxy instance generated by the framework and available for tests to use, if configured.
* Note that this is only configured for roomName, if a test wishes to use a different room name it can set up
* a WebhooksProxy instance itself.
*/
webhooksProxy: WebhookProxy;
};
export type IParticipantOptions = {
/** Whether it should use the iFrame API. */
iFrameApi?: boolean;
/** Determines the browser instance to use. */
name: 'p1' | 'p2' | 'p3' | 'p4' | 'p5' | 'p6';
/** An optional token to use. */
token?: IToken;
};
/**
* Options for joinConference.
*/
export type IParticipantJoinOptions = {
/**
* Config overwrites to use.
*/
configOverwrite?: IConfig;
/** The name of the room to join */
roomName: string;
/**
* Whether to skip setting display name.
*/
skipDisplayName?: boolean;
/**
* Whether to skip the prejoin button 'Join' click when joining.
*/
skipPrejoinButtonClick?: boolean;
/**
* Whether to skip waiting for the participant to join the room. Cases like lobby where we do not succeed to join
* based on the logic of the test.
*/
skipWaitToJoin?: boolean;
/**
* An optional tenant to use. If provided it overrides the default.
*/
tenant?: string;
/**
* An optional string to append to the URL when joining the room.
*/
urlAppendString?: string;
};
export type IJoinOptions = {
/**
* Config overwrites to pass to IParticipantJoinOptions.
*/
configOverwrite?: IConfig;
/**
* To be able to override the ctx generated room name. If missing the one from the context will be used.
*/
roomName?: string;
/**
*The skip display name setting to pass to IParticipantJoinOptions.
*/
skipDisplayName?: boolean;
/**
* Whether to skip in meeting checks like ice connected and send receive data. For single in meeting participant.
*/
skipInMeetingChecks?: boolean;
/**
* Whether to skip the prejoin button 'Join' click when joining.
*/
skipPrejoinButtonClick?: boolean;
/**
* The skip waiting for the participant to join the room setting to pass to IParticipantJoinOptions.
*/
skipWaitToJoin?: boolean;
/**
* An optional tenant to use. If provided the default tenant is changed to it.
*/
tenant?: string;
/**
* Options used when generating a token.
*/
tokenOptions?: ITokenOptions;
/**
* An optional string to append to the URL when joining the room.
*/
urlAppendString?: string;
};

57
tests/helpers/utils.ts Normal file
View File

@@ -0,0 +1,57 @@
import { config as testsConfig } from './TestsConfig';
const https = require('https');
export function generateRoomName(testName: string) {
if (testsConfig.roomName.name) {
return testsConfig.roomName.name;
}
const rand = (Math.floor(Math.random() * 400) + 1).toString();
let roomName = `${testName}-${rand}`;
if (testsConfig.roomName.prefix) {
roomName = `${testsConfig.roomName.prefix}_${roomName}`;
}
if (testsConfig.roomName.suffix) {
roomName += `_${testsConfig.roomName.suffix}`;
}
return roomName.toLowerCase();
}
/**
* Fetches JSON data from a given URL.
* @param {string} url - The URL to fetch data from.
* @returns {Promise<Object>} - A promise that resolves to the parsed JSON object.
*/
export async function fetchJson(url) {
return new Promise((resolve, reject) => {
https.get(url, res => {
let data = '';
// Handle HTTP errors
if (res.statusCode < 200 || res.statusCode >= 300) {
return reject(new Error(`HTTP Status Code: ${res.statusCode}`));
}
// Collect data chunks
res.on('data', chunk => {
data += chunk;
});
// Parse JSON when the response ends
res.on('end', () => {
try {
const json = JSON.parse(data);
resolve(json);
} catch (err) {
reject(new Error('Invalid JSON response'));
}
});
}).on('error', err => {
reject(err);
});
});
}