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 2s

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

View File

@@ -0,0 +1,71 @@
import type { Participant } from '../../helpers/Participant';
import { setTestProperties } from '../../helpers/TestProperties';
import { config as testsConfig } from '../../helpers/TestsConfig';
import WebhookProxy from '../../helpers/WebhookProxy';
import { joinJaasMuc, generateJaasToken as t } from '../../helpers/jaas';
import { fetchJson } from '../../helpers/utils';
setTestProperties(__filename, {
requireWebhookProxy: true,
useJaas: true,
usesBrowsers: [ 'p1', 'p2' ]
});
describe('JaaS CHAT_UPLOADED webhook.', () => {
const tenant = testsConfig.jaas.tenant;
const customerId = tenant?.replace('vpaas-magic-cookie-', '');
let p1: Participant, p2: Participant;
let webhooksProxy: WebhookProxy;
let fqn: string;
it('setup', async () => {
const room = ctx.roomName;
webhooksProxy = ctx.webhooksProxy;
p1 = await joinJaasMuc({ name: 'p1', token: t({ room }) });
p2 = await joinJaasMuc({ name: 'p2', token: t({ room }) });
fqn = `${testsConfig.jaas.tenant}/${room}`;
});
it('test webhook', async () => {
await p1.getChatPanel().sendMessage('foo');
await p2.getChatPanel().sendMessage('bar');
await p1.getChatPanel().sendMessage('baz');
await p1.hangup();
await p2.hangup();
const event: {
appId: string;
customerId: string;
data: {
preAuthenticatedLink: string;
};
eventType: string;
fqn: string;
} = await webhooksProxy.waitForEvent('CHAT_UPLOADED');
expect(event.appId).toBe(tenant);
expect(event.customerId).toBe(customerId);
expect(event.data.preAuthenticatedLink).toBeDefined();
expect(event.eventType).toBe('CHAT_UPLOADED');
expect(event.fqn).toBe(fqn);
const uploadedChat: any = await fetchJson(event.data.preAuthenticatedLink);
expect(uploadedChat.meetingFqn).toBe(fqn);
expect(uploadedChat.messageType).toBe('CHAT');
const messages: any[] = uploadedChat.messages;
expect(messages).toBeDefined();
expect(messages.length).toBe(3);
console.log(JSON.stringify(messages));
expect(messages.some(m => m.name === 'p1' && m.content === 'foo')).toBe(true);
expect(messages.some(m => m.name === 'p2' && m.content === 'bar')).toBe(true);
expect(messages.some(m => m.name === 'p1' && m.content === 'baz')).toBe(true);
messages.forEach(m => {
expect(typeof m.timestamp).toBe('number');
});
});
});

View File

@@ -0,0 +1,82 @@
import type { Participant } from '../../../helpers/Participant';
import { setTestProperties } from '../../../helpers/TestProperties';
import { config as testsConfig } from '../../../helpers/TestsConfig';
import WebhookProxy from '../../../helpers/WebhookProxy';
import { expectations } from '../../../helpers/expectations';
import { joinJaasMuc, generateJaasToken as t } from '../../../helpers/jaas';
import {
assertDialInDisplayed, assertUrlDisplayed,
dialIn,
isDialInEnabled, verifyMoreNumbersPage,
} from '../../helpers/DialIn';
import { verifyEndedWebhook, verifyStartedWebhooks, waitForMedia } from './util';
setTestProperties(__filename, {
useJaas: true,
useWebhookProxy: true
});
describe('Dial-in', () => {
let p1: Participant, webhooksProxy: WebhookProxy;
const customerId: string = testsConfig.jaas.customerId || '';
it('setup', async () => {
const room = ctx.roomName;
if (!process.env.DIAL_IN_REST_URL) {
ctx.skipSuiteTests = 'DIAL_IN_REST_URL is not set.';
return;
}
p1 = await joinJaasMuc({ name: 'p1', token: t({ room, moderator: true }) });
webhooksProxy = ctx.webhooksProxy;
if (!webhooksProxy) {
console.error('WebhooksProxy is not available, will not verify webhooks.');
}
expect(await p1.isInMuc()).toBe(true);
if (expectations.dialIn.enabled !== null) {
expect(await isDialInEnabled(p1)).toBe(expectations.dialIn.enabled);
}
expect(customerId).toBeDefined();
});
it ('Invite UI', async () => {
await assertUrlDisplayed(p1);
if (expectations.dialIn.enabled !== null) {
await assertDialInDisplayed(p1, expectations.dialIn.enabled);
}
if (expectations.dialIn.moreNumbersPage) {
// TODO: assert the page is NOT shown when the expectation is false.
await verifyMoreNumbersPage(p1);
}
});
it('dial-in', async () => {
const dialInPin = await p1.getDialInPin();
expect(dialInPin.length).toBeGreaterThanOrEqual(8);
await dialIn(dialInPin);
await waitForMedia(p1);
let startedPayload: any;
if (webhooksProxy) {
startedPayload
= await verifyStartedWebhooks(webhooksProxy, 'in', 'DIAL_IN_STARTED', customerId);
}
const endpointId = await p1.execute(() => APP?.conference?.listMembers()[0].getId());
await p1.getFilmstrip().kickParticipant(endpointId);
if (webhooksProxy) {
await verifyEndedWebhook(webhooksProxy, 'DIAL_IN_ENDED', customerId, startedPayload);
}
await p1.hangup();
});
});

View File

@@ -0,0 +1,50 @@
import type { Participant } from '../../../helpers/Participant';
import { setTestProperties } from '../../../helpers/TestProperties';
import { config as testsConfig } from '../../../helpers/TestsConfig';
import WebhookProxy from '../../../helpers/WebhookProxy';
import { joinJaasMuc, generateJaasToken as t } from '../../../helpers/jaas';
import { verifyEndedWebhook, verifyStartedWebhooks, waitForMedia } from './util';
setTestProperties(__filename, {
requireWebhookProxy: true,
useJaas: true
});
describe('Dial-out', () => {
let p1: Participant, webhooksProxy: WebhookProxy;
const dialOutUrl = process.env.DIAL_OUT_URL || '';
const customerId = testsConfig.jaas.customerId || '';
it('setup', async () => {
const room = ctx.roomName;
if (!dialOutUrl) {
ctx.skipSuiteTests = 'DIAL_OUT_URL is not set.';
return;
}
webhooksProxy = ctx.webhooksProxy;
p1 = await joinJaasMuc({ name: 'p1', iFrameApi: true, token: t({ room, moderator: true }) });
expect(await p1.isInMuc()).toBe(true);
expect(Boolean(await p1.execute(() => config.dialOutAuthUrl))).toBe(true);
});
it('dial-out', async () => {
await p1.switchToMainFrame();
await p1.getIframeAPI().invitePhone(dialOutUrl);
await p1.switchToIFrame();
await p1.waitForParticipants(1);
await waitForMedia(p1);
const startedPayload
= await verifyStartedWebhooks(webhooksProxy, 'out', 'DIAL_OUT_STARTED', customerId);
const endpointId = await p1.execute(() => APP?.conference?.listMembers()[0].getId());
await p1.getFilmstrip().kickParticipant(endpointId);
await verifyEndedWebhook(webhooksProxy, 'DIAL_OUT_ENDED', customerId, startedPayload);
});
});

View File

@@ -0,0 +1,79 @@
import type { Participant } from '../../../helpers/Participant';
import { setTestProperties } from '../../../helpers/TestProperties';
import { config as testsConfig } from '../../../helpers/TestsConfig';
import WebhookProxy from '../../../helpers/WebhookProxy';
import { joinJaasMuc, generateJaasToken as t } from '../../../helpers/jaas';
import { waitForMedia } from './util';
setTestProperties(__filename, {
requireWebhookProxy: true,
useJaas: true,
});
describe('SIP jibri invite', () => {
let p1: Participant, webhooksProxy: WebhookProxy;
const customerId = testsConfig.jaas.customerId || '';
const dialOutUrl = process.env.SIP_JIBRI_DIAL_OUT_URL || '';
it('setup', async () => {
const room = ctx.roomName;
if (!dialOutUrl) {
ctx.skipSuiteTests = 'SIP_JIBRI_DIAL_OUT_URL is not set.';
return;
}
p1 = await joinJaasMuc({ name: 'p1', iFrameApi: true, token: t({ room, moderator: true }) });
webhooksProxy = ctx.webhooksProxy;
expect(await p1.isInMuc()).toBe(true);
expect(Boolean(await p1.execute(() => config.inviteServiceUrl))).toBe(true);
});
it('sip jibri', async () => {
await p1.switchToMainFrame();
await p1.getIframeAPI().inviteSIP(dialOutUrl);
await p1.switchToIFrame();
await p1.waitForParticipants(1);
await waitForMedia(p1);
const startedEvent: {
customerId: string;
data: {
participantFullJid: string;
participantId: string;
participantJid: string;
sipAddress: string;
};
eventType: string;
} = await webhooksProxy.waitForEvent('SIP_CALL_OUT_STARTED');
expect('SIP_CALL_OUT_STARTED').toBe(startedEvent.eventType);
expect(startedEvent.data.sipAddress).toBe(`sip:${process.env.SIP_JIBRI_DIAL_OUT_URL}`);
expect(startedEvent.customerId).toBe(customerId);
const endpointId = await p1.execute(() => APP?.conference?.listMembers()[0].getId());
await p1.getFilmstrip().kickParticipant(endpointId);
const endedEvent: {
customerId: string;
data: {
direction: string;
participantFullJid: string;
participantId: string;
participantJid: string;
};
eventType: string;
} = await webhooksProxy.waitForEvent('SIP_CALL_OUT_ENDED');
expect('SIP_CALL_OUT_ENDED').toBe(endedEvent.eventType);
expect(endedEvent.customerId).toBe(customerId);
expect(endedEvent.data.participantFullJid).toBe(startedEvent.data.participantFullJid);
expect(endedEvent.data.participantId).toBe(startedEvent.data.participantId);
expect(endedEvent.data.participantJid).toBe(startedEvent.data.participantJid);
});
});

View File

