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): Promise { 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 { 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 { 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 { 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(); }); });