Files
upstream-jitsi-meet/tests/specs/misc/recordingDialogConfig.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

372 lines
17 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { setTestProperties } from '../../helpers/TestProperties';
import { config as testsConfig } from '../../helpers/TestsConfig';
import { joinMuc } from '../../helpers/joinMuc';
/**
* Joins (or rejoins) p1 as a moderator with the given config.js overrides, using the shared
* preconfigured token (see TestsConfig.ts) rather than a self-signed one. This suite only ever
* varies config.js, never JWT feature claims — on this (non-JaaS) tenant, getRecordButtonProps()
* and friends fall back to config alone once the JWT carries no feature claims, so the shared
* token is enough. The JWT-feature-driven side of this same dialog (only reachable on a JaaS
* tenant, where that config-only fallback doesn't apply) is covered separately by
* tests/specs/jaas/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 config.js combination.
*
* Waits for p1's moderator role to actually land client-side before returning: the server grants
* it (the JWT's moderator claim) on every join, but the toolbar's recording button renders the
* non-moderator "Record" label (instead of "Record & Transcribe") until that presence update is
* processed — a gap the rest of this file otherwise has no reason to guard against itself.
*/
async function joinWithConfig(configOverwrite: Record<string, unknown>): Promise<void> {
await joinMuc({
name: 'p1',
token: testsConfig.jwt.preconfiguredToken
}, { configOverwrite });
await ctx.p1.driver.waitUntil(() => ctx.p1.isModerator(), {
timeout: 5_000,
timeoutMsg: 'p1 did not become a moderator in time'
});
}
/**
* Mirrors LocalRecordingManager.isSupported() plus the embedding guard that
* supportsLocalRecording() adds — the same probe recordingButtonVisibility.spec.ts uses, because
* these browser capability gates are frequently unmet in the automated test browser regardless of
* the localRecording.disable config value.
*/
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;
});
}
/**
* Whether Dropbox is configured as a recording storage integration in this deployment
* (config.dropbox.appKey), independent of anything this file overrides. AbstractStartRecordingDialog
* treats it as a fallback recording service whenever recordingService.enabled is false (and prefers
* it over an empty selection, though local recording still wins over it when both are available) —
* so wherever "is some recording service available" is checked, Dropbox has to be accounted for too
* on any deployment where it happens to be configured, exactly like localRecordingSupport.byBrowser.
*/
async function isDropboxEnabledInDeployment(): Promise<boolean> {
return ctx.p1.execute(() => typeof config.dropbox?.appKey === 'string');
}
/**
* Fakes a "recording running" / "transcription running" state by dispatching the same low-level,
* side-effect-free redux actions the app's own middleware dispatches once a session is actually
* running, without going through the real start flow — which would either need a Jibri/Jigasi
* backend (cloud recording/transcription) or hit the browser's native showSaveFilePicker dialog
* (local recording), neither of which this environment can drive.
*
* - "recording running": SET_LOCAL_RECORDING_RUNNING only sets the reducer's localRecordingRunning
* flag (recording/middleware.ts has no side-effecting case for it, unlike START_LOCAL_RECORDING);
* isRecordingRunning() ORs it with cloud-recording state, so the dialog can't tell it apart from a
* real recording session.
* - "transcription running": isRecorderTranscriptionsRunning() requires BOTH
* features/transcribing.isTranscribing (set by TRANSCRIBER_JOINED/LEFT, a pure reducer
* transition — see transcribing/reducer.ts) AND the conference metadata's
* recording.isTranscribingEnabled flag (set via UPDATE_CONFERENCE_METADATA).
*/
async function setRunningState(recordingRunning: boolean, transcriptionRunning: boolean): Promise<void> {
await ctx.p1.execute((recording: boolean, transcription: boolean) => {
const { dispatch } = APP.store;
dispatch({ type: 'SET_LOCAL_RECORDING_RUNNING',
running: recording });
if (transcription) {
dispatch({ type: 'TRANSCRIBER_JOINED',
transcriberJID: 'fake-transcriber-for-testing' });
dispatch({
type: 'UPDATE_CONFERENCE_METADATA',
metadata: { recording: { isTranscribingEnabled: true } }
});
} else {
dispatch({ type: 'TRANSCRIBER_LEFT' });
dispatch({ type: 'UPDATE_CONFERENCE_METADATA',
metadata: null });
}
}, recordingRunning, transcriptionRunning);
}
setTestProperties(__filename, {
description: 'Recording & Transcription dialog rendering across config.js flag combinations',
usesBrowsers: [ 'p1' ]
});
/*
* Full 2^3 matrix over the three independently togglable config.js flags: recordingService.enabled,
* transcription.enabled and localRecording.disable. Expected behavior for each combination, derived
* from the source (AbstractStartRecordingDialog(Content), recording/functions.ts#getRecordButtonProps):
*
* - Every environment this suite runs against grants the local participant a default "recording"
* JWT feature claim regardless of authentication — confirmed via diagnostics logged below
* (localParticipantFeatures.recording is "true" even with no token/JWT at all). getRecordButtonProps()
* checks that claim *before* falling back to config-only visibility, so that fallback (which would
* let transcription.enabled alone drive visibility) is unreachable here: the toolbar button's
* visibility (and therefore whether the recording section can be inspected at all) reduces to
* recordingService.enabled || dropboxAvailable || localRecordingAvailable, same as
* recordingEnabled in getRecordButtonProps (which ORs in Dropbox too) — transcription.enabled never
* factors into it.
* - Whenever the dialog *can* open, the recording section renders — what changes is which storage
* service ends up selected by default: "Recording service" takes priority, then "Local recording",
* then "Dropbox" (see the constructor's own priority chain in AbstractStartRecordingDialog.ts).
* - The transcription section, its Start button, and the footer's "Start both" button are each
* governed purely by transcription.enabled (independent of the other two flags, since nothing is
* actually started in these tests) — but only reachable at all when the dialog can open in the
* first place, i.e. when some recording service is also available.
*
* The Live Streaming toolbar button is JWT-feature-gated with no config-only fallback (see
* LiveStream/AbstractLiveStreamButton.ts), so it can't be driven by config.js alone here — that's
* covered on the JaaS side instead, by tests/specs/jaas/recordingDialogConfig.spec.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, exactly
* like recordingButtonVisibility.spec.ts does, so the per-combination expectations stay accurate
* regardless of what the test browser actually supports. dropboxAvailable is similarly probed once,
* since it depends on this deployment's config rather than anything this file overrides.
*/
describe('Recording dialog config matrix — recording × transcription × local recording', () => {
// 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 properties (set
// once, in 'setup', before any of the loop's tests run) actually change.
const environment = { dropboxAvailable: false, localRecordingByBrowser: false };
it('setup', async () => {
await joinWithConfig({});
environment.localRecordingByBrowser = await isLocalRecordingSupportedByBrowser();
environment.dropboxAvailable = await isDropboxEnabledInDeployment();
});
const BOOLEANS = [ true, false ];
for (const recordingServiceEnabled of BOOLEANS) {
for (const transcriptionEnabled of BOOLEANS) {
for (const localRecordingDisabled of BOOLEANS) {
const title = `recordingService.enabled=${recordingServiceEnabled} `
+ `transcription.enabled=${transcriptionEnabled} `
+ `localRecording.disable=${localRecordingDisabled}`;
it(title, async () => {
await joinWithConfig({
recordingService: { enabled: recordingServiceEnabled },
transcription: { enabled: transcriptionEnabled },
localRecording: { disable: localRecordingDisabled }
});
const p1 = ctx.p1;
const localRecordingAvailable = !localRecordingDisabled && environment.localRecordingByBrowser;
const dialogCanOpen
= recordingServiceEnabled || localRecordingAvailable || environment.dropboxAvailable;
expect(await p1.getToolbar().hasRecordingButton()).toBe(dialogCanOpen);
if (!dialogCanOpen) {
// Nothing else is inspectable: the dialog cannot be opened at all.
return;
}
const dialog = p1.getRecordingTranscriptionDialog();
await p1.getToolbar().clickRecordingButton();
await dialog.waitForDisplay();
// The recording section always renders here (same condition as dialogCanOpen);
// which service ends up selected is what differs.
await dialog.toggleRecordingOptions();
const expectedService = recordingServiceEnabled ? 'Recording service'
: localRecordingAvailable ? 'Local recording'
: 'Dropbox';
expect(await dialog.getSelectedService()).toBe(expectedService);
expect(await dialog.hasTranscriptionOptions()).toBe(transcriptionEnabled);
expect(await dialog.hasStartTranscriptionButton()).toBe(transcriptionEnabled);
// The footer only makes sense when both sections are present; the recording
// section is always present on this branch, so this reduces to transcriptionEnabled.
expect(await dialog.hasStartBothButton()).toBe(transcriptionEnabled);
await dialog.cancel();
});
}
}
}
});
/*
* With both services enabled and available, the 2x2 matrix of which one is actually running.
* Expectations are read directly off web/StartRecordingDialogContent.tsx: each section swaps its
* own Start/Stop button independently, and the footer's _renderFooter() shows "Stop both" whenever
* ANY service is running and "Start both" whenever ANY service is NOT running — so in a mixed state
* (one running, one not) BOTH footer buttons render at once: "Stop both" stops the running one,
* "Start both" starts the missing one.
*/
describe('Recording dialog config — mixed running state (recording vs transcription)', () => {
const RUNNING_STATE_COMBINATIONS: Array<[boolean, boolean]> = [
[ false, false ],
[ true, false ],
[ false, true ],
[ true, true ]
];
for (const [ recordingRunning, transcriptionRunning ] of RUNNING_STATE_COMBINATIONS) {
it(`recording running=${recordingRunning}, transcription running=${transcriptionRunning}`, async () => {
await joinWithConfig({
recordingService: { enabled: true },
transcription: { enabled: true }
});
await setRunningState(recordingRunning, transcriptionRunning);
const dialog = ctx.p1.getRecordingTranscriptionDialog();
await ctx.p1.getToolbar().clickRecordingButton();
await dialog.waitForDisplay();
expect(await dialog.hasStartRecordingButton()).toBe(!recordingRunning);
expect(await dialog.hasStopRecordingButton()).toBe(recordingRunning);
expect(await dialog.hasStartTranscriptionButton()).toBe(!transcriptionRunning);
expect(await dialog.hasStopTranscriptionButton()).toBe(transcriptionRunning);
expect(await dialog.hasStopBothButton()).toBe(recordingRunning || transcriptionRunning);
expect(await dialog.hasStartBothButton()).toBe(!recordingRunning || !transcriptionRunning);
await dialog.cancel();
});
}
});
describe('Recording dialog config — recordingService.sharingEnabled', () => {
it('sharingEnabled: true shows the "share the recording link" switch', async () => {
await joinWithConfig({
recordingService: {
enabled: true,
sharingEnabled: true
}
});
const dialog = ctx.p1.getRecordingTranscriptionDialog();
await ctx.p1.getToolbar().clickRecordingButton();
await dialog.waitForDisplay();
await dialog.toggleRecordingOptions();
expect(await dialog.getSelectedService()).toBe('Recording service');
expect(await dialog.hasFileSharingSwitch()).toBe(true);
await dialog.cancel();
});
it('sharingEnabled: false hides the "share the recording link" switch', async () => {
await joinWithConfig({
recordingService: {
enabled: true,
sharingEnabled: false
}
});
const dialog = ctx.p1.getRecordingTranscriptionDialog();
await ctx.p1.getToolbar().clickRecordingButton();
await dialog.waitForDisplay();
await dialog.toggleRecordingOptions();
expect(await dialog.getSelectedService()).toBe('Recording service');
expect(await dialog.hasFileSharingSwitch()).toBe(false);
await dialog.cancel();
});
});
describe('Recording dialog config — localRecording.notifyAllParticipants', () => {
const NOTIFICATION_WARNING = 'will not be announced to other participants';
let localRecordingSupported: boolean;
it('setup', async () => {
await joinWithConfig({
localRecording: {
disable: false,
notifyAllParticipants: true
},
recordingService: { enabled: false }
});
localRecordingSupported = await isLocalRecordingSupportedByBrowser();
});
it('notifyAllParticipants: true — no "will not be announced" warning is shown', async () => {
if (!localRecordingSupported) {
return;
}
const dialog = ctx.p1.getRecordingTranscriptionDialog();
await ctx.p1.getToolbar().clickRecordingButton();
await dialog.waitForDisplay();
await dialog.toggleRecordingOptions();
expect(await dialog.getRecordingOptionsBodyText()).not.toContain(NOTIFICATION_WARNING);
await dialog.cancel();
});
it('notifyAllParticipants: false — the warning clears once "record only myself" is on', async () => {
if (!localRecordingSupported) {
return;
}
await joinWithConfig({
localRecording: {
disable: false,
notifyAllParticipants: false
},
recordingService: { enabled: false }
});
const dialog = ctx.p1.getRecordingTranscriptionDialog();
await ctx.p1.getToolbar().clickRecordingButton();
await dialog.waitForDisplay();
await dialog.toggleRecordingOptions();
expect(await dialog.getRecordingOptionsBodyText()).toContain(NOTIFICATION_WARNING);
await dialog.toggleLocalRecordingOnlySelfSwitch();
expect(await dialog.getRecordingOptionsBodyText()).not.toContain(NOTIFICATION_WARNING);
await dialog.cancel();
});
});