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

View File

@@ -0,0 +1,155 @@
import type { Participant } from '../../helpers/Participant';
import { setTestProperties } from '../../helpers/TestProperties';
import { config as testsConfig } from '../../helpers/TestsConfig';
import { joinMuc } from '../../helpers/joinMuc';
import { checkIframeApi } from './util';
setTestProperties(__filename, {
usesBrowsers: [ 'p1', 'p2' ]
});
describe('Chat', () => {
let p1: Participant, p2: Participant;
it('setup', async () => {
p1 = await joinMuc({ name: 'p1', iFrameApi: true, token: testsConfig.jwt.preconfiguredToken });
if (!await checkIframeApi(p1)) {
return;
}
p2 = await joinMuc({ name: 'p2', iFrameApi: true });
await p1.switchToMainFrame();
await p2.switchToMainFrame();
});
it('send message', async () => {
await p2.getIframeAPI().addEventListener('chatUpdated');
await p2.getIframeAPI().addEventListener('incomingMessage');
await p1.getIframeAPI().addEventListener('outgoingMessage');
const testMessage = 'Hello world';
await p1.getIframeAPI().executeCommand('sendChatMessage', testMessage);
const chatUpdatedEvent: {
isOpen: boolean;
unreadCount: number;
} = await p2.driver.waitUntil(() => p2.getIframeAPI().getEventResult('chatUpdated'), {
timeout: 3000,
timeoutMsg: 'Chat was not updated'
});
expect(chatUpdatedEvent).toEqual({
isOpen: false,
unreadCount: 1
});
const incomingMessageEvent: {
from: string;
message: string;
nick: string;
privateMessage: boolean;
} = await p2.getIframeAPI().getEventResult('incomingMessage');
expect(incomingMessageEvent).toMatchObject({
from: await p1.getEndpointId(),
message: testMessage,
nick: p1.name,
privateMessage: false
});
const outgoingMessageEvent: {
message: string;
privateMessage: boolean;
} = await p1.getIframeAPI().getEventResult('outgoingMessage');
expect(outgoingMessageEvent).toEqual({
message: testMessage,
privateMessage: false
});
await p1.getIframeAPI().clearEventResults('outgoingMessage');
await p2.getIframeAPI().clearEventResults('chatUpdated');
await p2.getIframeAPI().clearEventResults('incomingMessage');
});
it('toggle chat', async () => {
await p2.getIframeAPI().executeCommand('toggleChat');
await testSendGroupMessageWithChatOpen(p1, p2);
await p1.getIframeAPI().clearEventResults('outgoingMessage');
await p2.getIframeAPI().clearEventResults('chatUpdated');
await p2.getIframeAPI().clearEventResults('incomingMessage');
});
it('private chat', async () => {
const testMessage = 'Hello private world!';
await p1.getIframeAPI().executeCommand('initiatePrivateChat', await p2.getEndpointId());
await p1.getIframeAPI().executeCommand('sendChatMessage', testMessage, await p2.getEndpointId());
const incomingMessageEvent = await p2.driver.waitUntil(
() => p2.getIframeAPI().getEventResult('incomingMessage'), {
timeout: 3000,
timeoutMsg: 'Chat was not received'
});
expect(incomingMessageEvent).toMatchObject({
from: await p1.getEndpointId(),
message: testMessage,
nick: p1.name,
privateMessage: true
});
expect(await p1.getIframeAPI().getEventResult('outgoingMessage')).toEqual({
message: testMessage,
privateMessage: true
});
await p1.getIframeAPI().executeCommand('cancelPrivateChat');
await p2.getIframeAPI().clearEventResults('chatUpdated');
await p2.getIframeAPI().clearEventResults('incomingMessage');
await testSendGroupMessageWithChatOpen(p1, p2);
});
});
/**
* Send a group message from [sender], verify that it was received correctly by [receiver].
* @param sender the Participant that sends the message.
* @param receiver the Participant that receives the message.
*/
async function testSendGroupMessageWithChatOpen(sender: Participant, receiver: Participant) {
const testMessage = 'Hello world again';
await sender.getIframeAPI().executeCommand('sendChatMessage', testMessage);
const chatUpdatedEvent: {
isOpen: boolean;
unreadCount: number;
} = await receiver.driver.waitUntil(() => receiver.getIframeAPI().getEventResult('chatUpdated'), {
timeout: 3000,
timeoutMsg: 'Chat was not updated'
});
expect(chatUpdatedEvent).toEqual({
isOpen: true,
unreadCount: 0
});
const incomingMessageEvent = await receiver.driver.waitUntil(
() => receiver.getIframeAPI().getEventResult('incomingMessage'), {
timeout: 3000,
timeoutMsg: 'Chat was not received'
});
expect(incomingMessageEvent).toMatchObject({
from: await sender.getEndpointId(),
message: testMessage,
nick: sender.name,
privateMessage: false
});
}

View File

