Files
upstream-jitsi-meet/tests/specs/iframe/secondScreen.spec.ts
upstream-mirror 1d2bfcac0c
Some checks failed
Prosody Plugin Tests / test (push) Failing after 10m11s
Close stale issues and PRs / stale (push) Failing after 1s
snapshot of https://github.com/jitsi/jitsi-meet.git @ fd1749dd28f4138aa80234fc7f311b19aaffa0e7 (2026-09-15)
2026-09-15 15:56:32 +05:00

241 lines
10 KiB
TypeScript

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'
});
});
});