@@ -0,0 +1,81 @@
import { Participant } from '../../../helpers/Participant';
import WebhookProxy from '../../../helpers/WebhookProxy';
interface IStartedWebhookPayload {
direction: string;
participantFullJid: string;
participantId: string;
participantJid: string;
}
/**
* Checks the dial events for a participant and clean up at the end.
* @param webhooksProxy
* @param startedEventName
* @param direction
* @param customerId
*/
export async function verifyStartedWebhooks(
webhooksProxy: WebhookProxy,
direction: 'in' | 'out',
startedEventName: string,
customerId: string): Promise<IStartedWebhookPayload> {
const startedEvent: {
customerId: string;
data: IStartedWebhookPayload;
eventType: string;
} = await webhooksProxy.waitForEvent(startedEventName);
expect(startedEventName).toBe(startedEvent.eventType);
expect(startedEvent.data.direction).toBe(direction);
expect(startedEvent.customerId).toBe(customerId);
const usageEvent: {
customerId: string;
data: any;
eventType: string;
} = await webhooksProxy.waitForEvent('USAGE');
expect('USAGE').toBe(usageEvent.eventType);
expect(usageEvent.customerId).toBe(customerId);
expect(usageEvent.data.some((el: any) =>
el.participantId === startedEvent.data.participantId && el.callDirection === direction)).toBe(true);
return startedEvent.data;
}
export async function verifyEndedWebhook(
webhooksProxy: WebhookProxy,
endedEventName: string,
customerId: string,
startedPayload: IStartedWebhookPayload) {
const endedEvent: {
customerId: string;
data: {
direction: string;
participantFullJid: string;
participantId: string;
participantJid: string;
};
eventType: string;
} = await webhooksProxy.waitForEvent(endedEventName);
expect(endedEventName).toBe(endedEvent.eventType);
expect(endedEvent.customerId).toBe(customerId);
expect(endedEvent.data.participantFullJid).toBe(startedPayload.participantFullJid);
expect(endedEvent.data.participantId).toBe(startedPayload.participantId);
expect(endedEvent.data.participantJid).toBe(startedPayload.participantJid);
}
/**
* Wait until there is at least one remote participant, ICE is connected, the participant has a stream, and data is
* both received and sent.
*/
export async function waitForMedia(p: Participant) {
await p.waitForParticipants(1);
await p.waitForIceConnected();
await p.waitForRemoteStreams(1);
await p.waitForSendReceiveData(20_000);
}

View File

@@ -0,0 +1,172 @@
import path from 'node:path';
import type { Participant } from '../../helpers/Participant';
import { setTestProperties } from '../../helpers/TestProperties';
import { expectations } from '../../helpers/expectations';
import { joinJaasMuc, generateJaasToken as t } from '../../helpers/jaas';
setTestProperties(__filename, {
useJaas: true,
usesBrowsers: [ 'p1', 'p2' ]
});
// __dirname-based (not CWD-relative) - this is a local path read on the machine running the
// tests (uploaded to the remote grid session via wdio's own uploadFile), so it needs to resolve
// correctly regardless of whether the process's CWD is the repo root or tests/ itself.
const TEST_FILE_PATH = path.join(__dirname, '..', '..', 'resources', 'test-upload.txt');
const TEST_FILE_NAME = 'test-upload.txt';
describe('File sharing', () => {
let p1: Participant, p2: Participant;
let fileSharingEnabled: boolean;
it('setup', async () => {
const room = ctx.roomName;
p1 = await joinJaasMuc({
name: 'p1',
token: t({ room, features: { 'file-upload': 'true' } })
});
fileSharingEnabled = await p1.execute(
() => Boolean((window as any).config?.fileSharing?.enabled && (window as any).config?.fileSharing?.apiUrl)
);
expect(fileSharingEnabled).toBe(expectations.jaas.fileSharingEnabled);
});
it('upload button enabled with file-upload feature', async () => {
if (!fileSharingEnabled) {
return;
}
const panel = p1.getFileSharingPanel();
await panel.open();
expect(await panel.isUploadButtonEnabled()).toBe(true);
});
it('upload button disabled without file-upload feature', async () => {
if (!fileSharingEnabled) {
return;
}
p2 = await joinJaasMuc({ name: 'p2', token: t({ room: ctx.roomName }) });
const panel = p2.getFileSharingPanel();
await panel.open();
expect(await panel.isUploadButtonEnabled()).toBe(false);
});
it('user with file-upload can delete files uploaded by another participant', async () => {
if (!fileSharingEnabled) {
return;
}
// p1 (with file-upload) uploads a file
const p1Panel = p1.getFileSharingPanel();
await p1Panel.open();
await p1Panel.uploadFile(TEST_FILE_PATH);
await p1Panel.waitForFile(TEST_FILE_NAME);
// p2 (joined earlier without file-upload) rejoins with file-upload to test deletion of p1's file
p2 = await joinJaasMuc({
name: 'p2',
token: t({ room: ctx.roomName, features: { 'file-upload': 'true' } })
});
const p2Panel = p2.getFileSharingPanel();
await p2Panel.open();
await p2Panel.waitForFile(TEST_FILE_NAME);
// p2 should be able to see and use the remove button for p1's file
expect(await p2Panel.canRemoveFile(TEST_FILE_NAME)).toBe(true);
await p2Panel.removeFile(TEST_FILE_NAME);
await p1Panel.waitForFileGone(TEST_FILE_NAME);
});
it('user without file-upload can download but not delete files', async () => {
if (!fileSharingEnabled) {
return;
}
// p1 (with file-upload) uploads a fresh file
const p1Panel = p1.getFileSharingPanel();
await p1Panel.open();
await p1Panel.uploadFile(TEST_FILE_PATH);
await p1Panel.waitForFile(TEST_FILE_NAME);
// p2 rejoins without file-upload
p2 = await joinJaasMuc({ name: 'p2', token: t({ room: ctx.roomName }) });
const p2Panel = p2.getFileSharingPanel();
await p2Panel.open();
await p2Panel.waitForFile(TEST_FILE_NAME);
// Download button should be present, remove button should not
expect(await p2Panel.canDownloadFile(TEST_FILE_NAME)).toBe(true);
expect(await p2Panel.canRemoveFile(TEST_FILE_NAME)).toBe(false);
});
it('dragging into conference opens file sharing tab', async () => {
if (!fileSharingEnabled) {
return;
}
// p1 already has file-upload feature
const panel = p1.getFileSharingPanel();
// Ensure chat is closed before starting
if (await panel.isChatOpen()) {
await p1.getToolbar().clickCloseChatButton();
}
await panel.simulateDragIntoConference();
await p1.driver.waitUntil(
() => panel.isChatOpen(),
{ timeout: 3000, timeoutMsg: 'Chat did not open after drag' }
);
expect(await panel.isActive()).toBe(true);
});
it('dragging on pre-join screen does not open file sharing', async () => {
if (!fileSharingEnabled) {
return;
}
// Join with iFrame API, file-upload feature, and pre-join screen enabled — but do NOT click join
p1 = await joinJaasMuc(
{
name: 'p1',
iFrameApi: true,
token: t({ room: ctx.roomName, features: { 'file-upload': 'true' } })
},
{
configOverwrite: { prejoinConfig: { enabled: true } },
skipPrejoinButtonClick: true,
skipWaitToJoin: true
}
);
// Wait for pre-join screen to appear
await p1.getPreJoinScreen().waitForLoading();
// Simulate drag while on pre-join screen
const panel = p1.getFileSharingPanel();
await panel.simulateDragIntoConference();
// Chat/file sharing should NOT have opened
expect(await panel.isChatOpen()).toBe(false);
});
});

View File

@@ -0,0 +1,113 @@
import { setTestProperties } from '../../helpers/TestProperties';
import { expectations } from '../../helpers/expectations';
import { joinJaasMuc, generateJaasToken as t } from '../../helpers/jaas';
import { TOKEN_AUTH_FAILED_TEST_ID, TOKEN_AUTH_FAILED_TITLE_TEST_ID } from '../../pageobjects/Notifications';
setTestProperties(__filename, {
useJaas: true
});
describe('XMPP login and MUC join', () => {
it('with a valid token (wildcard room)', async () => {
console.log('Joining a MUC with a valid token (wildcard room)');
const p = await joinJaasMuc({ token: t({ room: '*' }) });
expect(await p.isInMuc()).toBe(true);
expect(await p.isModerator()).toBe(false);
});
it('with a valid token (specific room)', async () => {
console.log('Joining a MUC with a valid token (specific room)');
const p = await joinJaasMuc({ token: t({ room: ctx.roomName }) });
expect(await p.isInMuc()).toBe(true);
expect(await p.isModerator()).toBe(false);
});
it('with a token with bad signature', async () => {
console.log('Joining a MUC with a token with bad signature');
const token = t({ room: ctx.roomName });
token.jwt = token.jwt + 'badSignature';
const p = await joinJaasMuc({ token });
expect(Boolean(await p.isInMuc())).toBe(false);
const errorText = await p.getNotifications().getNotificationText(TOKEN_AUTH_FAILED_TEST_ID)
|| await p.getNotifications().getNotificationText(TOKEN_AUTH_FAILED_TITLE_TEST_ID);
expect(errorText).toContain('not allowed to join');
});
it('with an expired token', async () => {
console.log('Joining a MUC with an expired token');
const p = await joinJaasMuc({ token: t({ exp: '-1m' }) });
expect(Boolean(await p.isInMuc())).toBe(false);
const errorText = await p.getNotifications().getNotificationText(TOKEN_AUTH_FAILED_TITLE_TEST_ID);
expect(errorText).toContain('Token is expired');
});
it('with a token using the wrong key ID', async () => {
console.log('Joining a MUC with a token using the wrong key ID');
const p = await joinJaasMuc({ token: t({ keyId: 'invalid-key-id' }) });
expect(Boolean(await p.isInMuc())).toBe(false);
const errorText = await p.getNotifications().getNotificationText(TOKEN_AUTH_FAILED_TEST_ID);
expect(errorText).toContain('not allowed to join');
});
it('with a token for a different room', async () => {
console.log('Joining a MUC with a token for a different room');
const p = await joinJaasMuc({ token: t({ room: ctx.roomName + 'different' }) });
expect(Boolean(await p.isInMuc())).toBe(false);
const errorText = await p.getNotifications().getNotificationText(TOKEN_AUTH_FAILED_TEST_ID);
expect(errorText).toContain('not allowed to join');
});
it('with a moderator token', async () => {
console.log('Joining a MUC with a moderator token');
const p = await joinJaasMuc({ token: t({ moderator: true }) });
expect(await p.isInMuc()).toBe(true);
expect(await p.isModerator()).toBe(true);
});
// This is dependent on jaas account configuration. All tests under jaas/ expect that "unauthenticated access" is
// disabled.
it('without a token', async () => {
console.log('Joining a MUC without a token');
const p = await joinJaasMuc();
if (expectations.jaas.unauthenticatedJoins) {
expect(Boolean(await p.isInMuc())).toBe(true);
} else {
expect(Boolean(await p.isInMuc())).toBe(false);
const errorText = await p.getNotifications().getNotificationText(TOKEN_AUTH_FAILED_TEST_ID);
expect(errorText).toContain('not allowed to join');
}
});
it('without sending a conference-request', async () => {
console.log('Joining a MUC without sending a conference-request');
const p = await joinJaasMuc({
token: t({ room: `${ctx.roomName}-no-cf` })
}, {
configOverwrite: {
disableFocus: true // this effectively disables the sending of a conference-request
}
});
expect(Boolean(await p.isInMuc())).toBe(false);
});
});

