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