diff --git a/packages/@webex/plugin-meetings/README.md b/packages/@webex/plugin-meetings/README.md index 6b0ba929d47..527a23d375c 100644 --- a/packages/@webex/plugin-meetings/README.md +++ b/packages/@webex/plugin-meetings/README.md @@ -990,6 +990,7 @@ meeting.on(...) | `meeting:recording:stopped` | Fired when member stops recording | | `meeting:recording:paused` | Fired when member pauses recording | | `meeting:recording:resumed` | Fired when member resumes recording | +| `meeting:recording:durationUpdated` | Fired after the SDK hydrates the recording duration from the recording stream service (e.g. on rejoin). Distinct from the transition events above so consumers can update timer state without re-running side effects tied to start/pause/resume. | | `meeting:receiveTranscription:started` | Fired when transcription is received | | `meeting:receiveTranscription:stopped` | Fired when transcription has stopped from being received | | `meeting:meetingContainer:update` | Fired when the meetingContainerUrl is updated | diff --git a/packages/@webex/plugin-meetings/src/constants.ts b/packages/@webex/plugin-meetings/src/constants.ts index 6c37066befc..edf86eebf14 100644 --- a/packages/@webex/plugin-meetings/src/constants.ts +++ b/packages/@webex/plugin-meetings/src/constants.ts @@ -296,6 +296,7 @@ export const EVENT_TRIGGERS = { MEETING_RECEIVE_REACTIONS: 'meeting:receiveReactions', MEETING_PAUSED_RECORDING: 'meeting:recording:paused', MEETING_RESUMED_RECORDING: 'meeting:recording:resumed', + MEETING_RECORDING_DURATION_UPDATED: 'meeting:recording:durationUpdated', MEETING_ADDED: 'meeting:added', MEETING_REMOVED: 'meeting:removed', MEETING_RINGING: 'meeting:ringing', diff --git a/packages/@webex/plugin-meetings/src/locus-info/controlsUtils.ts b/packages/@webex/plugin-meetings/src/locus-info/controlsUtils.ts index 25e26adc387..b7c743c981c 100644 --- a/packages/@webex/plugin-meetings/src/locus-info/controlsUtils.ts +++ b/packages/@webex/plugin-meetings/src/locus-info/controlsUtils.ts @@ -22,6 +22,12 @@ ControlsUtils.parse = (controls: any) => { const parsedControls = {...controls}; if (controls && controls.record) { + // Passthrough any duration fields Locus may send so consumers can compute + // accumulated recording time on rejoin (parity with native's + // RecordingStreamSession::RecordingTime). Falls back to undefined when the + // server does not include them on this payload. + const rawDuration = controls.record.meta?.duration ?? controls.record.duration; + parsedControls.record = { modifiedBy: ControlsUtils.getId(controls), paused: controls.record.paused ? controls.record.paused : false, @@ -29,6 +35,14 @@ ControlsUtils.parse = (controls: any) => { lastModified: controls.record.meta?.lastModified, modifiedByServiceAppName: controls.record.meta?.modifiedByServiceAppName, modifiedByServiceAppId: controls.record.meta?.modifiedByServiceAppId, + // Duration metadata (parity with native iOS/Android). All optional. + // - lastDuration: accumulated recorded ms before the current segment + // - lastTime: server timestamp when the current segment/state started + // - needCalculate: true while RECORDING (UI must add now - lastTime), + // false while PAUSED (UI shows lastDuration as-is) + lastDuration: rawDuration?.lastDuration, + lastTime: rawDuration?.lastTime, + needCalculate: rawDuration?.needCalculate, }; } diff --git a/packages/@webex/plugin-meetings/src/locus-info/index.ts b/packages/@webex/plugin-meetings/src/locus-info/index.ts index bc240a230f4..96cf379da79 100644 --- a/packages/@webex/plugin-meetings/src/locus-info/index.ts +++ b/packages/@webex/plugin-meetings/src/locus-info/index.ts @@ -2100,6 +2100,11 @@ export default class LocusInfo extends EventsScope { lastModified: current.record.lastModified, modifiedByServiceAppName: current.record.modifiedByServiceAppName, modifiedByServiceAppId: current.record.modifiedByServiceAppId, + // Duration metadata (parity with native). All optional — present + // only when Locus includes them in `controls.record.meta.duration`. + lastDuration: current.record.lastDuration, + lastTime: current.record.lastTime, + needCalculate: current.record.needCalculate, } ); } diff --git a/packages/@webex/plugin-meetings/src/meeting/index.ts b/packages/@webex/plugin-meetings/src/meeting/index.ts index 8bcdf465c8a..8883532b7da 100644 --- a/packages/@webex/plugin-meetings/src/meeting/index.ts +++ b/packages/@webex/plugin-meetings/src/meeting/index.ts @@ -687,6 +687,13 @@ export default class Meeting extends StatelessWebexPlugin { recording: any; remoteMediaManager: RemoteMediaManager | null; recordingController: RecordingController; + pendingRecordingHydrationEvent?: string; + // Monotonic counter bumped on every recording state transition. Used to + // discard stale `getRecordingStatus` responses that resolve after the + // recording has been stopped (and possibly restarted) — preventing the + // previous segment's `lastDuration`/`lastTime` from being merged onto + // the new RECORDING state. + private recordingHydrationEpoch = 0; controlsOptionsManager: ControlsOptionsManager; requiredCaptcha: any; receiveSlotManager: ReceiveSlotManager; @@ -2812,6 +2819,119 @@ export default class Meeting extends StatelessWebexPlugin { }); } + /** + * Locus deltas do not include the accumulated recording duration. Native + * clients fetch it from the recording stream service + * (`GET {serviceUrl}/loci/{locusId}/resource`). This helper performs the + * same fetch and, on success, merges the duration metadata into + * `this.recording` and emits a dedicated `meeting:recording:durationUpdated` + * event so the consumer can hydrate its timer with the correct elapsed + * time (especially on rejoin while the meeting is paused). The original + * transition event (started / paused / resumed) is intentionally NOT + * replayed — it has already fired from `setupLocusControlsListener` and + * replaying it would cause notification / analytics consumers to process + * the same transition twice. + * + * @param {string} event - the recording transition event that triggered + * this hydration (used only for logging) + * @returns {Promise} + * @private + * @memberof Meeting + */ + private async hydrateRecordingDuration(event: string): Promise { + if (!this.recordingController) { + return; + } + + // The recording stream service URL is set asynchronously via the + // LINKS_SERVICES locus event. On initial join into an already RECORDING + // / PAUSED meeting, the controls update can fire before that. In that + // case, defer the hydration: it will be retried from + // `setUpLocusServicesListener` once the URL is available. + if (!this.recordingController.getServiceUrl() || !this.recordingController.getLocusId()) { + LoggerProxy.logger.debug( + `Meeting:index#hydrateRecordingDuration --> deferred (event=${event}); serviceUrl/locusId not yet available` + ); + this.pendingRecordingHydrationEvent = event; + + return; + } + + this.pendingRecordingHydrationEvent = undefined; + + // Capture the current epoch before issuing the request. If the recording + // state changes (e.g. stop, or stop -> start of a new segment) before the + // response arrives, the epoch bumps and we drop the response so we don't + // apply the previous segment's duration onto the new RECORDING state. + const epochAtRequest = this.recordingHydrationEpoch; + + try { + const duration = await this.recordingController.getRecordingStatus(); + + LoggerProxy.logger.debug( + `Meeting:index#hydrateRecordingDuration --> event=${event} duration=${JSON.stringify( + duration + )}` + ); + + if (this.recordingHydrationEpoch !== epochAtRequest) { + LoggerProxy.logger.debug( + `Meeting:index#hydrateRecordingDuration --> dropping stale response (event=${event}); epoch changed` + ); + + return; + } + + if (!duration || (duration.lastDuration === undefined && duration.lastTime === undefined)) { + return; + } + + // The recording stream service is eventually consistent with Locus. + // After a quick pause/resume or stop/start, the response can still + // describe the previous segment's state and carry its `lastDuration`. + // Drop the response if its status does not match our current local + // recording state so we never seed the new timer with a stale baseline. + if ( + duration.status && + this.recording?.state && + duration.status.toLowerCase() !== this.recording.state.toLowerCase() + ) { + LoggerProxy.logger.debug( + `Meeting:index#hydrateRecordingDuration --> dropping stale response (event=${event}); status=${duration.status} state=${this.recording.state}` + ); + + return; + } + + // Belt-and-suspenders: even if the epoch is unchanged, never resurrect + // duration metadata onto an IDLE state. + if (this.recording?.state === RECORDING_STATE.IDLE) { + return; + } + + this.recording = { + ...this.recording, + lastDuration: duration.lastDuration, + lastTime: duration.lastTime, + needCalculate: duration.needCalculate, + }; + + Trigger.trigger( + this, + { + file: 'meeting/index', + function: 'hydrateRecordingDuration', + }, + EVENT_TRIGGERS.MEETING_RECORDING_DURATION_UPDATED, + this.recording + ); + } catch (error) { + LoggerProxy.logger.warn( + `Meeting:index#hydrateRecordingDuration --> failed to fetch duration: ${error}` + ); + } + } + /** * Set up the locus info recording update listener * update recording value for the meeting @@ -2833,7 +2953,16 @@ export default class Meeting extends StatelessWebexPlugin { private setupLocusControlsListener() { this.locusInfo.on( LOCUSINFO.EVENTS.CONTROLS_RECORDING_UPDATED, - ({state, modifiedBy, lastModified, modifiedByServiceAppName, modifiedByServiceAppId}) => { + ({ + state, + modifiedBy, + lastModified, + modifiedByServiceAppName, + modifiedByServiceAppId, + lastDuration, + lastTime, + needCalculate, + }) => { let event; switch (state) { @@ -2854,6 +2983,44 @@ export default class Meeting extends StatelessWebexPlugin { break; } + // Locus deltas do NOT carry duration data. To avoid wiping the values + // we previously hydrated from the recording stream service (which + // would cause the UI to briefly flash 00:00:00 on every state change + // such as PAUSED -> RECORDING), preserve any prior duration metadata + // until hydrate runs again with fresh server data. EXCEPT when we + // transition into IDLE (recording fully stopped) — in that case the + // previous segment's duration is no longer meaningful, and keeping it + // around would cause the next START to briefly show the stale value. + const previousRecording = this.recording || {}; + const isStopping = state === RECORDING_STATE.IDLE; + const pickDurationField = ( + incoming: T | undefined, + previous: T | undefined + ): T | undefined => { + if (isStopping) { + return undefined; + } + if (incoming !== undefined) { + return incoming; + } + + return previous; + }; + const resolvedLastDuration = pickDurationField( + lastDuration, + previousRecording.lastDuration + ); + const resolvedLastTime = pickDurationField(lastTime, previousRecording.lastTime); + const resolvedNeedCalculate = pickDurationField( + needCalculate, + previousRecording.needCalculate + ); + + // Bump the hydration epoch on every recording state update so that + // any in-flight `getRecordingStatus` request from the previous state + // is discarded when it resolves. + this.recordingHydrationEpoch += 1; + // `RESUMED` state should be converted to `RECORDING` after triggering the event this.recording = { state: state === RECORDING_STATE.RESUMED ? RECORDING_STATE.RECORDING : state, @@ -2861,6 +3028,13 @@ export default class Meeting extends StatelessWebexPlugin { lastModified, modifiedByServiceAppName, modifiedByServiceAppId, + // Duration metadata for timer hydration on rejoin. Optional — + // present only when Locus includes `controls.record.meta.duration`, + // which today it never does. We therefore fall back to whatever we + // already had so the UI does not lose the timer baseline. + lastDuration: resolvedLastDuration, + lastTime: resolvedLastTime, + needCalculate: resolvedNeedCalculate, }; Trigger.trigger( this, @@ -2871,6 +3045,23 @@ export default class Meeting extends StatelessWebexPlugin { event, this.recording ); + + // Locus deltas do not carry the accumulated recording duration. + // When the meeting transitions into RECORDING or PAUSED (which also + // covers the case where we join a meeting that is already in one of + // those states), fetch the duration metadata from the recording + // stream service to mirror what native clients do. Re-emit the same + // event with the hydrated values so the UI can show the correct + // elapsed time instead of `00:00:00`. + if ( + event && + (state === RECORDING_STATE.RECORDING || + state === RECORDING_STATE.RESUMED || + state === RECORDING_STATE.PAUSED) && + (lastDuration === undefined || lastTime === undefined) + ) { + this.hydrateRecordingDuration(event); + } } ); @@ -3542,6 +3733,16 @@ export default class Meeting extends StatelessWebexPlugin { this.annotation.approvalUrlUpdate(payload?.services?.approval?.url); this.simultaneousInterpretation.approvalUrlUpdate(payload?.services?.approval?.url); this.aiEnableRequest.approvalUrlUpdate(payload?.services?.approval?.url); + + // If a recording event fired before the recording stream service URL + // was known (e.g. joining an already RECORDING/PAUSED meeting), retry + // the deferred hydration now that the URL is available. + if (this.pendingRecordingHydrationEvent) { + const event = this.pendingRecordingHydrationEvent; + + this.pendingRecordingHydrationEvent = undefined; + this.hydrateRecordingDuration(event); + } }); } diff --git a/packages/@webex/plugin-meetings/src/recording-controller/index.ts b/packages/@webex/plugin-meetings/src/recording-controller/index.ts index a721318993d..29f5639b423 100644 --- a/packages/@webex/plugin-meetings/src/recording-controller/index.ts +++ b/packages/@webex/plugin-meetings/src/recording-controller/index.ts @@ -341,4 +341,63 @@ export default class RecordingController { public resumeRecording(): Promise { return this.recordingFacade(RecordingAction.Resume); } + + /** + * Fetches the current recording resource from the recording stream service. + * The response carries the accumulated recording duration metadata that the + * native clients (iOS / Android / Desktop) use to hydrate the timer when + * (re)joining a meeting that is already RECORDING or PAUSED. Locus deltas + * do not include this data, so this REST call is the only source. + * + * Endpoint mirrors native: + * GET {serviceUrl}/loci/{locusId}/resource + * + * @returns {Promise} duration metadata or undefined + * @public + * @memberof RecordingController + */ + public async getRecordingStatus(): Promise< + | { + lastDuration?: number; + lastTime?: string; + needCalculate?: boolean; + status?: string; + } + | undefined + > { + if (!this.serviceUrl || !this.locusId) { + LoggerProxy.logger.info( + 'RecordingController:index#getRecordingStatus --> skipped, missing serviceUrl or locusId' + ); + + return undefined; + } + + try { + // @ts-ignore + const response = await this.request.request({ + uri: `${this.serviceUrl}/loci/${this.locusId}/resource`, + method: HTTP_VERBS.GET, + }); + + // Response shape (matches native parseResource): + // { recording: [ { status, duration: { lastDuration, lastTime, needCalculate }, ... } ] } + const body = response?.body ?? response; + const entry = Array.isArray(body?.recording) ? body.recording[0] : undefined; + const duration = entry?.duration; + + return { + status: entry?.status, + lastDuration: duration?.lastDuration, + lastTime: duration?.lastTime, + needCalculate: duration?.needCalculate, + }; + } catch (error) { + LoggerProxy.logger.warn( + `RecordingController:index#getRecordingStatus --> request failed: ${error}` + ); + + return undefined; + } + } } diff --git a/packages/@webex/plugin-meetings/test/unit/spec/locus-info/controlsUtils.js b/packages/@webex/plugin-meetings/test/unit/spec/locus-info/controlsUtils.js index f7a02e4ed21..4ca284aa4e2 100644 --- a/packages/@webex/plugin-meetings/test/unit/spec/locus-info/controlsUtils.js +++ b/packages/@webex/plugin-meetings/test/unit/spec/locus-info/controlsUtils.js @@ -274,6 +274,60 @@ describe('plugin-meetings', () => { assert.isUndefined(parsedControls.record.modifiedByServiceAppId); }); + it('should passthrough recording duration metadata from record.meta.duration', () => { + const newControls = { + record: { + recording: true, + paused: false, + meta: { + duration: { + lastDuration: 12345, + lastTime: '2026-06-03T10:00:00Z', + needCalculate: true, + }, + }, + }, + }; + + const parsedControls = ControlsUtils.parse(newControls); + + assert.equal(parsedControls.record.lastDuration, 12345); + assert.equal(parsedControls.record.lastTime, '2026-06-03T10:00:00Z'); + assert.equal(parsedControls.record.needCalculate, true); + }); + + it('should fall back to record.duration when record.meta.duration is absent', () => { + const newControls = { + record: { + recording: false, + paused: true, + duration: { + lastDuration: 7777, + lastTime: '2026-06-03T11:00:00Z', + needCalculate: false, + }, + }, + }; + + const parsedControls = ControlsUtils.parse(newControls); + + assert.equal(parsedControls.record.lastDuration, 7777); + assert.equal(parsedControls.record.lastTime, '2026-06-03T11:00:00Z'); + assert.equal(parsedControls.record.needCalculate, false); + }); + + it('should leave duration fields undefined when neither source is present', () => { + const newControls = { + record: {recording: true, paused: false, meta: {lastModified: '2026-01-01'}}, + }; + + const parsedControls = ControlsUtils.parse(newControls); + + assert.isUndefined(parsedControls.record.lastDuration); + assert.isUndefined(parsedControls.record.lastTime); + assert.isUndefined(parsedControls.record.needCalculate); + }); + describe('videoEnabled', () => { it('returns expected', () => { const result = ControlsUtils.parse({video: {enabled: true}}); diff --git a/packages/@webex/plugin-meetings/test/unit/spec/locus-info/index.js b/packages/@webex/plugin-meetings/test/unit/spec/locus-info/index.js index 4e3254d3dbf..ded681c28c5 100644 --- a/packages/@webex/plugin-meetings/test/unit/spec/locus-info/index.js +++ b/packages/@webex/plugin-meetings/test/unit/spec/locus-info/index.js @@ -1365,6 +1365,9 @@ describe('plugin-meetings', () => { lastModified: 'TODAY', modifiedByServiceAppName: undefined, modifiedByServiceAppId: undefined, + lastDuration: undefined, + lastTime: undefined, + needCalculate: undefined, } ); }); @@ -1401,6 +1404,9 @@ describe('plugin-meetings', () => { lastModified: 'TODAY', modifiedByServiceAppName: undefined, modifiedByServiceAppId: undefined, + lastDuration: undefined, + lastTime: undefined, + needCalculate: undefined, } ); }); @@ -1438,6 +1444,9 @@ describe('plugin-meetings', () => { lastModified: 'TODAY', modifiedByServiceAppName: undefined, modifiedByServiceAppId: undefined, + lastDuration: undefined, + lastTime: undefined, + needCalculate: undefined, } ); }); @@ -1476,6 +1485,9 @@ describe('plugin-meetings', () => { lastModified: 'TODAY', modifiedByServiceAppName: undefined, modifiedByServiceAppId: undefined, + lastDuration: undefined, + lastTime: undefined, + needCalculate: undefined, } ); }); @@ -1513,6 +1525,9 @@ describe('plugin-meetings', () => { lastModified: 'TODAY', modifiedByServiceAppName: undefined, modifiedByServiceAppId: undefined, + lastDuration: undefined, + lastTime: undefined, + needCalculate: undefined, } ); }); @@ -1549,6 +1564,48 @@ describe('plugin-meetings', () => { lastModified: 'TODAY', modifiedByServiceAppName: 'My Bot', modifiedByServiceAppId: 'app-id-123', + lastDuration: undefined, + lastTime: undefined, + needCalculate: undefined, + } + ); + }); + + it('should include recording duration metadata in the event when present', () => { + locusInfo.controls = { + record: { + recording: false, + paused: false, + meta: {lastModified: 'TODAY', modifiedBy: 'George Kittle'}, + }, + shareControl: {}, + transcribe: {}, + }; + newControls.record.recording = true; + newControls.record.meta.duration = { + lastDuration: 12345, + lastTime: '2026-06-03T10:00:00Z', + needCalculate: true, + }; + locusInfo.emitScoped = sinon.stub(); + locusInfo.updateControls(newControls); + + assert.calledWith( + locusInfo.emitScoped, + { + file: 'locus-info', + function: 'updateControls', + }, + LOCUSINFO.EVENTS.CONTROLS_RECORDING_UPDATED, + { + state: RECORDING_STATE.RECORDING, + modifiedBy: 'George Kittle', + lastModified: 'TODAY', + modifiedByServiceAppName: undefined, + modifiedByServiceAppId: undefined, + lastDuration: 12345, + lastTime: '2026-06-03T10:00:00Z', + needCalculate: true, } ); }); diff --git a/packages/@webex/plugin-meetings/test/unit/spec/meeting/index.js b/packages/@webex/plugin-meetings/test/unit/spec/meeting/index.js index 8b19f6e74c1..acc4e088a24 100644 --- a/packages/@webex/plugin-meetings/test/unit/spec/meeting/index.js +++ b/packages/@webex/plugin-meetings/test/unit/spec/meeting/index.js @@ -11721,6 +11721,9 @@ describe('plugin-meetings', () => { lastModified, modifiedByServiceAppName: undefined, modifiedByServiceAppId: undefined, + lastDuration: undefined, + lastTime: undefined, + needCalculate: undefined, }); assert.calledWith( @@ -11751,6 +11754,9 @@ describe('plugin-meetings', () => { lastModified, modifiedByServiceAppName, modifiedByServiceAppId, + lastDuration: undefined, + lastTime: undefined, + needCalculate: undefined, }); assert.calledWith( @@ -11762,6 +11768,285 @@ describe('plugin-meetings', () => { ); }); + describe('recording duration metadata', () => { + beforeEach(() => { + meeting.recordingController = { + getServiceUrl: sinon.stub().returns(undefined), + getLocusId: sinon.stub().returns(undefined), + getRecordingStatus: sinon.stub().resolves(undefined), + }; + }); + + it('passes through duration fields from the event onto meeting.recording', async () => { + await meeting.locusInfo.emitScoped( + {function: 'test', file: 'test'}, + LOCUSINFO.EVENTS.CONTROLS_RECORDING_UPDATED, + { + state: RECORDING_STATE.RECORDING, + modifiedBy: 'u', + lastModified: 't', + modifiedByServiceAppName: undefined, + modifiedByServiceAppId: undefined, + lastDuration: 5000, + lastTime: '2026-06-03T10:00:00Z', + needCalculate: true, + } + ); + + assert.equal(meeting.recording.lastDuration, 5000); + assert.equal(meeting.recording.lastTime, '2026-06-03T10:00:00Z'); + assert.equal(meeting.recording.needCalculate, true); + }); + + it('preserves prior duration metadata across deltas that omit them (non-IDLE)', async () => { + meeting.recording = { + state: RECORDING_STATE.RECORDING, + lastDuration: 9999, + lastTime: 'prev-time', + needCalculate: true, + }; + + await meeting.locusInfo.emitScoped( + {function: 'test', file: 'test'}, + LOCUSINFO.EVENTS.CONTROLS_RECORDING_UPDATED, + { + state: RECORDING_STATE.PAUSED, + modifiedBy: 'u', + lastModified: 't', + modifiedByServiceAppName: undefined, + modifiedByServiceAppId: undefined, + lastDuration: undefined, + lastTime: undefined, + needCalculate: undefined, + } + ); + + assert.equal(meeting.recording.lastDuration, 9999); + assert.equal(meeting.recording.lastTime, 'prev-time'); + assert.equal(meeting.recording.needCalculate, true); + assert.equal(meeting.recording.state, RECORDING_STATE.PAUSED); + }); + + it('clears duration metadata when transitioning to IDLE', async () => { + meeting.recording = { + state: RECORDING_STATE.RECORDING, + lastDuration: 9999, + lastTime: 'prev-time', + needCalculate: true, + }; + + await meeting.locusInfo.emitScoped( + {function: 'test', file: 'test'}, + LOCUSINFO.EVENTS.CONTROLS_RECORDING_UPDATED, + { + state: RECORDING_STATE.IDLE, + modifiedBy: 'u', + lastModified: 't', + modifiedByServiceAppName: undefined, + modifiedByServiceAppId: undefined, + lastDuration: undefined, + lastTime: undefined, + needCalculate: undefined, + } + ); + + assert.isUndefined(meeting.recording.lastDuration); + assert.isUndefined(meeting.recording.lastTime); + assert.isUndefined(meeting.recording.needCalculate); + assert.equal(meeting.recording.state, RECORDING_STATE.IDLE); + }); + + it('defers hydration when serviceUrl/locusId are unavailable on RECORDING', async () => { + await meeting.locusInfo.emitScoped( + {function: 'test', file: 'test'}, + LOCUSINFO.EVENTS.CONTROLS_RECORDING_UPDATED, + { + state: RECORDING_STATE.RECORDING, + modifiedBy: 'u', + lastModified: 't', + modifiedByServiceAppName: undefined, + modifiedByServiceAppId: undefined, + } + ); + + assert.notCalled(meeting.recordingController.getRecordingStatus); + assert.equal(meeting.pendingRecordingHydrationEvent, EVENT_TRIGGERS.MEETING_STARTED_RECORDING); + }); + + it('hydrates immediately when serviceUrl/locusId are available and merges the response', async () => { + meeting.recordingController.getServiceUrl.returns('svc'); + meeting.recordingController.getLocusId.returns('locus-id'); + meeting.recordingController.getRecordingStatus.resolves({ + status: 'paused', + lastDuration: 12345, + lastTime: '2026-06-03T10:00:00Z', + needCalculate: true, + }); + + await meeting.locusInfo.emitScoped( + {function: 'test', file: 'test'}, + LOCUSINFO.EVENTS.CONTROLS_RECORDING_UPDATED, + { + state: RECORDING_STATE.PAUSED, + modifiedBy: 'u', + lastModified: 't', + modifiedByServiceAppName: undefined, + modifiedByServiceAppId: undefined, + } + ); + // allow microtask queue to flush hydrate promise + await new Promise((r) => setImmediate(r)); + + assert.calledOnce(meeting.recordingController.getRecordingStatus); + assert.equal(meeting.recording.lastDuration, 12345); + assert.equal(meeting.recording.lastTime, '2026-06-03T10:00:00Z'); + assert.equal(meeting.recording.needCalculate, true); + assert.isUndefined(meeting.pendingRecordingHydrationEvent); + + // Hydration should fire the dedicated duration-updated event, + // NOT replay the original transition event. + assert.calledWith( + TriggerProxy.trigger, + meeting, + {file: 'meeting/index', function: 'hydrateRecordingDuration'}, + EVENT_TRIGGERS.MEETING_RECORDING_DURATION_UPDATED, + meeting.recording + ); + }); + + it('does not hydrate when transitioning into IDLE', async () => { + meeting.recordingController.getServiceUrl.returns('svc'); + meeting.recordingController.getLocusId.returns('locus-id'); + + await meeting.locusInfo.emitScoped( + {function: 'test', file: 'test'}, + LOCUSINFO.EVENTS.CONTROLS_RECORDING_UPDATED, + { + state: RECORDING_STATE.IDLE, + modifiedBy: 'u', + lastModified: 't', + modifiedByServiceAppName: undefined, + modifiedByServiceAppId: undefined, + } + ); + + assert.notCalled(meeting.recordingController.getRecordingStatus); + }); + + it('drops a hydrate response whose status does not match the current recording state', async () => { + meeting.recordingController.getServiceUrl.returns('svc'); + meeting.recordingController.getLocusId.returns('locus-id'); + + // Resource service still reports the previous segment's PAUSED + // status while Locus has already transitioned to RECORDING. + meeting.recordingController.getRecordingStatus.resolves({ + status: 'paused', + lastDuration: 99999, + lastTime: '2026-06-03T10:00:00Z', + needCalculate: true, + }); + + await meeting.locusInfo.emitScoped( + {function: 'test', file: 'test'}, + LOCUSINFO.EVENTS.CONTROLS_RECORDING_UPDATED, + { + state: RECORDING_STATE.RECORDING, + modifiedBy: 'u', + lastModified: 't', + modifiedByServiceAppName: undefined, + modifiedByServiceAppId: undefined, + } + ); + await new Promise((r) => setImmediate(r)); + + assert.calledOnce(meeting.recordingController.getRecordingStatus); + assert.equal(meeting.recording.state, RECORDING_STATE.RECORDING); + assert.isUndefined(meeting.recording.lastDuration); + assert.isUndefined(meeting.recording.lastTime); + + assert.neverCalledWith( + TriggerProxy.trigger, + meeting, + {file: 'meeting/index', function: 'hydrateRecordingDuration'}, + EVENT_TRIGGERS.MEETING_RECORDING_DURATION_UPDATED, + sinon.match.any + ); + }); + + it('drops a stale hydrate response when the recording state changes mid-flight', async () => { + meeting.recordingController.getServiceUrl.returns('svc'); + meeting.recordingController.getLocusId.returns('locus-id'); + + // First request: hangs until we resolve it. + let resolveFirst; + const firstPending = new Promise((resolve) => { + resolveFirst = resolve; + }); + meeting.recordingController.getRecordingStatus.onFirstCall().returns(firstPending); + + // Kick off the first hydration (segment A). + await meeting.locusInfo.emitScoped( + {function: 'test', file: 'test'}, + LOCUSINFO.EVENTS.CONTROLS_RECORDING_UPDATED, + { + state: RECORDING_STATE.RECORDING, + modifiedBy: 'u', + lastModified: 't1', + modifiedByServiceAppName: undefined, + modifiedByServiceAppId: undefined, + } + ); + + // Stop, then start a fresh segment B before A's response resolves. + await meeting.locusInfo.emitScoped( + {function: 'test', file: 'test'}, + LOCUSINFO.EVENTS.CONTROLS_RECORDING_UPDATED, + { + state: RECORDING_STATE.IDLE, + modifiedBy: 'u', + lastModified: 't2', + modifiedByServiceAppName: undefined, + modifiedByServiceAppId: undefined, + } + ); + + meeting.recordingController.getRecordingStatus.onSecondCall().resolves({ + status: 'recording', + lastDuration: 0, + lastTime: '2026-06-03T11:00:00Z', + needCalculate: true, + }); + + await meeting.locusInfo.emitScoped( + {function: 'test', file: 'test'}, + LOCUSINFO.EVENTS.CONTROLS_RECORDING_UPDATED, + { + state: RECORDING_STATE.RECORDING, + modifiedBy: 'u', + lastModified: 't3', + modifiedByServiceAppName: undefined, + modifiedByServiceAppId: undefined, + } + ); + // allow segment B's hydrate to flush + await new Promise((r) => setImmediate(r)); + + // Now resolve segment A's stale response with a large duration — + // it must NOT overwrite segment B. + resolveFirst({ + status: 'recording', + lastDuration: 99999, + lastTime: '2026-06-03T10:00:00Z', + needCalculate: true, + }); + await new Promise((r) => setImmediate(r)); + + assert.equal(meeting.recording.state, RECORDING_STATE.RECORDING); + assert.equal(meeting.recording.lastDuration, 0); + assert.equal(meeting.recording.lastTime, '2026-06-03T11:00:00Z'); + }); + }); + it('listens to the locus interpretation update event', () => { const interpretation = { siLanguages: [{languageCode: 20, languageName: 'en'}], diff --git a/packages/@webex/plugin-meetings/test/unit/spec/recording-controller/index.js b/packages/@webex/plugin-meetings/test/unit/spec/recording-controller/index.js index 7677b6e35b7..be1d7ac4533 100644 --- a/packages/@webex/plugin-meetings/test/unit/spec/recording-controller/index.js +++ b/packages/@webex/plugin-meetings/test/unit/spec/recording-controller/index.js @@ -358,6 +358,109 @@ describe('plugin-meetings', () => { }); }); }); + + describe('getRecordingStatus', () => { + let controller; + + beforeEach(() => { + request = { + request: sinon.stub(), + }; + controller = new RecordingController(request); + controller.set({ + serviceUrl: 'test', + sessionId: 'testId', + locusUrl: 'test/id', + displayHints: [], + }); + }); + + it('returns undefined when serviceUrl is missing', async () => { + const noUrlController = new RecordingController({request: sinon.stub()}); + + const result = await noUrlController.getRecordingStatus(); + + assert.isUndefined(result); + }); + + it('issues a GET to the recording stream resource endpoint', async () => { + request.request.resolves({body: {recording: []}}); + + await controller.getRecordingStatus(); + + assert.calledWith(request.request, { + uri: 'test/loci/id/resource', + method: HTTP_VERBS.GET, + }); + }); + + it('parses status and duration from the first recording entry', async () => { + request.request.resolves({ + body: { + recording: [ + { + status: 'recording', + duration: { + lastDuration: 12345, + lastTime: '2026-06-03T10:00:00Z', + needCalculate: true, + }, + }, + ], + }, + }); + + const result = await controller.getRecordingStatus(); + + assert.deepEqual(result, { + status: 'recording', + lastDuration: 12345, + lastTime: '2026-06-03T10:00:00Z', + needCalculate: true, + }); + }); + + it('handles a response without a body wrapper', async () => { + request.request.resolves({ + recording: [ + { + status: 'paused', + duration: {lastDuration: 100, lastTime: '2026-06-03T11:00:00Z', needCalculate: false}, + }, + ], + }); + + const result = await controller.getRecordingStatus(); + + assert.deepEqual(result, { + status: 'paused', + lastDuration: 100, + lastTime: '2026-06-03T11:00:00Z', + needCalculate: false, + }); + }); + + it('returns an object with all undefined fields when recording array is empty', async () => { + request.request.resolves({body: {recording: []}}); + + const result = await controller.getRecordingStatus(); + + assert.deepEqual(result, { + status: undefined, + lastDuration: undefined, + lastTime: undefined, + needCalculate: undefined, + }); + }); + + it('returns undefined when the request rejects', async () => { + request.request.rejects(new Error('boom')); + + const result = await controller.getRecordingStatus(); + + assert.isUndefined(result); + }); + }); }); }); });