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

618 lines
24 KiB
TypeScript

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