View File

@@ -0,0 +1,31 @@
import { setTestProperties } from '../../helpers/TestProperties';
import { joinJaasMuc, generateJaasToken as t } from '../../helpers/jaas';
setTestProperties(__filename, {
requireWebhookProxy: true,
useJaas: true,
usesBrowsers: [ 'p1', 'p2', 'p3' ]
});
describe('MaxOccupants', () => {
it('test maxOccupants limit', async () => {
ctx.webhooksProxy.defaultMeetingSettings = {
maxOccupants: 2
};
const p1 = await joinJaasMuc({ token: t({ room: ctx.roomName }) });
const p2 = await joinJaasMuc({ name: 'p2', token: t({ room: ctx.roomName }) });
expect(await p1.isInMuc()).toBe(true);
expect(await p2.isInMuc()).toBe(true);
// Third participant should be rejected (exceeding maxOccupants), even if it's a moderator
let p3 = await joinJaasMuc({ name: 'p3', token: t({ room: ctx.roomName, moderator: true }) });
expect(Boolean(await p3.isInMuc())).toBe(false);
await p1.hangup();
p3 = await joinJaasMuc({ name: 'p3', token: t({ room: ctx.roomName }) });
expect(await p3.isInMuc()).toBe(true);
});
});

View File

@@ -0,0 +1,37 @@
import { setTestProperties } from '../../helpers/TestProperties';
import { joinJaasMuc, generateJaasToken as t } from '../../helpers/jaas';
setTestProperties(__filename, {
useJaas: true
});
// JaaS deployments set deeplinking.[android|ios].appScheme to 'com.8x8.meet',
// which makes getDeepLinkingPage() short-circuit on mobile browsers (see
// react/features/deep-linking/functions.web.ts). This test verifies that
// behavior by emulating an iPhone and asserting the meeting is reached.
describe('JaaS mobile browser deep linking', () => {
before(async () => {
// Make Chrome look like an iPhone so Platform.OS resolves to 'ios' and
// isMobileBrowser() returns true. emulate() persists for the session.
await multiRemoteBrowser.getInstance('p1').emulate('device', 'iPhone 15');
});
it('does not show the deep linking page', async () => {
// joinJaasMuc waits for the prejoin screen with a 3s timeout. If the
// deep linking mobile page had rendered instead, that lookup would
// throw, failing the test. Reaching the assertion below means the
// prejoin was found.
const p = await joinJaasMuc({
token: t({ room: '*' })
}, {
skipWaitToJoin: true
});
// Belt-and-suspenders: the deep linking mobile page renders the
// 'launchMeetingLabel' string; assert it isn't on the page.
const deepLinkLabel = p.driver.$(
'//*[contains(text(),"How do you want to join this meeting?")]');
expect(await deepLinkLabel.isExisting()).toBe(false);
});
});

View File

@@ -0,0 +1,51 @@
import { setTestProperties } from '../../helpers/TestProperties';
import { joinJaasMuc, generateJaasToken as t } from '../../helpers/jaas';
import { IToken } from '../../helpers/token';
setTestProperties(__filename, {
requireWebhookProxy: true,
useJaas: true,
usesBrowsers: [ 'p1', 'p2' ]
});
const passcode = '1234';
describe('Setting passcode through settings provisioning', () => {
it('With a valid passcode', async () => {
ctx.webhooksProxy.defaultMeetingSettings = {
passcode: passcode,
visitorsEnabled: true
};
// We want to keep the room from getting destroyed, because the visitors queue has a timeout and causes
// problems. We could use different rooms instead, but the webhooksProxy is only configured for the default room.
await joinWithPassword('p1', t({ room: ctx.roomName }));
await joinWithPassword('p2', t({ room: ctx.roomName, moderator: true }));
await joinWithPassword('p2', t({ room: ctx.roomName, visitor: true }));
});
});
/**
* Join a password-protected room. Assert that a password is required, that a wrong password does not work, and that
* the correct password does work.
*/
async function joinWithPassword(instanceId: string, token: IToken) {
// @ts-ignore
const p = await joinJaasMuc({ name: instanceId, token }, { roomName: ctx.roomName });
await p.waitForMucJoinedOrError();
expect(await p.isInMuc()).toBe(false);
expect(await p.getPasswordDialog().isOpen()).toBe(true);
await p.getPasswordDialog().submitPassword('wrong password');
await p.waitForMucJoinedOrError();
expect(await p.isInMuc()).toBe(false);
expect(await p.getPasswordDialog().isOpen()).toBe(true);
await p.getPasswordDialog().submitPassword(passcode);
await p.waitToJoinMUC();
expect(await p.isInMuc()).toBe(true);
expect(await p.getPasswordDialog().isOpen()).toBe(false);
}

View File

@@ -0,0 +1,25 @@
import { setTestProperties } from '../../helpers/TestProperties';
import { joinJaasMuc, generateJaasToken as t } from '../../helpers/jaas';
setTestProperties(__filename, {
requireWebhookProxy: true,
useJaas: true
});
// This test is separate from passcode.spec.ts, because it needs to use a different room name, and webhooksProxy is only
// setup for the default room name.
describe('Setting invalid passcode through settings provisioning', () => {
it('With an invalid passcode', async () => {
ctx.webhooksProxy.defaultMeetingSettings = {
passcode: 'passcode-must-be-digits-only'
};
const p = await joinJaasMuc({ token: t({ room: ctx.roomName }) }, { roomName: ctx.roomName });
// The settings provisioning contains an invalid passcode, the expected result is that the room is not
// configured to require a passcode.
await p.waitToJoinMUC();
expect(await p.isInMuc()).toBe(true);
expect(await p.getPasswordDialog().isOpen()).toBe(false);
});
});

View File

@@ -0,0 +1,176 @@
import { Participant } from '../../helpers/Participant';
import { setTestProperties } from '../../helpers/TestProperties';
import { config as testsConfig } from '../../helpers/TestsConfig';
import WebhookProxy from '../../helpers/WebhookProxy';
import { joinJaasMuc, generateJaasToken as t } from '../../helpers/jaas';
setTestProperties(__filename, {
requireWebhookProxy: true,
useJaas: true,
usesBrowsers: [ 'p1', 'p2' ]
});
/**
* Tests the basic webhooks fired for participants joining, leaving, and creating/destroying a conference:
* PARTICIPANT_JOINED, PARTICIPANT_LEFT, ROOM_CREATED, ROOM_DESTROYED, ROLE_CHANGED, USAGE.
*/
describe('Create/destroy/join/leave webhooks', () => {
let conferenceJid: string = '';
let p1: Participant, p2: Participant;
let p1EpId: string, p2EpId: string;
let webhooksProxy: WebhookProxy;
let room: string;
async function checkParticipantJoinedHook(p: Participant) {
const event: {
data: {
conference: string;
isBreakout: boolean;
moderator: boolean;
name: string;
participantId: string;
};
eventType: string;
} = await webhooksProxy.waitForEvent('PARTICIPANT_JOINED');
expect(event.eventType).toBe('PARTICIPANT_JOINED');
expect(event.data.conference).toBe(conferenceJid);
expect(event.data.isBreakout).toBe(false);
expect(event.data.moderator).toBe(p.getToken()?.options?.moderator);
expect(event.data.name).toBe(await p.getLocalDisplayName());
expect(event.data.participantId).toBe(await p.getEndpointId());
expect(event.data.name).toBe(p.name);
}
async function checkParticipantLeftHook(p: Participant, reason: string) {
const event: {
customerId: string;
data: {
conference: string;
disconnectReason: string;
group: string;
id: string;
isBreakout: boolean;
name: string;
participantId: string;
};
eventType: string;
} = await webhooksProxy.waitForEvent('PARTICIPANT_LEFT');
expect(event.eventType).toBe('PARTICIPANT_LEFT');
expect(event.data.conference).toBe(conferenceJid);
expect(event.data.disconnectReason).toBe(reason);
expect(event.data.isBreakout).toBe(false);
expect(event.data.participantId).toBe(await p.getEndpointId());
expect(event.data.name).toBe(p.name);
const jwtPayload = p.getToken()?.payload;
expect(event.data.id).toBe(jwtPayload?.context?.user?.id);
expect(event.data.group).toBe(jwtPayload?.context?.group);
expect(event.customerId).toBe(testsConfig.jaas.customerId);
}
it('setup', async () => {
room = ctx.roomName;
conferenceJid = `${room}@conference.${testsConfig.jaas.tenant}.${new URL(process.env.BASE_URL || '').hostname}`;
webhooksProxy = ctx.webhooksProxy;
p1 = await joinJaasMuc({ name: 'p1', iFrameApi: true, token: t({ room, moderator: true }) });
p1EpId = await p1.getEndpointId();
expect(await p1.isModerator()).toBe(true);
await checkParticipantJoinedHook(p1);
await p1.switchToMainFrame();
p2 = await joinJaasMuc({ name: 'p2', token: t({ room }) });
p2EpId = await p2.getEndpointId();
expect(await p2.isModerator()).toBe(false);
await checkParticipantJoinedHook(p2);
});
it('USAGE webhook', async () => {
const event: {
data: [
{ participantId: string; }
];
eventType: string;
} = await webhooksProxy.waitForEvent('USAGE');
expect(event.eventType).toBe('USAGE');
expect(event.data.some(d => d.participantId === p1EpId));
expect(event.data.some(d => d.participantId === p2EpId));
});
it('ROOM_CREATED webhook', async () => {
const event: {
data: {
conference: string;
isBreakout: boolean;
};
eventType: string;
} = await webhooksProxy.waitForEvent('ROOM_CREATED');
expect(event.eventType).toBe('ROOM_CREATED');
expect(event.data.conference).toBe(conferenceJid);
expect(event.data.isBreakout).toBe(false);
});
it('ROLE_CHANGED webhook', async () => {
await p1.getIframeAPI().executeCommand('grantModerator', p2EpId);
const event: {
data: {
grantedBy: {
participantId: string;
};
grantedTo: {
participantId: string;
};
role: string;
};
eventType: string;
} = await webhooksProxy.waitForEvent('ROLE_CHANGED');
expect(event.eventType).toBe('ROLE_CHANGED');
expect(event.data.role).toBe('moderator');
expect(event.data.grantedBy.participantId).toBe(p1EpId);
expect(event.data.grantedTo.participantId).toBe(p2EpId);
});
it('kick participant', async () => {
webhooksProxy.clearCache();
await p1.getIframeAPI().executeCommand('kickParticipant', p2EpId);
await checkParticipantLeftHook(p2, 'kicked');
});
it('join after kick', async () => {
webhooksProxy.clearCache();
// join again
p2 = await joinJaasMuc({ name: 'p2', token: t({ room }) });
p2EpId = await p2.getEndpointId();
await checkParticipantJoinedHook(p2);
});
it('hangup', async () => {
await p2.hangup();
await checkParticipantLeftHook(p2, 'left');
});
it('dispose conference', async () => {
await p1.getIframeAPI().executeCommand('hangup');
await checkParticipantLeftHook(p1, 'left');
const event: {
data: {
conference: string;
isBreakout: boolean;
};
eventType: string;
} = await webhooksProxy.waitForEvent('ROOM_DESTROYED');
expect(event.eventType).toBe('ROOM_DESTROYED');
expect(event.data.conference).toBe(conferenceJid);
expect(event.data.isBreakout).toBe(false);
});
});

