Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand All @@ -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.
Comment on lines +434 to +435

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Document the new recording-state precedence

Update the cc-components and task module specs to describe boolean normalization, isPaused precedence, and the recordInProgress fallback. The commit changes the recording-state contract and adds remount/refresh behavior tests, but leaves packages/contact-center/cc-components/ai-docs/cc-components-spec.md and packages/contact-center/task/ai-docs/task-spec.md unchanged, so the canonical documentation can no longer validate or protect this fix.

AGENTS.md reference: AGENTS.md:L68-L68

Useful? React with 👍 / 👎.

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', {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
174 changes: 174 additions & 0 deletions packages/contact-center/task/tests/call-control-recording.tsx
Original file line number Diff line number Diff line change
@@ -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(),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Reset the store singleton after each recording test

Add per-test teardown that restores or resets the store singleton. This suite mutates cc, agentId, featureFlags, currentTask, and taskList, while every fake task reuses the same interaction ID; consequently later cases begin with the previous task and can take the isSameTask path during promoteTask, making results order-dependent and allowing stale state/listeners to mask regressions.

AGENTS.md reference: AGENTS.md:L87-L88

Useful? React with 👍 / 👎.

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

expect(recordButtonLabel()).toBe('Pause Recording');
});

it('should show resume action when the SDK reports recording paused', () => {
const task = createTask(false);
promoteTask(task);
render(<CallControl />);

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

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

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

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

emitRecordingPaused(task);
firstRender.unmount();
render(<CallControl />);

expect(recordButtonLabel()).toBe('Resume Recording');
});
});
Loading