diff --git a/packages/contact-center/cc-components/src/components/task/CallControl/call-control.utils.ts b/packages/contact-center/cc-components/src/components/task/CallControl/call-control.utils.ts index 72a732e01..bd37c6b49 100644 --- a/packages/contact-center/cc-components/src/components/task/CallControl/call-control.utils.ts +++ b/packages/contact-center/cc-components/src/components/task/CallControl/call-control.utils.ts @@ -402,6 +402,20 @@ export const filterButtonsForConsultation = ( } }; +/** + * The SDK normalizes callProcessingDetails booleans, but older backend payloads can + * still surface them as 'true'/'false' strings. + */ +const toOptionalBoolean = (value: unknown): boolean | undefined => { + if (typeof value === 'boolean') return value; + if (typeof value === 'string') { + const normalized = value.toLowerCase(); + if (normalized === 'true') return true; + if (normalized === 'false') return false; + } + return undefined; +}; + /** * Updates call state from current task data */ @@ -417,8 +431,18 @@ export const updateCallStateFromTask = ( const {callProcessingDetails} = interaction; if (callProcessingDetails) { - const {isPaused} = callProcessingDetails; - setIsRecording(isPaused !== 'true'); + // Mirrors the SDK's own precedence: isPaused wins when present, otherwise + // recordInProgress is the active recording signal. + const isPaused = toOptionalBoolean(callProcessingDetails.isPaused); + if (isPaused !== undefined) { + setIsRecording(!isPaused); + return; + } + + const recordInProgress = toOptionalBoolean(callProcessingDetails.recordInProgress); + if (recordInProgress !== undefined) { + setIsRecording(recordInProgress); + } } } catch (error) { logger?.error('CC-Widgets: CallControl: Error in updateCallStateFromTask', { diff --git a/packages/contact-center/cc-components/tests/components/task/CallControl/call-control.utils.tsx b/packages/contact-center/cc-components/tests/components/task/CallControl/call-control.utils.tsx index b275b120d..789ac36f6 100644 --- a/packages/contact-center/cc-components/tests/components/task/CallControl/call-control.utils.tsx +++ b/packages/contact-center/cc-components/tests/components/task/CallControl/call-control.utils.tsx @@ -956,6 +956,76 @@ describe('CallControl Utils', () => { expect(mockSetIsRecording).toHaveBeenCalledWith(true); }); + // The SDK's taskDataNormalizer coerces callProcessingDetails flags to real + // booleans before widgets ever see them. + it('should handle isPaused as boolean true from the SDK', () => { + const taskWithBooleanPaused = { + ...mockCurrentTask, + data: { + ...mockCurrentTask.data, + interaction: { + ...mockCurrentTask.data.interaction, + callProcessingDetails: {isPaused: true}, + }, + }, + }; + + updateCallStateFromTask(taskWithBooleanPaused as unknown as ITask, mockSetIsRecording); + + expect(mockSetIsRecording).toHaveBeenCalledWith(false); + }); + + it('should handle isPaused as boolean false from the SDK', () => { + const taskWithBooleanNotPaused = { + ...mockCurrentTask, + data: { + ...mockCurrentTask.data, + interaction: { + ...mockCurrentTask.data.interaction, + callProcessingDetails: {isPaused: false}, + }, + }, + }; + + updateCallStateFromTask(taskWithBooleanNotPaused as unknown as ITask, mockSetIsRecording); + + expect(mockSetIsRecording).toHaveBeenCalledWith(true); + }); + + it('should fall back to recordInProgress when isPaused is absent', () => { + const taskWithoutIsPaused = { + ...mockCurrentTask, + data: { + ...mockCurrentTask.data, + interaction: { + ...mockCurrentTask.data.interaction, + callProcessingDetails: {recordingStarted: true, recordInProgress: false}, + }, + }, + }; + + updateCallStateFromTask(taskWithoutIsPaused as unknown as ITask, mockSetIsRecording); + + expect(mockSetIsRecording).toHaveBeenCalledWith(false); + }); + + it('should not change recording state when no recording flags are present', () => { + const taskWithUnrelatedDetails = { + ...mockCurrentTask, + data: { + ...mockCurrentTask.data, + interaction: { + ...mockCurrentTask.data.interaction, + callProcessingDetails: {pauseResumeEnabled: true}, + }, + }, + }; + + updateCallStateFromTask(taskWithUnrelatedDetails as unknown as ITask, mockSetIsRecording); + + expect(mockSetIsRecording).not.toHaveBeenCalled(); + }); + it('should return early when currentTask is null', () => { updateCallStateFromTask(null as unknown as ITask, mockSetIsRecording); diff --git a/packages/contact-center/task/tests/call-control-recording.tsx b/packages/contact-center/task/tests/call-control-recording.tsx new file mode 100644 index 000000000..9b5ea11ee --- /dev/null +++ b/packages/contact-center/task/tests/call-control-recording.tsx @@ -0,0 +1,174 @@ +import React from 'react'; +import {render, screen, act} from '@testing-library/react'; +import '@testing-library/jest-dom'; +import {EventEmitter} from 'events'; +import store, {TASK_EVENTS, IContactCenter} from '@webex/cc-store'; +import {mockTask, mockCC, createEnabledMainTaskUIControls} from '@webex/test-fixtures'; +import {CallControl} from '../src/CallControl'; + +const INTERACTION_ID = 'interaction-recording-1'; +const AGENT_ID = 'agent-recording-1'; + +/** + * Task double as a class instance, like the SDK's Task: MobX leaves non-plain + * objects by reference, so a plain object would not behave like production here. + * Extends EventEmitter so SDK-style emits reach the widget's listeners. + */ +class FakeTask extends EventEmitter { + public data; + public uiControls; + public pauseRecording = jest.fn().mockResolvedValue({}); + public resumeRecording = jest.fn().mockResolvedValue({}); + + constructor(isPaused: boolean) { + super(); + this.uiControls = createEnabledMainTaskUIControls(); + this.data = { + ...mockTask.data, + interactionId: INTERACTION_ID, + agentId: AGENT_ID, + interaction: { + ...mockTask.data.interaction, + interactionId: INTERACTION_ID, + mediaType: 'telephony', + mediaChannel: 'telephony', + state: 'connected', + callProcessingDetails: { + ...mockTask.data.interaction.callProcessingDetails, + recordingStarted: true, + // taskDataNormalizer coerces these to real booleans before widgets see them. + isPaused, + recordInProgress: !isPaused, + pauseResumeEnabled: true, + }, + participants: { + [AGENT_ID]: {id: AGENT_ID, pType: 'Agent', hasJoined: true, joinTimestamp: Date.now()}, + }, + media: {}, + }, + }; + } + + /** Mirrors the SDK replacing task.data from the websocket payload. */ + setPaused(isPaused: boolean) { + this.data = { + ...this.data, + interaction: { + ...this.data.interaction, + callProcessingDetails: { + ...this.data.interaction.callProcessingDetails, + isPaused, + recordInProgress: !isPaused, + }, + }, + }; + } +} + +const createTask = (isPaused: boolean) => new FakeTask(isPaused); + +const promoteTask = (task: FakeTask) => { + store.store.cc = { + ...mockCC, + agentConfig: {agentId: AGENT_ID}, + taskManager: {getAllTasks: jest.fn().mockReturnValue({[INTERACTION_ID]: task})}, + } as unknown as IContactCenter; + store.store.agentId = AGENT_ID; + // Registers the store's own task listeners (refreshTaskList on recording + // pause/resume, etc.) exactly as production does. + store.handleIncomingTask(task); + store.setCurrentTask(task); +}; + +/** What the SDK does when the ContactRecordingPaused websocket event arrives. */ +const emitRecordingPaused = (task: FakeTask) => + act(() => { + task.setPaused(true); + task.emit(TASK_EVENTS.TASK_RECORDING_PAUSED, task); + }); + +const emitRecordingResumed = (task: FakeTask) => + act(() => { + task.setPaused(false); + task.emit(TASK_EVENTS.TASK_RECORDING_RESUMED, task); + }); + +const recordButtonLabel = () => screen.getByTestId('call-control:recording-toggle').getAttribute('aria-label'); + +describe('CallControl recording pause/resume state', () => { + beforeAll(() => { + store.setDeviceType('BROWSER'); + store.store.featureFlags = {isEndCallEnabled: true, isEndConsultEnabled: true, webRtcEnabled: true}; + store.store.logger = { + log: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + trace: jest.fn(), + }; + }); + + it('should show pause action while recording is active', () => { + promoteTask(createTask(false)); + render(); + + expect(recordButtonLabel()).toBe('Pause Recording'); + }); + + it('should show resume action when the SDK reports recording paused', () => { + const task = createTask(false); + promoteTask(task); + render(); + + emitRecordingPaused(task); + + expect(recordButtonLabel()).toBe('Resume Recording'); + }); + + it('should show pause action again when the SDK reports recording resumed', () => { + const task = createTask(false); + promoteTask(task); + render(); + + emitRecordingPaused(task); + emitRecordingResumed(task); + + expect(recordButtonLabel()).toBe('Pause Recording'); + }); + + it('should show resume action for a task that is already paused on mount', () => { + promoteTask(createTask(true)); + render(); + + expect(recordButtonLabel()).toBe('Resume Recording'); + }); + + it('should keep the paused state when a later task event refreshes the store', () => { + const task = createTask(false); + promoteTask(task); + render(); + + emitRecordingPaused(task); + + // Later websocket events re-promote the task, handing call control a freshly + // cloned currentTask, which must not reset the paused state. + act(() => { + task.emit(TASK_EVENTS.TASK_UI_CONTROLS_UPDATED, task.uiControls); + }); + + expect(recordButtonLabel()).toBe('Resume Recording'); + }); + + it('should keep the paused state when call control remounts while paused', () => { + const task = createTask(false); + promoteTask(task); + const firstRender = render(); + + emitRecordingPaused(task); + firstRender.unmount(); + render(); + + expect(recordButtonLabel()).toBe('Resume Recording'); + }); +});