View File

@@ -0,0 +1,224 @@
import { Participant } from '../../helpers/Participant';
import { setTestProperties } from '../../helpers/TestProperties';
import { config as testsConfig } from '../../helpers/TestsConfig';
import WebhookProxy from '../../helpers/WebhookProxy';
import { expectations } from '../../helpers/expectations';
import { joinJaasMuc, generateJaasToken as t } from '../../helpers/jaas';
setTestProperties(__filename, {
requireWebhookProxy: true,
retry: true,
useJaas: true
});
/**
* Tests the recording and live-streaming functionality of JaaS (including relevant webhooks) exercising the iFrame API
* commands and functions.
* TODO: also assert "this meeting is being recorded" notifications are show/played?
*/
describe('Recording and live-streaming', () => {
const tenant = testsConfig.jaas.tenant;
const customerId = tenant?.replace('vpaas-magic-cookie-', '');
let recordingEnabled: boolean;
let liveStreamingEnabled: boolean;
let p: Participant;
let webhooksProxy: WebhookProxy;
it('setup', async () => {
webhooksProxy = ctx.webhooksProxy;
p = await joinJaasMuc({ iFrameApi: true, token: t({ moderator: true }) }, { roomName: ctx.roomName });
recordingEnabled = Boolean(await p.execute(() => config.recordingService?.enabled));
expect(recordingEnabled).toBe(expectations.jaas.recordingEnabled);
liveStreamingEnabled = Boolean(await p.execute(() => config.liveStreaming?.enabled));
expect(liveStreamingEnabled).toBe(expectations.jaas.liveStreamingEnabled);
if (liveStreamingEnabled && !process.env.YTUBE_TEST_STREAM_KEY) {
liveStreamingEnabled = false;
console.log('Skipping live-streaming tests because YTUBE_TEST_STREAM_KEY is not set.');
}
await p.switchToMainFrame();
});
/**
* Starts recording and asserts that the expected iFrame and JaaS events are received.
* @param command whether to use the "command" or the "function" iFrame API.
*/
async function startRecording(command: boolean) {
await p.getIframeAPI().addEventListener('recordingStatusChanged');
await p.getIframeAPI().addEventListener('recordingLinkAvailable');
if (command) {
await p.getIframeAPI().executeCommand('startRecording', {
mode: 'file'
});
} else {
await p.getIframeAPI().startRecording({
mode: 'file'
});
}
const jaasEvent: {
customerId: string;
eventType: string;
} = await webhooksProxy.waitForEvent('RECORDING_STARTED');
expect('RECORDING_STARTED').toBe(jaasEvent.eventType);
expect(jaasEvent.customerId).toBe(customerId);
webhooksProxy.clearCache();
const iFrameEvent = await p.driver.waitUntil(() =>
p.getIframeAPI().getEventResult('recordingStatusChanged'), {
timeout: 5000,
timeoutMsg: 'recordingStatusChanged event not received'
});
expect(iFrameEvent.mode).toBe('file');
expect(iFrameEvent.on).toBe(true);
const linkEvent = await p.driver.waitUntil(() =>
p.getIframeAPI().getEventResult('recordingLinkAvailable'), {
timeout: 5000,
timeoutMsg: 'recordingLinkAvailable event not received'
});
expect(linkEvent.link).toStartWith('https://', 'recording link');
expect(linkEvent.link).toContain(tenant);
expect(linkEvent.ttl).toBeGreaterThan(0);
}
/**
* Stops recording and asserts that the expected iFrame and JaaS events are received.
* @param command whether to use the "command" or the "function" iFrame API.
*/
async function stopRecording(command: boolean) {
if (command) {
await p.getIframeAPI().executeCommand('stopRecording', 'file');
} else {
await p.getIframeAPI().stopRecording('file');
}
const jaasEndedEvent: {
customerId: string;
eventType: string;
} = await webhooksProxy.waitForEvent('RECORDING_ENDED');
expect('RECORDING_ENDED').toBe(jaasEndedEvent.eventType);
expect(jaasEndedEvent.customerId).toBe(customerId);
const jaasUploadedEvent: {
customerId: string;
data: {
initiatorId: string;
participants: Array<string>;
};
eventType: string;
} = await webhooksProxy.waitForEvent('RECORDING_UPLOADED');
const jwtPayload = p.getToken()?.payload;
expect(jaasUploadedEvent.data.initiatorId).toBe(jwtPayload?.context?.user?.id);
expect(jaasUploadedEvent.data.participants.some(
// @ts-ignore
e => e.id === jwtPayload?.context?.user?.id)).toBe(true);
webhooksProxy.clearCache();
const iFrameEvent = (await p.getIframeAPI().getEventResult('recordingStatusChanged'));
expect(iFrameEvent.mode).toBe('file');
expect(iFrameEvent.on).toBe(false);
await p.getIframeAPI().clearEventResults('recordingStatusChanged');
}
it('start/stop recording using the iFrame command', async () => {
if (!recordingEnabled) {
return;
}
await startRecording(true);
await stopRecording(true);
// to avoid rate limits
await p.driver.pause(30000);
});
it('start/stop recording using the iFrame function', async () => {
if (!recordingEnabled) {
return;
}
await startRecording(false);
await stopRecording(false);
// to avoid rate limits
await p.driver.pause(30000);
});
it('start/stop live-streaming using the iFrame command', async () => {
if (!liveStreamingEnabled) {
return;
}
await p.getIframeAPI().addEventListener('recordingStatusChanged');
await p.getIframeAPI().executeCommand('startRecording', {
youtubeBroadcastID: process.env.YTUBE_TEST_BROADCAST_ID,
mode: 'stream',
youtubeStreamKey: process.env.YTUBE_TEST_STREAM_KEY
});
const jaasEvent: {
customerId: string;
eventType: string;
} = await webhooksProxy.waitForEvent('LIVE_STREAM_STARTED');
expect('LIVE_STREAM_STARTED').toBe(jaasEvent.eventType);
expect(jaasEvent.customerId).toBe(customerId);
const iFrameEvent = await p.driver.waitUntil(async () => {
const e = await p.getIframeAPI().getEventResult('recordingStatusChanged');
return e && e.mode === 'stream' && e.on === true ? e : false;
}, {
timeout: 5000,
timeoutMsg: 'recordingStatusChanged event with mode=stream, on=true not received'
});
expect(iFrameEvent.mode).toBe('stream');
expect(iFrameEvent.on).toBe(true);
if (process.env.YTUBE_TEST_BROADCAST_ID) {
const liveStreamUrl = await p.getIframeAPI().getLivestreamUrl();
expect(liveStreamUrl.livestreamUrl).toBeDefined();
}
await p.getIframeAPI().executeCommand('stopRecording', 'stream');
const jaasEndedEvent: {
customerId: string;
eventType: string;
} = await webhooksProxy.waitForEvent('LIVE_STREAM_ENDED');
expect(jaasEndedEvent.eventType).toBe('LIVE_STREAM_ENDED');
expect(jaasEndedEvent.customerId).toBe(customerId);
const iFrameEndedEvent = await p.driver.waitUntil(async () => {
const e = await p.getIframeAPI().getEventResult('recordingStatusChanged');
return e && e.mode === 'stream' && e.on === false ? e : false;
}, {
timeout: 5000,
timeoutMsg: 'recordingStatusChanged event with mode=stream, on=false not received'
});
expect(iFrameEndedEvent.mode).toBe('stream');
expect(iFrameEndedEvent.on).toBe(false);
});
});

View File