@@ -0,0 +1,137 @@
import { isEqual } from 'lodash-es';
import { setTestProperties } from '../../helpers/TestProperties';
import { ensureTwoParticipants } from '../../helpers/participants';
import { checkIframeApi } from './util';
setTestProperties(__filename, {
retry: true,
usesBrowsers: [ 'p1', 'p2' ]
});
describe('Kick participants', () => {
it('joining the meeting', async () => {
await ensureTwoParticipants({}, { name: 'p1', iFrameApi: true });
const { p1, p2 } = ctx;
if (!await checkIframeApi(p1)) {
return;
}
await Promise.all([
p1.switchToMainFrame(),
p2.switchToMainFrame()
]);
expect(await p1.getIframeAPI().getEventResult('isModerator')).toBe(true);
expect(await p1.getIframeAPI().getEventResult('videoConferenceJoined')).toBeDefined();
expect(await p2.getIframeAPI().getEventResult('videoConferenceJoined')).toBeDefined();
});
it('kick participant', async () => {
await ctx.p2.getIframeAPI().clearEventResults('videoConferenceLeft');
await ctx.p2.getIframeAPI().addEventListener('videoConferenceLeft');
await ctx.p2.switchToMainFrame();
await ctx.p2.getIframeAPI().executeCommand('hangup');
await ctx.p2.driver.waitUntil(() =>
ctx.p2.getIframeAPI().getEventResult('videoConferenceLeft'), {
timeout: 4000,
timeoutMsg: 'videoConferenceLeft not received'
});
await ensureTwoParticipants({}, { name: 'p1', iFrameApi: true });
const { p1, p2, roomName } = ctx;
const p1EpId = await p1.getEndpointId();
const p2EpId = await p2.getEndpointId();
const p1DisplayName = await p1.getLocalDisplayName();
const p2DisplayName = await p2.getLocalDisplayName();
await p1.switchToMainFrame();
await p2.switchToMainFrame();
await p1.getIframeAPI().addEventListener('participantKickedOut');
await p2.getIframeAPI().addEventListener('participantKickedOut');
await p2.getIframeAPI().clearEventResults('videoConferenceLeft');
await p2.getIframeAPI().addEventListener('videoConferenceLeft');
await p1.getIframeAPI().executeCommand('kickParticipant', p2EpId);
const eventP1 = await p1.driver.waitUntil(() => p1.getIframeAPI().getEventResult('participantKickedOut'), {
timeout: 2000,
timeoutMsg: 'participantKickedOut event not received on p1 side'
});
const eventP2 = await p2.driver.waitUntil(() => p2.getIframeAPI().getEventResult('participantKickedOut'), {
timeout: 2000,
timeoutMsg: 'participantKickedOut event not received on p2 side'
});
expect(eventP1).toBeDefined();
expect(eventP2).toBeDefined();
expect(isEqual(eventP1, {
kicked: {
id: p2EpId,
local: false,
name: p2DisplayName
},
kicker: {
id: p1EpId,
local: true,
name: p1DisplayName
}
})).toBe(true);
expect(isEqual(eventP2, {
kicked: {
id: 'local',
local: true,
name: p2DisplayName
},
kicker: {
id: p1EpId,
name: p1DisplayName
}
})).toBe(true);
const eventConferenceLeftP2 = await p2.driver.waitUntil(() =>
p2.getIframeAPI().getEventResult('videoConferenceLeft'), {
timeout: 4000,
timeoutMsg: 'videoConferenceLeft not received'
});
expect(eventConferenceLeftP2).toBeDefined();
expect(eventConferenceLeftP2.roomName).toBe(roomName);
});
it('join after kick', async () => {
const { p1 } = ctx;
await p1.getIframeAPI().addEventListener('participantJoined');
// join again
await ensureTwoParticipants({}, { name: 'p1', iFrameApi: true });
const { p2 } = ctx;
await p1.switchToMainFrame();
const event = await p1.driver.waitUntil(() => p1.getIframeAPI().getEventResult('participantJoined'), {
timeout: 2000,
timeoutMsg: 'participantJoined not received'
});
const p2DisplayName = await p2.getLocalDisplayName();
expect(event).toBeDefined();
expect(event.id).toBe(await p2.getEndpointId());
expect(event.displayName).toBe(p2DisplayName);
expect(event.formattedDisplayName).toBe(p2DisplayName);
});
});

View File

