diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index 4b4d8dc26..79e5d8e18 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -264,4 +264,4 @@ jobs: run: yarn run test:tooling - name: Test CC Widgets - run: yarn run test:cc-widgets + run: yarn run test:cc-widgets \ No newline at end of file diff --git a/package.json b/package.json index d8668e2c8..18b2c5ac7 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "jest": "29.7.0", "jest-canvas-mock": "^2.5.2", "node-gyp": "^10.2.0", + "nodemailer": "^7.0.3", "os-browserify": "^0.3.0", "process": "^0.11.10", "querystring-es3": "^0.2.1", 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 08d5aeb91..b8afefe27 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 @@ -295,7 +295,7 @@ function CallControlComponent(props: CallControlComponentProps) { button.className + (button.disabled || (consultInitiated && isTelephony) ? ` ${button.className}-disabled` : '') } - data-testid="ButtonCircle" + data-testid={button.id === 'end' ? 'call-control:end-call' : button.id} onPress={button.onClick} disabled={button.disabled || (consultInitiated && isTelephony)} aria-label={button.tooltip} @@ -332,6 +332,7 @@ function CallControlComponent(props: CallControlComponentProps) { postfix-icon="arrow-down-bold" type="button" role="button" + data-testid="call-control:wrapup-button" > {WRAP_UP} @@ -362,6 +363,7 @@ function CallControlComponent(props: CallControlComponentProps) { info-icon-aria-label="" name="" className="wrapup-select" + data-testid="call-control:wrapup-select" placeholder={SELECT} onChange={(event: CustomEvent) => { const key = event.detail.value; @@ -370,7 +372,11 @@ function CallControlComponent(props: CallControlComponentProps) { }} > {wrapupCodes?.map((code) => ( - ))} @@ -379,7 +385,7 @@ function CallControlComponent(props: CallControlComponentProps) { onClick={handleWrapupCall} variant="primary" className="submit-wrapup-button" - data-testid="submit-wrapup-button" + data-testid="call-control:wrapup-submit" aria-label="Submit wrap-up" disabled={selectedWrapupId && selectedWrapupReason ? false : true} > diff --git a/packages/contact-center/cc-components/src/components/task/Task/index.tsx b/packages/contact-center/cc-components/src/components/task/Task/index.tsx index f4c4c198b..7d9ed1014 100644 --- a/packages/contact-center/cc-components/src/components/task/Task/index.tsx +++ b/packages/contact-center/cc-components/src/components/task/Task/index.tsx @@ -155,12 +155,12 @@ const Task: React.FC = ({
{acceptText ? ( - + {acceptText} ) : null} {declineText ? ( - + {declineText} ) : null} diff --git a/playwright.config.ts b/playwright.config.ts index 6986eb2f1..7ebe6da5b 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,13 +1,14 @@ -import {defineConfig, devices} from '@playwright/test'; +import { defineConfig, devices } from '@playwright/test'; import dotenv from 'dotenv'; import path from 'path'; // Alternatively, read from "../my.env" file. -dotenv.config({path: path.resolve(__dirname, '.env')}); +dotenv.config({ path: path.resolve(__dirname, '.env') }); /** * See https://playwright.dev/docs/test-configuration. */ +const dummyAudioPath = path.resolve(__dirname, './playwright/wav/dummyAudio.wav'); export default defineConfig({ testDir: './playwright', /* Maximum time one test can run for. */ @@ -45,7 +46,22 @@ export default defineConfig({ }, { name: 'Test: Chrome', - use: {...devices['Desktop Chrome']}, + use: { + ...devices['Desktop Chrome'], + launchOptions: { + args: [ + `--disable-site-isolation-trials`, + `--disable-web-security`, + `--no-sandbox`, + `--disable-features=WebRtcHideLocalIpsWithMdns`, + `--allow-file-access-from-files`, + `--use-fake-ui-for-media-stream`, + `--use-fake-device-for-media-stream`, + `--use-file-for-fake-audio-capture=${dummyAudioPath}`, + ], + } + }, + }, // Once we have stability for playwright tests, we can enable the following browsers // { diff --git a/playwright/Utils/helperUtils.ts b/playwright/Utils/helperUtils.ts index 3b4abb7fa..918c36d98 100644 --- a/playwright/Utils/helperUtils.ts +++ b/playwright/Utils/helperUtils.ts @@ -1,3 +1,10 @@ +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 { submitWrapup } from './wrapupUtils'; +import { acceptExtensionCall, submitRonaPopup } from './incomingTaskUtils'; +import { loginViaAccessToken, disableMultiLogin, enableMultiLogin, initialiseWidgets, enableAllWidgets, } from './initUtils'; +import { stationLogout, telephonyLogin } from './stationLoginUtils'; /** * Parses a time string in MM:SS format and converts it to total seconds * @param timeString - Time string in format "MM:SS" (e.g., "01:30" for 1 minute 30 seconds) @@ -72,4 +79,385 @@ export async function waitForWebSocketReconnection(consoleMessages: string[], ti await new Promise((resolve) => setTimeout(resolve, 100)); } return false; -} \ No newline at end of file +} + +/** + * Waits for a specific user state to be reached in the UI + * @param page - Playwright Page object + * @param expectedState - The expected user state to wait for + * @returns Promise + * @throws Error if the expected state is not reached within the timeout + * @description Continuously checks the current user state until it matches the expected state or times out + * @example + * ```typescript + * await waitForState(page, USER_STATES.AVAILABLE); + * // Waits until the user state changes to 'Available' + * ``` + */ + +export const waitForState = async ( + page: Page, + expectedState: userState, +): Promise => { + const start = Date.now(); + const timeoutMs: number = 10000 + while (true) { + const currentState = await getCurrentState(page); + if (currentState === expectedState) return; + if (Date.now() - start > timeoutMs) { + throw new Error(`Timed out waiting for state "${expectedState}", last state was "${currentState}"`); + } + await page.waitForTimeout(500); // Poll every 500ms + } +}; + + +/** + * Retrieves the last state from captured logs + * @param capturedLogs - Array of log messages + * @returns Promise - The last state name found in the logs, or a message if not found + * @description Filters logs for state change messages and extracts the last state name + * @example + * ```typescript + * const lastState = await getLastStateFromLogs(capturedLogs); + * console.log(lastState); // Outputs the last state name or a message if not found + * ``` + */ + + +export async function getLastStateFromLogs(capturedLogs: string[]) { + const stateChangeLogs = capturedLogs.filter(log => + log.includes('onStateChange invoked with state name:') + ); + + if (stateChangeLogs.length === 0) { + return 'No state change logs found'; + } + + const lastStateLog = stateChangeLogs[stateChangeLogs.length - 1]; + const match = lastStateLog.match(/onStateChange invoked with state name:\s*(.+)$/); + + if (!match) { + return 'No State change log found'; + } + + return match[1].trim(); +} + + +/** + * Waits for a specific state to appear in the captured logs + * @param capturedLogs - Array of log messages + * @param expectedState - The expected state to wait for + * @param timeoutMs - Maximum time to wait for the state in milliseconds (default: 10000) + * uses the manual logs for that, such as "onStateChange invoked with state name: AVAILABLE" + * @returns Promise + * @throws Error if the expected state is not found within the timeout + * @description Continuously checks the last state in logs until it matches the expected state or times out + * @example + * ```typescript + * await waitForStateLogs(capturedLogs, AVAILABLE); + * // Waits until the last state in logs changes to 'Available' + * ``` + */ + +export const waitForStateLogs = async ( + capturedLogs: string[], + expectedState: userState, + timeoutMs: number = 10000 +): Promise => { + const start = Date.now(); + while (true) { + // Check if the latest state in logs matches expectedState + try { + const lastState = await getLastStateFromLogs(capturedLogs); + if (lastState === expectedState) return; + } catch { + // Ignore error if no state log yet + } + if (Date.now() - start > timeoutMs) { + throw new Error(`Timed out waiting for state "${expectedState}" in logs`); + } + await new Promise(res => setTimeout(res, 300)); // Poll every 300ms + } +}; + + +/** + * Waits for a specific wrapup reason to appear in the captured logs + * @param capturedLogs - Array of log messages + * @param expectedReason - The expected wrapup reason to wait for + * @param timeoutMs - Maximum time to wait for the wrapup reason in milliseconds (default: 10000) + * Uses the manual logs for that, such as "onWrapup invoked with reason : Sale" + * @returns Promise + * @throws Error if the expected wrapup reason is not found within the timeout + * @description Continuously checks the last wrapup reason in logs until it matches the expected reason or times out + * @example + * ```typescript + * await waitForWrapupReasonLogs(capturedLogs, WRAPUP_REASONS.SALE); + * ``` + */ + +export const waitForWrapupReasonLogs = async ( + capturedLogs: string[], + expectedReason: WrapupReason, + timeoutMs: number = 10000 +): Promise => { + const start = Date.now(); + while (true) { + try { + const lastReason = await getLastWrapupReasonFromLogs(capturedLogs); + if (lastReason === expectedReason) return; + } catch { + // Ignore error if no wrapup log yet + } + if (Date.now() - start > timeoutMs) { + throw new Error(`Timed out waiting for wrapup reason "${expectedReason}" in logs`); + } + await new Promise(res => setTimeout(res, 300)); // Poll every 300ms + } +}; + + +/** + * Retrieves the last wrapup reason from captured logs + * @param capturedLogs - Array of log messages + * @returns Promise - The last wrapup reason found in the logs, or a message if not found + * @description Filters logs for wrapup messages and extracts the last wrapup reason + * Uses the manual logs for that, such as "onWrapup invoked with reason : Sale" + * @example + * ```typescript + * const lastWrapupReason = await getLastWrapupReasonFromLogs(capturedLogs); + * console.log(lastWrapupReason); // Outputs the last wrapup reason or a message if not found + * ``` + */ + + +export async function getLastWrapupReasonFromLogs(capturedLogs: string[]): Promise { + const wrapupLogs = capturedLogs.filter(log => + log.includes('onWrapup invoked with reason :') + ); + + if (wrapupLogs.length === 0) { + return 'No wrapup reason found'; + } + + const lastWrapupLog = wrapupLogs[wrapupLogs.length - 1]; + const match = lastWrapupLog.match(/onWrapup invoked with reason : (.+)$/); + + if (!match) { + return 'No wrapup reason found'; + } + + return match[1].trim(); +} + + +/** + * Compares two RGB color strings to check if they are within a specified tolerance + * @param receivedColor - The color received from the UI (e.g., "rgb(255, 0, 0)") + * @param expectedColor - The expected color to compare against (e.g., "rgb(250, 5, 0)") + * @param tolerance - The maximum allowed difference for each RGB component (default: 10) + * @returns boolean - True if colors are close enough, false otherwise + * @description Compares each RGB component of the two colors and checks if the absolute difference is within the specified tolerance + * @example + * ```typescript + * const isClose = isColorClose("rgb(255, 0, 0)", "rgb(250, 5, 0)"); + * expect(isClose).toBe(true); // Returns true if the colors are close enough + * ``` + */ + +export function isColorClose( + receivedColor: string, + expectedColor: ThemeColor, + tolerance: number = 10 +): boolean { + const receivedRgb = receivedColor.match(/\d+/g)?.map(Number) || []; + const expectedRgb = expectedColor.match(/\d+/g)?.map(Number) || []; + + for (let i = 0; i < 3; i++) { + if ( + typeof receivedRgb[i] !== 'number' || + typeof expectedRgb[i] !== 'number' + ) { + continue; // skip if not present + } + if (Math.abs(receivedRgb[i] - expectedRgb[i]) > tolerance) { + return false; + } + } + return true; +} + + +/** + * Handles stray incoming tasks by accepting them and performing wrap-up actions, to be used for clean up before tests + * @param page - Playwright Page object + * @param extensionPage - Optional extension page for handling calls (default: null) + * @returns Promise + * @description Continuously checks for incoming tasks, accepts them, and performs wrap-up actions until no more tasks are available + * @example + * ```typescript + * await handleStrayTasks(page, extensionPage); + * ``` + */ + +export const handleStrayTasks = async (page: Page, extensionPage: Page | null = null): Promise => { + await page.waitForTimeout(1000); + const incomingTaskDiv = page.getByTestId(/^samples:incoming-task(-\w+)?$/); + + while (true) { + let flag1 = false; + let flag2 = true; + while (true) { + const task = incomingTaskDiv.first(); + let isTaskVisible = await task.isVisible().catch(() => false); + if (!isTaskVisible) break; + const acceptButton = task.getByTestId('task:accept-button').first(); + const acceptButtonVisible = await acceptButton.isVisible().catch(() => false); + const isExtensionCall = await (await task.innerText()).includes('Ringing...'); + if (isExtensionCall) { + const extensionCallVisible = await extensionPage.locator('[data-test="right-action-button"]').waitFor({ state: 'visible', timeout: 40000 }).then(() => true).catch(() => false); + if (extensionCallVisible) { + await acceptExtensionCall(extensionPage); + flag1 = true + } else { + throw new Error('Accept button not visible and extension page is not available'); + } + } else { + try { + await acceptButton.click({ timeout: 5000 }); + } catch (error) { } + flag1 = true; + } + await page.waitForTimeout(1000); + } + const endButton = page.getByTestId('call-control:end-call').first(); + const endButtonVisible = await endButton.waitFor({ state: 'visible', timeout: 2000 }).then(() => true).catch(() => false); + if (endButtonVisible) { + await page.waitForTimeout(2000); + await endButton.click({ timeout: 5000 }); + await submitWrapup(page, WRAPUP_REASONS.SALE); + } else { + const wrapupBox = page.getByTestId('call-control:wrapup-button').first(); + const isWrapupBoxVisible = await wrapupBox.waitFor({ state: 'visible', timeout: 2000 }).then(() => true).catch(() => false); + if (isWrapupBoxVisible) { + await page.waitForTimeout(2000); + await submitWrapup(page, WRAPUP_REASONS.SALE); + await page.waitForTimeout(2000) + } else { + flag2 = false; + } + } + + if (!flag1 && !flag2) { + break; + } + } + +} + +/* +/ * Sets up the page for testing by logging in, enabling widgets, and handling user states, cleaning up stray tasks, submitting RONA popups + * @param page - Playwright Page object + * @param loginMode - The login mode to use (e.g., LOGIN_MODE.DESKTOP or LOGIN_MODE.EXTENSION) + * @param agentName - Name of the agent to be logged in, example: 'AGENT1' + * @param extensionPage - Optional extension page for handling calls in extension mode (default: null) + * The extension Page should have the webex calling web-client logged in + * @returns Promise + * @description Logs in via access token, enables all widgets, handles multi-login settings, initializes widgets, and manages user states + * @example + * ```typescript + * await pageSetup(page, LOGIN_MODE.DESKTOP); + * ``` + */ + +export const pageSetup = async (page: Page, loginMode: LoginMode, agentName: string, extensionPage: Page | null = null) => { + const maxRetries = 3; + await loginViaAccessToken(page, agentName); + await enableAllWidgets(page); + if (loginMode === LOGIN_MODE.DESKTOP) { + await disableMultiLogin(page); + } else { + await enableMultiLogin(page); + } + + for (let i = 0; i < maxRetries; i++) { + try { + await initialiseWidgets(page); + break; + } catch (error) { + + if (i == maxRetries - 1) { + throw new Error(`Failed to initialise widgets after ${maxRetries} attempts: ${error}`); + } + await page.reload(); + await page.waitForTimeout(2000); // Wait for page to settle + } + } + + let loginButtonExists = await page + .getByTestId('login-button') + .isVisible() + .catch(() => false); + if (loginButtonExists) { + await telephonyLogin(page, loginMode); + } else { + const stateSelectVisible = await page.getByTestId('state-select').waitFor({ state: 'visible', timeout: 30000 }).then(() => true).catch(() => false); + if (stateSelectVisible) { + const ronapopupVisible = await page.getByTestId('samples:rona-popup').waitFor({ state: 'visible', timeout: 5000 }).then(() => true).catch(() => false); + if (ronapopupVisible) { + await submitRonaPopup(page, RONA_OPTIONS.AVAILABLE); + } + const userState = await getCurrentState(page); + await changeUserState(page, USER_STATES.AVAILABLE); + await page.waitForTimeout(5000); + + const incomingTaskDiv = page.getByTestId(/^samples:incoming-task(-\w+)?$/).first(); + await incomingTaskDiv.waitFor({ state: 'visible', timeout: 5000 }).catch(() => false); + await handleStrayTasks(page, extensionPage); + } + loginButtonExists = await page + .getByTestId('login-button') + .isVisible() + .catch(() => false); + + + if (!loginButtonExists) await stationLogout(page); + await telephonyLogin(page, loginMode); + } + + let ronapopupVisible = await page.getByTestId('samples:rona-popup').waitFor({ state: 'visible', timeout: 5000 }).then(() => true).catch(() => false); + if (ronapopupVisible) { + await submitRonaPopup(page, RONA_OPTIONS.AVAILABLE); + } + + let stationLoginFailure = await page.getByTestId('station-login-failure-label').waitFor({ state: 'visible', timeout: 5000 }).then(() => true).catch(() => false); + for (let i = 0; i < maxRetries && stationLoginFailure; i++) { + loginButtonExists = await page + .getByTestId('login-button') + .isVisible() + .catch(() => false); + if (!loginButtonExists) await stationLogout(page); + await telephonyLogin(page, loginMode); + await page.getByTestId('state-select').waitFor({ state: 'visible', timeout: 30000 }); + stationLoginFailure = await page.getByTestId('station-login-failure-label').waitFor({ state: 'visible', timeout: 5000 }).then(() => true).catch(() => false); + if (i == maxRetries - 1 && stationLoginFailure) { + throw new Error(`Station Login Error Persists after ${maxRetries} attempts`); + } + } + + await page.getByTestId('state-select').waitFor({ state: 'visible', timeout: 30000 }); + + ronapopupVisible = await page.getByTestId('samples:rona-popup').waitFor({ state: 'visible', timeout: 3000 }).then(() => true).catch(() => false); + if (ronapopupVisible) { + await submitRonaPopup(page, RONA_OPTIONS.AVAILABLE); + } + + await changeUserState(page, USER_STATES.AVAILABLE); + await page.waitForTimeout(3000); + const incomingTaskDiv = page.getByTestId(/^samples:incoming-task(-\w+)?$/).first(); + await incomingTaskDiv.waitFor({ state: 'visible', timeout: 5000 }).catch(() => false); + await handleStrayTasks(page, extensionPage); + +} diff --git a/playwright/Utils/incomingTaskUtils.ts b/playwright/Utils/incomingTaskUtils.ts new file mode 100644 index 000000000..2df9d59d1 --- /dev/null +++ b/playwright/Utils/incomingTaskUtils.ts @@ -0,0 +1,311 @@ +import { Page, expect } from '@playwright/test'; +import { CALL_URL, RONA_OPTIONS, RonaOption } from '../constants'; +import { TASK_TYPES, TaskType } from '../constants'; +import nodemailer from 'nodemailer'; + +const maxRetries = 3; + +const transporter = nodemailer.createTransport({ + service: 'gmail', // Make sure to use Secure Port for Gmail SMTP + auth: { + user: process.env.PW_SENDER_EMAIL, + pass: process.env.PW_APP_PASSWORD + }, +}); + +/** + * Utility functions for dealing with creating, ending, and handling tasks in tests + * Includes helpers for creating and ending call/chat/email tasks, handling extension calls, + * and interacting with RONA popups and login flows. + * + * @packageDocumentation + */ + +/** + * Creates a call task by dialing the provided number, in the webex calling web-client. + * Prerequisite: The calling webclient must be logged in. + * @param page Playwright Page object + * @param number Phone number to dial (defaults to PW_DIAL_NUMBER env variable) + */ +export async function createCallTask(page: Page, number: string = process.env.PW_DIAL_NUMBER) { + if (!number || number.trim() === '') { + throw new Error('Dial number is required'); + } + try{ + await expect(page).toHaveURL(/.*\.webex\.com\/calling.*/); + }catch(error){ + throw new Error('The Input Page should be logged into calling web-client.'); + } + await page.getByRole('textbox', { name: 'Dial' }).waitFor({ state: 'visible', timeout: 5000 }); + await page.getByRole('textbox', { name: 'Dial' }).fill(number); + await expect(page.locator('[data-test="calling-ui-keypad-control"]').getByRole('button', { name: 'Call' })).toBeVisible(); + await page.locator('[data-test="calling-ui-keypad-control"]').getByRole('button', { name: 'Call' }).click({ timeout: 5000 }); +} + +/** + * Ends the current ongoing call in webex calling webclient. + * Prerequisite: The calling webclient must be logged in. + * @param page Playwright Page object + */ +export async function endCallTask(page: Page) { + try{ + await expect(page).toHaveURL(/.*\.webex\.com\/calling.*/); + }catch(error){ + throw new Error('The Input Page should be logged into calling web-client.'); + } + await page.locator('[data-test="call-end"]').waitFor({ state: 'visible', timeout: 4000 }); + await page.locator('[data-test="call-end"]').click({ timeout: 5000 }); + await page.waitForTimeout(500); +} + +/** + * Creates a chat task by going to the chat client and submitting required info. + * Retries up to maxRetries on failure. + * @param page Playwright Page object + */ +export async function createChatTask(page: Page) { + for (let i = 0; i < maxRetries; i++) { + try { + await page.goto('https://widgets.webex.com/chat-client'); + await page.locator('iframe[name="Livechat launcher icon"]').contentFrame().getByRole('button', { name: 'Livechat Button - 0 unread' }).waitFor({ state: 'visible', timeout: 60000 }); + await page.locator('iframe[name="Livechat launcher icon"]').contentFrame().getByRole('button', { name: 'Livechat Button - 0 unread' }).click({ timeout: 5000 }); + await page.locator('iframe[name="Conversation Window"]').contentFrame().getByRole('button', { name: 'Hit Us Up!' }).waitFor({ state: 'visible', timeout: 20000 }); + await page.locator('iframe[name="Conversation Window"]').contentFrame().getByRole('button', { name: 'Hit Us Up!' }).click({ timeout: 5000 }); + await page.locator('iframe[name="Conversation Window"]').contentFrame().getByRole('textbox', { name: 'Namemust fill field' }).waitFor({ state: 'visible', timeout: 50000 }); + await page.locator('iframe[name="Conversation Window"]').contentFrame().getByRole('textbox', { name: 'Namemust fill field' }).click({ timeout: 5000 }); + await page.locator('iframe[name="Conversation Window"]').contentFrame().getByRole('textbox', { name: 'Namemust fill field' }).fill('Playwright Test'); + await page.locator('iframe[name="Conversation Window"]').contentFrame().getByRole('button', { name: 'Submit Name' }).waitFor({ state: 'visible', timeout: 5000 }); + await page.locator('iframe[name="Conversation Window"]').contentFrame().getByRole('button', { name: 'Submit Name' }).click({ timeout: 5000 }); + await page.waitForTimeout(200); + await expect(page.locator('iframe[name="Conversation Window"]').contentFrame().getByRole('textbox', { name: 'Email*' })).toBeVisible(); + await page.locator('iframe[name="Conversation Window"]').contentFrame().getByRole('textbox', { name: 'Email*' }).click({ timeout: 5000 }); + await page.locator('iframe[name="Conversation Window"]').contentFrame().getByRole('textbox', { name: 'Email*' }).fill('playwright@test.com'); + await expect(page.locator('iframe[name="Conversation Window"]').contentFrame().getByRole('button', { name: 'Submit Email' })).toBeVisible(); + await page.locator('iframe[name="Conversation Window"]').contentFrame().getByRole('button', { name: 'Submit Email' }).click({ timeout: 5000 }); + break; + } catch (error) { + if (i === maxRetries - 1) { + throw new Error(`Failed to load chat client after ${maxRetries} attempts: ${error}`); + } + } + } +}; + +/** + * Ends the current chat task by navigating the chat UI. + * The Input page should have the chat client with the chat open. + * @param page Playwright Page object + */ +export async function endChatTask(page: Page) { + await expect(page.locator('iframe[name="Conversation Window"]').contentFrame().getByRole('button', { name: 'Menu' })).toBeVisible(); + await page.locator('iframe[name="Conversation Window"]').contentFrame().getByRole('button', { name: 'Menu' }).click({ timeout: 5000 }); + await page.waitForTimeout(500); + await expect(page.locator('iframe[name="Conversation Window"]').contentFrame().getByText('End chat')).toBeVisible(); + await page.locator('iframe[name="Conversation Window"]').contentFrame().getByText('End chat').click({ timeout: 5000 }); + await page.waitForTimeout(500); + await expect(page.locator('iframe[name="Conversation Window"]').contentFrame().getByRole('button', { name: 'End', exact: true })).toBeVisible(); + await page.locator('iframe[name="Conversation Window"]').contentFrame().getByRole('button', { name: 'End', exact: true }).click({ timeout: 5000 }); + await page.waitForTimeout(1000); +}; + +/** + * Sends a test email to trigger an incoming email task. + * @throws Error if sending fails + */ +export async function createEmailTask() { + const from = process.env.PW_SENDER_EMAIL; + const to = process.env.PW_EMAIL_ENTRY_POINT; + const subject = `Playwright Test Email - ${new Date().toISOString()}`; + const text = '--This Email is generated due to playwright automation test for incoming Tasks---'; + + try { + const mailOptions = { + from, + to, + subject, + text, + }; + await transporter.sendMail(mailOptions); + } catch (error) { + throw new Error(`Failed to send email: ${error}`); + } +} + +/** + * Accepts an incoming task of the given type (call, chat, email, social). + * Expects the incoming task to be already there. + * @param page Playwright Page object + * @param type Task type (see TASK_TYPES) + * @throws Error if accept button is not found + */ +export async function acceptIncomingTask(page: Page, type: TaskType) { + let incomingTaskDiv; + if (type === TASK_TYPES.CALL) { + incomingTaskDiv = page.getByTestId('samples:incoming-task-telephony').first(); + const isExtensionCall = await (await incomingTaskDiv.innerText()).includes('Ringing...'); + if(isExtensionCall){ + throw new Error('This is an extension call, use acceptExtensionCall instead'); + } + } else if (type === TASK_TYPES.CHAT) { + incomingTaskDiv = page.getByTestId('samples:incoming-task-chat').first(); + } else if (type === TASK_TYPES.EMAIL) { + incomingTaskDiv = page.getByTestId('samples:incoming-task-email').first(); + } else if (type === TASK_TYPES.SOCIAL) { + incomingTaskDiv = page.locator('samples:incoming-task-social').first(); + } + incomingTaskDiv = incomingTaskDiv.first(); + await expect(incomingTaskDiv).toBeVisible(); + const acceptButton = incomingTaskDiv.getByTestId('task:accept-button').first(); + if (!(await acceptButton.isVisible())) { throw new Error('Accept button not found'); } + await acceptButton.click({ timeout: 5000 }); +} + +/** + * Declines an incoming task of the given type (call, chat, email, social). + * Expects the incoming task to be already there. + * @param page Playwright Page object + * @param type Task type (see TASK_TYPES) + * @throws Error if decline button is not found + */ +export async function declineIncomingTask(page: Page, type: TaskType) { + let incomingTaskDiv; + if (type === TASK_TYPES.CALL) { + incomingTaskDiv = page.getByTestId('samples:incoming-task-telephony').first(); + const isExtensionCall = await (await incomingTaskDiv.innerText()).includes('Ringing...'); + if(isExtensionCall){ + throw new Error('This is an extension call, use declineExtensionCall instead'); + } + } else if (type === TASK_TYPES.CHAT) { + incomingTaskDiv = page.getByTestId('samples:incoming-task-chat').first(); + } else if (type === TASK_TYPES.EMAIL) { + incomingTaskDiv = page.getByTestId('samples:incoming-task-email').first(); + } else if (type === TASK_TYPES.SOCIAL) { + incomingTaskDiv = page.locator('samples:incoming-task-social').first(); + } + incomingTaskDiv = await incomingTaskDiv.first(); + await expect(incomingTaskDiv).toBeVisible(); + const declineButton = incomingTaskDiv.getByTestId('task:decline-button').first(); + if (!(await declineButton.isVisible())) { throw new Error('Decline button not found'); } + await declineButton.click({ timeout: 5000 }); + await incomingTaskDiv.waitFor({ state: 'hidden', timeout: 10000 }); +} + +/** + * Accepts an incoming extension call by clicking the right action button + * Prerequisite: The calling webclient must be logged in, and an incoming call must be present. + * @param page Playwright Page object + */ +export async function acceptExtensionCall(page: Page) { + try{ + await expect(page).toHaveURL(/.*\.webex\.com\/calling.*/); + }catch(error){ + throw new Error('The Input Page should be logged into calling web-client.'); + } + await page.locator('[data-test="right-action-button"]').waitFor({ state: 'visible', timeout: 5000 }); + await page.locator('[data-test="right-action-button"]').click({ timeout: 5000 }); +} + +/** + * Declines an incoming extension call by clicking the left action button. + * @param page Playwright Page object + */ +export async function declineExtensionCall(page: Page) { + try{ + await expect(page).toHaveURL(/.*\.webex\.com\/calling.*/); + }catch(error){ + throw new Error('The Input Page should be logged into calling web-client.'); + } + await page.locator('[data-test="left-action-button"]').waitFor({ state: 'visible', timeout: 5000 }); + await page.locator('[data-test="left-action-button"]').click({ timeout: 5000 }); +} + +/** + * Ends an ongoing extension call in the webex calling web-client by clicking the end call button. + * @param page Playwright Page object + */ +export async function endExtensionCall(page: Page) { + try{ + await expect(page).toHaveURL(/.*\.webex\.com\/calling.*/); + }catch(error){ + throw new Error('The Input Page should be logged into calling web-client.'); + } + await page.locator('[data-test="end-call"]').waitFor({ state: 'visible', timeout: 5000 }); + await page.locator('[data-test="end-call"]').click({ timeout: 5000 }); + await page.waitForTimeout(500); +} + +/** + * Logs into the web client for webex calling using the provided email and password. + * Retries up to maxRetries on failure. + * @param page Playwright Page object + * @param email User email + * @param password User password + * @throws Error if login fails after maxRetries + */ +export async function loginExtension(page: Page, email: string, password: string) { + if (!email || !password) { + throw new Error('Email and password are required for loginExtension'); + } + + if (email.trim() === '' || password.trim() === '') { + throw new Error('Email and password cannot be empty strings for loginExtension'); + } + if (!CALL_URL) { + throw new Error('CALL_URL is not defined. Please check your constants file.'); + } + + for (let i = 0; i < maxRetries; i++) { + try { + await page.goto(CALL_URL); + break; + } catch (error) { + if (i === maxRetries - 1) { + throw new Error(`Failed to login via extension after ${maxRetries} attempts: ${error}`); + } + } + } + const isLoginPageVisible = await page.getByRole('textbox', { name: 'Email address (required)' }).waitFor({ state: 'visible', timeout: 30000 }).then(() => true).catch(() => false); + if (!isLoginPageVisible) { + await expect(page.getByRole('button', { name: 'Back to sign in' })).toBeVisible(); + await page.getByRole('button', { name: 'Back to sign in' }).click({ timeout: 5000 }); + await page.getByRole('button', { name: 'Sign in' }).waitFor({ state: 'visible', timeout: 10000 }); + await page.getByRole('button', { name: 'Sign in' }).click({ timeout: 5000 }); + } + await page.getByRole('textbox', { name: 'Email address (required)' }).waitFor({ state: 'visible', timeout: 20000 }); + await page.getByRole('textbox', { name: 'Email address (required)' }).fill(email); + await page.getByRole('textbox', { name: 'Email address (required)' }).press('Enter'); + await page.getByRole('textbox', { name: 'Password' }).waitFor({ state: 'visible', timeout: 20000 }); + await page.getByRole('textbox', { name: 'Password' }).fill(password); + await page.getByRole('textbox', { name: 'Password' }).press('Enter'); + await page.getByRole('textbox', { name: 'Dial' }).waitFor({ state: 'visible', timeout: 32000 }); + try { + await page.locator('[data-test="statusMessage"]').waitFor({ state: 'hidden', timeout: 30000 }); + } catch (e) { + throw new Error('Unable to Login to the webex calling web-client'); + } + +} + +/** + * Submits the RONA popup by selecting the given state and confirming. + * @param page Playwright Page object + * @param select State to select (e.g., 'Available', 'Idle') + * @throws Error if the RONA state selection is not provided + */ +export async function submitRonaPopup(page: Page, nextState : RonaOption) { + if (!nextState) { + throw new Error('RONA next state selection is required'); + } + await page.waitForTimeout(1000); + await page.getByTestId('samples:rona-popup').waitFor({ state: 'visible', timeout: 5000 }); + await page.waitForTimeout(1000); + await expect(page.getByTestId('samples:rona-select-state')).toBeVisible(); + await page.getByTestId('samples:rona-select-state').click({ timeout: 5000 }); + await page.waitForTimeout(1000); + await expect(page.getByTestId(`samples:rona-option-${nextState.toLowerCase()}`)).toBeVisible(); + await page.getByTestId(`samples:rona-option-${nextState.toLowerCase()}`).click({ timeout: 5000 }); + await page.waitForTimeout(1000); + await expect(page.getByTestId('samples:rona-button-confirm')).toBeVisible(); + await page.getByTestId('samples:rona-button-confirm').click({ timeout: 5000 }); + await page.waitForTimeout(1000); +} diff --git a/playwright/Utils/initUtils.ts b/playwright/Utils/initUtils.ts index 9b9c6bcd0..167c4bcf1 100644 --- a/playwright/Utils/initUtils.ts +++ b/playwright/Utils/initUtils.ts @@ -1,6 +1,6 @@ -import {Page, expect, BrowserContext} from '@playwright/test'; +import { Page, expect, BrowserContext } from '@playwright/test'; import dotenv from 'dotenv'; -import {BASE_URL} from '../constants'; +import { BASE_URL } from '../constants'; dotenv.config(); @@ -67,10 +67,10 @@ export const oauthLogin = async (page: Page, agentId: string): Promise => await page.locator('#select-base-triggerid').getByText('Access Token').click(); await page.getByTestId('samples:login_option_oauth').getByText('Login with Webex').click(); await page.getByTestId('samples:login_with_webex_button').click(); - await page.getByRole('textbox', {name: 'name@example.com'}).fill(username); - await page.getByRole('link', {name: 'Sign in'}).click(); - await page.getByRole('textbox', {name: 'Password'}).fill(password); - await page.getByRole('button', {name: 'Sign in'}).click(); + await page.getByRole('textbox', { name: 'name@example.com' }).fill(username); + await page.getByRole('link', { name: 'Sign in' }).click(); + await page.getByRole('textbox', { name: 'Password' }).fill(password); + await page.getByRole('button', { name: 'Sign in' }).click(); }; /** @@ -138,13 +138,15 @@ export const initialiseWidgets = async (page: Page): Promise => { await page.getByTestId('samples:init-widgets-button').click(); try { - await page.getByTestId('station-login-widget').waitFor({state: 'visible', timeout: 50000}); + await page.getByTestId('station-login-widget').waitFor({ state: 'visible', timeout: 30000 }); } catch (error) { // First attempt failed, try clicking init widgets button again + await page.reload(); + await page.waitForTimeout(2000); // Wait for page to settle await page.getByTestId('samples:init-widgets-button').click(); - + try { - await page.getByTestId('station-login-widget').waitFor({state: 'visible', timeout: 50000}); + await page.getByTestId('station-login-widget').waitFor({ state: 'visible', timeout: 30000 }); } catch (secondError) { // Second attempt also failed, throw error throw new Error('Station login widget failed to become visible after two initialization attempts (100 seconds total)'); diff --git a/playwright/Utils/stationLoginUtils.ts b/playwright/Utils/stationLoginUtils.ts index 67c2413d2..8a1cc54b0 100644 --- a/playwright/Utils/stationLoginUtils.ts +++ b/playwright/Utils/stationLoginUtils.ts @@ -1,4 +1,4 @@ -import {Page, expect} from '@playwright/test'; +import { Page, expect } from '@playwright/test'; import dotenv from 'dotenv'; import {LOGIN_MODE, LONG_WAIT} from '../constants'; @@ -113,7 +113,7 @@ export const stationLogout = async (page: Page): Promise => { //check if the station logout button is hidden after logouts const isLogoutButtonHidden = await page .getByTestId('samples:station-logout-button') - .waitFor({state: 'hidden'}) + .waitFor({ state: 'hidden', timeout: 30000 }) .then(() => true) .catch(() => false); if (!isLogoutButtonHidden) { diff --git a/playwright/Utils/wrapupUtils.ts b/playwright/Utils/wrapupUtils.ts new file mode 100644 index 000000000..481d4364d --- /dev/null +++ b/playwright/Utils/wrapupUtils.ts @@ -0,0 +1,37 @@ +import { Page, expect } from '@playwright/test'; +import { WrapupReason } from 'playwright/constants'; + +/** + * Submits the wrap-up popup for a task in the UI. + * + * @param page Playwright Page object + * @param reason The wrap-up reason to select (string, case-insensitive) + * @throws Error if the wrap-up reason is not found or not provided + */ +export async function submitWrapup(page: Page, reason: WrapupReason): Promise { + if (!reason || reason.trim() === '') { + throw new Error('Wrapup reason is required'); + } + const wrapupBox = page.getByTestId('call-control:wrapup-button').first(); + const isWrapupBoxVisible = await wrapupBox.waitFor({ state: 'visible', timeout: 10000 }).then(() => true).catch(() => false); + if (!isWrapupBoxVisible) throw new Error('Wrapup box is not visible'); + await wrapupBox.click({ timeout: 5000 }); + await page.waitForTimeout(1000); + await expect(page.getByTestId('call-control:wrapup-select').first()).toBeVisible(); + await page.getByTestId('call-control:wrapup-select').first().click({ timeout: 5000 }); + await page.waitForTimeout(1000); + const optionLocator = page.getByTestId(`call-control:wrapup-reason-${reason.toLowerCase()}`).filter({ hasText: reason.toString() }); + try { + await expect(optionLocator.first()).toBeVisible(); + } catch (error) { + await page.waitForTimeout(1000); + await expect(page.getByTestId('call-control:wrapup-select').first()).toBeVisible(); + await page.getByTestId('call-control:wrapup-select').first().click({ timeout: 5000 }); + } + await expect(optionLocator.first()).toBeVisible(); + await optionLocator.first().click({ timeout: 5000 }); + await page.waitForTimeout(1000); + await expect(page.getByTestId(`call-control:wrapup-submit`).first()).toBeVisible(); + await page.getByTestId(`call-control:wrapup-submit`).first().click({ timeout: 5000 }); + await page.waitForTimeout(1000); +} diff --git a/playwright/constants.ts b/playwright/constants.ts index 7a29d6577..6c9a4c9af 100644 --- a/playwright/constants.ts +++ b/playwright/constants.ts @@ -4,17 +4,54 @@ export const USER_STATES = { MEETING: 'Meeting', AVAILABLE: 'Available', LUNCH: 'Lunch Break', + RONA: 'RONA', + ENGAGED: 'Engaged', }; +export type userState = typeof USER_STATES[keyof typeof USER_STATES]; + export const THEME_COLORS = { AVAILABLE: 'rgb(206, 245, 235)', MEETING: 'rgba(0, 0, 0, 0.11)', + ENGAGED: 'rgb(255, 235, 194)', + RONA: 'rgb(250, 233, 234)' }; +export type ThemeColor = typeof THEME_COLORS[keyof typeof THEME_COLORS]; + export const LOGIN_MODE = { DESKTOP: 'Desktop', EXTENSION: 'Extension', DIAL_NUMBER: 'Dial Number', }; +export type LoginMode = typeof LOGIN_MODE[keyof typeof LOGIN_MODE]; + export const LONG_WAIT = 40000; + +export const CALL_URL = 'https://web.webex.com/calling?calling'; + +export const TASK_TYPES = { + CALL: 'Call', + CHAT: 'Chat', + EMAIL: 'Email', + SOCIAL: 'Social' +} + +export type TaskType = typeof TASK_TYPES[keyof typeof TASK_TYPES]; + +export const CHAT_URL = 'https://widgets.webex.com/chat-client'; + +export const WRAPUP_REASONS = { + SALE: 'Sale', + RESOLVED: 'Resolved', +} + +export type WrapupReason = typeof WRAPUP_REASONS[keyof typeof WRAPUP_REASONS]; + +export const RONA_OPTIONS = { + AVAILABLE: 'Available', + IDLE: 'Idle', +} + +export type RonaOption = typeof RONA_OPTIONS[keyof typeof RONA_OPTIONS]; diff --git a/playwright/incoming-task-test.spec.ts b/playwright/incoming-task-test.spec.ts new file mode 100644 index 000000000..ee5ebd9e4 --- /dev/null +++ b/playwright/incoming-task-test.spec.ts @@ -0,0 +1,1043 @@ +import { test, Page, expect, BrowserContext } from '@playwright/test'; +import { changeUserState, verifyCurrentState } from './Utils/userStateUtils'; +import { createCallTask, createChatTask, declineExtensionCall, declineIncomingTask, endCallTask, endChatTask, loginExtension, acceptIncomingTask, acceptExtensionCall, createEmailTask, endExtensionCall, submitRonaPopup } from './Utils/incomingTaskUtils'; +import { TASK_TYPES, USER_STATES, LOGIN_MODE, THEME_COLORS, WRAPUP_REASONS, RONA_OPTIONS } from './constants'; +import { submitWrapup } from './Utils/wrapupUtils'; +import { waitForState, waitForStateLogs, getLastStateFromLogs, waitForWrapupReasonLogs, getLastWrapupReasonFromLogs, isColorClose, pageSetup } from './Utils/helperUtils'; + + +let page: Page | null = null; +let context: BrowserContext | null = null; +let callerpage: Page | null = null; +let extensionPage: Page | null = null; +let context2: BrowserContext | null = null; +let chatPage: Page | null = null; +let page2: Page | null = null; +let capturedLogs: string[] = []; +const maxRetries = 3; + + +//NOTE : Make Sure to set RONA Timeout to 18 seconds before running this test. + +/** + * Verifies the captured logs for wrapup and state change events + * @param capturedLogs - Array of log messages + * @param expectedWrapupReason - The expected wrapup reason to verify + * @param expectedState - The expected state name to verify + * @param shouldWrapupComeFirst - Whether the wrapup log should come before the state change log (default: true) + * @returns Promise - True if verification is successful, otherwise throws an error + * @throws Error if logs do not match expected values or order + * @description Checks the last wrapup reason and state name in logs against expected values, ensuring correct order if specified + * @example + * ```typescript + * await verifyCallbackLogs(capturedLogs, WRAPUP_REASONS.SALE, USER_STATES.AVAILABLE); + * ``` + */ + +export async function verifyCallbackLogs( + capturedLogs: string[], + expectedWrapupReason: string, + expectedState: string, + shouldWrapupComeFirst: boolean = true +): Promise { + const wrapupLogs = capturedLogs.filter(log => + log.includes('onWrapup invoked with reason :') + ); + const stateChangeLogs = capturedLogs.filter(log => + log.includes('onStateChange invoked with state name:') + ); + + if (wrapupLogs.length === 0 || stateChangeLogs.length === 0) { + throw new Error('Missing required logs, check callbacks for wrapup or statechange'); + } + + const lastWrapupLog = wrapupLogs[wrapupLogs.length - 1]; + const lastStateChangeLog = stateChangeLogs[stateChangeLogs.length - 1]; + + const wrapupLogIndex = capturedLogs.lastIndexOf(lastWrapupLog); + const stateChangeLogIndex = capturedLogs.lastIndexOf(lastStateChangeLog); + + if (shouldWrapupComeFirst && wrapupLogIndex >= stateChangeLogIndex) { + throw new Error('Wrapup log should come before state change log'); + } + + const wrapupMatch = lastWrapupLog.match(/onWrapup invoked with reason : (.+)$/); + const stateMatch = lastStateChangeLog.match(/onStateChange invoked with state name:\s*(.+)$/); + + if (!wrapupMatch || !stateMatch) { + throw new Error('Could not extract values from logs'); + } + + const actualWrapupReason = wrapupMatch[1].trim(); + const actualStateName = stateMatch[1].trim(); + + // Verify expected values + if (actualWrapupReason !== expectedWrapupReason) { + throw new Error('Wrapup reason mismatch, expected ' + expectedWrapupReason + ', got ' + actualWrapupReason); + } + + if (actualStateName !== expectedState) { + throw new Error('State name mismatch, expected ' + expectedState + ', got ' + actualStateName); + } + + return true; +} + +function setupConsoleLogging(page: Page): () => void { + capturedLogs.length = 0; + + const consoleHandler = (msg) => { + const logText = msg.text(); + if (logText.includes('onStateChange invoked with state name:') || + logText.includes('onWrapup invoked with reason :')) { + capturedLogs.push(logText); + } + }; + + page.on('console', consoleHandler); + + return () => page.off('console', consoleHandler); +} + + + + +test.describe('Incoming Call Task Tests for Desktop Mode', () => { + test.beforeEach(() => { + capturedLogs.length = 0; + }) + + test.beforeAll(async ({ browser }) => { + context = await browser.newContext(); + context2 = await browser.newContext(); + page = await context.newPage(); + callerpage = await context2.newPage(); + + setupConsoleLogging(page); + + await Promise.all([ + (async () => { + await loginExtension(callerpage, process.env.PW_AGENT2_USERNAME, process.env.PW_PASSWORD); + })(), + (async () => { + await pageSetup(page, LOGIN_MODE.DESKTOP, 'AGENT1'); + })(), + ]) + }) + + + test('should accept incoming call, end call and complete wrapup in desktop mode', async () => { + await createCallTask(callerpage); + await changeUserState(page, USER_STATES.AVAILABLE); + const incomingTaskDiv = page.getByTestId('samples:incoming-task-telephony').first(); + await incomingTaskDiv.waitFor({ state: 'visible', timeout: 40000 }); + await page.waitForTimeout(3000); + await acceptIncomingTask(page, TASK_TYPES.CALL); + await waitForState(page, USER_STATES.ENGAGED); + await verifyCurrentState(page, USER_STATES.ENGAGED); + await page.waitForTimeout(3000); + const userStateElement = page.getByTestId('state-select'); + const userStateElementColor = await userStateElement.evaluate((el) => getComputedStyle(el).backgroundColor); + expect(isColorClose(userStateElementColor, THEME_COLORS.ENGAGED)).toBe(true); + await waitForStateLogs(capturedLogs, USER_STATES.ENGAGED); + expect(await getLastStateFromLogs(capturedLogs)).toBe(USER_STATES.ENGAGED); + await page.getByTestId('call-control:end-call').first().click({ timeout: 5000 }); + await page.waitForTimeout(2000); + await submitWrapup(page, WRAPUP_REASONS.SALE); + await waitForState(page, USER_STATES.AVAILABLE); + await page.waitForTimeout(3000); + await waitForStateLogs(capturedLogs, USER_STATES.AVAILABLE); + expect(await getLastStateFromLogs(capturedLogs)).toBe(USER_STATES.AVAILABLE); + await waitForWrapupReasonLogs(capturedLogs, WRAPUP_REASONS.SALE); + expect(await getLastWrapupReasonFromLogs(capturedLogs)).toBe(WRAPUP_REASONS.SALE); + expect(await verifyCallbackLogs(capturedLogs, WRAPUP_REASONS.SALE, USER_STATES.AVAILABLE)).toBe(true); + }); + + test('should decline incoming call and verify RONA state in desktop mode', async () => { + await changeUserState(page, USER_STATES.AVAILABLE); + await createCallTask(callerpage); + const incomingTaskDiv = page.getByTestId('samples:incoming-task-telephony').first(); + await incomingTaskDiv.waitFor({ state: 'visible', timeout: 40000 }); + await page.waitForTimeout(3000); + await declineIncomingTask(page, TASK_TYPES.CALL); + await page.getByTestId('samples:rona-popup').waitFor({ state: 'visible', timeout: 15000 }); + await waitForState(page, USER_STATES.RONA); + await verifyCurrentState(page, USER_STATES.RONA); + await page.waitForTimeout(3000); + const userStateElement = page.getByTestId('state-select'); + const userStateElementColor = await userStateElement.evaluate((el) => getComputedStyle(el).backgroundColor); + expect(isColorClose(userStateElementColor, THEME_COLORS.RONA)).toBe(true); + await endCallTask(callerpage); + await submitRonaPopup(page, RONA_OPTIONS.IDLE); + await waitForState(page, USER_STATES.MEETING); + }); + + test('should ignore incoming call and wait for RONA popup in desktop mode', async () => { + await page.waitForTimeout(2000); + await changeUserState(page, USER_STATES.AVAILABLE); + await createCallTask(callerpage); + const incomingTaskDiv = page.getByTestId('samples:incoming-task-telephony').first(); + await incomingTaskDiv.waitFor({ state: 'visible', timeout: 40000 }); + await incomingTaskDiv.waitFor({ state: 'hidden', timeout: 30000 }); + await page.getByTestId('samples:rona-popup').waitFor({ state: 'visible', timeout: 15000 }); + await expect(page.getByTestId('samples:rona-popup')).toBeVisible(); + await endCallTask(callerpage); + await submitRonaPopup(page, RONA_OPTIONS.IDLE); + await waitForState(page, USER_STATES.MEETING); + }); + + test('should set agent state to Available and receive another call in desktop mode', async () => { + await page.waitForTimeout(2000); + await changeUserState(page, USER_STATES.AVAILABLE); + await createCallTask(callerpage); + let incomingTaskDiv = page.getByTestId('samples:incoming-task-telephony').first(); + await incomingTaskDiv.waitFor({ state: 'visible', timeout: 40000 }); + await page.waitForTimeout(3000); + await declineIncomingTask(page, TASK_TYPES.CALL); + await page.getByTestId('samples:rona-popup').waitFor({ state: 'visible', timeout: 15000 }); + await waitForState(page, USER_STATES.RONA); + await verifyCurrentState(page, USER_STATES.RONA); + await submitRonaPopup(page, RONA_OPTIONS.AVAILABLE); + await expect(page.getByTestId('samples:rona-popup')).not.toBeVisible(); + await page.waitForTimeout(5000); + await verifyCurrentState(page, USER_STATES.AVAILABLE); + incomingTaskDiv = page.getByTestId('samples:incoming-task-telephony').first(); + await incomingTaskDiv.waitFor({ state: 'visible', timeout: 10000 }); + await expect(incomingTaskDiv).toBeVisible(); + await page.waitForTimeout(3000); + await declineIncomingTask(page, TASK_TYPES.CALL); + await page.getByTestId('samples:rona-popup').waitFor({ state: 'visible', timeout: 15000 }); + await expect(page.getByTestId('samples:rona-popup')).toBeVisible(); + await endCallTask(callerpage); + await submitRonaPopup(page, RONA_OPTIONS.IDLE); + await waitForState(page, USER_STATES.MEETING); + }); + + + + test('should set agent state to busy after declining call in desktop mode', async () => { + await page.waitForTimeout(2000); + await createCallTask(callerpage); + await changeUserState(page, USER_STATES.AVAILABLE); + let incomingTaskDiv = page.getByTestId('samples:incoming-task-telephony').first(); + await incomingTaskDiv.waitFor({ state: 'visible', timeout: 40000 }); + await page.waitForTimeout(3000); + await declineIncomingTask(page, TASK_TYPES.CALL); + await page.getByTestId('samples:rona-popup').waitFor({ state: 'visible', timeout: 15000 }); + await waitForState(page, USER_STATES.RONA); + await verifyCurrentState(page, USER_STATES.RONA); + await submitRonaPopup(page, RONA_OPTIONS.IDLE); + await waitForState(page, USER_STATES.MEETING); + await expect(page.getByTestId('samples:rona-popup')).not.toBeVisible(); + await waitForState(page, USER_STATES.MEETING); + await verifyCurrentState(page, USER_STATES.MEETING); + incomingTaskDiv = page.getByTestId('samples:incoming-task-telephony').first(); + await expect(incomingTaskDiv).toBeHidden(); + await endCallTask(callerpage); + await page.waitForTimeout(2000); + }); + + test('should handle customer disconnect before agent answers in desktop mode', async () => { + await changeUserState(page, USER_STATES.AVAILABLE); + await createCallTask(callerpage); + const incomingTaskDiv = page.getByTestId('samples:incoming-task-telephony').first(); + await incomingTaskDiv.waitFor({ state: 'visible', timeout: 40000 }); + await endCallTask(callerpage); + await incomingTaskDiv.waitFor({ state: 'hidden', timeout: 30000 }); + await expect(incomingTaskDiv).toBeHidden(); + await waitForState(page, USER_STATES.AVAILABLE); + await verifyCurrentState(page, USER_STATES.AVAILABLE); + }); + + test.afterAll(async () => { + if (page) { + await page.close(); + page = null; + } + if (callerpage) { + await callerpage.close(); + callerpage = null; + } + + if (context) { + await context.close(); + context = null; + } + + if (context2) { + await context2.close(); + context2 = null; + } + }) + +}); + +test.describe('Incoming Task Tests in Extension Mode', () => { + test.beforeEach(() => { + capturedLogs.length = 0; + }) + + + test.beforeAll(async ({ browser }) => { + context = await browser.newContext(); + context2 = await browser.newContext(); + page = await context.newPage(); + chatPage = await context.newPage(); + extensionPage = await context.newPage(); + callerpage = await context2.newPage(); + setupConsoleLogging(page); + + 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.EXTENSION, 'AGENT1', extensionPage); + })(), + (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}`); + } + } + } + })() + ]) + }) + + test('should accept incoming call, end call and complete wrapup in extension mode', async () => { + await page.waitForTimeout(2000); + await createCallTask(callerpage); + await changeUserState(page, USER_STATES.AVAILABLE); + const incomingTaskDiv = page.getByTestId('samples:incoming-task-telephony').first(); + await incomingTaskDiv.waitFor({ state: 'visible', timeout: 40000 }); + await extensionPage.locator('[data-test="generic-person-item-base"]').waitFor({ state: 'visible', timeout: 20000 }); + await page.waitForTimeout(3000); + await acceptExtensionCall(extensionPage); + await waitForState(page, USER_STATES.ENGAGED); + await verifyCurrentState(page, USER_STATES.ENGAGED); + await page.waitForTimeout(3000); + const userStateElement = page.getByTestId('state-select'); + const userStateElementColor = await userStateElement.evaluate((el) => getComputedStyle(el).backgroundColor); + expect(isColorClose(userStateElementColor, THEME_COLORS.ENGAGED)).toBe(true); + await waitForStateLogs(capturedLogs, USER_STATES.ENGAGED); + expect(await getLastStateFromLogs(capturedLogs)).toBe(USER_STATES.ENGAGED); + await endCallTask(extensionPage); + await page.waitForTimeout(5000); + await submitWrapup(page, WRAPUP_REASONS.SALE); + await waitForState(page, USER_STATES.AVAILABLE); + await waitForStateLogs(capturedLogs, USER_STATES.AVAILABLE); + expect(await getLastStateFromLogs(capturedLogs)).toBe(USER_STATES.AVAILABLE); + await waitForWrapupReasonLogs(capturedLogs, WRAPUP_REASONS.SALE); + expect(await getLastWrapupReasonFromLogs(capturedLogs)).toBe(WRAPUP_REASONS.SALE); + expect(await verifyCallbackLogs(capturedLogs, WRAPUP_REASONS.SALE, USER_STATES.AVAILABLE)).toBe(true); + await page.waitForTimeout(10000); + }); + + + test('should decline incoming call and verify RONA state in extension mode', async () => { + await createCallTask(callerpage); + await changeUserState(page, USER_STATES.AVAILABLE); + const incomingTaskDiv = page.getByTestId('samples:incoming-task-telephony').first(); + await incomingTaskDiv.waitFor({ state: 'visible', timeout: 40000 }); + await extensionPage.locator('[data-test="generic-person-item-base"]').waitFor({ state: 'visible', timeout: 20000 }); + await page.waitForTimeout(5000); + await declineExtensionCall(extensionPage); + await extensionPage.locator('[data-test="generic-person-item-base"]').first().waitFor({ state: 'hidden', timeout: 5000 }); + await page.getByTestId('samples:rona-popup').waitFor({ state: 'visible', timeout: 15000 }); + await waitForState(page, USER_STATES.RONA); + await verifyCurrentState(page, USER_STATES.RONA); + await endCallTask(callerpage); + await page.waitForTimeout(3000); + const userStateElement = page.getByTestId('state-select'); + const userStateElementColor = await userStateElement.evaluate((el) => getComputedStyle(el).backgroundColor); + expect(isColorClose(userStateElementColor, THEME_COLORS.RONA)).toBe(true); + await waitForStateLogs(capturedLogs, USER_STATES.RONA); + expect(await getLastStateFromLogs(capturedLogs)).toBe(USER_STATES.RONA); + await submitRonaPopup(page, RONA_OPTIONS.IDLE); + await page.waitForTimeout(10000); + }); + + test('should ignore incoming call and wait for RONA popup in extension mode', async () => { + await createCallTask(callerpage); + await changeUserState(page, USER_STATES.AVAILABLE); + const incomingTaskDiv = page.getByTestId('samples:incoming-task-telephony').first(); + await incomingTaskDiv.waitFor({ state: 'visible', timeout: 40000 }); + await extensionPage.locator('[data-test="generic-person-item-base"]').first().waitFor({ state: 'visible', timeout: 20000 }); + await incomingTaskDiv.waitFor({ state: 'hidden', timeout: 30000 }); + await extensionPage.locator('[data-test="generic-person-item-base"]').first().waitFor({ state: 'hidden', timeout: 10000 }); + await page.getByTestId('samples:rona-popup').waitFor({ state: 'visible', timeout: 15000 }); + await expect(page.getByTestId('samples:rona-popup')).toBeVisible(); + await endCallTask(callerpage); + await waitForState(page, USER_STATES.RONA); + await verifyCurrentState(page, USER_STATES.RONA); + await waitForStateLogs(capturedLogs, USER_STATES.RONA); + expect(await getLastStateFromLogs(capturedLogs)).toBe(USER_STATES.RONA); + await submitRonaPopup(page, RONA_OPTIONS.IDLE); + await page.waitForTimeout(10000); + }); + + + test('should set agent state to Available and receive another call in extension mode', async () => { + await createCallTask(callerpage); + await changeUserState(page, USER_STATES.AVAILABLE); + const incomingTaskDiv = page.getByTestId('samples:incoming-task-telephony').first(); + await incomingTaskDiv.waitFor({ state: 'visible', timeout: 40000 }); + await extensionPage.locator('[data-test="generic-person-item-base"]').waitFor({ state: 'visible', timeout: 20000 }); + await page.waitForTimeout(5000); + await declineExtensionCall(extensionPage); + await page.getByTestId('samples:rona-popup').waitFor({ state: 'visible', timeout: 15000 }); + await expect(page.getByTestId('samples:rona-popup')).toBeVisible(); + await waitForState(page, USER_STATES.RONA); + await verifyCurrentState(page, USER_STATES.RONA); + await waitForStateLogs(capturedLogs, USER_STATES.RONA); + expect(await getLastStateFromLogs(capturedLogs)).toBe(USER_STATES.RONA); + await submitRonaPopup(page, RONA_OPTIONS.AVAILABLE); + await expect(page.getByTestId('samples:rona-popup')).not.toBeVisible(); + await waitForState(page, USER_STATES.AVAILABLE); + await verifyCurrentState(page, USER_STATES.AVAILABLE); + await incomingTaskDiv.waitFor({ state: 'visible', timeout: 10000 }); + await expect(incomingTaskDiv).toBeVisible(); + await endCallTask(callerpage); + await page.waitForTimeout(8000); + }); + + + test('should set agent state to busy after declining call in extension mode', async () => { + await createCallTask(callerpage); + await changeUserState(page, USER_STATES.AVAILABLE); + const incomingTaskDiv = page.getByTestId('samples:incoming-task-telephony').first(); + await incomingTaskDiv.waitFor({ state: 'visible', timeout: 40000 }); + await extensionPage.locator('[data-test="generic-person-item-base"]').first().waitFor({ state: 'visible', timeout: 20000 }); + await page.waitForTimeout(5000); + await declineExtensionCall(extensionPage); + await page.getByTestId('samples:rona-popup').waitFor({ state: 'visible', timeout: 15000 }); + await expect(page.getByTestId('samples:rona-popup')).toBeVisible(); + await waitForState(page, USER_STATES.RONA); + await verifyCurrentState(page, USER_STATES.RONA); + await waitForStateLogs(capturedLogs, USER_STATES.RONA); + expect(await getLastStateFromLogs(capturedLogs)).toBe(USER_STATES.RONA); + await submitRonaPopup(page, RONA_OPTIONS.IDLE); + await waitForState(page, USER_STATES.MEETING); + await expect(page.getByTestId('samples:rona-popup')).not.toBeVisible(); + await expect(incomingTaskDiv).toBeHidden(); + await expect(extensionPage.locator('[data-test="generic-person-item-base"]').first()).toBeHidden(); + await verifyCurrentState(page, USER_STATES.MEETING); + await endCallTask(callerpage); + await page.waitForTimeout(10000); + }); + + + + test('should handle call disconnect before agent answers in extension mode', async () => { + await createCallTask(callerpage); + await changeUserState(page, USER_STATES.AVAILABLE); + const incomingTaskDiv = page.getByTestId('samples:incoming-task-telephony').first(); + await incomingTaskDiv.waitFor({ state: 'visible', timeout: 40000 }); + await page.waitForTimeout(5000); + await endCallTask(callerpage); + await page.waitForTimeout(5000); + await incomingTaskDiv.waitFor({ state: 'hidden', timeout: 20000 }); + await expect(incomingTaskDiv).toBeHidden(); + await waitForState(page, USER_STATES.AVAILABLE); + await verifyCurrentState(page, USER_STATES.AVAILABLE); + }); + + + test('should ignore incoming chat task and wait for RONA popup', async () => { + await createChatTask(chatPage); + await changeUserState(page, USER_STATES.AVAILABLE); + const incomingTaskDiv = page.getByTestId('samples:incoming-task-chat').first(); + await incomingTaskDiv.waitFor({ state: 'visible', timeout: 60000 }); + await incomingTaskDiv.waitFor({ state: 'hidden', timeout: 20000 }); + await expect(incomingTaskDiv).toBeHidden(); + await page.getByTestId('samples:rona-popup').waitFor({ state: 'visible', timeout: 15000 }); + await expect(page.getByTestId('samples:rona-popup')).toBeVisible(); + await verifyCurrentState(page, USER_STATES.RONA); + await waitForStateLogs(capturedLogs, USER_STATES.RONA); + expect(await getLastStateFromLogs(capturedLogs)).toBe(USER_STATES.RONA); + await page.waitForTimeout(3000); + const userStateElement = page.getByTestId('state-select'); + const userStateElementColor = await userStateElement.evaluate((el) => getComputedStyle(el).backgroundColor); + expect(isColorClose(userStateElementColor, THEME_COLORS.RONA)).toBe(true); + await submitRonaPopup(page, RONA_OPTIONS.IDLE); + await waitForState(page, USER_STATES.MEETING); + await endChatTask(chatPage); + }); + + test('should set agent to Available and verify chat task behavior', async () => { + await page.waitForTimeout(2000); + await createChatTask(chatPage); + await changeUserState(page, USER_STATES.AVAILABLE); + const incomingTaskDiv = page.getByTestId('samples:incoming-task-chat').first(); + await incomingTaskDiv.waitFor({ state: 'visible', timeout: 60000 }); + await incomingTaskDiv.waitFor({ state: 'hidden', timeout: 30000 }); + await expect(incomingTaskDiv).toBeHidden(); + await page.getByTestId('samples:rona-popup').waitFor({ state: 'visible', timeout: 15000 }); + await expect(page.getByTestId('samples:rona-popup')).toBeVisible(); + await waitForState(page, USER_STATES.RONA); + await verifyCurrentState(page, USER_STATES.RONA); + await waitForStateLogs(capturedLogs, USER_STATES.RONA); + expect(await getLastStateFromLogs(capturedLogs)).toBe(USER_STATES.RONA); + await submitRonaPopup(page, RONA_OPTIONS.AVAILABLE); + await waitForState(page, USER_STATES.AVAILABLE); + await expect(page.getByTestId('samples:rona-popup')).not.toBeVisible(); + await verifyCurrentState(page, USER_STATES.AVAILABLE); + await incomingTaskDiv.waitFor({ state: 'visible', timeout: 10000 }); + await expect(incomingTaskDiv).toBeVisible(); + await incomingTaskDiv.waitFor({ state: 'hidden', timeout: 30000 }); + await page.getByTestId('samples:rona-popup').waitFor({ state: 'visible', timeout: 15000 }); + await submitRonaPopup(page, RONA_OPTIONS.IDLE); + await waitForState(page, USER_STATES.MEETING); + await endChatTask(chatPage); + }); + + test('should set agent state to busy after ignoring chat task', async () => { + await page.waitForTimeout(2000); + await createChatTask(chatPage); + await changeUserState(page, USER_STATES.AVAILABLE); + const incomingTaskDiv = page.getByTestId('samples:incoming-task-chat').first(); + await incomingTaskDiv.waitFor({ state: 'visible', timeout: 60000 }); + await incomingTaskDiv.waitFor({ state: 'hidden', timeout: 30000 }); + await expect(incomingTaskDiv).toBeHidden(); + await page.getByTestId('samples:rona-popup').waitFor({ state: 'visible', timeout: 15000 }); + await expect(page.getByTestId('samples:rona-popup')).toBeVisible(); + await verifyCurrentState(page, USER_STATES.RONA); + await waitForStateLogs(capturedLogs, USER_STATES.RONA); + expect(await getLastStateFromLogs(capturedLogs)).toBe(USER_STATES.RONA); + await submitRonaPopup(page, RONA_OPTIONS.IDLE); + await waitForState(page, USER_STATES.MEETING); + await expect(page.getByTestId('samples:rona-popup')).not.toBeVisible(); + await page.waitForTimeout(3000); + await verifyCurrentState(page, USER_STATES.MEETING); + await endChatTask(chatPage); + }); + + test('should accept incoming chat, end chat and complete wrapup with callback verification', async () => { + await createChatTask(chatPage); + await page.waitForTimeout(2000); + await changeUserState(page, USER_STATES.AVAILABLE); + const incomingTaskDiv = page.getByTestId('samples:incoming-task-chat').first(); + await incomingTaskDiv.waitFor({ state: 'visible', timeout: 60000 }); + await acceptIncomingTask(page, TASK_TYPES.CHAT); + await waitForState(page, USER_STATES.ENGAGED); + await verifyCurrentState(page, USER_STATES.ENGAGED); + await page.waitForTimeout(3000); + const userStateElement = page.getByTestId('state-select'); + const userStateElementColor = await userStateElement.evaluate((el) => getComputedStyle(el).backgroundColor); + expect(isColorClose(userStateElementColor, THEME_COLORS.ENGAGED)).toBe(true); + await waitForStateLogs(capturedLogs, USER_STATES.ENGAGED); + expect(await getLastStateFromLogs(capturedLogs)).toBe(USER_STATES.ENGAGED); + await expect(page.getByTestId('call-control:end-call').first()).toBeVisible(); + await page.getByTestId('call-control:end-call').first().click({ timeout: 5000 }); + await page.waitForTimeout(500); + await submitWrapup(page, WRAPUP_REASONS.SALE); + await waitForState(page, USER_STATES.AVAILABLE); + await waitForStateLogs(capturedLogs, USER_STATES.AVAILABLE); + expect(await getLastStateFromLogs(capturedLogs)).toBe(USER_STATES.AVAILABLE); + await waitForWrapupReasonLogs(capturedLogs, WRAPUP_REASONS.SALE); + expect(await getLastWrapupReasonFromLogs(capturedLogs)).toBe(WRAPUP_REASONS.SALE); + expect(await verifyCallbackLogs(capturedLogs, WRAPUP_REASONS.SALE, USER_STATES.AVAILABLE)).toBe(true); + }); + + test('should handle chat disconnect before agent answers', async () => { + await createChatTask(chatPage); + await changeUserState(page, USER_STATES.AVAILABLE); + const incomingTaskDiv = page.getByTestId('samples:incoming-task-chat').first(); + await incomingTaskDiv.waitFor({ state: 'visible', timeout: 60000 }); + await endChatTask(chatPage); + await incomingTaskDiv.waitFor({ state: 'hidden', timeout: 30000 }); + await waitForState(page, USER_STATES.AVAILABLE); + await verifyCurrentState(page, USER_STATES.AVAILABLE); + }) + + test('should accept incoming email task, end email and complete wrapup with callback verification', async () => { + await createEmailTask(); + await changeUserState(page, USER_STATES.AVAILABLE); + const incomingTaskDiv = page.getByTestId('samples:incoming-task-email').first(); + await incomingTaskDiv.waitFor({ state: 'visible', timeout: 50000 }) + await acceptIncomingTask(page, TASK_TYPES.EMAIL); + await waitForState(page, USER_STATES.ENGAGED); + await verifyCurrentState(page, USER_STATES.ENGAGED); + await page.waitForTimeout(3000); + const userStateElement = page.getByTestId('state-select'); + const userStateElementColor = await userStateElement.evaluate((el) => getComputedStyle(el).backgroundColor); + expect(isColorClose(userStateElementColor, THEME_COLORS.ENGAGED)).toBe(true); + await waitForState(page, USER_STATES.ENGAGED); + await waitForStateLogs(capturedLogs, USER_STATES.ENGAGED); + expect(await getLastStateFromLogs(capturedLogs)).toBe(USER_STATES.ENGAGED); + await expect(page.getByTestId('call-control:end-call').first()).toBeVisible(); + await page.getByTestId('call-control:end-call').first().click({ timeout: 5000 }); + await submitWrapup(page, WRAPUP_REASONS.SALE); + await waitForState(page, USER_STATES.AVAILABLE); + await waitForStateLogs(capturedLogs, USER_STATES.AVAILABLE); + expect(await getLastStateFromLogs(capturedLogs)).toBe(USER_STATES.AVAILABLE); + await waitForWrapupReasonLogs(capturedLogs, WRAPUP_REASONS.SALE); + expect(await getLastWrapupReasonFromLogs(capturedLogs)).toBe(WRAPUP_REASONS.SALE); + expect(await verifyCallbackLogs(capturedLogs, WRAPUP_REASONS.SALE, USER_STATES.AVAILABLE)).toBe(true); + }) + + + test('should ignore incoming email task and wait for RONA popup', async () => { + await createEmailTask(); + await changeUserState(page, USER_STATES.AVAILABLE); + const incomingTaskDiv = page.getByTestId('samples:incoming-task-email').first(); + await incomingTaskDiv.waitFor({ state: 'visible', timeout: 50000 }); + await incomingTaskDiv.waitFor({ state: 'hidden', timeout: 30000 }); + await expect(incomingTaskDiv).toBeHidden(); + await waitForState(page, USER_STATES.RONA); + await page.getByTestId('samples:rona-popup').waitFor({ state: 'visible', timeout: 15000 }); + await page.waitForTimeout(3000); + const userStateElement = page.getByTestId('state-select'); + const userStateElementColor = await userStateElement.evaluate((el) => getComputedStyle(el).backgroundColor); + expect(isColorClose(userStateElementColor, THEME_COLORS.RONA)).toBe(true); + await expect(page.getByTestId('samples:rona-popup')).toBeVisible(); + await verifyCurrentState(page, USER_STATES.RONA); + await submitRonaPopup(page, RONA_OPTIONS.AVAILABLE); + await waitForState(page, USER_STATES.AVAILABLE); + await incomingTaskDiv.waitFor({ state: 'visible', timeout: 10000 }); + await acceptIncomingTask(page, TASK_TYPES.EMAIL); + const endButton = page.getByTestId('call-control:end-call').first(); + await endButton.waitFor({ state: 'visible', timeout: 7000 }); + await endButton.click({ timeout: 5000 }); + await page.waitForTimeout(1000); + await submitWrapup(page, WRAPUP_REASONS.SALE); + await waitForState(page, USER_STATES.AVAILABLE); + await page.waitForTimeout(2000); + }) + + test('should set agent to Available and verify email task behavior', async () => { + await createEmailTask(); + await changeUserState(page, USER_STATES.AVAILABLE); + const incomingTaskDiv = page.getByTestId('samples:incoming-task-email').first(); + await incomingTaskDiv.waitFor({ state: 'visible', timeout: 50000 }); + await incomingTaskDiv.waitFor({ state: 'hidden', timeout: 30000 }); + await expect(incomingTaskDiv).toBeHidden(); + await page.getByTestId('samples:rona-popup').waitFor({ state: 'visible', timeout: 15000 }); + await expect(page.getByTestId('samples:rona-popup')).toBeVisible(); + await verifyCurrentState(page, USER_STATES.RONA); + await waitForStateLogs(capturedLogs, USER_STATES.RONA); + expect(await getLastStateFromLogs(capturedLogs)).toBe(USER_STATES.RONA); + await submitRonaPopup(page, RONA_OPTIONS.AVAILABLE); + await waitForState(page, USER_STATES.AVAILABLE); + await expect(page.getByTestId('samples:rona-popup')).not.toBeVisible(); + await verifyCurrentState(page, USER_STATES.AVAILABLE); + await incomingTaskDiv.waitFor({ state: 'visible', timeout: 10000 }); + await expect(incomingTaskDiv).toBeVisible(); + await acceptIncomingTask(page, TASK_TYPES.EMAIL); + await page.waitForTimeout(1000); + const endButton = page.getByTestId('call-control:end-call').first(); + await endButton.waitFor({ state: 'visible', timeout: 12000 }); + await endButton.click({ timeout: 5000 }); + await page.waitForTimeout(1000); + await submitWrapup(page, WRAPUP_REASONS.SALE); + await waitForState(page, USER_STATES.AVAILABLE); + await page.waitForTimeout(2000); + }) + + + test('should set agent state to busy after ignoring email task', async () => { + await createEmailTask(); + await changeUserState(page, USER_STATES.AVAILABLE); + const incomingTaskDiv = page.getByTestId('samples:incoming-task-email').first(); + await incomingTaskDiv.waitFor({ state: 'visible', timeout: 50000 }); + await incomingTaskDiv.waitFor({ state: 'hidden', timeout: 30000 }); + await page.getByTestId('samples:rona-popup').waitFor({ state: 'visible', timeout: 15000 }); + await submitRonaPopup(page, RONA_OPTIONS.IDLE); + await waitForState(page, USER_STATES.MEETING); + await verifyCurrentState(page, USER_STATES.MEETING); + await incomingTaskDiv.waitFor({ state: 'hidden', timeout: 5000 }); + await expect(incomingTaskDiv).toBeHidden(); + await page.waitForTimeout(2000); + await changeUserState(page, USER_STATES.AVAILABLE); + await incomingTaskDiv.waitFor({ state: 'visible', timeout: 10000 }); + await acceptIncomingTask(page, TASK_TYPES.EMAIL); + await page.waitForTimeout(1000); + await page.getByTestId('call-control:end-call').first().click({ timeout: 5000 }); + await submitWrapup(page, WRAPUP_REASONS.SALE); + await waitForState(page, USER_STATES.AVAILABLE); + }) + + + test('should handle multiple incoming tasks with callback verifications', async () => { + + await changeUserState(page, USER_STATES.MEETING); + await page.waitForTimeout(1000); + + await Promise.all([ + createCallTask(callerpage), + createChatTask(chatPage), + createEmailTask() + ]); + + + await page.waitForTimeout(50000); + + await changeUserState(page, USER_STATES.AVAILABLE); + + const incomingCallTaskDiv = page.getByTestId('samples:incoming-task-telephony').first(); + const incomingChatTaskDiv = page.getByTestId('samples:incoming-task-chat').first(); + const incomingEmailTaskDiv = page.getByTestId('samples:incoming-task-email').first(); + + + await incomingCallTaskDiv.waitFor({ state: 'visible', timeout: 5000 }); + await extensionPage.locator('[data-test="generic-person-item-base"]').first().waitFor({ state: 'visible', timeout: 5000 }); + await page.waitForTimeout(3000); + await acceptExtensionCall(extensionPage); + + await waitForState(page, USER_STATES.ENGAGED); + await verifyCurrentState(page, USER_STATES.ENGAGED); + await waitForStateLogs(capturedLogs, USER_STATES.ENGAGED); + expect(await getLastStateFromLogs(capturedLogs)).toBe(USER_STATES.ENGAGED); + + capturedLogs.length = 0; + + await incomingChatTaskDiv.waitFor({ state: 'visible', timeout: 5000 }); + await page.waitForTimeout(3000); + await acceptIncomingTask(page, TASK_TYPES.CHAT); + + + await waitForState(page, USER_STATES.ENGAGED); + await verifyCurrentState(page, USER_STATES.ENGAGED); + await waitForStateLogs(capturedLogs, USER_STATES.ENGAGED); + expect(await getLastStateFromLogs(capturedLogs)).toBe(USER_STATES.ENGAGED); + + capturedLogs.length = 0; + + + await incomingEmailTaskDiv.waitFor({ state: 'visible', timeout: 5000 }); + await page.waitForTimeout(3000); + await acceptIncomingTask(page, TASK_TYPES.EMAIL); + + + await waitForState(page, USER_STATES.ENGAGED); + await verifyCurrentState(page, USER_STATES.ENGAGED); + await waitForStateLogs(capturedLogs, USER_STATES.ENGAGED); + expect(await getLastStateFromLogs(capturedLogs)).toBe(USER_STATES.ENGAGED); + + let count = 3; + + + while (count > 0) { + capturedLogs.length = 0; + await page.waitForTimeout(2000); + const endButton = page.getByTestId('call-control:end-call').first(); + const endButtonVisible = await endButton.waitFor({ state: 'visible', timeout: 2000 }).then(() => true).catch(() => false); + if (endButtonVisible) { + await endButton.click({ timeout: 5000 }); + await submitWrapup(page, WRAPUP_REASONS.SALE); + } else { + + const wrapupBox = page.getByTestId('wrapup-button').first(); + const isWrapupBoxVisible = await wrapupBox.waitFor({ state: 'visible', timeout: 2000 }).then(() => true).catch(() => false); + if (isWrapupBoxVisible) { + await submitWrapup(page, WRAPUP_REASONS.SALE); + await page.waitForTimeout(2000) + } else { + break; + } + + } + + + await waitForState(page, count === 1 ? USER_STATES.AVAILABLE : USER_STATES.ENGAGED); + await verifyCurrentState(page, count === 1 ? USER_STATES.AVAILABLE : USER_STATES.ENGAGED); + await waitForStateLogs(capturedLogs, count === 1 ? USER_STATES.AVAILABLE : USER_STATES.ENGAGED); + expect(await getLastStateFromLogs(capturedLogs)).toBe(count === 1 ? USER_STATES.AVAILABLE : USER_STATES.ENGAGED); + await waitForWrapupReasonLogs(capturedLogs, WRAPUP_REASONS.SALE); + expect(await getLastWrapupReasonFromLogs(capturedLogs)).toBe(WRAPUP_REASONS.SALE); + expect(await verifyCallbackLogs(capturedLogs, WRAPUP_REASONS.SALE, count === 1 ? USER_STATES.AVAILABLE : USER_STATES.ENGAGED)).toBe(true); + count--; + } + }) + + + test.afterAll(async () => { + if (page) { + const logoutButton = page.getByTestId('samples:station-logout-button'); + const isLogoutButtonVisible = await logoutButton.isVisible().catch(() => false); + if (isLogoutButtonVisible) { + await page.getByTestId('samples:station-logout-button').click({ timeout: 5000 }); + const isLogoutButtonHidden = await page + .getByTestId('samples:station-logout-button') + .waitFor({ state: 'hidden', timeout: 20000 }) + .then(() => true) + .catch(() => false); + } + + await page.close(); + page = null; + } + if (callerpage) { + await callerpage.close(); + callerpage = null; + } + + if (extensionPage) { + await extensionPage.close(); + extensionPage = null; + } + if (chatPage) { + await chatPage.close(); + chatPage = null; + } + + + if (context) { + await context.close(); + context = null; + } + + if (context2) { + await context2.close(); + context2 = null; + } + }) + +}); + + +test.describe('Incoming Tasks tests for multi-session', () => { + + + test.beforeAll(async ({ browser }) => { + context = await browser.newContext(); + context2 = await browser.newContext(); + page = await context.newPage(); + page2 = await context.newPage(); + chatPage = await context.newPage(); + extensionPage = await context.newPage(); + callerpage = await context2.newPage(); + setupConsoleLogging(page); + + 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.EXTENSION, 'AGENT1', extensionPage); + })(), + (async () => { + await pageSetup(page2, LOGIN_MODE.EXTENSION, 'AGENT1', extensionPage); + })(), + (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}`); + } + } + } + })() + ]) + }) + + test('should handle multi-session incoming call with state synchronization', async () => { + await createCallTask(callerpage); + await changeUserState(page, USER_STATES.AVAILABLE); + const incomingTaskDiv = page.getByTestId('samples:incoming-task-telephony').first(); + const incomingTaskDiv2 = page2.getByTestId('samples:incoming-task-telephony').first(); + await incomingTaskDiv.waitFor({ state: 'visible', timeout: 40000 }); + await extensionPage.locator('[data-test="generic-person-item-base"]').first().waitFor({ state: 'visible', timeout: 20000 }); + await incomingTaskDiv2.waitFor({ state: 'visible', timeout: 10000 }); + await page.waitForTimeout(5000); + await declineExtensionCall(extensionPage); + await page.getByTestId('samples:rona-popup').waitFor({ state: 'visible', timeout: 10000 }); + await page2.getByTestId('samples:rona-popup').waitFor({ state: 'visible', timeout: 10000 }); + await page.waitForTimeout(3000); + await submitRonaPopup(page2, RONA_OPTIONS.IDLE); + await waitForState(page, USER_STATES.MEETING); + await waitForState(page2, USER_STATES.MEETING); + await verifyCurrentState(page2, USER_STATES.MEETING); + await verifyCurrentState(page, USER_STATES.MEETING); + await expect(page.getByTestId('samples:rona-popup')).not.toBeVisible(); + await expect(page2.getByTestId('samples:rona-popup')).not.toBeVisible(); + await page.waitForTimeout(2000); + await changeUserState(page, USER_STATES.AVAILABLE); + await waitForState(page2, USER_STATES.AVAILABLE); + await verifyCurrentState(page2, USER_STATES.AVAILABLE); + await incomingTaskDiv.waitFor({ state: 'visible', timeout: 10000 }); + await extensionPage.locator('[data-test="generic-person-item-base"]').first().waitFor({ state: 'visible', timeout: 10000 }); + await incomingTaskDiv2.waitFor({ state: 'visible', timeout: 10000 }); + await page.waitForTimeout(2000); + await acceptExtensionCall(extensionPage); + await waitForState(page, USER_STATES.ENGAGED); + await verifyCurrentState(page, USER_STATES.ENGAGED); + await verifyCurrentState(page2, USER_STATES.ENGAGED); + await page.waitForTimeout(3000); + const userStateElement = page.getByTestId('state-select'); + const userStateElementColor = await userStateElement.evaluate((el) => getComputedStyle(el).backgroundColor); + expect(isColorClose(userStateElementColor, THEME_COLORS.ENGAGED)).toBe(true); + const userStateElement2 = page2.getByTestId('state-select'); + const userStateElementColor2 = await userStateElement2.evaluate((el) => getComputedStyle(el).backgroundColor); + expect(isColorClose(userStateElementColor2, THEME_COLORS.ENGAGED)).toBe(true); + await expect(incomingTaskDiv).toBeHidden(); + await expect(incomingTaskDiv2).toBeHidden(); + await page2.getByTestId('call-control:end-call').first().click({ timeout: 5000 }); + await page.waitForTimeout(1000); + await submitWrapup(page, WRAPUP_REASONS.SALE); + await waitForState(page, USER_STATES.AVAILABLE); + await waitForState(page2, USER_STATES.AVAILABLE); + await verifyCurrentState(page, USER_STATES.AVAILABLE); + await verifyCurrentState(page2, USER_STATES.AVAILABLE); + }) + + test('should handle multi-session incoming chat with state synchronization', async () => { + await createChatTask(chatPage); + await changeUserState(page, USER_STATES.AVAILABLE); + const incomingTaskDiv = page.getByTestId('samples:incoming-task-chat').first(); + const incomingTaskDiv2 = page2.getByTestId('samples:incoming-task-chat').first(); + await incomingTaskDiv.waitFor({ state: 'visible', timeout: 60000 }); + await incomingTaskDiv2.waitFor({ state: 'visible', timeout: 10000 }); + await incomingTaskDiv.waitFor({ state: 'hidden', timeout: 30000 }); + await incomingTaskDiv2.waitFor({ state: 'hidden', timeout: 10000 }); + await page.getByTestId('samples:rona-popup').waitFor({ state: 'visible', timeout: 15000 }); + await page2.getByTestId('samples:rona-popup').waitFor({ state: 'visible', timeout: 15000 }); + await submitRonaPopup(page2, RONA_OPTIONS.IDLE); + await waitForState(page, USER_STATES.MEETING); + await waitForState(page2, USER_STATES.MEETING); + await verifyCurrentState(page, USER_STATES.MEETING); + await verifyCurrentState(page2, USER_STATES.MEETING); + await page.waitForTimeout(2000); + await changeUserState(page, USER_STATES.AVAILABLE); + await waitForState(page2, USER_STATES.AVAILABLE); + await verifyCurrentState(page2, USER_STATES.AVAILABLE); + await incomingTaskDiv.waitFor({ state: 'visible', timeout: 10000 }); + await incomingTaskDiv2.waitFor({ state: 'visible', timeout: 10000 }); + await acceptIncomingTask(page, TASK_TYPES.CHAT); + await waitForState(page, USER_STATES.ENGAGED); + await waitForState(page2, USER_STATES.ENGAGED); + await verifyCurrentState(page, USER_STATES.ENGAGED); + await verifyCurrentState(page2, USER_STATES.ENGAGED); + await page.waitForTimeout(3000); + const userStateElement = page.getByTestId('state-select'); + const userStateElementColor = await userStateElement.evaluate((el) => getComputedStyle(el).backgroundColor); + expect(isColorClose(userStateElementColor, THEME_COLORS.ENGAGED)).toBe(true); + const userStateElement2 = page2.getByTestId('state-select'); + const userStateElementColor2 = await userStateElement2.evaluate((el) => getComputedStyle(el).backgroundColor); + expect(isColorClose(userStateElementColor2, THEME_COLORS.ENGAGED)).toBe(true); + await expect(incomingTaskDiv).toBeHidden(); + await expect(incomingTaskDiv2).toBeHidden(); + await page2.getByTestId('call-control:end-call').first().click({ timeout: 5000 }); + await submitWrapup(page2, WRAPUP_REASONS.SALE); + await waitForState(page, USER_STATES.AVAILABLE); + await waitForState(page2, USER_STATES.AVAILABLE); + await verifyCurrentState(page, USER_STATES.AVAILABLE); + await verifyCurrentState(page2, USER_STATES.AVAILABLE); + }); + + test('should handle multi-session incoming email with state synchronization', async () => { + await createEmailTask(); + await changeUserState(page, USER_STATES.AVAILABLE); + const incomingTaskDiv = page.getByTestId('samples:incoming-task-email').first(); + const incomingTaskDiv2 = page2.getByTestId('samples:incoming-task-email').first(); + await incomingTaskDiv.waitFor({ state: 'visible', timeout: 50000 }); + await incomingTaskDiv2.waitFor({ state: 'visible', timeout: 10000 }); + await incomingTaskDiv.waitFor({ state: 'hidden', timeout: 30000 }); + await incomingTaskDiv2.waitFor({ state: 'hidden', timeout: 10000 }); + await page.getByTestId('samples:rona-popup').waitFor({ state: 'visible', timeout: 15000 }); + await page2.getByTestId('samples:rona-popup').waitFor({ state: 'visible', timeout: 15000 }); + await page.waitForTimeout(3000); + await submitRonaPopup(page2, RONA_OPTIONS.IDLE); + await waitForState(page, USER_STATES.MEETING); + await waitForState(page2, USER_STATES.MEETING); + await verifyCurrentState(page, USER_STATES.MEETING); + await verifyCurrentState(page2, USER_STATES.MEETING); + await page.waitForTimeout(3000); + await changeUserState(page, USER_STATES.AVAILABLE); + await waitForState(page2, USER_STATES.AVAILABLE); + await waitForState(page, USER_STATES.AVAILABLE); + await verifyCurrentState(page2, USER_STATES.AVAILABLE); + await incomingTaskDiv.waitFor({ state: 'visible', timeout: 15000 }); + await incomingTaskDiv2.waitFor({ state: 'visible', timeout: 15000 }); + await acceptIncomingTask(page, TASK_TYPES.EMAIL); + await waitForState(page, USER_STATES.ENGAGED); + await waitForState(page2, USER_STATES.ENGAGED); + await verifyCurrentState(page, USER_STATES.ENGAGED); + await verifyCurrentState(page2, USER_STATES.ENGAGED); + await page.waitForTimeout(3000); + const userStateElement = page.getByTestId('state-select'); + const userStateElementColor = await userStateElement.evaluate((el) => getComputedStyle(el).backgroundColor); + expect(isColorClose(userStateElementColor, THEME_COLORS.ENGAGED)).toBe(true); + const userStateElement2 = page.getByTestId('state-select'); + const userStateElementColor2 = await userStateElement2.evaluate((el) => getComputedStyle(el).backgroundColor); + expect(isColorClose(userStateElementColor2, THEME_COLORS.ENGAGED)).toBe(true); + await expect(incomingTaskDiv).toBeHidden(); + await expect(incomingTaskDiv2).toBeHidden(); + await page2.getByTestId('call-control:end-call').first().click({ timeout: 5000 }); + await submitWrapup(page, WRAPUP_REASONS.SALE); + await waitForState(page, USER_STATES.AVAILABLE); + await waitForState(page2, USER_STATES.AVAILABLE); + await verifyCurrentState(page, USER_STATES.AVAILABLE); + await verifyCurrentState(page2, USER_STATES.AVAILABLE); + }); + + + test.afterAll(async () => { + if (page) { + await page.close(); + page = null; + } + if (callerpage) { + await callerpage.close(); + callerpage = null; + } + + if (extensionPage) { + await extensionPage.close(); + extensionPage = null; + } + if (chatPage) { + await chatPage.close(); + chatPage = null; + } + + if (page2) { + await page2.close(); + page2 = null; + } + + + if (context) { + await context.close(); + context = null; + } + + if (context2) { + await context2.close(); + context2 = null; + } + }) + +}); diff --git a/playwright/wav/dummyAudio.wav b/playwright/wav/dummyAudio.wav new file mode 100644 index 000000000..ead42a0ad Binary files /dev/null and b/playwright/wav/dummyAudio.wav differ diff --git a/widgets-samples/cc/samples-cc-react-app/src/App.tsx b/widgets-samples/cc/samples-cc-react-app/src/App.tsx index 73e19e036..5d0ea298d 100644 --- a/widgets-samples/cc/samples-cc-react-app/src/App.tsx +++ b/widgets-samples/cc/samples-cc-react-app/src/App.tsx @@ -181,6 +181,8 @@ const onTaskDeclined = (task,reason) => { const onWrapUp = (params) => { console.log('onWrapup invoked', params); + //the below log is used by e2e tests + if (params && params.wrapUpReason) console.log(`onWrapup invoked with reason : ${params.wrapUpReason}`); }; const enableDisableMultiLogin = () => { @@ -773,6 +775,7 @@ const onTaskDeclined = (task,reason) => { setCollapsedTasks((prev) => prev.filter((id) => id !== task.data.interactionId)); } }} + data-testid={`samples:incoming-task-${task.data.mediaType}`} > <> @@ -829,17 +832,20 @@ const onTaskDeclined = (task,reason) => { onChange={(e: CustomEvent) => { setSelectedState(e.detail.value); }} + data-testid="samples:rona-select-state" > - - - + +
)} diff --git a/yarn.lock b/yarn.lock index f84429f86..2458c41d2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -26741,6 +26741,13 @@ __metadata: languageName: node linkType: hard +"nodemailer@npm:^7.0.3": + version: 7.0.3 + resolution: "nodemailer@npm:7.0.3" + checksum: 10c0/835492262328471b94a080cea43ea20f4232e19a915400cd71c7f4f4ab93a7d361775154eebe30a8fc40379eecf11a0bbc73e6cf4bbee9dccb6dd1cf7a1dc792 + languageName: node + linkType: hard + "nopt@npm:*, nopt@npm:^8.0.0": version: 8.0.0 resolution: "nopt@npm:8.0.0" @@ -35092,6 +35099,7 @@ __metadata: jest: "npm:29.7.0" jest-canvas-mock: "npm:^2.5.2" node-gyp: "npm:^10.2.0" + nodemailer: "npm:^7.0.3" os-browserify: "npm:^0.3.0" process: "npm:^0.11.10" querystring-es3: "npm:^0.2.1"