@@ -0,0 +1,138 @@
import { setTestProperties } from '../../helpers/TestProperties';
import { expectations } from '../../helpers/expectations';
import { joinJaasMuc, generateJaasToken as t } from '../../helpers/jaas';
/**
* Joins (or rejoins) p1 as a moderator granting only the given JWT feature claims, with no
* config.js overrides anywhere in this file — see recordButtonProps() / isLiveStreamingButtonVisible()
* in react/features/recording: on a JaaS (vpaas) tenant there is no config-only fallback once the
* JWT lacks a feature claim, unlike the non-JaaS tenant, so toolbar visibility here is driven purely
* by these feature claims against the environment's ambient (always-on, per expectations.jaas.*)
* config. The config-driven side of this same dialog (no custom JWT, varying config.js instead) is
* covered separately by tests/specs/misc/recordingDialogConfig.spec.ts.
*
* A fresh join is required (rather than ensureOneParticipant, which no-ops if already in the
* conference) because each test in this file exercises a different set of JWT feature claims.
*/
async function joinWithFeatures(features: Record<string, boolean>): Promise<void> {
await joinJaasMuc({
name: 'p1',
token: t({
moderator: true,
features
})
});
}
/**
* Mirrors LocalRecordingManager.isSupported() plus the embedding guard that
* supportsLocalRecording() adds — the same probe tests/specs/misc/recordingDialogConfig.spec.ts and
* recordingButtonVisibility.spec.ts use, because these browser capability gates are frequently
* unmet in the automated test browser regardless of any JWT feature claim.
*/
async function isLocalRecordingSupportedByBrowser(): Promise<boolean> {
return ctx.p1.execute(() => {
const browser = JitsiMeetJS.util.browser;
const PREFERRED_MEDIA_TYPE = 'video/webm;codecs=vp8,opus';
const isSupported = browser.isChromiumBased()
&& !browser.isElectron()
&& !browser.isReactNative()
&& !browser.isMobileDevice()
// @ts-ignore
&& Boolean(navigator.mediaDevices.setCaptureHandleConfig)
// @ts-ignore
&& typeof window.showSaveFilePicker !== 'undefined'
&& MediaRecorder.isTypeSupported(PREFERRED_MEDIA_TYPE);
let embeddedOk = true;
try {
if (window.self !== window.top) {
embeddedOk = window.self.location.host === window.parent.location.host;
}
} catch (e) {
embeddedOk = false;
}
return isSupported && embeddedOk;
});
}
setTestProperties(__filename, {
description: 'Recording & Transcription dialog toolbar visibility across JWT feature claims',
usesBrowsers: [ 'p1' ],
useJaas: true
});
/*
* Full 2^3 matrix over the three independently grantable JWT feature claims: recording,
* transcription and livestreaming. No config.js override is used anywhere here — the environment's
* ambient config is assumed to always enable recordingService, transcription and liveStreaming (see
* expectations.jaas.*), so every combination below isolates the JWT-feature-gated branches of
* getRecordButtonProps() and isLiveStreamingButtonVisible() rather than config:
*
* - On a JaaS tenant, getRecordButtonProps() has no config-only fallback: the toolbar Record &
* Transcribe button's visibility reduces to localRecordingAvailable || recording || transcription
* (the JWT claims), since the ambient config enables both services.
* - The Live Streaming toolbar button is fully orthogonal and purely JWT-gated: only the
* livestreaming claim matters (see LiveStream/AbstractLiveStreamButton.ts).
*
* localRecordingAvailable also depends on browser capabilities (MediaRecorder/File System Access
* support) that are frequently unmet in the automated test browser — probed once up front so the
* per-combination expectations stay accurate regardless of what the test browser actually supports.
*/
describe('Recording dialog JWT feature matrix — recording × transcription × live streaming', () => {
// A const-bound holder object, rather than a reassigned `let`, so the it() closures created
// inside the loop below don't trip @typescript-eslint/no-loop-func — only its property (set
// once, in 'setup', before any of the loop's tests run) actually changes.
const localRecordingSupport = { byBrowser: false };
it('setup', async () => {
if (!expectations.jaas.recordingEnabled
|| !expectations.jaas.transcriptionEnabled
|| !expectations.jaas.liveStreamingEnabled) {
ctx.skipSuiteTests = 'Recording, transcription and live streaming must all be enabled '
+ 'in this environment for the JWT feature claims to be the only variable';
return;
}
await joinWithFeatures({
recording: true,
transcription: true,
livestreaming: true
});
localRecordingSupport.byBrowser = await isLocalRecordingSupportedByBrowser();
});
const BOOLEANS = [ true, false ];
for (const recordingFeature of BOOLEANS) {
for (const transcriptionFeature of BOOLEANS) {
for (const livestreamingFeature of BOOLEANS) {
const title = `recording=${recordingFeature} `
+ `transcription=${transcriptionFeature} `
+ `livestreaming=${livestreamingFeature}`;
it(title, async () => {
await joinWithFeatures({
recording: recordingFeature,
transcription: transcriptionFeature,
livestreaming: livestreamingFeature
});
const p1 = ctx.p1;
expect(await p1.getToolbar().hasLiveStreamingButton()).toBe(livestreamingFeature);
const dialogCanOpen = recordingFeature || transcriptionFeature || localRecordingSupport.byBrowser;
expect(await p1.getToolbar().hasRecordingButton()).toBe(dialogCanOpen);
});
}
}
}
});

View File

