diff --git a/packages/contact-center/cc-components/src/components/task/CallControl/call-control.tsx b/packages/contact-center/cc-components/src/components/task/CallControl/call-control.tsx index 362b13573..250e04067 100644 --- a/packages/contact-center/cc-components/src/components/task/CallControl/call-control.tsx +++ b/packages/contact-center/cc-components/src/components/task/CallControl/call-control.tsx @@ -192,6 +192,7 @@ function CallControlComponent(props: CallControlComponentProps) { className: 'call-control-button', disabled: false, isVisible: controlVisibility.holdResume, + dataTestId: 'call-control:hold-toggle', }, { id: 'consult', @@ -201,6 +202,7 @@ function CallControlComponent(props: CallControlComponentProps) { disabled: false, menuType: 'Consult', isVisible: controlVisibility.consult, + dataTestId: 'call-control:consult', }, { id: 'transfer', @@ -210,6 +212,7 @@ function CallControlComponent(props: CallControlComponentProps) { disabled: false, menuType: 'Transfer', isVisible: controlVisibility.transfer, + dataTestId: 'call-control:transfer', }, { id: 'record', @@ -219,6 +222,7 @@ function CallControlComponent(props: CallControlComponentProps) { className: 'call-control-button', disabled: false, isVisible: controlVisibility.pauseResumeRecording, + dataTestId: 'call-control:recording-toggle', }, { id: 'end', @@ -228,6 +232,7 @@ function CallControlComponent(props: CallControlComponentProps) { className: 'call-control-button-cancel', disabled: isHeld, isVisible: controlVisibility.end, + dataTestId: 'call-control:end-call', }, ]; @@ -287,7 +292,7 @@ function CallControlComponent(props: CallControlComponentProps) { className={button.className} aria-label={button.tooltip} disabled={button.disabled || (consultInitiated && isTelephony)} - data-testid="ButtonCircle" + data-testid={button.dataTestId} onPress={() => handlePopoverOpen(button.menuType as CallControlMenuType)} > @@ -328,7 +333,7 @@ function CallControlComponent(props: CallControlComponentProps) { button.className + (button.disabled || (consultInitiated && isTelephony) ? ` ${button.className}-disabled` : '') } - data-testid={button.id === 'end' ? 'call-control:end-call' : button.id} + data-testid={button.dataTestId} onPress={button.onClick} disabled={button.disabled || (consultInitiated && isTelephony)} aria-label={button.tooltip} diff --git a/playwright.config.ts b/playwright.config.ts index 7ebe6da5b..12a995b60 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -46,6 +46,8 @@ export default defineConfig({ }, { name: 'Test: Chrome', + // Run all tests except advanced tests and transfer/consult tests on Chrome + testIgnore: [/advanced-task-controls-test\.spec\.ts/], use: { ...devices['Desktop Chrome'], launchOptions: { @@ -61,14 +63,35 @@ export default defineConfig({ ], } }, - + // dependencies: ['OAuth: Get Access Token'], + }, + { + name: 'Test: Firefox - Advanced Tests', + // Run only advanced tests and transfer/consult tests on Firefox + testMatch: [/advanced-task-controls-test\.spec\.ts/], + use: { + ...devices['Desktop Firefox'], + launchOptions: { + // Firefox-specific preferences for fake media + firefoxUserPrefs: { + // Essential media preferences for testing + 'media.navigator.streams.fake': true, + 'media.navigator.permission.disabled': true, + 'media.getusermedia.insecure.enabled': true, + 'media.peerconnection.enabled': true, + 'permissions.default.microphone': 1, + 'permissions.default.camera': 1, + + // Automation-friendly settings + 'media.autoplay.default': 0, + 'media.autoplay.blocking_policy': 0, + 'privacy.webrtc.legacyGlobalIndicator': false, + } + }, + // Remove permissions - Firefox handles this differently + }, + // dependencies: ['OAuth: Get Access Token'], }, - // Once we have stability for playwright tests, we can enable the following browsers - // { - // name: 'Test: Firefox', - // use: {...devices['Desktop Firefox']}, - // dependencies: ['chromium'], - // }, // { // name: 'Test: Webkit', @@ -76,4 +99,4 @@ export default defineConfig({ // dependencies: ['firefox'], // }, ], -}); +}); \ No newline at end of file diff --git a/playwright/Utils/advancedTaskControlUtils.ts b/playwright/Utils/advancedTaskControlUtils.ts new file mode 100644 index 000000000..b3e0681e1 --- /dev/null +++ b/playwright/Utils/advancedTaskControlUtils.ts @@ -0,0 +1,204 @@ +import { Page, expect } from '@playwright/test'; +import { WRAPUP_REASONS } from '../constants'; + +/** + * Utility functions for advanced task controls testing. + * Provides functions for consult operations, transfer operations, and end consult actions. + * These utilities handle complex multi-agent scenarios and task state transitions. + * + * @packageDocumentation + */ + +// Array to store captured console logs for verification +let capturedAdvancedLogs: string[] = []; + +/** + * Sets up console logging to capture transfer and consult related callback logs. + * Captures transfer success, consult start/end success, and related SDK messages. + * @param page - The agent's main page + * @returns Function to remove the console handler + */ +export function setupAdvancedConsoleLogging(page: Page): () => void { + capturedAdvancedLogs.length = 0; + + const consoleHandler = (msg) => { + const logText = msg.text(); + if (logText.includes('WXCC_SDK_TASK_TRANSFER_SUCCESS') || + logText.includes('WXCC_SDK_TASK_CONSULT_START_SUCCESS') || + logText.includes('WXCC_SDK_TASK_CONSULT_END_SUCCESS') || + logText.includes('AgentConsultTransferred') || + logText.includes('onEnd invoked') || + logText.includes('onTransfer invoked') || + logText.includes('onConsult invoked')) { + capturedAdvancedLogs.push(logText); + } + }; + + page.on('console', consoleHandler); + return () => page.off('console', consoleHandler); +} + +/** + * Clears the captured advanced logs array. + * Should be called before each test or verification to ensure clean state. + */ +export function clearAdvancedCapturedLogs(): void { + capturedAdvancedLogs.length = 0; +} + +/** + * Verifies that transfer success logs are present. + * @throws Error if verification fails with detailed error message + */ +export function verifyTransferSuccessLogs(): void { + const transferLogs = capturedAdvancedLogs.filter(log => log.includes('WXCC_SDK_TASK_TRANSFER_SUCCESS')); + + if (transferLogs.length === 0) { + throw new Error(`No 'WXCC_SDK_TASK_TRANSFER_SUCCESS' logs found. Captured logs: ${JSON.stringify(capturedAdvancedLogs)}`); + } +} + +/** + * Verifies that consult start success logs are present. + * @throws Error if verification fails with detailed error message + */ +export function verifyConsultStartSuccessLogs(): void { + const consultStartLogs = capturedAdvancedLogs.filter(log => log.includes('WXCC_SDK_TASK_CONSULT_START_SUCCESS')); + + if (consultStartLogs.length === 0) { + throw new Error(`No 'WXCC_SDK_TASK_CONSULT_START_SUCCESS' logs found. Captured logs: ${JSON.stringify(capturedAdvancedLogs)}`); + } +} + +/** + * Verifies that consult end success logs are present. + * @throws Error if verification fails with detailed error message + */ +export function verifyConsultEndSuccessLogs(): void { + const consultEndLogs = capturedAdvancedLogs.filter(log => log.includes('WXCC_SDK_TASK_CONSULT_END_SUCCESS')); + + if (consultEndLogs.length === 0) { + throw new Error(`No 'WXCC_SDK_TASK_CONSULT_END_SUCCESS' logs found. Captured logs: ${JSON.stringify(capturedAdvancedLogs)}`); + } +} + +/** + * Verifies that agent consult transferred logs are present (when consult is converted to transfer). + * @throws Error if verification fails with detailed error message + */ +export function verifyConsultTransferredLogs(): void { + const consultTransferredLogs = capturedAdvancedLogs.filter(log => log.includes('AgentConsultTransferred')); + + if (consultTransferredLogs.length === 0) { + throw new Error(`No 'AgentConsultTransferred' logs found. Captured logs: ${JSON.stringify(capturedAdvancedLogs)}`); + } +} + +/** + * Utility function to get all captured logs for debugging purposes. + * @returns Array of all captured log messages + */ +export function getAllCapturedLogs(): string[] { + return [...capturedAdvancedLogs]; +} + +/** + * Initiates a consult with another agent via the agents tab. + * @param page - The agent's main page + * @param agentName - Name of the agent to consult with (e.g., 'User2 Agent2') + * @returns Promise + */ +export async function consultViaAgent(page: Page, agentName: string = 'User2 Agent2'): Promise { + // Click consult with another agent button + await page.getByTestId('call-control:consult').nth(1).click(); + // Navigate to Agents tab + await page.getByRole('tab', { name: 'Agents' }).click(); + + //hover over the agent name - use exact match to avoid confusion with similar names + await page.getByRole('listitem', { name: agentName, exact: true }).hover(); + + // Select the specific agent + await page.getByRole('listitem', { name: agentName, exact: true }).getByRole('button').click(); + + // Wait a moment for the consult to be initiated + await page.waitForTimeout(2000); +} + +/** + * Initiates a consult with a queue via the queues tab. + * @param page - The agent's main page + * @param queueName - Name of the queue to consult with (e.g., 'Customer Service Queue') + * @returns Promise + */ +export async function consultViaQueue(page: Page, queueName: string): Promise { + // Click consult with another agent button + await page.getByTestId('call-control:consult').nth(1).click(); + + // Navigate to Queues tab + await page.getByRole('tab', { name: 'Queues' }).click(); + + // Hover over the queue name - use exact match to avoid confusion with similar names + await page.getByRole('listitem', { name: queueName, exact: true }).hover(); + + // Select the specific queue + await page.getByRole('listitem', { name: queueName, exact: true }).getByRole('button').click(); + + // Wait a moment for the consult to be initiated + await page.waitForTimeout(2000); +} + +/** + * Cancels an ongoing consult and resumes the original call. + * @param page - The agent's main page + * @returns Promise + */ +export async function cancelConsult(page: Page): Promise { + // Click cancel consult button + await page.getByTestId('cancel-consult-btn').click(); + } + +/** + * Initiates a transfer via the agents tab (without prior consult). + * @param page - The agent's main page + * @param agentName - Name of the agent to transfer to (e.g., 'User2 Agent2') + * @returns Promise + */ +export async function transferViaAgent(page: Page, agentName: string = 'User2 Agent2'): Promise { + // Click transfer call button + await page.getByRole('group', { name: 'Call Control with Call' }).getByLabel('Transfer Call').click(); + + // Navigate to Agents tab + await page.getByRole('tab', { name: 'Agents' }).click(); + + // Hover over the agent name - use exact match to avoid confusion with similar names + await page.getByRole('listitem', { name: agentName, exact: true }).hover(); + + // Select the specific agent + await page.getByRole('listitem', { name: agentName, exact: true }).getByRole('button').click(); + + // Wait a moment for the transfer to be processed + await page.waitForTimeout(2000); +} + +/** + * Initiates a transfer via the queues tab (without prior consult). + * @param page - The agent's main page + * @param queueName - Name of the queue to transfer to (e.g., 'Customer Service Queue') + * @returns Promise + */ +export async function transferViaQueue(page: Page, queueName: string): Promise { + // Click transfer call button + await page.getByRole('group', { name: 'Call Control with Call' }).getByLabel('Transfer Call').click(); + + // Navigate to Queues tab + await page.getByRole('tab', { name: 'Queues' }).click(); + + // Hover over the queue name - use exact match to avoid confusion with similar names + await page.getByRole('listitem', { name: queueName, exact: true }).hover(); + + // Select the specific queue + await page.getByRole('listitem', { name: queueName, exact: true }).getByRole('button').click(); + + // Wait a moment for the transfer to be processed + await page.waitForTimeout(2000); +} diff --git a/playwright/Utils/helperUtils.ts b/playwright/Utils/helperUtils.ts index 918c36d98..bad820643 100644 --- a/playwright/Utils/helperUtils.ts +++ b/playwright/Utils/helperUtils.ts @@ -1,6 +1,6 @@ import { Page } from '@playwright/test'; import { getCurrentState, changeUserState } from './userStateUtils'; -import { WRAPUP_REASONS, USER_STATES, RONA_OPTIONS, LOGIN_MODE, LoginMode, ThemeColor, userState, WrapupReason } from 'playwright/constants'; +import { WRAPUP_REASONS, USER_STATES, RONA_OPTIONS, LOGIN_MODE, LoginMode, ThemeColor, userState, WrapupReason } from '../constants'; import { submitWrapup } from './wrapupUtils'; import { acceptExtensionCall, submitRonaPopup } from './incomingTaskUtils'; import { loginViaAccessToken, disableMultiLogin, enableMultiLogin, initialiseWidgets, enableAllWidgets, } from './initUtils'; @@ -317,6 +317,9 @@ export const handleStrayTasks = async (page: Page, extensionPage: Page | null = const acceptButtonVisible = await acceptButton.isVisible().catch(() => false); const isExtensionCall = await (await task.innerText()).includes('Ringing...'); if (isExtensionCall) { + if (!extensionPage) { + throw new Error('Extension page is not available for handling extension call'); + } const extensionCallVisible = await extensionPage.locator('[data-test="right-action-button"]').waitFor({ state: 'visible', timeout: 40000 }).then(() => true).catch(() => false); if (extensionCallVisible) { await acceptExtensionCall(extensionPage); diff --git a/playwright/Utils/taskControlUtils.ts b/playwright/Utils/taskControlUtils.ts new file mode 100644 index 000000000..cd7608a4a --- /dev/null +++ b/playwright/Utils/taskControlUtils.ts @@ -0,0 +1,420 @@ +import { Page, expect } from '@playwright/test'; +import { TASK_TYPES } from '../constants'; + +/** + * Utility functions for task controls testing. + * Verifies visibility of task control buttons for different task types. + * + * @packageDocumentation + */ + +/** + * Verifies that all call task control buttons are visible and accessible. + * Checks for hold, recording, transfer, consult, and end buttons. + * @param page - The agent's main page + * @returns Promise + */ +export async function callTaskControlCheck(page: Page): Promise { + // Verify call control container is visible + await expect(page.getByTestId('call-control-container').nth(0)).toBeVisible({ timeout: 30000 }); + + // Verify hold/resume toggle button is visible + await expect(page.getByTestId('call-control:hold-toggle').nth(0)).toBeVisible(); + + // Verify recording toggle button is visible + await expect(page.getByTestId('call-control:recording-toggle').nth(0)).toBeVisible(); + + // Verify transfer button is visible + await expect(page.getByTestId('call-control:transfer').nth(0)).toBeVisible(); + + // Verify consult button is visible + await expect(page.getByTestId('call-control:consult').nth(0)).toBeVisible(); + + // Verify end call button is visible + await expect(page.getByTestId('call-control:end-call').nth(0)).toBeVisible(); +} + +/** + * Verifies that chat task control buttons are visible and accessible. + * Checks for transfer and end buttons only. + * @param page - The agent's main page + * @returns Promise + */ +export async function chatTaskControlCheck(page: Page): Promise { + // Verify chat control container or equivalent is visible + await expect(page.getByTestId('call-control-container').nth(0)).toBeVisible({ timeout: 30000 }); + + // Verify transfer button is visible + await expect(page.getByTestId('call-control:transfer').nth(0)).toBeVisible(); + + // Verify end button is visible (for chat tasks) + await expect(page.getByTestId('call-control:end-call').nth(0)).toBeVisible(); +} + +/** + * Verifies that email task control buttons are visible and accessible. + * Checks for transfer and end buttons only. + * @param page - The agent's main page + * @returns Promise + */ +export async function emailTaskControlCheck(page: Page): Promise { + // Verify email control container or equivalent is visible + await expect(page.getByTestId('call-control-container').nth(0)).toBeVisible({ timeout: 30000 }); + + // Verify transfer button is visible + await expect(page.getByTestId('call-control:transfer').nth(0)).toBeVisible(); + + // Verify end button is visible (for email tasks) + await expect(page.getByTestId('call-control:end-call').nth(0)).toBeVisible(); +} + +/** + * Verifies task control buttons based on the task type. + * @param page - The agent's main page + * @param taskType - The type of the task (e.g., TASK_TYPES.CALL, TASK_TYPES.CHAT) + * @returns Promise + */ +export async function verifyTaskControls(page: Page, taskType: string): Promise { + switch (taskType) { + case TASK_TYPES.CALL: + await callTaskControlCheck(page); + break; + case TASK_TYPES.CHAT: + await chatTaskControlCheck(page); + break; + case TASK_TYPES.EMAIL: + await emailTaskControlCheck(page); + break; + default: + throw new Error(`Task control check not implemented for task type: ${taskType}`); + } +} + +/** + * Toggles the hold state of a call by clicking the hold/resume button. + * This function will put the call on hold if it's currently active, or resume it if it's on hold. + * @param page - The agent's main page + * @returns Promise + */ +export async function holdCallToggle(page: Page): Promise { + // Wait for hold toggle button to be visible and clickable + const holdButton = page.getByTestId('call-control:hold-toggle').nth(0); + await expect(holdButton).toBeVisible({ timeout: 10000 }); + + // Click the hold toggle button + await holdButton.click(); +} + +/** + * Toggles the recording state of a call by clicking the recording pause/resume button. + * This function will pause recording if it's currently active, or resume it if it's paused. + * @param page - The agent's main page + * @returns Promise + */ +export async function recordCallToggle(page: Page): Promise { + // Wait for recording toggle button to be visible and clickable + const recordButton = page.getByTestId('call-control:recording-toggle').nth(0); + await expect(recordButton).toBeVisible({ timeout: 10000 }); + + // Click the recording toggle button + await recordButton.click(); +} + +/** + * Verifies the hold timer visibility and content based on expected state. + * @param page - The agent's main page + * @param options - Configuration object + * @param options.shouldBeVisible - Whether the timer should be visible (true) or hidden (false) + * @param options.verifyContent - Whether to verify timer content (default: true when visible) + * @returns Promise + */ +export async function verifyHoldTimer(page: Page, { shouldBeVisible, verifyContent = shouldBeVisible }: { shouldBeVisible: boolean; verifyContent?: boolean }): Promise { + const holdTimerContainer = page.locator('.on-hold-chip-text'); + + if (shouldBeVisible) { + await expect(holdTimerContainer).toBeVisible({ timeout: 10000 }); + + if (verifyContent) { + // Verify "On hold" text is present + await expect(holdTimerContainer).toContainText('On hold'); + + // Verify timer format (should contain time like 00:XX) + await expect(holdTimerContainer).toContainText(/\d{2}:\d{2}/); + } + } else { + await expect(holdTimerContainer).toBeHidden({ timeout: 10000 }); + } +} + +/** + * Verifies the icon of the hold toggle button based on current hold state. + * - When call is NOT on hold: expects 'pause-bold' icon (to put call on hold) + * - When call IS on hold: expects 'play-bold' icon (to resume call) + * @param page - The agent's main page + * @param options - Configuration object + * @param options.expectedIsHeld - Expected hold state (true if call is on hold, false if active) + * @returns Promise + * @throws Error if icon verification fails + */ +export async function verifyHoldButtonIcon(page: Page, { expectedIsHeld }: { expectedIsHeld: boolean }): Promise { + const holdButton = page.getByTestId('call-control:hold-toggle').nth(0); + await expect(holdButton).toBeVisible({ timeout: 10000 }); + + // Get the icon element within the hold button + const iconElement = holdButton.locator('mdc-icon').nth(0); + await expect(iconElement).toBeVisible({ timeout: 5000 }); + + // Verify the correct icon based on hold state + const expectedIcon = expectedIsHeld ? 'play-bold' : 'pause-bold'; + const actualIcon = await iconElement.getAttribute('name'); + + if (actualIcon !== expectedIcon) { + throw new Error(`Hold button icon mismatch. Expected: '${expectedIcon}' (isHeld: ${expectedIsHeld}), but found: '${actualIcon}'`); + } +} + +/** + * Verifies the icon of the record toggle button based on current recording state. + * - When recording is ACTIVE: expects 'record-paused-bold' icon (to pause recording) + * - When recording is PAUSED: expects 'record-bold' icon (to resume recording) + * @param page - The agent's main page + * @param options - Configuration object + * @param options.expectedIsRecording - Expected recording state (true if recording, false if paused) + * @returns Promise + * @throws Error if icon verification fails + */ +export async function verifyRecordButtonIcon(page: Page, { expectedIsRecording }: { expectedIsRecording: boolean }): Promise { + const recordButton = page.getByTestId('call-control:recording-toggle').nth(0); + await expect(recordButton).toBeVisible({ timeout: 10000 }); + + // Get the icon element within the record button + const iconElement = recordButton.locator('mdc-icon').nth(0); + await expect(iconElement).toBeVisible({ timeout: 5000 }); + + // Verify the correct icon based on recording state + const expectedIcon = expectedIsRecording ? 'record-paused-bold' : 'record-bold'; + const actualIcon = await iconElement.getAttribute('name'); + + if (actualIcon !== expectedIcon) { + throw new Error(`Record button icon mismatch. Expected: '${expectedIcon}' (isRecording: ${expectedIsRecording}), but found: '${actualIcon}'`); + } +} + +// Global variable to store captured logs +let capturedLogs: string[] = []; + +/** + * Sets up console logging to capture callback logs for task controls. + * Captures onHoldResume, onRecordingToggle, onEnd callbacks and SDK success messages. + * @param page - The agent's main page + * @returns Function to remove the console handler + */ +export function setupConsoleLogging(page: Page): () => void { + capturedLogs.length = 0; + + const consoleHandler = (msg) => { + const logText = msg.text(); + if (logText.includes('onHoldResume invoked') || + logText.includes('onRecordingToggle invoked') || + logText.includes('onEnd invoked') || + logText.includes('WXCC_SDK_TASK_HOLD_SUCCESS') || + logText.includes('WXCC_SDK_TASK_RESUME_SUCCESS') || + logText.includes('WXCC_SDK_TASK_PAUSE_RECORDING_SUCCESS') || + logText.includes('WXCC_SDK_TASK_RESUME_RECORDING_SUCCESS')) { + capturedLogs.push(logText); + } + }; + + page.on('console', consoleHandler); + return () => page.off('console', consoleHandler); +} + +/** + * Clears the captured logs array. + * Should be called before each test or verification to ensure clean state. + */ +export function clearCapturedLogs(): void { + capturedLogs.length = 0; +} + +/** + * Verifies that hold/resume callback logs are present and contain expected values. + * @param options - Configuration object + * @param options.expectedIsHeld - Expected hold state (true for hold, false for resume) + * @throws Error if verification fails with detailed error message + */ +export function verifyHoldLogs({ expectedIsHeld }: { expectedIsHeld: boolean }): void { + const holdResumeLogs = capturedLogs.filter(log => log.includes('onHoldResume invoked')); + const statusLogs = capturedLogs.filter(log => + log.includes(expectedIsHeld ? 'WXCC_SDK_TASK_HOLD_SUCCESS' : 'WXCC_SDK_TASK_RESUME_SUCCESS') + ); + + if (holdResumeLogs.length === 0) { + throw new Error(`No 'onHoldResume invoked' logs found. Expected logs for isHeld: ${expectedIsHeld}. Captured logs: ${JSON.stringify(capturedLogs)}`); + } + + if (statusLogs.length === 0) { + const expectedStatus = expectedIsHeld ? 'WXCC_SDK_TASK_HOLD_SUCCESS' : 'WXCC_SDK_TASK_RESUME_SUCCESS'; + throw new Error(`No '${expectedStatus}' logs found. Captured logs: ${JSON.stringify(capturedLogs)}`); + } + + const lastHoldLog = holdResumeLogs[holdResumeLogs.length - 1]; + if (!lastHoldLog.includes(`isHeld: ${expectedIsHeld}`)) { + throw new Error(`Expected 'isHeld: ${expectedIsHeld}' in log but found: ${lastHoldLog}`); + } +} + +/** + * Verifies that recording pause/resume callback logs are present and contain expected values. + * @param options - Configuration object + * @param options.expectedIsRecording - Expected recording state (true for recording, false for paused) + * @throws Error if verification fails with detailed error message + */ +export function verifyRecordingLogs({ expectedIsRecording }: { expectedIsRecording: boolean }): void { + const recordingLogs = capturedLogs.filter(log => log.includes('onRecordingToggle invoked')); + const statusLogs = capturedLogs.filter(log => + log.includes(expectedIsRecording ? 'WXCC_SDK_TASK_RESUME_RECORDING_SUCCESS' : 'WXCC_SDK_TASK_PAUSE_RECORDING_SUCCESS') + ); + + if (recordingLogs.length === 0) { + throw new Error(`No 'onRecordingToggle invoked' logs found. Expected logs for isRecording: ${expectedIsRecording}. Captured logs: ${JSON.stringify(capturedLogs)}`); + } + + if (statusLogs.length === 0) { + const expectedStatus = expectedIsRecording ? 'WXCC_SDK_TASK_RESUME_RECORDING_SUCCESS' : 'WXCC_SDK_TASK_PAUSE_RECORDING_SUCCESS'; + throw new Error(`No '${expectedStatus}' logs found. Captured logs: ${JSON.stringify(capturedLogs)}`); + } + + const lastRecordingLog = recordingLogs[recordingLogs.length - 1]; + if (!lastRecordingLog.includes(`isRecording: ${expectedIsRecording}`)) { + throw new Error(`Expected 'isRecording: ${expectedIsRecording}' in log but found: ${lastRecordingLog}`); + } +} + +/** + * Verifies that onEnd callback logs are present when tasks are ended. + * @throws Error if verification fails with detailed error message + */ +export function verifyEndLogs(): void { + const endLogs = capturedLogs.filter(log => log.includes('onEnd invoked')); + + if (endLogs.length === 0) { + throw new Error(`No 'onEnd invoked' logs found. Captured logs: ${JSON.stringify(capturedLogs)}`); + } +} + +/** + * Verifies audio transfer from caller to browser by executing the exact console command. + * Executes: document.querySelector("#remote-audio").srcObject.getAudioTracks() + * Verifies that exactly 1 audio MediaStreamTrack is present with GUID label and proper properties + * @param page - The agent's main page (browser receiving audio) + * @returns Promise + * @throws Error if remote audio tracks verification fails + */ +export async function verifyRemoteAudioTracks(page: Page): Promise { + try { + // Execute the exact console command for audio tracks + const consoleResult = await page.evaluate(() => { + // This is the exact command from your console + const audioElem = document.querySelector("#remote-audio") as HTMLAudioElement; + + if (!audioElem) { + return []; + } + + if (!audioElem.srcObject) { + return []; + } + + const mediaStream = audioElem.srcObject as MediaStream; + const audioTracks = mediaStream.getAudioTracks(); + + // Convert MediaStreamTrack objects to serializable format (like console shows) + const result = audioTracks.map((track, index) => { + return { + index, + kind: track.kind, + id: track.id, + label: track.label, + enabled: track.enabled, + muted: track.muted, + readyState: track.readyState, + onended: track.onended, + onmute: track.onmute, + onunmute: track.onunmute + }; + }); + + return result; + }); + + // Verify we got exactly 1 audio track (no more, no less) + expect(consoleResult.length).toBe(1); + + // Get the single audio track (since we verified there's exactly 1) + const audioTrack = consoleResult[0]; + + // Verify it's an audio track + if (audioTrack.kind !== 'audio') { + throw new Error(`❌ Expected audio track but found ${audioTrack.kind} track. Track details: { kind: "${audioTrack.kind}", label: "${audioTrack.label}", id: "${audioTrack.id}" }`); + } + + // Verify essential track properties for audio transfer + expect(audioTrack.kind).toBe('audio'); + expect(audioTrack.enabled).toBe(true); + expect(audioTrack.muted).toBe(false); + expect(audioTrack.readyState).toBe('live'); + + } catch (error) { + throw new Error(`❌ Audio transfer verification failed: ${error.message}`); + } +} + +/** + * Verifies the presence of hold music audio element with autoplay and loop attributes. + * Looks for: + * This is checked on the caller page when call is put on hold + * @param page - The caller's page (where hold music should be playing) + * @returns Promise + * @throws Error if hold music element verification fails + */ +export async function verifyHoldMusicElement(page: Page): Promise { + try { + const holdMusicExists = await page.evaluate(() => { + // Look for audio elements with both autoplay and loop attributes + const audioElements = document.querySelectorAll('audio[autoplay][loop]'); + + if (audioElements.length === 0) { + return false; + } + + // Check if at least one element has the correct attributes + return Array.from(audioElements).some(audio => { + const a = audio as HTMLAudioElement; + return a.hasAttribute('autoplay') && + a.hasAttribute('loop') && + a.autoplay === true && + a.loop === true; + }); + }); + + if (!holdMusicExists) { + throw new Error('❌ No hold music audio elements found with autoplay and loop attributes'); + } + + } catch (error) { + throw new Error(`❌ Hold music element verification failed: ${error.message}`); + } +} + +/** + * Ends a task by clicking the end call button and waiting for it to be visible. + * This function can be used for any task type (call, chat, email) as they all use the same end button. + * @param page - The agent's main page + * @returns Promise + */ +export async function endTask(page: Page): Promise { + const endButton = page.getByTestId('call-control:end-call').nth(0); + await endButton.waitFor({ state: 'visible', timeout: 30000 }); + await endButton.click(); +} \ No newline at end of file diff --git a/playwright/advanced-task-controls-test.spec.ts b/playwright/advanced-task-controls-test.spec.ts new file mode 100644 index 000000000..e038472a5 --- /dev/null +++ b/playwright/advanced-task-controls-test.spec.ts @@ -0,0 +1,680 @@ +import { test, expect, Page, BrowserContext } from '@playwright/test'; +import { + consultViaAgent, + consultViaQueue, + transferViaAgent, + transferViaQueue, + cancelConsult, + setupAdvancedConsoleLogging, + clearAdvancedCapturedLogs, + verifyTransferSuccessLogs, + verifyConsultStartSuccessLogs, + verifyConsultEndSuccessLogs, + verifyConsultTransferredLogs, +} from './Utils/advancedTaskControlUtils'; + +import { stationLogout, telephonyLogin } from './Utils/stationLoginUtils'; +import { changeUserState, getCurrentState, verifyCurrentState } from './Utils/userStateUtils'; +import { + createCallTask, + acceptIncomingTask, + loginExtension, + declineIncomingTask +} from './Utils/incomingTaskUtils'; +import { submitWrapup } from './Utils/wrapupUtils'; +import { USER_STATES, LOGIN_MODE, TASK_TYPES, WRAPUP_REASONS } from './constants'; +import { holdCallToggle, endTask, setupConsoleLogging, clearCapturedLogs, verifyHoldButtonIcon, verifyTaskControls } from './Utils/taskControlUtils'; +import { pageSetup } from './Utils/helperUtils'; + +// Extract test functions for cleaner syntax +const { describe, beforeAll, afterAll, beforeEach } = test; + +/** + * Transfer and Consult Tests + * + * Comprehensive test suite covering: + * - Blind Transfer Operations (Agent to Agent, Agent to Queue) + * - Consult Transfer Operations (with acceptance, decline, timeout scenarios) + * - Queue Consult Operations (multi-agent scenarios) + * - Multi-stage Consult Transfer Operations + */ + +let agent1Page: Page; +let agent2Page: Page; +let callerPage: Page; +let agent1Context: BrowserContext; +let agent2Context: BrowserContext; +let callerContext: BrowserContext; +const maxRetries = 3; + +describe('Transfer and Consult Tests', () => { + + // ============================================================================= + // BLIND TRANSFER TESTS + // ============================================================================= + + describe('Blind Transfer Tests', () => { + + beforeAll(async ({ browser }) => { + agent1Context = await browser.newContext(); + agent2Context = await browser.newContext(); + callerContext = await browser.newContext(); + + agent1Page = await agent1Context.newPage(); + agent2Page = await agent2Context.newPage(); + callerPage = await callerContext.newPage(); + + await Promise.all([ + (async () => { + for (let i = 0; i < maxRetries; i++) { + try { + await loginExtension(callerPage, process.env.PW_AGENT1_USERNAME ?? '', process.env.PW_PASSWORD ?? ''); + break; + } catch (error) { + if (i == maxRetries - 1) { + throw new Error(`Failed to login extension after ${maxRetries} attempts: ${error}`); + } + } + } + })(), + (async () => { + await pageSetup(agent1Page, LOGIN_MODE.DESKTOP, 'AGENT1'); + // Setup console logging for callbacks + setupConsoleLogging(agent1Page); + setupAdvancedConsoleLogging(agent1Page); + })(), + (async () => { + await pageSetup(agent2Page, LOGIN_MODE.DESKTOP, 'AGENT2'); + // Setup console logging for callbacks + setupConsoleLogging(agent2Page); + setupAdvancedConsoleLogging(agent2Page); + })(), + ]); + }); + + afterAll(async () => { + await Promise.all([ + (async () => { + if(await getCurrentState(agent1Page) === USER_STATES.ENGAGED) { + await endTask(agent1Page); + await agent1Page.waitForTimeout(3000); + await submitWrapup(agent1Page, WRAPUP_REASONS.RESOLVED); + await agent1Page.waitForTimeout(2000); + } + await stationLogout(agent1Page); + })(), + (async () => { + if(await getCurrentState(agent2Page) === USER_STATES.ENGAGED) { + await endTask(agent2Page); + await agent2Page.waitForTimeout(3000); + await submitWrapup(agent2Page, WRAPUP_REASONS.RESOLVED); + await agent2Page.waitForTimeout(2000); + } + await stationLogout(agent2Page); + })(), + ]); + await agent1Context.close(); + await agent2Context.close(); + await callerContext.close(); + }); + + beforeEach(async () => { + await changeUserState(agent2Page, USER_STATES.MEETING); + // Create call task and agent 1 accepts it + await createCallTask(callerPage); + + const incomingTaskDiv = agent1Page.getByTestId('samples:incoming-task-telephony').first(); + await incomingTaskDiv.waitFor({ state: 'visible', timeout: 120000 }); + await agent1Page.waitForTimeout(3000); + + await acceptIncomingTask(agent1Page, TASK_TYPES.CALL); + await changeUserState(agent2Page, USER_STATES.AVAILABLE); + await agent1Page.waitForTimeout(5000); + + await verifyCurrentState(agent1Page, USER_STATES.ENGAGED); + + // Clear console logs to track transfer events + clearAdvancedCapturedLogs(); + }); + + test('Normal Call Blind Transferred by Agent to Another Agent', async () => { + // Agent 1 performs blind transfer to Agent 2 + await transferViaAgent(agent1Page, 'User2 Agent2'); + + // Verify transfer success in console logs + await agent1Page.waitForTimeout(3000); + verifyTransferSuccessLogs(); + + // Verify Agent 1 goes to wrapup state + await submitWrapup(agent1Page, WRAPUP_REASONS.SALE); + // Agent 2 should receive the transfer and accept it + const incomingTransferDiv = agent2Page.getByTestId('samples:incoming-task-telephony').first(); + await incomingTransferDiv.waitFor({ state: 'visible', timeout: 60000 }); + await agent2Page.waitForTimeout(3000); + + await acceptIncomingTask(agent2Page, TASK_TYPES.CALL); + await agent2Page.waitForTimeout(3000); + + // Verify Agent 2 now has the call and is engaged + await verifyCurrentState(agent2Page, USER_STATES.ENGAGED); + await verifyTaskControls(agent2Page, TASK_TYPES.CALL); + + // Verify transfer success was logged + await agent2Page.waitForTimeout(2000); + verifyTransferSuccessLogs(); + + // End the call and complete wrapup to clean up for next test + await endTask(agent2Page); + await agent2Page.waitForTimeout(3000); + await submitWrapup(agent2Page, WRAPUP_REASONS.RESOLVED); + await agent2Page.waitForTimeout(2000); + }); + + test('Normal Call Blind Transferred to Queue', async () => { + // First transfer from Agent 1 to Agent 2 + await transferViaQueue(agent1Page, 'Queue-1'); + + // Agent 2 accepts the transfer + const incomingTransferDiv = agent2Page.getByTestId('samples:incoming-task-telephony').first(); + await incomingTransferDiv.waitFor({ state: 'visible', timeout: 60000 }); + await submitWrapup(agent1Page, WRAPUP_REASONS.SALE); + await agent2Page.waitForTimeout(3000); + await acceptIncomingTask(agent2Page, TASK_TYPES.CALL); + await agent2Page.waitForTimeout(3000); + verifyTransferSuccessLogs(); + await verifyCurrentState(agent2Page, USER_STATES.ENGAGED); + await endTask(agent2Page); + await agent2Page.waitForTimeout(2000); + + // Verify Agent 2 goes to wrapup after transfer + await submitWrapup(agent2Page, WRAPUP_REASONS.RESOLVED); + await agent2Page.waitForTimeout(2000); + + // Verify Agent 2 is no longer engaged + await verifyCurrentState(agent2Page, USER_STATES.AVAILABLE); + }); +}); + +// ============================================================================= +// CONSULT TRANSFER TESTS +// ============================================================================= + +describe('Consult Transfer Tests', () => { + + beforeAll(async ({ browser }) => { + agent1Context = await browser.newContext(); + agent2Context = await browser.newContext(); + callerContext = await browser.newContext(); + + agent1Page = await agent1Context.newPage(); + agent2Page = await agent2Context.newPage(); + callerPage = await callerContext.newPage(); + + await Promise.all([ + (async () => { + for (let i = 0; i < maxRetries; i++) { + try { + await loginExtension(callerPage, process.env.PW_AGENT2_USERNAME ?? '', process.env.PW_PASSWORD ?? ''); + break; + } catch (error) { + if (i == maxRetries - 1) { + throw new Error(`Failed to login extension after ${maxRetries} attempts: ${error}`); + } + } + } + })(), + (async () => { + await pageSetup(agent1Page, LOGIN_MODE.DESKTOP, 'AGENT1'); + // Setup console logging for callbacks + setupConsoleLogging(agent1Page); + setupAdvancedConsoleLogging(agent1Page); + })(), + (async () => { + await pageSetup(agent2Page, LOGIN_MODE.DESKTOP, 'AGENT2'); + // Setup console logging for callbacks + setupConsoleLogging(agent2Page); + setupAdvancedConsoleLogging(agent2Page); + })(), + ]); + }); + + afterAll(async () => { + await Promise.all([ + (async () => { + if(await getCurrentState(agent1Page) === USER_STATES.ENGAGED) { + await endTask(agent1Page); + await agent1Page.waitForTimeout(3000); + await submitWrapup(agent1Page, WRAPUP_REASONS.RESOLVED); + await agent1Page.waitForTimeout(2000); + } + await stationLogout(agent1Page); + })(), + (async () => { + if(await getCurrentState(agent2Page) === USER_STATES.ENGAGED) { + await endTask(agent2Page); + await agent2Page.waitForTimeout(3000); + await submitWrapup(agent2Page, WRAPUP_REASONS.RESOLVED); + await agent2Page.waitForTimeout(2000); + } + await stationLogout(agent2Page); + })(), + ]); + await agent1Context.close(); + await agent2Context.close(); + await callerContext.close(); + }); + + beforeEach(async () => { + await changeUserState(agent2Page, USER_STATES.MEETING); + // Create call task and agent 1 accepts it + await createCallTask(callerPage); + + const incomingTaskDiv = agent1Page.getByTestId('samples:incoming-task-telephony').first(); + await incomingTaskDiv.waitFor({ state: 'visible', timeout: 120000 }); + await agent1Page.waitForTimeout(3000); + + await acceptIncomingTask(agent1Page, TASK_TYPES.CALL); + await changeUserState(agent2Page, USER_STATES.AVAILABLE); + await agent1Page.waitForTimeout(5000); + + await verifyCurrentState(agent1Page, USER_STATES.ENGAGED); + + // Clear console logs to track consult events + clearAdvancedCapturedLogs(); + }); + + test('Normal Call Consulted via Agent and Accepted (A1 → A2)', async () => { + + // Agent 1 initiates consult with Agent 2 + await consultViaAgent(agent1Page, 'User2 Agent2'); + + // Verify consult UI elements are visible + await expect(agent1Page.getByTestId('cancel-consult-btn')).toBeVisible(); + await expect(agent1Page.getByTestId('transfer-consult-btn')).toBeVisible(); + + // Agent 2 receives and accepts the consult + const consultRequestDiv = agent2Page.getByTestId('samples:incoming-task-telephony').first(); + await consultRequestDiv.waitFor({ state: 'visible', timeout: 60000 }); + await agent2Page.waitForTimeout(3000); + + await acceptIncomingTask(agent2Page, TASK_TYPES.CALL); + await agent2Page.waitForTimeout(3000); + + // Verify both agents are in consult state + await expect(agent1Page.getByTestId('transfer-consult-btn')).toBeVisible(); + + // Verify consult start success was logged + await agent1Page.waitForTimeout(2000); + verifyConsultStartSuccessLogs(); + + // End the consult and verify state + await cancelConsult(agent2Page); + + // Verify consult end success was logged + await agent1Page.waitForTimeout(2000); + verifyConsultEndSuccessLogs(); + + // Verify call is on hold after consult ends + await verifyHoldButtonIcon(agent1Page, { expectedIsHeld: true }); + + await verifyCurrentState(agent2Page, USER_STATES.AVAILABLE); + await holdCallToggle(agent1Page); + // End the call and complete wrapup to clean up for next test + await endTask(agent1Page); + await agent1Page.waitForTimeout(3000); + await submitWrapup(agent1Page, WRAPUP_REASONS.RESOLVED); + await agent1Page.waitForTimeout(2000); + await verifyCurrentState(agent1Page, USER_STATES.AVAILABLE); + }); + + test('Normal Call Consulted via Agent and Declined (A1 → A2)', async () => { + // Agent 1 initiates another consult with Agent 2 + await consultViaAgent(agent1Page, 'User2 Agent2'); + + // Agent 2 receives and declines the consult + const consultRequestDiv = agent2Page.getByTestId('samples:incoming-task-telephony').first(); + await consultRequestDiv.waitFor({ state: 'visible', timeout: 60000 }); + await agent2Page.waitForTimeout(3000); + + await declineIncomingTask(agent2Page, TASK_TYPES.CALL); + + // Verify Agent 1 returns to normal call state + await verifyTaskControls(agent1Page, TASK_TYPES.CALL); + + // Verify call is on hold after consult decline + await verifyHoldButtonIcon(agent1Page, { expectedIsHeld: true }); + + await holdCallToggle(agent1Page); + await agent1Page.waitForTimeout(2000); + await expect(agent1Page.getByTestId('cancel-consult-btn')).not.toBeVisible(); + + // Agent 1 should still be engaged with customer call + await verifyCurrentState(agent1Page, USER_STATES.ENGAGED); + + // End the call and complete wrapup to clean up for next test + await endTask(agent1Page); + await agent1Page.waitForTimeout(3000); + await submitWrapup(agent1Page, WRAPUP_REASONS.RESOLVED); + await agent1Page.waitForTimeout(2000); + }); + + test('Normal Call Consulted via Agent and Not Picked Up by Agent 2', async () => { + // Agent 1 initiates consult with Agent 2 + await consultViaAgent(agent1Page, 'User2 Agent2'); + + // Wait for consult to timeout (Agent 2 doesn't respond) + // This should timeout after some time and return to normal state + await agent1Page.waitForTimeout(20000); // Wait for timeout + + // Verify Agent 1 returns to call state (call should still be on hold) + await verifyTaskControls(agent1Page, TASK_TYPES.CALL); + + // Verify call is on hold after consult timeout + await verifyHoldButtonIcon(agent1Page, { expectedIsHeld: true }); + + await holdCallToggle(agent1Page); + await agent1Page.waitForTimeout(2000); + + // End the call and complete wrapup to clean up for next test + await endTask(agent1Page); + await agent1Page.waitForTimeout(3000); + await submitWrapup(agent1Page, WRAPUP_REASONS.RESOLVED); + await agent1Page.waitForTimeout(2000); + }); + + test('Consult Transfer - Normal Call to Agent 2', async () => { + await consultViaAgent(agent1Page, 'User2 Agent2'); + + // Agent 2 accepts the consult first + const consultRequestDiv = agent2Page.getByTestId('samples:incoming-task-telephony').first(); + await consultRequestDiv.waitFor({ state: 'visible', timeout: 60000 }); + await agent2Page.waitForTimeout(3000); + + await acceptIncomingTask(agent2Page, TASK_TYPES.CALL); + await agent2Page.waitForTimeout(3000); + await agent1Page.getByTestId('transfer-consult-btn').click(); + + // Agent 1 completes the transfer and goes to wrapup + await submitWrapup(agent1Page, WRAPUP_REASONS.SALE); + // Verify Agent 2 has the transferred call + await verifyCurrentState(agent2Page, USER_STATES.ENGAGED); + await verifyTaskControls(agent2Page, TASK_TYPES.CALL); + + // Verify consult start and transfer success were logged + await agent2Page.waitForTimeout(2000); + verifyConsultStartSuccessLogs(); + verifyTransferSuccessLogs(); + + // End the call and complete wrapup to clean up for next test + await endTask(agent2Page); + await agent2Page.waitForTimeout(3000); + await submitWrapup(agent2Page, WRAPUP_REASONS.RESOLVED); + await agent2Page.waitForTimeout(2000); + }); +}); + +// ============================================================================= +// QUEUE CONSULT TESTS +// ============================================================================= + +describe('Queue Consult Tests', () => { + + beforeAll(async ({ browser }) => { + agent1Context = await browser.newContext(); + agent2Context = await browser.newContext(); + callerContext = await browser.newContext(); + + agent1Page = await agent1Context.newPage(); + agent2Page = await agent2Context.newPage(); + callerPage = await callerContext.newPage(); + + await Promise.all([ + (async () => { + for (let i = 0; i < maxRetries; i++) { + try { + await loginExtension(callerPage, process.env.PW_AGENT1_USERNAME ?? '', process.env.PW_PASSWORD ?? ''); + break; + } catch (error) { + if (i == maxRetries - 1) { + throw new Error(`Failed to login extension after ${maxRetries} attempts: ${error}`); + } + } + } + })(), + (async () => { + await pageSetup(agent1Page, LOGIN_MODE.DESKTOP, 'AGENT1'); + await setupConsoleLogging(agent1Page); + await setupAdvancedConsoleLogging(agent1Page); + })(), + (async () => { + await pageSetup(agent2Page, LOGIN_MODE.DESKTOP, 'AGENT2'); + await setupConsoleLogging(agent2Page); + await setupAdvancedConsoleLogging(agent2Page); + // Set Agent 2 to idle for some tests + await changeUserState(agent2Page, USER_STATES.AVAILABLE); + })(), + ]); + }); + + afterAll(async () => { + await Promise.all([ + (async () => { + if(await getCurrentState(agent1Page) === USER_STATES.ENGAGED) { + await endTask(agent1Page); + await agent1Page.waitForTimeout(3000); + await submitWrapup(agent1Page, WRAPUP_REASONS.RESOLVED); + await agent1Page.waitForTimeout(2000); + } + await stationLogout(agent1Page); + })(), + (async () => { + if(await getCurrentState(agent2Page) === USER_STATES.ENGAGED) { + await endTask(agent2Page); + await agent2Page.waitForTimeout(3000); + await submitWrapup(agent2Page, WRAPUP_REASONS.RESOLVED); + await agent2Page.waitForTimeout(2000); + } + await stationLogout(agent2Page); + })(), + ]); + await agent1Context.close(); + await agent2Context.close(); + await callerContext.close(); + }); + + test('Agent 1 Consults via Queue When Agent 2 is Idle, Then Cancels the Consultation', async () => { + await changeUserState(agent2Page, USER_STATES.MEETING); + // Create call task and agent 1 accepts it + await createCallTask(callerPage); + + const incomingTaskDiv = agent1Page.getByTestId('samples:incoming-task-telephony').first(); + await incomingTaskDiv.waitFor({ state: 'visible', timeout: 120000 }); + await agent1Page.waitForTimeout(3000); + + await acceptIncomingTask(agent1Page, TASK_TYPES.CALL); + await agent1Page.waitForTimeout(5000); + + await verifyCurrentState(agent1Page, USER_STATES.ENGAGED); + + // Clear logs before consult + clearAdvancedCapturedLogs(); + + // Agent 1 initiates queue consult + await consultViaQueue(agent1Page, 'Queue-1'); + + // Verify consult UI elements are visible + await expect(agent1Page.getByTestId('cancel-consult-btn')).toBeVisible(); + await agent1Page.waitForTimeout(2000); + + // Agent 1 cancels consult before Agent 2 responds + await cancelConsult(agent1Page); + + // Verify customer call returns to regular connected state + await verifyTaskControls(agent1Page, TASK_TYPES.CALL); + await expect(agent1Page.getByTestId('cancel-consult-btn')).not.toBeVisible(); + + + // End the call and complete wrapup to clean up for next test + await endTask(agent1Page); + await agent1Page.waitForTimeout(3000); + await submitWrapup(agent1Page, WRAPUP_REASONS.RESOLVED); + await agent1Page.waitForTimeout(2000); + }); + + test('Agent 1 Consults via Queue with Available Agent 2, Then Ends Consultation', async () => { + await changeUserState(agent2Page, USER_STATES.MEETING); + // Create new call for this test + await createCallTask(callerPage); + + const incomingTaskDiv = agent1Page.getByTestId('samples:incoming-task-telephony').first(); + await incomingTaskDiv.waitFor({ state: 'visible', timeout: 120000 }); + await agent1Page.waitForTimeout(3000); + + await acceptIncomingTask(agent1Page, TASK_TYPES.CALL); + await changeUserState(agent2Page, USER_STATES.AVAILABLE); + await agent1Page.waitForTimeout(5000); + + await verifyCurrentState(agent1Page, USER_STATES.ENGAGED); + + // Clear logs before consult + clearAdvancedCapturedLogs(); + + // Agent 1 initiates queue consult + await consultViaQueue(agent1Page, 'Queue-1'); + + // Verify consult start success was logged + await agent1Page.waitForTimeout(2000); + verifyConsultStartSuccessLogs(); + + // Agent 2 accepts the consult + const consultRequestDiv = agent2Page.getByTestId('samples:incoming-task-telephony').first(); + await consultRequestDiv.waitFor({ state: 'visible', timeout: 60000 }); + await agent2Page.waitForTimeout(3000); + + await acceptIncomingTask(agent2Page, TASK_TYPES.CALL); + await agent2Page.waitForTimeout(3000); + + // Agent 1 ends the consultation + await cancelConsult(agent1Page); + await agent1Page.waitForTimeout(3000); + await verifyCurrentState(agent2Page, USER_STATES.AVAILABLE); + // Verify call returns to Agent 1 + await verifyTaskControls(agent1Page, TASK_TYPES.CALL); + + // Verify consult end success was logged + await agent1Page.waitForTimeout(2000); + verifyConsultEndSuccessLogs(); + + // Verify call is on hold after consult ends + await verifyHoldButtonIcon(agent1Page, { expectedIsHeld: true }); + + await holdCallToggle(agent1Page); + + // End the call and complete wrapup to clean up for next test + await endTask(agent1Page); + await agent1Page.waitForTimeout(3000); + await submitWrapup(agent1Page, WRAPUP_REASONS.RESOLVED); + await agent1Page.waitForTimeout(2000); + }); + + test('Agent 2 Ends the Consultation Initiated by Agent 1 via Queue', async () => { + await changeUserState(agent2Page, USER_STATES.MEETING); + // Create new call for this test + await createCallTask(callerPage); + + const incomingTaskDiv = agent1Page.getByTestId('samples:incoming-task-telephony').first(); + await incomingTaskDiv.waitFor({ state: 'visible', timeout: 120000 }); + await agent1Page.waitForTimeout(3000); + + await acceptIncomingTask(agent1Page, TASK_TYPES.CALL); + await changeUserState(agent2Page, USER_STATES.AVAILABLE); + await agent1Page.waitForTimeout(5000); + + await verifyCurrentState(agent1Page, USER_STATES.ENGAGED); + + // Clear logs before consult + clearAdvancedCapturedLogs(); + + // Agent 1 initiates queue consult + await consultViaQueue(agent1Page, 'Queue-1'); + + // Agent 2 accepts the consult + const consultRequestDiv = agent2Page.getByTestId('samples:incoming-task-telephony').first(); + await consultRequestDiv.waitFor({ state: 'visible', timeout: 60000 }); + await agent2Page.waitForTimeout(3000); + + await acceptIncomingTask(agent2Page, TASK_TYPES.CALL); + await agent2Page.waitForTimeout(3000); + + // Agent 2 ends the consultation from their side + await cancelConsult(agent2Page); + await agent2Page.waitForTimeout(3000); + await verifyCurrentState(agent2Page, USER_STATES.AVAILABLE); + // Customer call should return to Agent 1 + await verifyTaskControls(agent1Page, TASK_TYPES.CALL); + + // Verify call is on hold after consult ends + await verifyHoldButtonIcon(agent1Page, { expectedIsHeld: true }); + + await holdCallToggle(agent1Page); + // End the call and complete wrapup to clean up for next test + await endTask(agent1Page); + await agent1Page.waitForTimeout(3000); + await submitWrapup(agent1Page, WRAPUP_REASONS.RESOLVED); + await agent1Page.waitForTimeout(2000); + }); + + test('Agent 1 Consults via Queue with Agent 2, Then Transfers Call to Agent 2', async () => { + await changeUserState(agent2Page, USER_STATES.MEETING); + // Create new call for this test + await createCallTask(callerPage); + + const incomingTaskDiv = agent1Page.getByTestId('samples:incoming-task-telephony').first(); + await incomingTaskDiv.waitFor({ state: 'visible', timeout: 120000 }); + await agent1Page.waitForTimeout(3000); + + await acceptIncomingTask(agent1Page, TASK_TYPES.CALL); + await changeUserState(agent2Page, USER_STATES.AVAILABLE); + await agent1Page.waitForTimeout(5000); + + await verifyCurrentState(agent1Page, USER_STATES.ENGAGED); + + // Clear logs before consult + clearAdvancedCapturedLogs(); + + // Agent 1 initiates queue consult + await consultViaQueue(agent1Page, 'Queue-1'); + + // Agent 2 accepts the consultation + const consultRequestDiv = agent2Page.getByTestId('samples:incoming-task-telephony').first(); + await consultRequestDiv.waitFor({ state: 'visible', timeout: 60000 }); + await agent2Page.waitForTimeout(3000); + + await acceptIncomingTask(agent2Page, TASK_TYPES.CALL); + await agent2Page.waitForTimeout(3000); + + // Agent 1 transfers the call to Agent 2 + await agent1Page.getByTestId('transfer-consult-btn').click(); + await agent1Page.waitForTimeout(2000); + + // Agent 1 enters wrap-up state + await submitWrapup(agent1Page, WRAPUP_REASONS.SALE); + + // Verify ownership shifts to Agent 2 + await verifyCurrentState(agent2Page, USER_STATES.ENGAGED); + await verifyTaskControls(agent2Page, TASK_TYPES.CALL); + + // Verify consult start and transfer success were logged + await agent2Page.waitForTimeout(2000); + verifyConsultStartSuccessLogs(); + verifyConsultTransferredLogs(); + + // End the call and complete wrapup to clean up for next test + await endTask(agent2Page); + await agent2Page.waitForTimeout(3000); + await submitWrapup(agent2Page, WRAPUP_REASONS.RESOLVED); + await agent2Page.waitForTimeout(2000); + }); +}); + +}); \ No newline at end of file diff --git a/playwright/basic-task-controls-test.spec.ts b/playwright/basic-task-controls-test.spec.ts new file mode 100644 index 000000000..bc7c063f0 --- /dev/null +++ b/playwright/basic-task-controls-test.spec.ts @@ -0,0 +1,501 @@ +import { test, expect, Page, BrowserContext } from '@playwright/test'; +import { + enableAllWidgets, + enableMultiLogin, + initialiseWidgets, + loginViaAccessToken, +} from './Utils/initUtils'; +import { stationLogout, telephonyLogin } from './Utils/stationLoginUtils'; +import { changeUserState, getCurrentState, verifyCurrentState } from './Utils/userStateUtils'; +import { + createCallTask, + createChatTask, + createEmailTask, + acceptIncomingTask, + loginExtension, + endChatTask, + acceptExtensionCall, + endCallTask +} from './Utils/incomingTaskUtils'; +import { verifyTaskControls, holdCallToggle, recordCallToggle, setupConsoleLogging, clearCapturedLogs, verifyHoldLogs, verifyRecordingLogs, verifyEndLogs, verifyHoldTimer, verifyRemoteAudioTracks, verifyHoldMusicElement, endTask, verifyHoldButtonIcon, verifyRecordButtonIcon } from './Utils/taskControlUtils'; +import { submitWrapup } from './Utils/wrapupUtils'; +import { pageSetup } from './Utils/helperUtils'; +import { USER_STATES, LOGIN_MODE, TASK_TYPES, WRAPUP_REASONS } from './constants'; + +// Extract test functions for cleaner syntax +const { describe, beforeAll, afterAll, beforeEach } = test; + +let page: Page; +let context: BrowserContext; +let callerPage: Page; +let chatPage: Page; +let context2: BrowserContext; +const maxRetries = 3; + +describe('Basic Task Controls Tests', () => { + beforeEach(() => { + clearCapturedLogs(); + }); + + beforeAll(async ({ browser }) => { + context = await browser.newContext(); + context2 = await browser.newContext(); + page = await context.newPage(); + chatPage = await context.newPage(); + callerPage = await context2.newPage(); + + await Promise.all([ + (async () => { + for (let i = 0; i < maxRetries; i++) { + try { + await loginExtension(callerPage, process.env.PW_AGENT2_USERNAME ?? '', process.env.PW_PASSWORD ?? ''); + break; + } catch (error) { + if (i == maxRetries - 1) { + throw new Error(`Failed to login extension after ${maxRetries} attempts: ${error}`); + } + } + } + })(), + (async () => { + await pageSetup(page, LOGIN_MODE.DESKTOP, 'AGENT1'); + setupConsoleLogging(page); + })(), + ]); + }); + + afterAll(async () => { + if(await getCurrentState(page) === USER_STATES.ENGAGED) { + // If still engaged, end the call to clean up + await endTask(page); + await page.waitForTimeout(3000); + await submitWrapup(page, WRAPUP_REASONS.RESOLVED); + await page.waitForTimeout(2000); + } + await stationLogout(page); + await context.close(); + await context2.close(); + }); + + test('Call task - create call and verify all control buttons are visible', async () => { + // Create call task + await createCallTask(callerPage); + await changeUserState(page, USER_STATES.AVAILABLE); + + // Wait for incoming call notification + const incomingTaskDiv = page.getByTestId('samples:incoming-task-telephony').first(); + await incomingTaskDiv.waitFor({ state: 'visible', timeout: 120000 }); + await page.waitForTimeout(3000); + + // Accept the incoming call + await acceptIncomingTask(page, TASK_TYPES.CALL); + await page.waitForTimeout(5000); + + // Verify agent state changed to engaged + await verifyCurrentState(page, USER_STATES.ENGAGED); + + // Use utility to check all call control buttons are visible + try { + await verifyTaskControls(page, TASK_TYPES.CALL); + } catch (error) { + throw new Error(`Call control buttons verification failed: ${error.message}`); + } + }); + + + test('Call task - verify remote audio tracks from caller to browser', async () => { + // Verify we're still in an engaged call from previous test + await verifyCurrentState(page, USER_STATES.ENGAGED); + + try { + + // Then verify the audio tracks with the exact structure you provided + await verifyRemoteAudioTracks(page); + + } catch (error) { + throw new Error(`Remote audio tracks verification failed: ${error.message}`); + } + }); + + test('Call task - verify hold and resume functionality with callbacks, timer, and hold music', async () => { + // Verify we're still in an engaged call from previous test + await verifyCurrentState(page, USER_STATES.ENGAGED); + + try { + // Clear logs first to ensure clean state + clearCapturedLogs(); + + // Verify initial hold button icon (should show pause icon when call is active) + await verifyHoldButtonIcon(page, { expectedIsHeld: false }); + + // Put call on hold from agent side + await holdCallToggle(page); + await page.waitForTimeout(3000); // Allow time for hold to take effect + + // Verify hold callback logs + verifyHoldLogs({ expectedIsHeld: true }); + + // Verify hold button icon changed to play icon (when call is on hold) + await verifyHoldButtonIcon(page, { expectedIsHeld: true }); + + // Verify hold music element is present on the CALLER page (where hold music plays) + // The hold music plays on the caller's side when agent puts call on hold + await verifyHoldMusicElement(callerPage); + + // Verify hold timer is visible and functioning + await verifyHoldTimer(page, { shouldBeVisible: true }); + + clearCapturedLogs(); // Clear logs for next verification + + // Resume call from hold + await holdCallToggle(page); + await page.waitForTimeout(2000); + + // Verify resume callback logs + verifyHoldLogs({ expectedIsHeld: false }); + + // Verify hold button icon changed back to pause icon (when call is active) + await verifyHoldButtonIcon(page, { expectedIsHeld: false }); + + verifyHoldTimer(page, { shouldBeVisible: false }); + + } catch (error) { + throw new Error(`Hold/Resume functionality with callbacks, timer, and hold music verification failed: ${error.message}`); + } + }); + + test('Call task - verify recording pause and resume functionality with callbacks', async () => { + // Verify we're still in an engaged call from previous tests + await verifyCurrentState(page, USER_STATES.ENGAGED); + + try { + // Verify initial record button icon (should show pause icon when recording is active) + await verifyRecordButtonIcon(page, { expectedIsRecording: true }); + + // Pause the call recording + await recordCallToggle(page); + await page.waitForTimeout(2000); + + // Verify pause recording callback logs + verifyRecordingLogs({ expectedIsRecording: false }); + + // Verify record button icon changed to record icon (when recording is paused) + await verifyRecordButtonIcon(page, { expectedIsRecording: false }); + + clearCapturedLogs(); // Clear logs for next verification + + // Resume the call recording + await recordCallToggle(page); + await page.waitForTimeout(2000); + + // Verify resume recording callback logs + verifyRecordingLogs({ expectedIsRecording: true }); + + // Verify record button icon changed back to pause icon (when recording is active) + await verifyRecordButtonIcon(page, { expectedIsRecording: true }); + + } catch (error) { + throw new Error(`Recording pause/resume functionality verification failed: ${error.message}`); + } + }); + + test('Call task - end call and complete wrapup', async () => { + // Verify we're still in an engaged call from previous tests + await verifyCurrentState(page, USER_STATES.ENGAGED); + + try { + // End the call by clicking the end button + await endTask(page); + await page.waitForTimeout(3000); + + // Verify onEnd callback logs + verifyEndLogs(); + + // Submit wrapup + await submitWrapup(page, WRAPUP_REASONS.RESOLVED); + await page.waitForTimeout(2000); + + } catch (error) { + throw new Error(`Call task end and wrapup failed: ${error.message}`); + } + }); + + test('Chat task - verify transfer and end buttons are visible, end chat, and wrap up', async () => { + // Create chat task + await createChatTask(chatPage); + await changeUserState(page, USER_STATES.AVAILABLE); + + // Wait for incoming chat notification + const incomingTaskDiv = page.getByTestId('samples:incoming-task-chat').first(); + await incomingTaskDiv.waitFor({ state: 'visible', timeout: 120000 }); + await page.waitForTimeout(3000); + + // Accept the incoming chat + await acceptIncomingTask(page, TASK_TYPES.CHAT); + await page.waitForTimeout(5000); + + // Verify agent state changed to engaged + await verifyCurrentState(page, USER_STATES.ENGAGED); + + try { + // Use utility to check chat control buttons are visible + await verifyTaskControls(page, TASK_TYPES.CHAT); + + // End the chat by clicking the end button + await endTask(page); + await page.waitForTimeout(3000); + + // Verify onEnd callback logs + verifyEndLogs(); + + // Submit wrapup + await submitWrapup(page, WRAPUP_REASONS.RESOLVED); + await page.waitForTimeout(2000); + } catch (error) { + throw new Error(`Chat task control test failed: ${error.message}`); + } + }); + + test('Email task - verify transfer and end buttons are visible, end email, and wrap up', async () => { + // Create email task + await createEmailTask(); + await changeUserState(page, USER_STATES.AVAILABLE); + + // Wait for incoming email notification (emails may take longer) + const incomingTaskDiv = page.getByTestId('samples:incoming-task-email').first(); + await incomingTaskDiv.waitFor({ state: 'visible', timeout: 180000 }); // 3 minutes for email + await page.waitForTimeout(3000); + + // Accept the incoming email + await acceptIncomingTask(page, TASK_TYPES.EMAIL); + await page.waitForTimeout(5000); + + // Verify agent state changed to engaged + await verifyCurrentState(page, USER_STATES.ENGAGED); + + try { + // Use utility to check email control buttons are visible + await verifyTaskControls(page, TASK_TYPES.EMAIL); + + // End the email by clicking the end button + await endTask(page); + await page.waitForTimeout(3000); + + // Verify onEnd callback logs + verifyEndLogs(); + + // Submit wrapup + await submitWrapup(page, WRAPUP_REASONS.RESOLVED); + await page.waitForTimeout(2000); + } catch (error) { + throw new Error(`Email task control test failed: ${error.message}`); + } + }); +}); + +describe('Multi-Login Task Controls Tests', () => { + let session1Page: Page; + let session2Page: Page; + let session1Context: BrowserContext; + let session2Context: BrowserContext; + let callerPageMulti: Page; + let callerContextMulti: BrowserContext; + let extensionPage: Page; + let extensionContext: BrowserContext; + + beforeEach(() => { + clearCapturedLogs(); + }); + + beforeAll(async ({ browser }) => { + // Create separate browser contexts for multi-session testing + session1Context = await browser.newContext(); + session2Context = await browser.newContext(); + callerContextMulti = await browser.newContext(); + extensionContext = await browser.newContext(); + + session1Page = await session1Context.newPage(); + session2Page = await session2Context.newPage(); + callerPageMulti = await callerContextMulti.newPage(); + extensionPage = await extensionContext.newPage(); + + await Promise.all([ + (async () => { + for (let i = 0; i < maxRetries; i++) { + try { + await loginExtension(callerPageMulti, process.env.PW_AGENT2_USERNAME ?? '', process.env.PW_PASSWORD ?? ''); + break; + } catch (error) { + if (i == maxRetries - 1) { + throw new Error(`Failed to login extension for multi-session test after ${maxRetries} attempts: ${error}`); + } + } + } + })(), + (async () => { + await pageSetup(session1Page, LOGIN_MODE.EXTENSION, 'AGENT1', extensionPage); + setupConsoleLogging(session1Page); + })(), + (async () => { + await pageSetup(session2Page, LOGIN_MODE.EXTENSION, 'AGENT1', extensionPage); + setupConsoleLogging(session2Page); + })(), + (async () => { + + for (let i = 0; i < maxRetries; i++) { + try { + await loginExtension(extensionPage, process.env.PW_AGENT1_USERNAME ?? '', process.env.PW_PASSWORD ?? ''); + break; + } catch (error) { + if (i == maxRetries - 1) { + throw new Error(`Failed to login extension after ${maxRetries} attempts: ${error}`); + } + } + } + })(), + ]); + }); + + afterAll(async () => { + // If still engaged, end the call to clean up + if (await getCurrentState(session1Page) === USER_STATES.ENGAGED) { + await endTask(session1Page); + await session1Page.waitForTimeout(5000); + await submitWrapup(session1Page, WRAPUP_REASONS.RESOLVED); + await session1Page.waitForTimeout(2000); + } + await Promise.all([ + stationLogout(session1Page), + stationLogout(session2Page), + ]); + await session1Context.close(); + await session2Context.close(); + await callerContextMulti.close(); + await extensionContext.close(); + }); + + test('Multi-login call controls - verify controls are synchronized', async () => { + // Set both AGENT1 sessions to available state + await Promise.all([ + changeUserState(session1Page, USER_STATES.AVAILABLE), + ]); + await session1Page.waitForTimeout(2000); + + // Caller page creates call to extension page + await createCallTask(callerPageMulti); + + // Wait for incoming call notification on both AGENT1 sessions + const incomingTaskSession1 = session1Page.getByTestId('samples:incoming-task-telephony').first(); + const incomingTaskSession2 = session2Page.getByTestId('samples:incoming-task-telephony').first(); + + await Promise.all([ + incomingTaskSession1.waitFor({ state: 'visible', timeout: 40000 }), + incomingTaskSession2.waitFor({ state: 'visible', timeout: 40000 }), + ]); + + // Wait for extension caller to be visible and accept the call + await extensionPage.locator('[data-test="generic-person-item-base"]').waitFor({ state: 'visible', timeout: 20000 }); + await session1Page.waitForTimeout(3000); + await acceptExtensionCall(extensionPage); + await session1Page.waitForTimeout(2000); + + // Verify both AGENT1 sessions show engaged state + await Promise.all([ + verifyCurrentState(session1Page, USER_STATES.ENGAGED), + verifyCurrentState(session2Page, USER_STATES.ENGAGED), + ]); + + try { + // Verify call control buttons are visible on both AGENT1 sessions + await Promise.all([ + verifyTaskControls(session1Page, TASK_TYPES.CALL), + verifyTaskControls(session2Page, TASK_TYPES.CALL), + ]); + + // Setup console logging for both AGENT1 sessions + setupConsoleLogging(session1Page); + setupConsoleLogging(session2Page); + + // Verify initial hold button icons on both sessions (should show pause icon when call is active) + await Promise.all([ + verifyHoldButtonIcon(session1Page, { expectedIsHeld: false }), + verifyHoldButtonIcon(session2Page, { expectedIsHeld: false }), + ]); + + // Put call on hold from session 1 (AGENT1) + await holdCallToggle(session1Page); + await session1Page.waitForTimeout(3000); + + // Verify hold button icons changed to play icon on both sessions (when call is on hold) + await Promise.all([ + verifyHoldButtonIcon(session1Page, { expectedIsHeld: true }), + verifyHoldButtonIcon(session2Page, { expectedIsHeld: true }), + ]); + + // Verify hold timer is visible on both AGENT1 sessions + await Promise.all([ + verifyHoldTimer(session1Page, { shouldBeVisible: true }), + verifyHoldTimer(session2Page, { shouldBeVisible: true }), + ]); + + // Resume call from session 2 (AGENT1) + await holdCallToggle(session2Page); + await session2Page.waitForTimeout(3000); + + // Verify hold button icons changed back to pause icon on both sessions (when call is active) + await Promise.all([ + verifyHoldButtonIcon(session1Page, { expectedIsHeld: false }), + verifyHoldButtonIcon(session2Page, { expectedIsHeld: false }), + ]); + + // Verify hold timer disappears on both AGENT1 sessions + await Promise.all([ + verifyHoldTimer(session1Page, { shouldBeVisible: false }), + verifyHoldTimer(session2Page, { shouldBeVisible: false }), + ]); + + // Verify initial record button icons on both sessions (should show pause icon when recording is active) + await Promise.all([ + verifyRecordButtonIcon(session1Page, { expectedIsRecording: true }), + verifyRecordButtonIcon(session2Page, { expectedIsRecording: true }), + ]); + + // Pause recording from session 1 (AGENT1) + await recordCallToggle(session1Page); + await session1Page.waitForTimeout(2000); + + // Verify record button icons changed to record icon on both sessions (when recording is paused) + await Promise.all([ + verifyRecordButtonIcon(session1Page, { expectedIsRecording: false }), + verifyRecordButtonIcon(session2Page, { expectedIsRecording: false }), + ]); + + // Resume recording from session 2 (AGENT1) + await recordCallToggle(session2Page); + await session2Page.waitForTimeout(2000); + + // Verify record button icons changed back to pause icon on both sessions (when recording is active) + await Promise.all([ + verifyRecordButtonIcon(session1Page, { expectedIsRecording: true }), + verifyRecordButtonIcon(session2Page, { expectedIsRecording: true }), + ]); + + // End call from extension page + await endCallTask(extensionPage); + await session1Page.waitForTimeout(2000); + + // Submit wrapup from session 1 (AGENT1) + await submitWrapup(session1Page, WRAPUP_REASONS.RESOLVED); + await session1Page.waitForTimeout(2000); + + // Verify both AGENT1 sessions return to available state + await Promise.all([ + verifyCurrentState(session1Page, USER_STATES.AVAILABLE), + verifyCurrentState(session2Page, USER_STATES.AVAILABLE), + ]); + + } catch (error) { + throw new Error(`Multi-session call controls synchronization failed: ${error.message}`); + } + }); +}); \ No newline at end of file