@@ -0,0 +1,164 @@
import { P1, P2 } from '../../helpers/Participant';
import { setTestProperties } from '../../helpers/TestProperties';
import { ensureTwoParticipants, parseJid } from '../../helpers/participants';
import { checkIframeApi } from './util';
setTestProperties(__filename, {
usesBrowsers: [ 'p1', 'p2' ]
});
describe('Participants presence', () => {
it('joining the meeting', async () => {
await ensureTwoParticipants({}, { name: 'p1', iFrameApi: true });
const { p1, p2 } = ctx;
if (!await checkIframeApi(p1)) {
return;
}
await Promise.all([
p1.switchToMainFrame(),
p2.switchToMainFrame()
]);
expect(await p1.getIframeAPI().getEventResult('isModerator')).toBe(true);
expect(await p1.getIframeAPI().getEventResult('videoConferenceJoined')).toBeDefined();
expect(await p2.getIframeAPI().getEventResult('videoConferenceJoined')).toBeDefined();
});
it('participants info',
async () => {
const { p1, roomName } = ctx;
const roomsInfo = (await p1.getIframeAPI().getRoomsInfo()).rooms[0];
expect(roomsInfo).toBeDefined();
expect(roomsInfo.isMainRoom).toBe(true);
expect(roomsInfo.id).toBeDefined();
const { node: roomNode } = parseJid(roomsInfo.id);
expect(roomNode).toBe(roomName);
const { node, resource } = parseJid(roomsInfo.jid);
const p1EpId = await p1.getEndpointId();
expect(node).toBe(roomName);
expect(resource).toBe(p1EpId);
expect(roomsInfo.participants.length).toBe(2);
expect(await p1.getIframeAPI().getNumberOfParticipants()).toBe(2);
}
);
it('participants pane', async () => {
const { p1 } = ctx;
expect(await p1.getIframeAPI().isParticipantsPaneOpen()).toBe(false);
await p1.getIframeAPI().addEventListener('participantsPaneToggled');
await p1.getIframeAPI().executeCommand('toggleParticipantsPane', true);
expect(await p1.getIframeAPI().isParticipantsPaneOpen()).toBe(true);
expect((await p1.getIframeAPI().getEventResult('participantsPaneToggled'))?.open).toBe(true);
await p1.getIframeAPI().executeCommand('toggleParticipantsPane', false);
expect(await p1.getIframeAPI().isParticipantsPaneOpen()).toBe(false);
expect((await p1.getIframeAPI().getEventResult('participantsPaneToggled'))?.open).toBe(false);
});
it('overwrite names', async () => {
const { p1, p2 } = ctx;
const p1EpId = await p1.getEndpointId();
const p2EpId = await p2.getEndpointId();
const newP1Name = P1;
const newP2Name = P2;
const newNames: ({ id: string; name: string; })[] = [ {
id: p2EpId,
name: newP2Name
}, {
id: p1EpId,
name: newP1Name
} ];
await p1.getIframeAPI().executeCommand('overwriteNames', newNames);
await p1.switchToIFrame();
expect(await p1.getLocalDisplayName()).toBe(newP1Name);
expect(await p1.getFilmstrip().getRemoteDisplayName(p2EpId)).toBe(newP2Name);
});
it('hangup', async () => {
const { p1, p2, roomName } = ctx;
await p1.switchToMainFrame();
await p2.switchToMainFrame();
await p2.getIframeAPI().clearEventResults('videoConferenceLeft');
await p2.getIframeAPI().addEventListener('videoConferenceLeft');
await p2.getIframeAPI().addEventListener('readyToClose');
await p2.getIframeAPI().executeCommand('hangup');
const eventConferenceLeftP2 = await p2.driver.waitUntil(() =>
p2.getIframeAPI().getEventResult('videoConferenceLeft'), {
timeout: 4000,
timeoutMsg: 'videoConferenceLeft not received'
});
expect(eventConferenceLeftP2).toBeDefined();
expect(eventConferenceLeftP2.roomName).toBe(roomName);
const eventReadyToCloseP2 = await p2.driver.waitUntil(() => p2.getIframeAPI().getEventResult('readyToClose'), {
timeout: 2000,
timeoutMsg: 'readyToClose not received'
});
expect(eventReadyToCloseP2).toBeDefined();
});
it('dispose conference', async () => {
const { p1, roomName } = ctx;
await p1.switchToMainFrame();
await p1.getIframeAPI().clearEventResults('videoConferenceLeft');
await p1.getIframeAPI().addEventListener('videoConferenceLeft');
await p1.getIframeAPI().addEventListener('readyToClose');
await p1.getIframeAPI().executeCommand('hangup');
const eventConferenceLeft = await p1.driver.waitUntil(() =>
p1.getIframeAPI().getEventResult('videoConferenceLeft'), {
timeout: 4000,
timeoutMsg: 'videoConferenceLeft not received'
});
expect(eventConferenceLeft).toBeDefined();
expect(eventConferenceLeft.roomName).toBe(roomName);
const eventReadyToClose = await p1.driver.waitUntil(() => p1.getIframeAPI().getEventResult('readyToClose'), {
timeout: 2000,
timeoutMsg: 'readyToClose not received'
});
expect(eventReadyToClose).toBeDefined();
// dispose
await p1.getIframeAPI().dispose();
// check there is no iframe on the page
await p1.driver.$('iframe').waitForExist({
reverse: true,
timeout: 2000,
timeoutMsg: 'iframe is still on the page'
});
});
});

View File