@@ -0,0 +1,617 @@
import { Participant } from '../../helpers/Participant';
import { setTestProperties } from '../../helpers/TestProperties';
import { config as testsConfig } from '../../helpers/TestsConfig';
import { expectations } from '../../helpers/expectations';
import { joinJaasMuc, generateJaasToken as t } from '../../helpers/jaas';
setTestProperties(__filename, {
useJaas: true
});
// Counter used to give each test its own room — see joinAsModerator().
let roomCounter = 0;
/**
* Builds a fresh, per-test room name out of ctx.roomName (see joinAsModerator()).
*
* ctx.roomName already has the environment's ROOM_NAME_SUFFIX (e.g. "_alpha-canary") appended as
* its trailing component (see generateRoomName() in helpers/utils.ts), and some environments rely
* on that suffix being the tail of the room name (e.g. to route/map the conference correctly).
* Simply appending "-${roomCounter}" to ctx.roomName would push the suffix out of that position,
* so the counter is inserted right before it instead.
*/
function uniqueRoomName(): string {
const suffix = testsConfig.roomName.suffix;
if (suffix && ctx.roomName.endsWith(`_${suffix}`)) {
return `${ctx.roomName.slice(0, -`_${suffix}`.length)}-${roomCounter}_${suffix}`;
}
return `${ctx.roomName}-${roomCounter}`;
}
/**
* Joins a JaaS meeting as a moderator using the iFrame API wrapper.
*
* Each call joins a unique room. All tests in this spec otherwise share ctx.roomName, and joinMuc
* rejoins it each test, so prosody's per-room jibri start/stop throttle (mod_filter_iq_jibri,
* max_number_room_attempts_per_minute, default 3 — both start and stop count) accumulates across
* the suite and trips the "started a recording too quickly" policy error. A fresh room per test
* resets that throttle, keeping each test's ≤2 start/stop attempts well under the limit. The
* moderator token is room-agnostic (room defaults to '*'), so it is valid for any room.
*/
async function joinAsModerator(): Promise<Participant> {
roomCounter += 1;
const p = await joinJaasMuc(
{ iFrameApi: true, token: t({ moderator: true }) },
{ roomName: uniqueRoomName() });
// joinJaasMuc leaves the driver focused inside the Jitsi iframe, but the iFrame API
// (window.jitsiAPI) lives on the wrapper page. Switch to the main frame so executeCommand()
// works, matching the setup in recording.spec.ts / transcriptions.spec.ts.
await p.switchToMainFrame();
return p;
}
/**
* Minimum wall-clock gap enforced between successive Jibri start/stop commands sent by this
* spec, in ms.
*
* Prosody's mod_filter_iq_jibri throttles jibri start/stop IQs per source IP
* (max_number_ip_attempts_per_minute, default 9/min), separately from the per-room throttle
* joinAsModerator()'s fresh room already dodges — a fresh room resets the room-scoped throttle
* only, not this one. This suite alone sends far more than 9 start/stop IQs across its tests, so
* without pacing it trips the per-IP throttle (observed as "policy-violation"/wait on the start
* or stop IQ, surfacing as a session that never actually starts or stops). 8s keeps this file's
* rate under the throttle's refill rate (60s / 9 ≈ 6.7s), with headroom for other jaas specs
* sharing the same CI egress IP.
*/
const MIN_JIBRI_COMMAND_GAP_MS = 8_000;
let lastJibriCommandTime = 0;
/**
* Waits out whatever is left of MIN_JIBRI_COMMAND_GAP_MS since the last call, then marks now as
* the new last call time. Await this immediately before every jibri start/stop command.
*/
async function paceJibriCommand(): Promise<void> {
const wait = MIN_JIBRI_COMMAND_GAP_MS - (Date.now() - lastJibriCommandTime);
if (wait > 0) {
await new Promise(resolve => setTimeout(resolve, wait));
}
lastJibriCommandTime = Date.now();
}
/**
* Sends the startRecording command and waits until jicofo actually accepts it (a live FILE
* session shows up in the recording state), retrying on silent rejection.
*
* A start IQ that reaches jicofo right after joining can be rejected with policy-violation
* (type=wait) while the conference is still being set up, and the client surfaces nothing —
* the recording just never starts. The error type says "retry later", so do that. Waiting for
* the JVB session before starting instead is NOT an option: jicofo does not allocate a session
* for a lone participant at all, it does so only when a second endpoint (e.g. jibri) joins.
*
* Expects the driver on the main frame; leaves it there.
*/
async function startFileRecording(p: Participant, options: { mode: string; transcription?: boolean; }) {
for (let attempt = 1; attempt <= 3; attempt++) {
await paceJibriCommand();
await p.getIframeAPI().executeCommand('startRecording', options);
await p.switchToIFrame();
const accepted = await p.driver.waitUntil(
() => p.execute(() => Boolean(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
APP.store.getState()['features/recording'].sessionDatas.some(
(s: any) => s.mode === 'file' && (s.status === 'pending' || s.status === 'on')))),
{ timeout: 5_000, timeoutMsg: '' }
).catch(() => false);
await p.switchToMainFrame();
if (accepted) {
return;
}
}
throw new Error('startRecording was not accepted by jicofo after 3 attempts');
}
/**
* Waits for the "dialog.recording" notification to appear inside the current iframe context.
* The notification fires whenever recording or transcription starts (or both).
*/
async function waitForRecordingNotification(p: Participant, timeoutMsg: string): Promise<void> {
await p.driver.$('[data-testid="dialog.recording"]').waitForExist({
timeout: 30_000,
timeoutMsg
});
}
/**
* Dismisses the current "dialog.recording" notification (if it is showing) and waits for it
* to disappear, so subsequent assertions start from a clean slate.
*/
async function dismissRecordingNotification(p: Participant): Promise<void> {
const dismissButton = p.driver.$('[data-testid="dialog.recording-dismiss"]');
if (await dismissButton.isExisting()) {
await dismissButton.moveTo();
await dismissButton.click();
await p.driver.$('[data-testid="dialog.recording"]')
.waitForExist({ reverse: true, timeout: 3_000 });
}
}
/**
* Waits until the recording feature state reports that a FILE-mode session is fully ON.
* A PENDING session is not enough: jicofo rejects a stop request for a session whose jibri
* is still starting (policy-violation/wait), so acting on a PENDING session races the backend.
*/
async function waitForRecordingRunning(p: Participant): Promise<void> {
await p.driver.waitUntil(
() => p.execute(() => Boolean(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
APP.store.getState()['features/recording'].sessionDatas.some(
(s: any) => s.mode === 'file' && s.status === 'on'))),
{ timeout: 30_000, timeoutMsg: 'Recording session did not become active' }
);
}
/**
* Waits until the transcription feature state reports that transcription is active.
*/
async function waitForTranscriptionRunning(p: Participant): Promise<void> {
await p.driver.waitUntil(
() => p.execute(() => Boolean(APP.store.getState()['features/transcribing'].isTranscribing)),
{ timeout: 30_000, timeoutMsg: 'Transcription did not become active' }
);
}
/**
* Waits until the transcription feature state reports that transcription is no longer active.
*
* @param {Participant} p - The participant.
* @param {number} timeout - How long to wait, in ms.
*/
async function waitForTranscriptionStopped(p: Participant, timeout = 20_000): Promise<void> {
await p.driver.waitUntil(
() => p.execute(() => !APP.store.getState()['features/transcribing'].isTranscribing),
{ timeout, timeoutMsg: 'Transcription did not stop' }
);
}
/**
* Waits until the recording feature state reports that no FILE session is active.
*
* @param {Participant} p - The participant.
* @param {number} timeout - How long to wait, in ms.
*/
async function waitForRecordingStopped(p: Participant, timeout = 20_000): Promise<void> {
await p.driver.waitUntil(
() => p.execute(() => Boolean(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
APP.store.getState()['features/recording'].sessionDatas.every(
(s: any) => s.mode !== 'file' || s.status === 'off'))),
{ timeout, timeoutMsg: 'Recording session did not stop' }
);
}
/**
* Best-effort wait, used in cleanup, for a stopRecording command to actually take effect before the
* participant leaves. Never fails the test — a timeout here just means the stop was slow to reflect,
* which must not turn a passing test red during teardown.
*
* @param {Participant} p - The participant.
* @param {boolean} transcription - Whether transcription was also stopped (stopRecording(..., true)).
*/
async function waitForRecordingStopExecuted(p: Participant, transcription = false): Promise<void> {
// Short, bounded wait — this is best-effort teardown, not an assertion, so don't burn the full
// 20s assertion timeout here.
const cleanupTimeout = 5_000;
await p.switchToIFrame();
try {
await waitForRecordingStopped(p, cleanupTimeout);
if (transcription) {
await waitForTranscriptionStopped(p, cleanupTimeout);
}
} catch (e) {
// Best-effort cleanup — ignore timeouts so teardown never fails the test.
}
}
// ─── Nudge notification tests ────────────────────────────────────────────────
describe('Recording & transcription nudge notifications', () => {
it('nudge shows "Start transcribing" action when only recording starts', async () => {
if (!expectations.jaas.recordingEnabled || !expectations.jaas.transcriptionEnabled) {
return;
}
const p = await joinAsModerator();
try {
await startFileRecording(p, { mode: 'file' });
await p.switchToIFrame();
await waitForRecordingNotification(p, 'Recording-started notification did not appear');
const nudgeButton = p.driver.$('[data-testid="dialog.startTranscribing"]');
// A "recording pending" notification appears (same data-testid) before the "recording
// started" notification that actually carries the nudge, so wait for the nudge to show up
// rather than checking immediately. The iFrame startRecording path sets
// isTranscribingEnabled:false, so transcription never auto-starts here and the
// "Start transcribing" nudge always applies once recording is running.
await nudgeButton.waitForExist({
timeout: 30_000,
timeoutMsg: '"Start transcribing" nudge did not appear'
});
} finally {
await p.switchToMainFrame();
await paceJibriCommand();
await p.getIframeAPI().executeCommand('stopRecording', 'file');
await waitForRecordingStopExecuted(p);
}
});
it('nudge shows "Start recording" action when only transcription starts', async () => {
if (!expectations.jaas.transcriptionEnabled || !expectations.jaas.recordingEnabled) {
return;
}
const p = await joinAsModerator();
try {
await paceJibriCommand();
await p.getIframeAPI().executeCommand('startRecording', { transcription: true });
await p.switchToIFrame();
await waitForRecordingNotification(p, 'Transcription-started notification did not appear');
const nudgeButton = p.driver.$('[data-testid="dialog.startRecording"]');
expect(await nudgeButton.isExisting()).toBe(true);
} finally {
await p.switchToMainFrame();
await paceJibriCommand();
await p.getIframeAPI().executeCommand('stopRecording', 'file', true);
await waitForRecordingStopExecuted(p, true);
}
});
it('no spurious "recording started" notification when stopping transcription while recording runs', async () => {
if (!expectations.jaas.recordingEnabled || !expectations.jaas.transcriptionEnabled) {
return;
}
const p = await joinAsModerator();
try {
await startFileRecording(p, {
mode: 'file',
transcription: true
});
await p.switchToIFrame();
await waitForRecordingNotification(p, 'Recording+transcription start notification did not appear');
await dismissRecordingNotification(p);
await p.switchToMainFrame();
// Stop only transcription while recording keeps running.
await paceJibriCommand();
await p.getIframeAPI().executeCommand('stopRecording', 'file', true);
await p.switchToIFrame();
// Give the UI time to settle — a spurious notification would appear within ~3 s.
await p.driver.pause(4_000);
// The description keys used on a new "recording started" notification —
// neither must appear after a transcription-only stop.
expect(await p.driver.$('[data-testid="recording.on"]').isExisting()).toBe(false);
expect(await p.driver.$('[data-testid="recording.onWithTranscription"]').isExisting()).toBe(false);
} finally {
await p.switchToMainFrame();
await paceJibriCommand();
await p.getIframeAPI().executeCommand('stopRecording', 'file');
await waitForRecordingStopExecuted(p);
}
});
});
// ─── Manage-mode (session active) dialog tests ───────────────────────────────
describe('Recording & transcription dialog — manage mode', () => {
it('recording section shows Stop when recording is running', async () => {
if (!expectations.jaas.recordingEnabled) {
return;
}
const p = await joinAsModerator();
try {
await startFileRecording(p, { mode: 'file' });
await p.switchToIFrame();
await waitForRecordingRunning(p);
await p.getToolbar().clickRecordingButton();
const dialog = p.getRecordingTranscriptionDialog();
await dialog.waitForDisplay();
expect(await dialog.hasStopRecordingButton()).toBe(true);
expect(await dialog.hasStartTranscriptionButton()).toBe(true);
// Mixed state — the footer offers both bulk actions.
expect(await dialog.hasStopBothButton()).toBe(true);
expect(await dialog.hasStartBothButton()).toBe(true);
await dialog.cancel();
} finally {
await p.switchToMainFrame();
await paceJibriCommand();
await p.getIframeAPI().executeCommand('stopRecording', 'file');
await waitForRecordingStopExecuted(p);
}
});
it('transcription section shows Stop when transcription is running', async () => {
if (!expectations.jaas.transcriptionEnabled) {
return;
}
const p = await joinAsModerator();
try {
await paceJibriCommand();
await p.getIframeAPI().executeCommand('startRecording', { transcription: true });
await p.switchToIFrame();
await waitForTranscriptionRunning(p);
await p.getToolbar().clickRecordingButton();
const dialog = p.getRecordingTranscriptionDialog();
await dialog.waitForDisplay();
expect(await dialog.hasStopTranscriptionButton()).toBe(true);
expect(await dialog.hasStartRecordingButton()).toBe(true);
// Mixed state — the footer offers both bulk actions.
expect(await dialog.hasStopBothButton()).toBe(true);
expect(await dialog.hasStartBothButton()).toBe(true);
await dialog.cancel();
} finally {
await p.switchToMainFrame();
await paceJibriCommand();
await p.getIframeAPI().executeCommand('stopRecording', 'file', true);
await waitForRecordingStopExecuted(p, true);
}
});
it('both sections show Stop and footer shows only "Stop both" when both run', async () => {
if (!expectations.jaas.recordingEnabled || !expectations.jaas.transcriptionEnabled) {
return;
}
const p = await joinAsModerator();
try {
await startFileRecording(p, {
mode: 'file',
transcription: true
});
await p.switchToIFrame();
await waitForRecordingRunning(p);
await waitForTranscriptionRunning(p);
await p.getToolbar().clickRecordingButton();
const dialog = p.getRecordingTranscriptionDialog();
await dialog.waitForDisplay();
expect(await dialog.hasStopRecordingButton()).toBe(true);
expect(await dialog.hasStopTranscriptionButton()).toBe(true);
expect(await dialog.hasStopBothButton()).toBe(true);
expect(await dialog.hasStartBothButton()).toBe(false);
await dialog.cancel();
} finally {
await p.switchToMainFrame();
await paceJibriCommand();
await p.getIframeAPI().executeCommand('stopRecording', 'file');
await waitForRecordingStopExecuted(p);
}
});
it('"Recorder follows me" is locked while recording runs', async () => {
if (!expectations.jaas.recordingEnabled) {
return;
}
const p = await joinAsModerator();
try {
await startFileRecording(p, { mode: 'file' });
await p.switchToIFrame();
await waitForRecordingRunning(p);
await p.getToolbar().clickRecordingButton();
const dialog = p.getRecordingTranscriptionDialog();
await dialog.waitForDisplay();
await dialog.toggleRecordingOptions();
// The switch only shows for moderators with a cloud service selected.
if (await dialog.hasFollowMeRecorderSwitch()) {
// The recorder cannot pick up the change mid-session.
expect(await dialog.isFollowMeRecorderEnabled()).toBe(false);
}
await dialog.cancel();
} finally {
await p.switchToMainFrame();
await paceJibriCommand();
await p.getIframeAPI().executeCommand('stopRecording', 'file');
await waitForRecordingStopExecuted(p);
}
});
it('can stop transcription independently while recording keeps running', async () => {
if (!expectations.jaas.recordingEnabled || !expectations.jaas.transcriptionEnabled) {
return;
}
const p = await joinAsModerator();
try {
await startFileRecording(p, {
mode: 'file',
transcription: true
});
await p.switchToIFrame();
await waitForRecordingRunning(p);
await waitForTranscriptionRunning(p);
await p.getToolbar().clickRecordingButton();
const dialog = p.getRecordingTranscriptionDialog();
await dialog.waitForDisplay();
// Stop only transcription — the action applies immediately and closes the dialog.
await paceJibriCommand();
await dialog.stopTranscription();
// Transcription should stop.
await waitForTranscriptionStopped(p);
// Recording should still be running.
const recordingStillOn = await p.execute(() => Boolean(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
APP.store.getState()['features/recording'].sessionDatas.some(
(s: any) => s.mode === 'file' && s.status !== 'off')
));
expect(recordingStillOn).toBe(true);
} finally {
await p.switchToMainFrame();
await paceJibriCommand();
await p.getIframeAPI().executeCommand('stopRecording', 'file');
await waitForRecordingStopExecuted(p);
}
});
it('can stop recording independently while transcription keeps running', async () => {
if (!expectations.jaas.recordingEnabled || !expectations.jaas.transcriptionEnabled) {
return;
}
const p = await joinAsModerator();
try {
await startFileRecording(p, {
mode: 'file',
transcription: true
});
await p.switchToIFrame();
await waitForRecordingRunning(p);
await waitForTranscriptionRunning(p);
await p.getToolbar().clickRecordingButton();
const dialog = p.getRecordingTranscriptionDialog();
await dialog.waitForDisplay();
// Stop only recording — the action applies immediately and closes the dialog.
await paceJibriCommand();
await dialog.stopRecording();
// Recording should stop.
await waitForRecordingStopped(p);
// Transcription should still be running.
const transcriptionStillOn = await p.execute(
() => Boolean(APP.store.getState()['features/transcribing'].isTranscribing)
);
expect(transcriptionStillOn).toBe(true);
} finally {
await p.switchToMainFrame();
await paceJibriCommand();
await p.getIframeAPI().executeCommand('stopRecording', 'file', true);
await waitForRecordingStopExecuted(p, true);
}
});
it('"Stop both" stops recording and transcription together', async () => {
if (!expectations.jaas.recordingEnabled || !expectations.jaas.transcriptionEnabled) {
return;
}
const p = await joinAsModerator();
try {
await startFileRecording(p, {
mode: 'file',
transcription: true
});
await p.switchToIFrame();
await waitForRecordingRunning(p);
await waitForTranscriptionRunning(p);
await p.getToolbar().clickRecordingButton();
const dialog = p.getRecordingTranscriptionDialog();
await dialog.waitForDisplay();
await paceJibriCommand();
await dialog.stopBoth();
await waitForRecordingStopped(p);
await waitForTranscriptionStopped(p);
} finally {
await p.switchToMainFrame();
await paceJibriCommand();
await p.getIframeAPI().executeCommand('stopRecording', 'file', true);
await waitForRecordingStopExecuted(p, true);
}
});
});
// ─── Button visibility ────────────────────────────────────────────────────────
describe('Recording button visibility', () => {
it('moderator button has aria-label "Record & Transcribe"', async () => {
if (!expectations.jaas.recordingEnabled) {
return;
}
const p = await joinJaasMuc({ token: t({ moderator: true }) });
expect(await p.getToolbar().hasRecordingButton()).toBe(true);
// This participant joined without the iFrame API, so hang up via the app, not window.jitsiAPI.
await p.hangup();
});
});

View File

@@ -0,0 +1,251 @@
import type { Participant } from '../../helpers/Participant';
import { setTestProperties } from '../../helpers/TestProperties';
import type WebhookProxy from '../../helpers/WebhookProxy';
import { expectations } from '../../helpers/expectations';
import { joinJaasMuc, generateJaasToken as t } from '../../helpers/jaas';
setTestProperties(__filename, {
requireWebhookProxy: true,
useJaas: true,
usesBrowsers: [ 'p1', 'p2' ]
});
const asyncTranscriptionValues = [
...expectations.jaas.transcription.jigasiTranscription ? [ false ] : [],
...expectations.jaas.transcription.asyncTranscription ? [ true ] : []
];
for (const asyncTranscriptions of asyncTranscriptionValues) {
describe(`Transcription (async=${asyncTranscriptions})`, () => {
let p1: Participant, p2: Participant;
let webhooksProxy: WebhookProxy;
async function clearTranscriptionStatusChange() {
await p1.getIframeAPI().clearEventResults('transcribingStatusChanged');
await p2.getIframeAPI().clearEventResults('transcribingStatusChanged');
}
/**
* Wait until a transcribingStatusChanged iFrame event is received for both p1 and p2, with a specific value
* for the "on" field.
* Note that addEventListener for 'transcriptionChunkReceived' should have been called prior to this function.
*/
async function waitForTranscriptionStatusChange(expectedOn: boolean) {
for (const p of [ p1, p2 ]) {
const event = await p.driver.waitUntil(
() => p.getIframeAPI().getEventResult('transcribingStatusChanged'),
{
timeout: 10000,
timeoutMsg: `transcribingStatusChanged event not received on ${p.name}`
});
if (event.on !== expectedOn) {
throw new Error(`Expected transcribing to be ${expectedOn} for ${p.name}, got ${event.on}`);
}
if (!expectedOn && !asyncTranscriptions) {
// The "stopped" event is sometimes fired before the jigasi participant leaves. If we re-start
// transcription before jigasi has left, jicofo will reject the request.
await p.switchToIFrame();
await p.waitForParticipants(
1,
'Unexpected number of participants. Jigasi failed to leave?');
await p.switchToMainFrame();
}
}
}
it('setup', async () => {
const room = ctx.roomName;
webhooksProxy = ctx.webhooksProxy;
webhooksProxy.defaultMeetingSettings = { asyncTranscriptions };
p1 = await joinJaasMuc({
name: 'p1',
token: t({ room, moderator: true }),
iFrameApi: true
});
const transcriptionEnabled = await p1.execute(() => config.transcription?.enabled);
expect(transcriptionEnabled).toBe(expectations.jaas.transcriptionEnabled);
const roomMetadata = await p1.getRoomMetadata();
if (asyncTranscriptions) {
expect(roomMetadata.asyncTranscription).toBe(true);
} else {
expect(roomMetadata.asyncTranscription).toBeFalsy();
}
p2 = await joinJaasMuc({
name: 'p2',
token: t({ room }),
iFrameApi: true
}, {
configOverwrite: {
startWithAudioMuted: true
}
});
await Promise.all([
p1.switchToMainFrame(),
p2.switchToMainFrame(),
]);
expect(await p1.getIframeAPI().getEventResult('isModerator')).toBe(true);
expect(await p1.getIframeAPI().getEventResult('videoConferenceJoined')).toBeDefined();
await p1.getIframeAPI().addEventListener('transcribingStatusChanged');
await p2.getIframeAPI().addEventListener('transcribingStatusChanged');
await p1.getIframeAPI().addEventListener('transcriptionChunkReceived');
await p2.getIframeAPI().addEventListener('transcriptionChunkReceived');
});
it('toggle subtitles', async () => {
await clearTranscriptionStatusChange();
await p1.getIframeAPI().executeCommand('toggleSubtitles');
await waitForTranscriptionStatusChange(true);
await checkReceivingChunks(p1, p2, webhooksProxy, asyncTranscriptions);
await clearTranscriptionStatusChange();
await p1.getIframeAPI().executeCommand('toggleSubtitles');
await waitForTranscriptionStatusChange(false);
});
it('set subtitles on and off', async () => {
// we need to clear results or the last one will be used, from the previous time subtitles were on
await clearTranscriptionStatusChange();
await p1.getIframeAPI().clearEventResults('transcriptionChunkReceived');
await p2.getIframeAPI().clearEventResults('transcriptionChunkReceived');
webhooksProxy.clearCache();
await p1.getIframeAPI().executeCommand('setSubtitles', true, true);
await waitForTranscriptionStatusChange(true);
await checkReceivingChunks(p1, p2, webhooksProxy, asyncTranscriptions);
await clearTranscriptionStatusChange();
await p1.getIframeAPI().executeCommand('setSubtitles', false);
await waitForTranscriptionStatusChange(false);
});
it('start/stop transcriptions via recording', async () => {
// we need to clear results or the last one will be used, from the previous time subtitles were on
await p1.getIframeAPI().clearEventResults('transcriptionChunkReceived');
await p2.getIframeAPI().clearEventResults('transcriptionChunkReceived');
await clearTranscriptionStatusChange();
webhooksProxy.clearCache();
await p1.getIframeAPI().executeCommand('startRecording', { transcription: true });
await waitForTranscriptionStatusChange(true);
await checkReceivingChunks(p1, p2, webhooksProxy, asyncTranscriptions);
await clearTranscriptionStatusChange();
await p1.getIframeAPI().executeCommand('stopRecording', 'file', true);
await waitForTranscriptionStatusChange(false);
await p1.getIframeAPI().executeCommand('hangup');
await p2.getIframeAPI().executeCommand('hangup');
// sometimes events are not immediately received,
// let's wait for destroy event before waiting for those that depends on it
await webhooksProxy.waitForEvent('ROOM_DESTROYED');
if (!asyncTranscriptions || expectations.jaas.transcription.asyncTranscriptionWebhook) {
const event: {
data: {
preAuthenticatedLink: string;
};
eventType: string;
} = await webhooksProxy.waitForEvent('TRANSCRIPTION_UPLOADED');
expect(event.eventType).toBe('TRANSCRIPTION_UPLOADED');
expect(event.data.preAuthenticatedLink).toBeDefined();
}
});
});
}
/**
*
* @param p1
* @param p2
* @param webhooksProxy
* @param asyncTranscription Whether async transciptions are used.
*/
async function checkReceivingChunks(p1: Participant, p2: Participant, webhooksProxy: WebhookProxy, asyncTranscription = false) {
const p1Promise = p1.driver.waitUntil(() => p1.getIframeAPI()
.getEventResult('transcriptionChunkReceived'), {
timeout: 60000,
timeoutMsg: 'transcriptionChunkReceived event not received on p1 side'
});
const p2Promise = p2.driver.waitUntil(() => p2.getIframeAPI()
.getEventResult('transcriptionChunkReceived'), {
timeout: 60000,
timeoutMsg: 'transcriptionChunkReceived event not received on p2 side'
});
const [ p1Event, p2Event ] = await Promise.all([ p1Promise, p2Promise ]);
const p1Id = await p1.getEndpointId();
const p1Transcript = p1Event.data.stable || p1Event.data.final;
const p2Transcript = p2Event.data.stable || p2Event.data.final;
expect(p2Transcript).toPartiallyMatch(p1Transcript, 'p2 transcript', 'p1 transcript');
expect(p2Event.data.language).toBe(p1Event.data.language);
expect(p2Event.data.messageID).toBe(p1Event.data.messageID);
expect(p1Event.data.participant.id).toBe(p1Id);
expect(p2Event.data.participant.id).toBe(p1Id);
if (!asyncTranscription) {
expect(p1Event.data.participant.name).toBe(p1.name);
expect(p2Event.data.participant.name).toBe(p1.name);
}
if (!asyncTranscription || expectations.jaas.transcription.asyncTranscriptionWebhook) {
// In async mode the backend re-IDs and re-segments chunks after post-processing, so live (iframe) and
// async (webhook) ID spaces don't overlap and transcript text won't correlate. Match by participant only
// and skip the content comparison.
const event: {
data: {
final: string;
language: string;
messageID: string;
participant: {
id: string;
name: string;
};
stable: string;
};
eventType: string;
} = await webhooksProxy.waitForEvent(
'TRANSCRIPTION_CHUNK_RECEIVED',
asyncTranscription
? e => e.data.participant.id === p1Id
: e => e.data.messageID === p1Event.data.messageID
);
expect(event.eventType).toBe('TRANSCRIPTION_CHUNK_RECEIVED');
if (!asyncTranscription) {
const webhookTranscript = event.data.final;
expect(webhookTranscript).toPartiallyMatch(p1Transcript, 'webhook transcript', 'p1 transcript');
}
if (p1Event.data.language) {
expect(event.data.language).toBe(p1Event.data.language);
}
expect(event.data.participant.id).toBe(p1Id);
if (!asyncTranscription) {
expect(event.data.participant.name).toBe(p1.name);
}
}
}

View File