@@ -0,0 +1,140 @@
# Second screen — manual test checklist
Companion to `secondScreen.spec.ts`. That spec covers the contract the External
API owes an embedder: that a command is answered, with which code, and that a
failure leaves no window behind. Everything below is what an automated run cannot
reach, and it is not a small set, so it is written to be reproducible rather than
left to whoever last touched the feature.
## Why these are not automated
CI runs headless with a single display. Two things follow, and only the second is
the obvious one.
Headless Chrome **does** expose `getScreenDetails`, so the feature reports itself
as supported and a command gets past the enablement gate. It is not the
"unsupported browser" case it is often assumed to be. What it cannot do is reach
the permission: `navigator.permissions.query` reports `prompt`, but no prompt is
ever raised, because a command arriving over the external API carries no
transient user activation and Chromium refuses to ask without one. The call
rejects with `NotAllowedError` in ~2ms (verified on HeadlessChrome/151), so an
enabled command in CI lands on `window-management-unavailable` almost at once,
not on `second-screen-disabled`.
That also means a prompt left unanswered is only reachable from an in-app
trigger in a real browser, which is why cases A3 and A5 below cannot be
approximated in CI at all.
And with one display there is no second screen to place a window on, so placement,
fullscreen, occupancy and every multi-display behaviour below are unreachable
regardless of permissions. Stubbing `getScreenDetails` would test the stub.
## Setup
- Chromium-based browser. The Window Management API is Chromium-only, so Firefox
and Safari are only useful for confirming the feature stays off (case A6).
- At least two physical displays. Cases D1 to D4 need one that can be undocked or
unplugged.
- `secondScreen: { enabled: true }`, either in `config.js` or as
`#config.secondScreen.enabled=true` on the URL, since the option is in
`configWhitelist`.
- For an iframe embed, the iframe needs `allow="window-management; fullscreen"`.
- **Allow popups for the origin.** A command arriving over the External API runs
with no user activation, so the popup blocker refuses the window and every such
case reports `popup-blocked` instead of what it was meant to test. An in-app
trigger is a real click and does not need this.
- **Grant window-management out of band for the API cases.** For the same reason,
Chromium will not raise the permission prompt for an API-driven open: it rejects
immediately, and the case reports `window-management-unavailable`. Grant it under
the site settings padlock first, or drive the case from an in-app trigger.
- Start each permission case from a profile where window-management has not been
answered yet. Reset it under the site settings padlock, or use a fresh profile;
the permission is sticky per origin, so a second run of A2 tests nothing.
Cases marked **[#17666]** cover behaviour the in-app triggers added. That PR
merged on 19 August, so every case here runs against master.
---
## A. Opening, permissions and placement
**A2 to A5c have to be driven by an in-app trigger, not by the External API**, and
are therefore **[#17666]** cases whatever else they test. Only a real click carries
the user activation Chromium requires before it will raise the permission prompt;
over the API the call rejects at once and the case never reaches what it was
written for.
| # | Steps | Expected |
|---|---|---|
| A1 | On a granted profile, `setSecondScreen` with `{ id: 'a', source: { role: 'stage' } }` | Window opens on an external display, fullscreen, showing the active speaker. `secondScreenSourceChanged` fires with `id: 'a'`. |
| A2 | On an ungranted profile, click an in-app trigger and press **Allow** promptly | Prompt appears, window opens and moves onto the external display. One `secondScreenSourceChanged`. |
| A3 | Same as A2, but wait ~30s before pressing Allow | The window still opens and places correctly. It must not be blocked by the popup blocker, and no error is reported. This is the transient-activation case: the open runs before anything is awaited. **[#17666]** The window renders its content on the meeting's own screen while the prompt is up, and jumps to the external display on Allow; it is not blank while waiting. |
| A4 | Same as A2, but press **Block** | `secondScreenError` with `window-management-unavailable`, and nothing after it. No window is left on screen, and no `secondScreenClosed` follows the error: reporting the error alone is master's contract for a failed open, and an embedder that reopens on `secondScreenClosed` would loop on the extra event. **[#17666]** |
| A5 | Same as A2, but dismiss nothing and leave the prompt open | **[#17666]** The window renders on the meeting's own screen meanwhile, so it looks like it worked; after 30s `secondScreenError` with `window-management-unavailable`, the window closes, and again no `secondScreenClosed` follows. The bound is what keeps an unanswered prompt from parking the open forever. |
| A5b | During A5, while the prompt is still up, click the trigger again to cancel | **[#17666]** The window closes at once and `secondScreenClosed` is reported. It must not stay up until the prompt is answered, and the trigger must not stay inverted. |
| A5c | During A5, while the prompt is still up, send the same id again | **[#17666]** The existing window re-sources in place. It must not open a second window and must not return silently. |
| A6 | Repeat A1 in Firefox or Safari | `secondScreenError` with `second-screen-disabled`. No window. |
| A7 | With popups blocked for the site, repeat A1 | `secondScreenError` with `popup-blocked`. |
| A8 | On a profile where window-management was previously **denied**, click an in-app trigger | **[#17666]** `secondScreenError` with `window-management-unavailable`, and no window is left behind. Expect a brief flash: the rejection is not acted on until the window is registered, so it opens, loads and renders before being torn down. Shortening that needs the denied state to be known before the open, which `preloadScreenDetails` could cache. |
## B. Sources and live behaviour
| # | Steps | Expected |
|---|---|---|
| B1 | `{ role: 'stage' }`, then have a different participant speak | The window follows the new active speaker without a re-send. A `secondScreenSourceChanged` fires per change, with the new `participantId`. |
| B2 | `{ role: 'tile' }`, then have someone join and someone leave | The grid re-lays out both times. |
| B3 | `{ role: 'screenshare' }` while one person shares | The share renders full-bleed. |
| B4 | `{ role: 'screenshare', participant: X }` with **two** shares live | Specifically X's share, not the stage layout. Note this differs from pre-#17666 behaviour, where the participant was ignored. |
| B5 | `{ role: 'whiteboard' }` | The whiteboard renders and is drawable from the meeting window, updating on the second screen. |
| B6 | `{ role: 'sharedvideo' }` while a video is shared | The video plays. **Audio must come from the meeting window only** — confirm it is not doubled or echoed. |
| B7 | Pause the shared video from the meeting | The second screen keeps showing it; the window must not tear down. |
| B8 | `{ participant: X }` for a participant with video, then mute their camera | Video, then their avatar. No blank window. |
| B9 | `{ participant: X, media: 'desktop' }` | Their screen rather than their camera. |
| B10 | Reuse the same `id` with a different `source` | The window updates in place. It must not close and reopen, and must not move screens. |
## C. Closing
| # | Steps | Expected |
|---|---|---|
| C1 | `setSecondScreen` with the same `id` and no `source` | Window closes, `secondScreenClosed` fires once. |
| C2 | Close the popup by hand on the other display | `secondScreenClosed` fires. Re-sending the same id opens a fresh window. |
| C3 | End the conference with a window open | Window closes, `secondScreenClosed` fires. |
| C4 | Reload the meeting tab with a window open | The orphan window closes rather than being left on the display. |
| C5 | Send a close for an id that was never opened | Nothing happens, and no `secondScreenClosed` is fabricated. |
## D. Multiple displays and occupancy — **[#17666]**
These are the cases the positional occupancy logic exists for. Three displays are
ideal; two will exercise D1 and D2.
| # | Steps | Expected |
|---|---|---|
| D1 | Meeting on A, externals B and C. Trigger a send twice | The two windows land on B and C, one each, never stacked. |
| D2 | With windows on B and C, close B's popup by hand, then undock B. Send again | The send re-sources the surviving window rather than opening a second one on top of it. |
| D3 | With windows on B and C, undock C (its window relocates onto A), then redock C. Send again | A window opens on the now-free C. It must **not** silently re-source the window sitting over the meeting. |
| D4 | Undock C so its window relocates onto A, leaving no free external screen. Send again | The send takes over the window on B, not the one overlapping the meeting. |
| D5 | Start with only the laptop panel, send once, then dock an external monitor and send again | The second send opens on the newly attached monitor, not over the first window. |
| D6 | Drag a second-screen window to a different display by hand, then send again | Occupancy follows where the window actually is, not where it was opened. |
## E. In-app triggers — **[#17666]**
| # | Steps | Expected |
|---|---|---|
| E1 | With the feature **disabled**, hover a screenshare tile and the shared-video tile | No trigger, and no invisible hit area: clicking the top-right corner of the tile still pins as it always did. |
| E2 | Hover a screenshare tile with the feature enabled | Trigger appears; clicking sends that share to a second screen. |
| E3 | Click the same trigger again | The window closes. |
| E4 | Send a screenshare, then have the sharer stop sharing | The window closes on its own and reports `secondScreenClosed`, about 5s later. The tile and its trigger are gone, so nothing else could have closed it. |
| E5 | Send a participant, then have them leave | Same as E4, including the delay. |
| E6 | Send the shared video, then stop the share | The window closes at once: the shared video is not participant-backed, so it does not wait. |
| E7 | Send the whiteboard from its context menu, then close the whiteboard | Same as E6, at once. The window must not be left showing the whiteboard placeholder. |
| E7b | Send a participant, then take their tab offline in DevTools for ~2s and let it recover | The window survives. This is the case the 5s grace exists for: the participant is briefly absent, and closing on absence alone would destroy a window with no way to get it back. Best-effort, since it needs the client to resume the same session rather than rejoin as a new one; a full reload is a new id and the window closes correctly after the grace. |
| E8 | Start a send, and while the permission prompt is still open, click the trigger again to cancel | The window closes. It must not first fly to the other display and then vanish. |
| E9 | Open a window through the External API, then use an in-app trigger | The trigger opens its own window; it must never take over or close the embedder's. |
## F. Sanity
| # | Steps | Expected |
|---|---|---|
| F1 | With a second screen open, check the participant count in the meeting | Unchanged. The window is a view, not a second join. |
| F2 | Watch bandwidth/stats with a stage window open | No duplicated media subscription for the same source. |
| F3 | Leave a window open for several minutes | No drift, no memory growth, no repeated re-mounts in the console. |

View File

@@ -0,0 +1,240 @@
import { expect } from '@wdio/globals';
import type { Participant } from '../../helpers/Participant';
import { setTestProperties } from '../../helpers/TestProperties';
import { ensureOneParticipant } from '../../helpers/participants';
import { checkIframeApi } from './util';
setTestProperties(__filename, {
usesBrowsers: [ 'p1' ]
});
/**
* Every code the feature can report. Which one comes back depends on the
* environment, so the tests below assert membership of this set rather than a
* specific value, except where the outcome is deterministic.
*
* Placement on a real second display and the fullscreen behaviour are not
* covered here at all: both need hardware and a granted permission that CI does
* not have, and stubbing the Window Management API would test the stub rather
* than the feature. Those cases are written up as a reproducible checklist in
* secondScreen.manual.md, alongside this file.
*/
const ERROR_CODES = [
'second-screen-disabled',
'popup-blocked',
'window-management-unavailable',
'window-load-failed',
'window-setup-failed'
];
const SCREEN_ID = 'test-screen';
/**
* Waits briefly for an event that is expected NOT to arrive, and returns
* whatever the API recorded for it. {@code getEventResult} returns false when an
* event has never fired, so a falsy result is the assertion this supports.
*
* @param {Object} p - The participant.
* @param {string} event - The event name.
* @returns {Promise<any>}
*/
async function readEventAfterSettling(p: Participant, event: string) {
await p.driver.pause(2000);
return await p.getIframeAPI().getEventResult(event);
}
describe('setSecondScreen iframe API command', () => {
it('reports second-screen-disabled when the feature is off', async () => {
await ensureOneParticipant({}, { name: 'p1', iFrameApi: true });
const { p1 } = ctx;
if (!await checkIframeApi(p1)) {
return;
}
await p1.switchToMainFrame();
await p1.getIframeAPI().addEventListener('secondScreenError');
const handlesBefore = (await p1.driver.getWindowHandles()).length;
await p1.getIframeAPI().executeCommand('setSecondScreen', {
id: SCREEN_ID,
source: { role: 'stage' }
});
const event = await p1.driver.waitUntil(
() => p1.getIframeAPI().getEventResult('secondScreenError'), {
timeout: 5000,
timeoutMsg: 'secondScreenError was not received for a disabled feature'
});
// The feature is off by default, so this outcome does not depend on the
// browser or on any permission: it is the one fully deterministic path.
expect(event.id).toBe(SCREEN_ID);
expect(event.error).toBe('second-screen-disabled');
// And it must fail without leaving a window behind.
expect((await p1.driver.getWindowHandles()).length).toBe(handlesBefore);
});
it('does not report a close for a window that was never open', async () => {
const { p1 } = ctx;
await p1.switchToMainFrame();
await p1.getIframeAPI().addEventListener('secondScreenClosed');
await p1.getIframeAPI().clearEventResults('secondScreenClosed');
// A command with no source is the documented way to close a window. For
// an id that has none, it is a no-op: nothing was closed, so nothing is
// reported.
await p1.getIframeAPI().executeCommand('setSecondScreen', { id: 'never-opened' });
expect(await readEventAfterSettling(p1, 'secondScreenClosed')).toBeFalsy();
});
it('treats a source with neither role nor participant as a close', async () => {
const { p1 } = ctx;
await p1.switchToMainFrame();
await p1.getIframeAPI().clearEventResults('secondScreenError');
await p1.getIframeAPI().clearEventResults('secondScreenClosed');
await p1.getIframeAPI().executeCommand('setSecondScreen', {
id: 'never-opened',
source: {}
});
// An empty source is a close request, not an attempt to open something
// unrenderable, so the disabled feature is never consulted and no error
// is reported.
expect(await readEventAfterSettling(p1, 'secondScreenError')).toBeFalsy();
expect(await p1.getIframeAPI().getEventResult('secondScreenClosed')).toBeFalsy();
});
it('always reports an outcome once the feature is enabled', async () => {
await ctx.p1.hangup();
await ensureOneParticipant({
configOverwrite: {
secondScreen: { enabled: true }
}
}, { name: 'p1', iFrameApi: true });
const { p1 } = ctx;
await p1.switchToMainFrame();
await p1.getIframeAPI().addEventListener('secondScreenError');
await p1.getIframeAPI().addEventListener('secondScreenSourceChanged');
await p1.getIframeAPI().clearEventResults('secondScreenError');
await p1.getIframeAPI().clearEventResults('secondScreenSourceChanged');
await p1.getIframeAPI().executeCommand('setSecondScreen', {
id: SCREEN_ID,
source: { role: 'stage' }
});
// What happens next depends on the environment, and both outcomes are
// acceptable. What the feature must never do is accept the command and
// then say nothing, which is what this pins down.
//
// Headless Chrome is not the "unsupported browser" case it is sometimes
// assumed to be: it does expose getScreenDetails, so the feature reports
// itself as supported and the command gets past the enablement gate.
// The permission reports `prompt`, but no prompt is ever raised here,
// because a command arriving over the external API carries no user
// activation and Chromium refuses to ask without one. The call rejects
// with NotAllowedError in a couple of milliseconds (verified on 151), so
// this lands on window-management-unavailable almost at once rather than
// waiting out the permission bound.
const outcome = await p1.driver.waitUntil(async () => {
const error = await p1.getIframeAPI().getEventResult('secondScreenError');
const changed = await p1.getIframeAPI().getEventResult('secondScreenSourceChanged');
return error || changed || false;
}, {
timeout: 15000,
timeoutMsg: 'setSecondScreen reported neither a source change nor an error'
});
expect(outcome.id).toBe(SCREEN_ID);
if (outcome.error) {
expect(ERROR_CODES).toContain(outcome.error);
// A failed open must not leave the window it could not finish
// setting up, since nothing in the meeting could close it afterwards.
expect((await p1.driver.getWindowHandles()).length).toBe(1);
} else {
expect(outcome.source.role).toBe('stage');
}
});
it('reports the source it resolved rather than only what was asked for', async function() {
const { p1 } = ctx;
const changed = await p1.getIframeAPI().getEventResult('secondScreenSourceChanged');
if (!changed) {
// secondScreenSourceChanged never fired, so no window was ever
// opened/registered in this environment. Skip rather than return:
// headless cannot grant the window-management permission, so this
// would otherwise report green without ever touching the payload
// it is named for.
//
// When changed is truthy the window may since have closed itself
// (see the next test) - that does not matter here, since this only
// checks the shape of the payload already recorded, not that a
// window is still open.
// eslint-disable-next-line @typescript-eslint/no-invalid-this
this.skip();
}
// participantId is what the role resolved to, and is null when nothing
// backs it yet. Either way the field is part of the payload.
expect(changed).toHaveProperty('participantId');
expect(changed).toHaveProperty('source');
});
it('closes an open window and reports it', async function() {
const { p1 } = ctx;
if ((await p1.driver.getWindowHandles()).length === 1) {
// Nothing to close: either no window was ever opened in this
// environment, or it already closed itself asynchronously (e.g. a
// window-management-unavailable failure resolving shortly after
// secondScreenSourceChanged had already fired) before this test
// got a chance to close it. The live handle count is the direct
// signal for "is there a window to close", unlike the cached event
// above, which stays truthy even once that window is long gone -
// that is what makes this a skip rather than a pass in practice on
// every environment CI runs today; see secondScreen.manual.md for
// the coverage that would otherwise exercise this path.
// eslint-disable-next-line @typescript-eslint/no-invalid-this
this.skip();
}
await p1.switchToMainFrame();
await p1.getIframeAPI().addEventListener('secondScreenClosed');
await p1.getIframeAPI().clearEventResults('secondScreenClosed');
await p1.getIframeAPI().executeCommand('setSecondScreen', { id: SCREEN_ID });
const event = await p1.driver.waitUntil(
() => p1.getIframeAPI().getEventResult('secondScreenClosed'), {
timeout: 5000,
timeoutMsg: 'secondScreenClosed was not received'
});
expect(event.id).toBe(SCREEN_ID);
// The window itself has to be gone, not just the redux entry.
await p1.driver.waitUntil(
async () => (await p1.driver.getWindowHandles()).length === 1, {
timeout: 5000,
timeoutMsg: 'the second-screen window was not closed'
});
});
});

View File

@@ -0,0 +1,197 @@
import { expect } from '@wdio/globals';
import { setTestProperties } from '../../helpers/TestProperties';
import { ensureOneParticipant } from '../../helpers/participants';
import { checkIframeApi } from './util';
setTestProperties(__filename, {
usesBrowsers: [ 'p1' ]
});
const PILL_SELECTOR = '[data-testid="time-timer-pill"]';
const ENDED_NOTIFICATION_SELECTOR = '[data-testid="timeTimer.endedTitle"]';
const EXPIRED_BORDER_SELECTOR = '#videospace.timer-expired';
// The duration these tests push through the iframe API. Distinct from any
// server-enforced limit, so assertions can tell the two apart on a deployment
// that runs mod_time_restricted.
const PUSHED_DURATION = 1800;
describe('setMeetingTimer iframe API command', () => {
it('does not show the timer until a duration is pushed', async () => {
// The default test config disables the timer, so enable it explicitly.
// `suppressForSeconds: 0` is pinned rather than left to the deployment:
// a target that configures a window (alpha sets 180) would otherwise
// hold the pill back and every pill assertion below would read as a
// missing timer. The suppression window has its own test further down,
// which sets the value it needs.
await ensureOneParticipant({
configOverwrite: {
timeTimer: {
enabled: true,
suppressForSeconds: 0
}
}
}, { name: 'p1', iFrameApi: true });
const { p1 } = ctx;
if (!await checkIframeApi(p1)) {
return;
}
// Inside the iframe — nothing has pushed a duration through the API yet.
// This keys off the duration rather than asserting the pill is absent:
// a deployment that enforces its own limit (mod_time_restricted pushes
// one to every occupant on join) legitimately has a timer running here,
// and the point of this case is that the API has not set one.
await p1.switchToIFrame();
expect(await p1.execute(() => APP.store.getState()['features/time-timer'].durationSeconds))
.not.toBe(PUSHED_DURATION);
});
it('shows the timer pill when a duration is pushed via the iframe API', async () => {
const { p1 } = ctx;
await p1.switchToMainFrame();
await p1.getIframeAPI().executeCommand('setMeetingTimer', {
duration: 1800, // 30 min
elapsed: 0
});
await p1.switchToIFrame();
await expect(await p1.driver.$(PILL_SELECTOR)).toBeDisplayed();
});
it('shows a running (not expired) timer when joining before the scheduled start', async () => {
const { p1 } = ctx;
// Negative elapsed === the meeting starts in 10 minutes. The timer must
// show as running (anchored to the scheduled start), NOT expired — an
// early joiner should never see the red over-schedule state.
await p1.switchToMainFrame();
await p1.getIframeAPI().executeCommand('setMeetingTimer', {
duration: 1800, // 30 min
elapsed: -600 // joined 10 min before the scheduled start
});
await p1.switchToIFrame();
await expect(await p1.driver.$(PILL_SELECTOR)).toBeDisplayed();
// No expired border and no ended notification while still before / within
// the scheduled window.
await expect(await p1.driver.$(EXPIRED_BORDER_SELECTOR)).not.toExist();
await expect(await p1.driver.$(ENDED_NOTIFICATION_SELECTOR)).not.toExist();
});
it('flips to the expired state when an over-schedule timer is pushed', async () => {
const { p1 } = ctx;
await p1.switchToMainFrame();
await p1.getIframeAPI().executeCommand('setMeetingTimer', {
duration: 1800, // 30 min
elapsed: 2520 // already 12 minutes past the scheduled end
});
await p1.switchToIFrame();
// Pill stays visible …
await expect(await p1.driver.$(PILL_SELECTOR)).toBeDisplayed();
// … the videospace gets the red expired-border class …
await p1.driver.waitUntil(
async () => await p1.driver.$(EXPIRED_BORDER_SELECTOR).isExisting(),
{ timeout: 3000, timeoutMsg: 'expired border did not appear' });
// … and the sticky "Timer ended" notification appears.
await p1.driver.waitUntil(
async () => await p1.driver.$(ENDED_NOTIFICATION_SELECTOR).isExisting(),
{ timeout: 3000, timeoutMsg: 'timer-ended notification did not appear' });
});
it('clears the pill, border and notification when the timer is cleared', async () => {
const { p1 } = ctx;
await p1.switchToMainFrame();
// No duration => clear.
await p1.getIframeAPI().executeCommand('setMeetingTimer', {});
await p1.switchToIFrame();
await p1.driver.waitUntil(
async () => !await p1.driver.$(PILL_SELECTOR).isDisplayed(),
{ timeout: 3000, timeoutMsg: 'pill did not disappear on clear' });
await p1.driver.waitUntil(
async () => !await p1.driver.$(EXPIRED_BORDER_SELECTOR).isExisting(),
{ timeout: 3000, timeoutMsg: 'expired border did not clear' });
await p1.driver.waitUntil(
async () => !await p1.driver.$(ENDED_NOTIFICATION_SELECTOR).isExisting(),
{ timeout: 3000, timeoutMsg: 'timer-ended notification did not clear' });
});
it('holds the pill back for the configured suppression window', async () => {
// Rejoin with a suppression window: the timer runs from the moment a
// duration is known, but stays off screen until the meeting is that far
// along. This is what lets `mod_time_restricted` push the limit on join
// while the countdown only surfaces part-way through.
await ctx.p1.hangup();
await ensureOneParticipant({
configOverwrite: {
timeTimer: {
enabled: true,
suppressForSeconds: 600 // 10 min
}
}
}, { name: 'p1', iFrameApi: true });
const { p1 } = ctx;
await p1.switchToMainFrame();
await p1.getIframeAPI().executeCommand('setMeetingTimer', {
duration: 1800, // 30 min
elapsed: 0
});
await p1.switchToIFrame();
// Inside the window — a duration is known and the timer is running, but
// the pill must not be on screen yet.
await p1.driver.pause(1000);
await expect(await p1.driver.$(PILL_SELECTOR)).not.toBeDisplayed();
// Past the window (elapsed is measured from the scheduled start, so this
// is also the late-joiner case) — the pill appears.
await p1.switchToMainFrame();
await p1.getIframeAPI().executeCommand('setMeetingTimer', {
duration: 1800,
elapsed: 900 // 15 min in, past the 10 min suppression window
});
await p1.switchToIFrame();
await expect(await p1.driver.$(PILL_SELECTOR)).toBeDisplayed();
});
it('ignores the command when the feature is disabled', async () => {
// Hangup and rejoin the same conference with the default test config,
// which disables the timer, so we can prove the command is a no-op on
// opt-out deployments.
await ctx.p1.hangup();
await ensureOneParticipant({}, { name: 'p1', iFrameApi: true });
const { p1 } = ctx;
await p1.switchToMainFrame();
await p1.getIframeAPI().executeCommand('setMeetingTimer', {
duration: 1800,
elapsed: 0
});
await p1.switchToIFrame();
// Give the dispatch a moment, then assert the pill never appeared.
await p1.driver.pause(1000);
await expect(await p1.driver.$(PILL_SELECTOR)).not.toBeDisplayed();
});
});

View File

@@ -0,0 +1,13 @@
import type { Participant } from '../../helpers/Participant';
import { expectations } from '../../helpers/expectations';
export async function checkIframeApi(p: Participant) {
const iframeEnabled = !await p.execute(() => config.disableIframeAPI);
expect(iframeEnabled).toBe(expectations.iframe.enabled);
if (!iframeEnabled) {
ctx.skipSuiteTests = 'The iFrame API is disabled';
}
return iframeEnabled;
}