@@ -0,0 +1,49 @@
import { setTestProperties } from '../../../helpers/TestProperties';
import { joinJaasMuc, generateJaasToken as t } from '../../../helpers/jaas';
setTestProperties(__filename, {
useJaas: true,
useWebhookProxy: true,
usesBrowsers: [ 'p1', 'p2', 'p3' ]
});
describe('Visitors triggered by reaching participantsSoftLimit', () => {
it('test participantsSoftLimit', async () => {
ctx.webhooksProxy.defaultMeetingSettings = {
participantsSoftLimit: 2,
visitorsEnabled: true
};
/// XXX the "name" of the participant MUST match one of the "capabilities" defined in wdio. It's not a "participant", it's an instance configuration!
const m = await joinJaasMuc({
token: t({ room: ctx.roomName, displayName: 'Mo de Rator', moderator: true })
});
expect(await m.isInMuc()).toBe(true);
expect(await m.isModerator()).toBe(true);
expect(await m.isVisitor()).toBe(false);
console.log('Moderator joined');
// Joining with a participant token before participantSoftLimit has been reached
const p = await joinJaasMuc({
name: 'p2',
token: t({ room: ctx.roomName, displayName: 'Parti Cipant' })
});
expect(await p.isInMuc()).toBe(true);
expect(await p.isModerator()).toBe(false);
expect(await p.isVisitor()).toBe(false);
console.log('Participant joined');
// Joining with a participant token after participantSoftLimit has been reached
const v = await joinJaasMuc({
name: 'p3',
token: t({ room: ctx.roomName, displayName: 'Visi Tor' })
});
expect(await v.isInMuc()).toBe(true);
expect(await v.isModerator()).toBe(false);
expect(await v.isVisitor()).toBe(true);
console.log('Visitor joined');
});
});

View File

@@ -0,0 +1,60 @@
import { setTestProperties } from '../../../helpers/TestProperties';
import { joinJaasMuc, generateJaasToken as t } from '../../../helpers/jaas';
setTestProperties(__filename, {
requireWebhookProxy: true,
useJaas: true,
usesBrowsers: [ 'p1', 'p2', 'p3', 'p4' ]
});
/**
* This is a case which fails if jitsi-videobridge doesn't properly forward PLIs from visitors.
*/
describe('Visitor receiving video from a single remote participant', () => {
it('joining the meeting', async () => {
ctx.webhooksProxy.defaultMeetingSettings = {
visitorsEnabled: true,
visitorsLive: true,
};
// Force a connection via JVB.
const configOverwrite = {
p2p: {
enabled: false
}
};
const sender = await joinJaasMuc({
token: t({ room: ctx.roomName, displayName: 'Sender', moderator: true })
}, {
configOverwrite
});
const senderEndpointId = await sender.getEndpointId();
const testVisitor = async function(instanceId: 'p1' | 'p2' | 'p3' | 'p4') {
const visitor = await joinJaasMuc({
name: instanceId,
token: t({ room: ctx.roomName, displayName: 'Visitor', visitor: true })
}, {
configOverwrite
});
await visitor.waitForIceConnected();
const iceConnected = performance.now();
await visitor.driver.waitUntil(
() => visitor.isRemoteVideoReceivedAndDisplayed(senderEndpointId), {
timeout: 10_000,
timeoutMsg: `Visitor (${instanceId}) is not receiving video from the sender`
});
const duration = performance.now() - iceConnected;
console.log(`Video displayed after ${duration} ms after ICE connected (${instanceId})`);
};
await testVisitor('p2');
await testVisitor('p3');
await testVisitor('p4');
});
});

View File

@@ -0,0 +1,140 @@
import { Participant } from '../../../helpers/Participant';
import { setTestProperties } from '../../../helpers/TestProperties';
import { config as testsConfig } from '../../../helpers/TestsConfig';
import WebhookProxy from '../../../helpers/WebhookProxy';
import { joinJaasMuc, generateJaasToken as t } from '../../../helpers/jaas';
setTestProperties(__filename, {
requireWebhookProxy: true,
useJaas: true,
usesBrowsers: [ 'p1', 'p2', 'p3' ]
});
describe('Visitors triggered by visitor tokens', () => {
let webhooksProxy: WebhookProxy;
let room: string;
async function verifyJoinedWebhook(participant: Participant) {
const context = participant.getToken()?.payload.context;
const event: {
customerId: string;
data: {
avatar: string;
email: string;
group: string;
id: string;
name: string;
participantJid: string;
role: string;
};
eventType: string;
} = await webhooksProxy.waitForEvent('PARTICIPANT_JOINED');
expect('PARTICIPANT_JOINED').toBe(event.eventType);
expect(event.data.avatar).toBe(context.user.avatar);
expect(event.data.email).toBe(context.user.email);
expect(event.data.id).toBe(context.user.id);
expect(event.data.group).toBe(context.group);
expect(event.data.name).toBe(context.user.name);
if (context.user.visitor) {
expect(event.data.participantJid.indexOf('meet.jitsi') != -1).toBe(true);
expect(event.data.role).toBe('visitor');
}
expect(event.customerId).toBe(testsConfig.jaas.customerId);
}
async function verifyLeftWebhook(participant: Participant) {
const context = participant.getToken()?.payload.context;
const eventLeft: {
customerId: string;
data: {
avatar: string;
email: string;
group: string;
id: string;
name: string;
participantJid: string;
role: string;
};
eventType: string;
} = await webhooksProxy.waitForEvent('PARTICIPANT_LEFT');
expect('PARTICIPANT_LEFT').toBe(eventLeft.eventType);
expect(eventLeft.data.avatar).toBe(context.user.avatar);
expect(eventLeft.data.email).toBe(context.user.email);
expect(eventLeft.data.id).toBe(context.user.id);
expect(eventLeft.data.group).toBe(context.group);
expect(eventLeft.data.name).toBe(context.user.name);
if (context.user.visitor) {
expect(eventLeft.data.participantJid.indexOf('meet.jitsi') != -1).toBe(true);
expect(eventLeft.data.role).toBe('visitor');
}
expect(eventLeft.customerId).toBe(testsConfig.jaas.customerId);
}
it('setup', async () => {
webhooksProxy = ctx.webhooksProxy;
webhooksProxy.defaultMeetingSettings = {
visitorsEnabled: true
};
room = ctx.roomName;
});
it('test visitor tokens', async () => {
webhooksProxy.clearCache();
const moderatorToken = t({ room, displayName: 'Mo de Rator', moderator: true });
const moderator = await joinJaasMuc({ name: 'p1', token: moderatorToken });
expect(await moderator.isInMuc()).toBe(true);
expect(await moderator.isModerator()).toBe(true);
expect(await moderator.isVisitor()).toBe(false);
await verifyJoinedWebhook(moderator);
webhooksProxy.clearCache();
// Joining with a participant token before any visitors
const participantToken = t({ room, displayName: 'Parti Cipant' });
const participant = await joinJaasMuc({ name: 'p2', token: participantToken });
expect(await participant.isInMuc()).toBe(true);
expect(await participant.isModerator()).toBe(false);
expect(await participant.isVisitor()).toBe(false);
await verifyJoinedWebhook(participant);
webhooksProxy.clearCache();
// Joining with a visitor token
const visitorToken = t({ room, displayName: 'Visi Tor', visitor: true });
const visitor = await joinJaasMuc({ name: 'p3', token: visitorToken });
expect(await visitor.isInMuc()).toBe(true);
expect(await visitor.isModerator()).toBe(false);
expect(await visitor.isVisitor()).toBe(true);
await verifyJoinedWebhook(visitor);
webhooksProxy.clearCache();
await participant.hangup();
await verifyLeftWebhook(participant);
webhooksProxy.clearCache();
// Joining with a participant token after visitors -> visitor
const participantToken2 = t({ room, displayName: 'Visi Tor 2' });
const visitor2 = await joinJaasMuc({ name: 'p2', token: participantToken2 });
expect(await visitor2.isInMuc()).toBe(true);
expect(await visitor2.isModerator()).toBe(false);
expect(await visitor2.isVisitor()).toBe(true);
await verifyJoinedWebhook(visitor2);
webhooksProxy.clearCache();
await visitor.hangup();
await verifyLeftWebhook(visitor);
webhooksProxy.clearCache();
await visitor2.hangup();
await verifyLeftWebhook(visitor2);
webhooksProxy.clearCache();
await moderator.hangup();
await verifyLeftWebhook(moderator);
});
});

View File

@@ -0,0 +1,73 @@
import { Participant } from '../../../helpers/Participant';
import { setTestProperties } from '../../../helpers/TestProperties';
import { expectations } from '../../../helpers/expectations';
import { joinJaasMuc, generateJaasToken as t } from '../../../helpers/jaas';
setTestProperties(__filename, {
requireWebhookProxy: true,
useJaas: true,
usesBrowsers: [ 'p1', 'p2' ]
});
describe('Visitors live', () => {
let visitor: Participant, moderator: Participant;
it('setup', async () => {
ctx.webhooksProxy.defaultMeetingSettings = {
visitorsEnabled: true,
visitorsLive: false
};
moderator = await joinJaasMuc({
name: 'p1',
token: t({ room: ctx.roomName, displayName: 'Mo de Rator', moderator: true })
});
if (expectations.jaas.visitors) {
await moderator.driver.waitUntil(() => moderator.execute(() => APP.conference._room.isVisitorsSupported()), {
timeout: 2000
}).catch(e => {
throw new Error(`isVisitorsSupported() returned an error: ${e}.`);
});
}
visitor = await joinJaasMuc({
name: 'p2',
token: t({ room: ctx.roomName, displayName: 'Visi Tor', visitor: true })
}, {
skipWaitToJoin: true
});
});
it('go live', async () => {
const vVisitors = visitor.getVisitors();
const mVisitors = moderator.getVisitors();
await visitor.driver.waitUntil(async () => vVisitors.isVisitorsQueueUIShown(), {
timeout: 5000,
timeoutMsg: 'Missing visitors queue UI'
});
await moderator.driver.waitUntil(async () => await mVisitors.getWaitingVisitorsInQueue()
=== 'Viewers waiting in queue: 1', {
timeout: 15000,
timeoutMsg: 'Missing visitors queue count in UI'
});
await mVisitors.goLive();
await visitor.waitToJoinMUC();
await visitor.waitForReceiveMedia(15000, 'Visitor is not receiving media');
await visitor.waitForRemoteStreams(1);
await visitor.driver.waitUntil(() => vVisitors.hasVisitorsDialog(), {
timeout: 5000,
timeoutMsg: 'Missing visitors dialog'
});
expect((await mVisitors.getVisitorsCount()).trim()).toBe('1');
expect((await mVisitors.getVisitorsHeaderFromParticipantsPane()).trim()).toBe('Viewers 1');
});
});