diff --git a/playwright.config.ts b/playwright.config.ts index fb196a2de..6986eb2f1 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -10,7 +10,8 @@ dotenv.config({path: path.resolve(__dirname, '.env')}); */ export default defineConfig({ testDir: './playwright', - + /* Maximum time one test can run for. */ + timeout: 180000, /* Run your local dev server before starting the tests */ webServer: { command: 'yarn workspace samples-cc-react-app serve', diff --git a/playwright/Utils/initUtils.ts b/playwright/Utils/initUtils.ts new file mode 100644 index 000000000..848b9e25e --- /dev/null +++ b/playwright/Utils/initUtils.ts @@ -0,0 +1,183 @@ +import {Page, expect, BrowserContext} from '@playwright/test'; +import dotenv from 'dotenv'; +import {BASE_URL} from '../constants'; + +dotenv.config(); + +/** + * Performs login using an access token from environment variables + * @param page - The Playwright page object + * @param agentId - Agent identifier to get access token for (e.g., 'AGENT1', 'AGENT2') + * @description Requires PW_{agentId}_ACCESS_TOKEN environment variable to be set + * @throws {Error} When PW_{agentId}_ACCESS_TOKEN environment variable is not defined + * @example + * ```typescript + * // Ensure PW_AGENT1_ACCESS_TOKEN is set in .env file + * await loginViaAccessToken(page, 'AGENT1'); + * + * // Different agents with their own access tokens + * await loginViaAccessToken(page, 'AGENT2'); // Uses PW_AGENT2_ACCESS_TOKEN + * await loginViaAccessToken(page, 'ADMIN'); // Uses PW_ADMIN_ACCESS_TOKEN + * ``` + */ +export const loginViaAccessToken = async (page: Page, agentId: string): Promise => { + await page.goto(BASE_URL); + const accessToken = process.env[`PW_${agentId}_ACCESS_TOKEN`]; + await page.getByRole('textbox').click(); + if (!accessToken) { + throw new Error(`PW_${agentId}_ACCESS_TOKEN is not defined, OAuth failed`); + } + await page.getByRole('textbox').fill(accessToken); +}; + +/** + * Performs OAuth login with Webex using agent credentials from environment variables + * @param page - The Playwright page object + * @param agentId - Agent identifier to validate against environment variables (e.g., 'AGENT1', 'AGENT2') + * @description Validates credentials against PW_{agentId}_USERNAME and PW_{agentId}_PASSWORD + * @throws {Error} When agent credentials are not found in environment variables + * @example + * ```typescript + * // OAuth login with agent credentials from environment variables + * await oauthLogin(page, 'AGENT1'); // validates against PW_AGENT1_USERNAME/PW_AGENT1_PASSWORD + * await oauthLogin(page, 'AGENT2'); // validates against PW_AGENT2_USERNAME/PW_AGENT2_PASSWORD + * await oauthLogin(page, 'ADMIN'); // validates against PW_ADMIN_USERNAME/PW_ADMIN_PASSWORD + * ``` + */ +export const oauthLogin = async (page: Page, agentId: string): Promise => { + // Check 1: Validate agentId parameter is provided + if (!agentId) { + throw new Error('Agent ID parameter is required'); + } + + // Check 2: Validate agentId is not empty string + if (agentId.trim() === '') { + throw new Error('Agent ID cannot be empty string'); + } + + // Check 3: Get credentials from environment variables + const username = process.env[`PW_${agentId}_USERNAME`]; + const password = process.env[`PW_PASSWORD`]; + // Check 4: Validate environment variables are set + if (!username || !password) { + throw new Error(`Environment variables PW_${agentId}_USERNAME and PW_PASSWORD must be set`); + } + + await page.goto(BASE_URL); + 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(); +}; + +/** + * Enables all available contact center widgets + * @param page - The Playwright page object + * @description Checks all widget checkboxes including station login, user state, tasks, and call controls + * @example + * ```typescript + * await enableAllWidgets(page); + * await initialiseWidgets(page); // Now all widgets will be available + * ``` + */ +export const enableAllWidgets = async (page: Page): Promise => { + await page.getByTestId('samples:widget-stationLogin').check(); + await page.getByTestId('samples:widget-userState').check(); + await page.getByTestId('samples:widget-incomingTask').check(); + await page.getByTestId('samples:widget-taskList').check(); + await page.getByTestId('samples:widget-callControl').check(); + await page.getByTestId('samples:widget-callControlCAD').check(); + await page.getByTestId('samples:widget-outdialCall').check(); +}; + +/** + * Enables multi-login functionality for the SDK + * @param page - The Playwright page object + * @description Must be called before SDK initialization to take effect + * @example + * ```typescript + * await enableMultiLogin(page); + * await initialiseWidgets(page); // Multi-login is now enabled + * ``` + */ +export const enableMultiLogin = async (page: Page): Promise => { + await page.getByTestId('samples:multi-login-enable-checkbox').check(); +}; + +/** + * Disables multi-login functionality for the SDK + * @param page - The Playwright page object + * @description Must be called before SDK initialization to take effect + * @example + * ```typescript + * await disableMultiLogin(page); + * await initialiseWidgets(page); // Multi-login is now disabled + * ``` + */ +export const disableMultiLogin = async (page: Page): Promise => { + await page.getByTestId('samples:multi-login-enable-checkbox').uncheck(); +}; + +/** + * Initializes the widgets by clicking the init widgets button and waiting for station-login widget to be visible + * @param page - The Playwright page object + * @description The station-login widget should be checked/enabled before using this function + * @throws {Error} When station-login widget is not visible after initialization + * @example + * ```typescript + * // Ensure station-login widget is checked first + * await page.getByTestId('samples:widget-stationLogin').check(); + * await initialiseWidgets(page); + * ``` + */ +export const initialiseWidgets = async (page: Page): Promise => { + await page.getByTestId('samples:init-widgets-button').click(); + + await page.getByTestId('station-login-widget').waitFor({state: 'visible', timeout: 50000}); +}; + +/** + * Reloads the page and reinitializes widgets to simulate agent relogin + * @param page - The Playwright page object + * @description Useful for testing state persistence after page reload + * @throws {Error} When widget reinitialization fails after reload + * @example + * ```typescript + * // Test state persistence + * await changeUserState(page, 'Available'); + * await agentRelogin(page); // State should persist after reload + * ``` + */ +// Helper method for agent relogin - simulates user login along with page reload +export const agentRelogin = async (page: Page): Promise => { + await page.reload(); + await initialiseWidgets(page); +}; + +/** + * Creates a new page in the same browser context for multi-login testing + * @param context - The Playwright browser context + * @returns Promise - The new page with widgets initialized + * @description Useful for testing multi-login scenarios + * @throws {Error} When widget initialization fails on the new page + * @example + * ```typescript + * const context = await browser.newContext(); + * const primaryPage = await context.newPage(); + * const secondaryPage = await setupMultiLoginPage(context); + * + * // Test state synchronization between pages + * await changeUserState(primaryPage, 'Available'); + * await verifyCurrentState(secondaryPage, 'Available'); + * ``` + */ +// Helper method for multisession - creates new page and initializes widgets in same context +export const setupMultiLoginPage = async (context: BrowserContext): Promise => { + const multiLoginPage = await context.newPage(); + await multiLoginPage.goto(BASE_URL); + await initialiseWidgets(multiLoginPage); + return multiLoginPage; +}; diff --git a/playwright/Utils/stationLoginUtils.ts b/playwright/Utils/stationLoginUtils.ts new file mode 100644 index 000000000..c80a70a3d --- /dev/null +++ b/playwright/Utils/stationLoginUtils.ts @@ -0,0 +1,156 @@ +import {Page, expect} from '@playwright/test'; +import dotenv from 'dotenv'; +import {LOGIN_MODE} from '../constants'; + +dotenv.config(); + +/** + * Performs desktop login for contact center agents + * @param page - The Playwright page object + * @throws {Error} When login fails or required elements are not found + * @example + * ```typescript + * await desktopLogin(page); + * ``` + */ +export const desktopLogin = async (page: Page): Promise => { + await page.getByTestId('login-option-select').locator('#select-base-triggerid svg').click(); + await page.getByTestId('login-option-Desktop').click(); + await page.getByTestId('teams-select-dropdown').locator('#select-base-triggerid div').click(); + await page.waitForTimeout(200); + await page.locator('[data-testid^="teams-dropdown-"]').nth(0).locator('span, div').first().click(); + await page.waitForTimeout(200); + + await page.getByTestId('login-button').click(); +}; + +/** + * Performs extension-based login for contact center agents + * @param page - The Playwright page object + * @param extensionNumber - Optional extension number. Falls back to PW_EXTENSION_NUMBER env variable + * @throws {Error} When extension number is not provided or empty + * @throws {Error} When login fails or required elements are not found + * @example + * ```typescript + * // Using environment variable + * await extensionLogin(page); + * + * // Using custom extension number + * await extensionLogin(page, "1234"); + * ``` + */ +export const extensionLogin = async (page: Page, extensionNumber?: string): Promise => { + const number = extensionNumber ?? process.env.PW_AGENT1_EXTENSION_NUMBER; + if (!number) { + throw new Error('PW_AGENT1_EXTENSION_NUMBER must be provided'); + } + + if (number.trim() === '') { + throw new Error('Extension number is empty. Please provide a valid extension number.'); + } + + await page.getByTestId('login-option-select').locator('#select-base-triggerid svg').click(); + await page.getByTestId('login-option-Extension').click(); + await page.getByTestId('dial-number-input').locator('input').fill(number); + await page.getByTestId('teams-select-dropdown').locator('#select-base-triggerid div').click(); + await page.waitForTimeout(200); + await page.locator('[data-testid^="teams-dropdown-"]').nth(0).locator('span, div').first().click(); + await page.getByTestId('login-button').click(); +}; + +/** + * Performs dial number-based login for contact center agents + * @param page - The Playwright page object + * @param dialNumber - Optional dial number. Falls back to PW_DIAL_NUMBER env variable + * @throws {Error} When dial number is not provided or empty + * @throws {Error} When login fails or required elements are not found + * @example + * ```typescript + * // Using environment variable + * await dialLogin(page); + * + * // Using custom dial number + * await dialLogin(page, "+1234567890"); + * ``` + */ +export const dialLogin = async (page: Page, dialNumber?: string): Promise => { + const number = dialNumber ?? process.env.PW_DIAL_NUMBER; + if (!number) { + throw new Error('PW_DIAL_NUMBER is not defined in the .env file'); + } + + if (number.trim() === '') { + throw new Error('Dial number is empty. Please provide a valid dial number.'); + } + + await page.getByTestId('login-option-select').locator('#select-base-triggerid svg').click(); + await page.getByTestId('login-option-Dial Number').click(); + await page.getByTestId('dial-number-input').locator('div').nth(1).click(); + await page.getByTestId('dial-number-input').locator('input').fill(number); + await page.getByTestId('teams-select-dropdown').locator('#select-base-triggerid div').click(); + await page.waitForTimeout(200); + await page.locator('[data-testid^="teams-dropdown-"]').nth(0).locator('span, div').first().click(); + await page.getByTestId('login-button').click(); +}; + +/** + * Performs station logout for contact center agents + * @param page - The Playwright page object + * @throws {Error} When logout fails or button remains visible after logout + * @example + * ```typescript + * await stationLogout(page); + * ``` + */ +export const stationLogout = async (page: Page): Promise => { + // Ensure the logout button is visible before clicking + const logoutButton = page.getByTestId('samples:station-logout-button'); + const isLogoutButtonVisible = await logoutButton.isVisible().catch(() => false); + if (!isLogoutButtonVisible) { + throw new Error('Station logout button is not visible. Cannot perform logout.'); + } + await page.getByTestId('samples:station-logout-button').click(); + //check if the station logout button is hidden after logouts + const isLogoutButtonHidden = await page + .getByTestId('samples:station-logout-button') + .waitFor({state: 'hidden'}) + .then(() => true) + .catch(() => false); + if (!isLogoutButtonHidden) { + throw new Error('Station logout button is still visible after logout'); + } +}; + +/** + * Unified telephony login function that supports multiple login modes + * @param page - The Playwright page object + * @param mode - The login mode (Desktop, Extension, or Dial Number) + * @param number - Optional number for Extension or Dial Number modes + * @throws {Error} When unsupported login mode is provided + * @throws {Error} When number is required but not provided + * @example + * ```typescript + * // Desktop login + * await telephonyLogin(page, LOGIN_MODE.DESKTOP); + * + * // Extension login with env variable + * await telephonyLogin(page, LOGIN_MODE.EXTENSION); + * + * // Extension login with custom number + * await telephonyLogin(page, LOGIN_MODE.EXTENSION, "1234"); + * + * // Dial number login with custom number + * await telephonyLogin(page, LOGIN_MODE.DIAL_NUMBER, "+1234567890"); + * ``` + */ +export const telephonyLogin = async (page: Page, mode: string, number?: string): Promise => { + if (mode === LOGIN_MODE.DESKTOP) { + await desktopLogin(page); + } else if (mode === LOGIN_MODE.EXTENSION) { + await extensionLogin(page, number); + } else if (mode === LOGIN_MODE.DIAL_NUMBER) { + await dialLogin(page, number); + } else { + throw new Error(`Unsupported login mode: ${mode}. Use one of: ${Object.values(LOGIN_MODE).join(', ')}`); + } +}; diff --git a/playwright/Utils/userStateUtils.ts b/playwright/Utils/userStateUtils.ts new file mode 100644 index 000000000..6035b67da --- /dev/null +++ b/playwright/Utils/userStateUtils.ts @@ -0,0 +1,211 @@ +import {Page, expect} from '@playwright/test'; +import dotenv from 'dotenv'; +import {USER_STATES} from '../constants'; + +dotenv.config(); + +/** + * Changes the user state in the contact center widget + * @param page - The Playwright page object + * @param userState - The target user state (e.g., 'Available', 'Meeting', 'Lunch Break') + * @description Skips the change if already in the target state + * @throws {Error} When the specified state is not a valid option + * @example + * ```typescript + * await changeUserState(page, USER_STATES.AVAILABLE); + * await changeUserState(page, 'Meeting'); + * ``` + */ +export const changeUserState = async (page: Page, userState: string): Promise => { + // Get the current state name + const currentState = await page.getByTestId('state-select').getByTestId('state-name').innerText(); + if (currentState.trim() === userState) { + return; + } + + await page.getByTestId('state-select').click(); + const stateItem = page.getByTestId(`state-item-${userState}`); + const isValidState = await stateItem.isVisible().catch(() => false); + + if (!isValidState) { + throw new Error(`State "${userState}" is not a valid state option.`); + } + + await stateItem.click(); +}; + +/** + * Retrieves the current user state from the widget + * @param page - The Playwright page object + * @returns Promise - The current state name (trimmed) + * @example + * ```typescript + * const currentState = await getCurrentState(page); + * console.log(`Agent is currently: ${currentState}`); + * ``` + */ +export const getCurrentState = async (page: Page): Promise => { + const stateName = await page.getByTestId('state-select').getByTestId('state-name').innerText(); + return stateName.trim(); +}; + +/** + * Verifies that the current user state matches the expected state + * @param page - The Playwright page object + * @param expectedState - The state that should be currently active + * @throws {Error} When the current state doesn't match the expected state + * @example + * ```typescript + * await changeUserState(page, USER_STATES.AVAILABLE); + * await verifyCurrentState(page, USER_STATES.AVAILABLE); // Will pass + * await verifyCurrentState(page, USER_STATES.MEETING); // Will throw error + * ``` + */ +export const verifyCurrentState = async (page: Page, expectedState: string): Promise => { + const currentState = await getCurrentState(page); + if (currentState !== expectedState) { + throw new Error(`Expected state "${expectedState}" but found "${currentState}".`); + } +}; + +/** + * Retrieves the elapsed time for the current user state + * @param page - The Playwright page object + * @returns Promise - The elapsed time in format "MM:SS" or "MM:SS / MM:SS" for dual timers + * @description For idle states like 'Lunch Break', returns dual timer format showing both timers + * @example + * ```typescript + * const timer = await getStateElapsedTime(page); + * console.log(`Time in current state: ${timer}`); + * // Output: "05:23" or "05:23 / 12:45" for dual timers + * ``` + */ +export const getStateElapsedTime = async (page: Page): Promise => { + // Directly select the timer by its test id + const timerText = await page.getByTestId('elapsed-time').innerText(); + return timerText.trim(); +}; + +/** + * Validates that the console state change matches the expected state by checking onStateChange logs + * @param page - The Playwright page object + * @param state - The expected state name to validate against + * @param consoleMessages - Array of console messages to search through + * @returns Promise - True if the last onStateChange log matches the expected state + * @description Searches for the most recent "onStateChange invoked with state name:" log and validates the state + * @throws {Error} When no onStateChange log is found or state name cannot be extracted + * @example + * ```typescript + * const consoleMessages: string[] = []; + * page.on('console', (msg) => consoleMessages.push(msg.text())); + * + * await changeUserState(page, USER_STATES.AVAILABLE); + * const isValid = await validateConsoleStateChange(page, USER_STATES.AVAILABLE, consoleMessages); + * ``` + */ +// Validates that the console state change matches the expected state by checking the last onStateChange log +// and comparing it to the expected state name. +export const validateConsoleStateChange = async ( + page: Page, + state: string, + consoleMessages: string[] +): Promise => { + const lastStateChangeMessage = consoleMessages + .slice() + .reverse() + .find((msg) => msg.match(/onStateChange invoked with state name:\s*(.+)/i)); + + if (!lastStateChangeMessage) { + throw new Error('No onStateChange log found in console messages'); + } + + const stateMatch = lastStateChangeMessage.match(/onStateChange invoked with state name:\s*(.+)/i); + const actualState = stateMatch?.[1]?.trim(); + + if (!actualState) { + throw new Error('Failed to extract state name from onStateChange console message'); + } + + // Simplified comparison logic + const expectedState = state.trim().toLowerCase(); + const loggedState = actualState.toLowerCase(); + return expectedState === loggedState; +}; + +/** + * Validates the correct sequence of API success and callback invocation for state changes + * @param page - The Playwright page object + * @param expectedState - The expected state name to validate against + * @param consoleMessages - Array of console messages to analyze for sequence validation + * @returns Promise - True if callback sequence is correct and state matches + * @description Ensures that API success occurs before onStateChange callback and validates the final state + * @throws {Error} When API success message is not found + * @throws {Error} When onStateChange callback is not found + * @throws {Error} When callback occurs before API success (incorrect sequence) + * @throws {Error} When no onStateChange log is found + * @throws {Error} When state name cannot be extracted from onStateChange log + * @example + * ```typescript + * const consoleMessages: string[] = []; + * page.on('console', (msg) => consoleMessages.push(msg.text())); + * + * await changeUserState(page, USER_STATES.AVAILABLE); + * const isSequenceValid = await checkCallbackSequence(page, USER_STATES.AVAILABLE, consoleMessages); + * if (!isSequenceValid) { + * throw new Error('Callback sequence validation failed'); + * } + * ``` + */ +export async function checkCallbackSequence( + page: Page, + expectedState: string, + consoleMessages: string[] +): Promise { + const reversedMessages = consoleMessages.slice().reverse(); + + // Find last index of API success using reverse().findIndex() + const apiSuccessReverseIndex = reversedMessages.findIndex((msg) => + msg.includes('WXCC_SDK_AGENT_STATE_CHANGE_SUCCESS') + ); + + // Find last index of onStateChange callback using reverse().findIndex() + const callbackReverseIndex = reversedMessages.findIndex( + (msg) => msg.toLowerCase().includes('onstatechange') && msg.toLowerCase().includes('invoked') + ); + + // Validate that both messages exist + if (apiSuccessReverseIndex === -1) { + throw new Error('API success message not found in console'); + } + if (callbackReverseIndex === -1) { + throw new Error('onStateChange callback not found in console'); + } + + // Convert reversed indices to original indices for comparison + const apiSuccessIndex = consoleMessages.length - 1 - apiSuccessReverseIndex; + const callbackIndex = consoleMessages.length - 1 - callbackReverseIndex; + + // Validate sequence: callback must come after API success + if (callbackIndex <= apiSuccessIndex) { + throw new Error( + `Callback occurred before API success (callback index: ${callbackIndex}, API index: ${apiSuccessIndex})` + ); + } + + const lastStateChangeMessage = reversedMessages.find((msg) => + msg.match(/onStateChange invoked with state name:\s*(.+)/i) + ); + + if (!lastStateChangeMessage) { + throw new Error('No onStateChange log found in console messages'); + } + + const stateMatch = lastStateChangeMessage.match(/onStateChange invoked with state name:\s*(.+)/i); + const actualState = stateMatch?.[1]?.trim(); + + if (!actualState) { + throw new Error('Failed to extract state name from onStateChange console message'); + } + + return actualState.toLowerCase() === expectedState.trim().toLowerCase(); +} diff --git a/playwright/constants.ts b/playwright/constants.ts new file mode 100644 index 000000000..7a29d6577 --- /dev/null +++ b/playwright/constants.ts @@ -0,0 +1,20 @@ +export const BASE_URL = 'http://localhost:3000'; + +export const USER_STATES = { + MEETING: 'Meeting', + AVAILABLE: 'Available', + LUNCH: 'Lunch Break', +}; + +export const THEME_COLORS = { + AVAILABLE: 'rgb(206, 245, 235)', + MEETING: 'rgba(0, 0, 0, 0.11)', +}; + +export const LOGIN_MODE = { + DESKTOP: 'Desktop', + EXTENSION: 'Extension', + DIAL_NUMBER: 'Dial Number', +}; + +export const LONG_WAIT = 40000; diff --git a/playwright/global.setup.ts b/playwright/global.setup.ts index 757df67d5..01ad05265 100644 --- a/playwright/global.setup.ts +++ b/playwright/global.setup.ts @@ -1,25 +1,15 @@ import {test as setup} from '@playwright/test'; +import {oauthLogin} from './Utils/initUtils'; const fs = require('fs'); const path = require('path'); +import dotenv from 'dotenv'; + +dotenv.config(); setup('OAuth', async ({browser}) => { - if (!process.env.PLAYWRIGHT_USERNAME || !process.env.PLAYWRIGHT_PASSWORD) { - throw new Error('PLAYWRIGHT_USERNAME and PLAYWRIGHT_PASSWORD must be set in the environment variables'); - } + const agentId = 'AGENT1'; // Configure which agent to set up const page = await browser.newPage(); - await page.goto('http://localhost:3000/'); - - await page.locator('#select-base-triggerid').getByText('Access Token').click(); - await page.getByTestId('samples:login_option_oauth').click(); - await page.getByRole('button', {name: 'Login with Webex'}).click(); - - await page.getByRole('textbox', {name: 'name@example.com'}).click(); - await page.getByRole('textbox', {name: 'name@example.com'}).fill(process.env.PLAYWRIGHT_USERNAME); - await page.getByRole('link', {name: 'Sign in'}).click(); - - await page.getByRole('textbox', {name: 'Password'}).click(); - await page.getByAltText('Password ').fill(process.env.PLAYWRIGHT_PASSWORD); - await page.getByRole('button', {name: 'Sign in'}).click(); + await oauthLogin(page, agentId); await page.getByRole('textbox').click(); const accessToken = await page.getByRole('textbox').inputValue(); @@ -28,11 +18,14 @@ setup('OAuth', async ({browser}) => { let envContent = ''; if (fs.existsSync(envPath)) { envContent = fs.readFileSync(envPath, 'utf8'); - // Remove any existing ACCESS_TOKEN line + // Remove any existing ACCESS_TOKEN line for this agent + const accessTokenPattern = new RegExp(`^PW_${agentId}_ACCESS_TOKEN=.*$`, 'm'); + envContent = envContent.replace(accessTokenPattern, ''); + // Also remove legacy ACCESS_TOKEN for backward compatibility envContent = envContent.replace(/^ACCESS_TOKEN=.*$/m, ''); // Ensure trailing newline if (!envContent.endsWith('\n')) envContent += '\n'; } - envContent += `ACCESS_TOKEN=${accessToken}\n`; + envContent += `PW_${agentId}_ACCESS_TOKEN=${accessToken}\n`; fs.writeFileSync(envPath, envContent, 'utf8'); }); diff --git a/playwright/login-user-state.spec.ts b/playwright/login-user-state.spec.ts index 8059c4b19..8dd9960eb 100644 --- a/playwright/login-user-state.spec.ts +++ b/playwright/login-user-state.spec.ts @@ -1,18 +1,20 @@ import {test, expect} from '@playwright/test'; import fs from 'fs'; +import dotenv from 'dotenv'; +dotenv.config(); test.describe('Login and User State tests', async () => { test('Login: should login using Extension login option', async ({page}) => { await page.goto('http://localhost:3000/'); await page.getByRole('textbox').click(); - if (!process.env.ACCESS_TOKEN) { + if (!process.env.PW_AGENT1_ACCESS_TOKEN) { throw new Error('ACCESS_TOKEN is not defined, OAuth failed'); } - await page.getByRole('textbox').fill(process.env.ACCESS_TOKEN); + await page.getByRole('textbox').fill(process.env.PW_AGENT1_ACCESS_TOKEN); await page.getByRole('checkbox', {name: 'Enable Multi Login'}).check(); await page.getByRole('button', {name: 'Init Widgets'}).click(); - await page.getByTestId('station-login-widget').waitFor({state: 'visible'}); + await page.getByTestId('station-login-widget').waitFor({state: 'visible', timeout: 70000}); const loginButtonExists = await page .getByTestId('login-button') @@ -23,7 +25,7 @@ test.describe('Login and User State tests', async () => { await expect(page.getByTestId('login-button')).toContainText('Save & Continue'); await page.getByTestId('login-option-select').click(); await page.getByTestId('login-option-Extension').click(); - await page.getByTestId('dial-number-input').getByRole('textbox').fill('1234'); + await page.getByTestId('dial-number-input').getByRole('textbox').fill('1001'); await expect(page.getByTestId('login-option-select').locator('#select-base-triggerid')).toContainText( 'Extension' @@ -40,6 +42,7 @@ test.describe('Login and User State tests', async () => { await page.getByTestId('state-item-Available').click(); await expect(page.getByTestId('state-select').getByTestId('state-name')).toContainText('Available'); + await page.close(); }); test('Multilogin: should login across tabs', async ({browser}) => { @@ -52,11 +55,11 @@ test.describe('Login and User State tests', async () => { await page.getByRole('textbox').click(); await page2.getByRole('textbox').click(); - if (!process.env.ACCESS_TOKEN) { + if (!process.env.PW_AGENT1_ACCESS_TOKEN) { throw new Error('ACCESS_TOKEN is not defined, OAuth failed'); } - await page.getByRole('textbox').fill(process.env.ACCESS_TOKEN); - await page2.getByRole('textbox').fill(process.env.ACCESS_TOKEN); + await page.getByRole('textbox').fill(process.env.PW_AGENT1_ACCESS_TOKEN); + await page2.getByRole('textbox').fill(process.env.PW_AGENT1_ACCESS_TOKEN); await page.getByRole('checkbox', {name: 'Enable Multi Login'}).check(); await page2.getByRole('checkbox', {name: 'Enable Multi Login'}).check(); @@ -64,8 +67,8 @@ test.describe('Login and User State tests', async () => { await page.getByRole('button', {name: 'Init Widgets'}).click(); await page2.getByRole('button', {name: 'Init Widgets'}).click(); - await page.getByTestId('station-login-widget').waitFor({state: 'visible'}); - await page2.getByTestId('station-login-widget').waitFor({state: 'visible'}); + await page.getByTestId('station-login-widget').waitFor({state: 'visible', timeout: 70000}); + await page2.getByTestId('station-login-widget').waitFor({state: 'visible', timeout: 70000}); const loginButtonExists = await page .getByTestId('login-button') @@ -77,7 +80,7 @@ test.describe('Login and User State tests', async () => { await page.getByTestId('login-option-select').click(); await page.getByTestId('login-option-Extension').click(); - await page.getByTestId('dial-number-input').getByRole('textbox').fill('1234'); + await page.getByTestId('dial-number-input').getByRole('textbox').fill('1001'); await expect(page.getByTestId('login-option-select').locator('#select-base-triggerid')).toContainText( 'Extension' @@ -98,6 +101,10 @@ test.describe('Login and User State tests', async () => { // Tab 2 should reflect Available state if login synced await expect(page2.getByTestId('state-select').getByTestId('state-name')).toContainText('Available'); + + await page.close(); + await page2.close(); + await context.close(); }); test('Relogin: should login after a refresh with same deviceType', async ({browser}) => { @@ -108,14 +115,14 @@ test.describe('Login and User State tests', async () => { await page.goto('http://localhost:3000/'); await page.getByRole('textbox').click(); - if (!process.env.ACCESS_TOKEN) { + if (!process.env.PW_AGENT1_ACCESS_TOKEN) { throw new Error('ACCESS_TOKEN is not defined, OAuth failed'); } - await page.getByRole('textbox').fill(process.env.ACCESS_TOKEN); + await page.getByRole('textbox').fill(process.env.PW_AGENT1_ACCESS_TOKEN); await page.getByRole('button', {name: 'Init Widgets'}).click(); - await page.getByTestId('station-login-widget').waitFor({state: 'visible'}); + await page.getByTestId('station-login-widget').waitFor({state: 'visible', timeout: 70000}); const loginButtonExists = await page .getByTestId('login-button') .isVisible() @@ -125,7 +132,7 @@ test.describe('Login and User State tests', async () => { await expect(page.getByTestId('login-button')).toContainText('Save & Continue'); await page.getByTestId('login-option-select').click(); await page.getByTestId('login-option-Extension').click(); - await page.getByTestId('dial-number-input').getByRole('textbox').fill('1234'); + await page.getByTestId('dial-number-input').getByRole('textbox').fill('1001'); await expect(page.getByTestId('login-option-select').locator('#select-base-triggerid')).toContainText( 'Extension' @@ -142,18 +149,21 @@ test.describe('Login and User State tests', async () => { await expect(page.getByTestId('state-select')).toBeVisible(); await page.getByTestId('state-item-Available').click(); + await page.waitForTimeout(5000); await expect(page.getByTestId('state-select').getByTestId('state-name')).toContainText('Available'); await page.reload(); await page.getByRole('textbox').click(); - if (!process.env.ACCESS_TOKEN) { + if (!process.env.PW_AGENT1_ACCESS_TOKEN) { throw new Error('ACCESS_TOKEN is not defined, OAuth failed'); } - await page.getByRole('textbox').fill(process.env.ACCESS_TOKEN); + await page.getByRole('textbox').fill(process.env.PW_AGENT1_ACCESS_TOKEN); await page.getByRole('button', {name: 'Init Widgets'}).click(); - await page.getByTestId('station-login-widget').waitFor({state: 'visible'}); + await page.getByTestId('station-login-widget').waitFor({state: 'visible', timeout: 70000}); await expect(page.getByTestId('login-option-select').locator('#select-base-triggerid')).toContainText('Extension'); await expect(page.getByTestId('state-name')).toContainText('Available'); + await page.close(); + await context.close(); }); }); diff --git a/playwright/user-state-test.spec.ts b/playwright/user-state-test.spec.ts new file mode 100644 index 000000000..2ef25e5a0 --- /dev/null +++ b/playwright/user-state-test.spec.ts @@ -0,0 +1,186 @@ +import {test, expect, Page, BrowserContext} from '@playwright/test'; +import { + enableAllWidgets, + enableMultiLogin, + initialiseWidgets, + agentRelogin, + setupMultiLoginPage, + loginViaAccessToken, +} from './Utils/initUtils'; +import {stationLogout, telephonyLogin} from './Utils/stationLoginUtils'; +import { + getCurrentState, + changeUserState, + verifyCurrentState, + getStateElapsedTime, + validateConsoleStateChange, + checkCallbackSequence, +} from './Utils/userStateUtils'; +import {USER_STATES, THEME_COLORS, LOGIN_MODE} from './constants'; +import dotenv from 'dotenv'; + +dotenv.config(); + +let page: Page; +let context: BrowserContext; +let consoleMessages: string[] = []; + +// Shared login and setup before all tests + +test.describe('User State Widget Functionality Tests', () => { + test.beforeAll(async ({browser}) => { + context = await browser.newContext(); + page = await context.newPage(); + consoleMessages = []; + page.on('console', (msg) => consoleMessages.push(msg.text())); + await loginViaAccessToken(page, 'AGENT1'); + await enableMultiLogin(page); + await enableAllWidgets(page); + + await initialiseWidgets(page); + + const loginButtonExists = await page + .getByTestId('login-button') + .isVisible() + .catch(() => false); + if (loginButtonExists) { + await telephonyLogin(page, LOGIN_MODE.EXTENSION); + } else { + await stationLogout(page); + await telephonyLogin(page, LOGIN_MODE.EXTENSION); + } + await expect(page.getByTestId('state-select')).toBeVisible(); + }); + + test.afterAll(async () => { + await stationLogout(page); + await context.close(); + }); + + test.beforeEach(async () => { + consoleMessages.length = 0; + }); + + test('should verify initial state is Meeting', async () => { + const state = await getCurrentState(page); + if (state !== USER_STATES.MEETING) throw new Error('Initial state is not Meeting'); + }); + + test('should verify Meeting state theme color', async () => { + const meetingThemeElement = page.getByTestId('state-select'); + const meetingThemeColor = await meetingThemeElement.evaluate((el) => getComputedStyle(el).backgroundColor); + expect(meetingThemeColor).toBe(THEME_COLORS.MEETING); + }); + + test('should change state to Available and verify theme and timer reset', async () => { + await verifyCurrentState(page, USER_STATES.MEETING); + await page.waitForTimeout(5000); + const timerBefore = await getStateElapsedTime(page); + await changeUserState(page, USER_STATES.AVAILABLE); + await page.waitForTimeout(3000); + const timerAfter = await getStateElapsedTime(page); + + const parseTimer = (timer: string) => { + const parts = timer.split(':'); + return parseInt(parts[0], 10) * 60 + parseInt(parts[1], 10); + }; + + expect(parseTimer(timerAfter)).toBeLessThan(parseTimer(timerBefore)); + + const themeElement = page.getByTestId('state-select'); + const themeColor = await themeElement.evaluate((el) => getComputedStyle(el).backgroundColor); + expect(themeColor).toBe(THEME_COLORS.AVAILABLE); + }); + + test('should verify existence and order in which callback and API success are logged for Available state', async () => { + await changeUserState(page, USER_STATES.MEETING); + await page.waitForTimeout(3000); + consoleMessages.length = 0; + await changeUserState(page, USER_STATES.AVAILABLE); + await page.waitForTimeout(3000); + const isCallbackSuccessful = await checkCallbackSequence(page, USER_STATES.AVAILABLE, consoleMessages); + if (!isCallbackSuccessful) throw new Error('Callback for Available state not successful'); + }); + + test('should verify state persistence after page reload', async () => { + await changeUserState(page, USER_STATES.AVAILABLE); + await verifyCurrentState(page, USER_STATES.AVAILABLE); + await page.waitForTimeout(3000); + + consoleMessages.length = 0; + await agentRelogin(page); + + const visible = await page.getByTestId('state-select').isVisible(); + if (!visible) throw new Error('State select not visible after reload'); + + await verifyCurrentState(page, USER_STATES.AVAILABLE); + const callbackTriggered = await validateConsoleStateChange(page, USER_STATES.AVAILABLE, consoleMessages); + if (!callbackTriggered) throw new Error('Callback not triggered after reload'); + + const state = await getCurrentState(page); + if (state !== USER_STATES.AVAILABLE) throw new Error('State is not Available after reload'); + }); + + test('should test multi-session synchronization', async () => { + const multiSessionPage = await setupMultiLoginPage(context); + + await changeUserState(page, USER_STATES.MEETING); + await verifyCurrentState(page, USER_STATES.MEETING); + await multiSessionPage.waitForTimeout(3000); + + await verifyCurrentState(multiSessionPage, USER_STATES.MEETING); + + await multiSessionPage.waitForTimeout(3000); + const [timer1, timer2] = await Promise.all([getStateElapsedTime(page), getStateElapsedTime(multiSessionPage)]); + + //Parse the timers to compare + const parseTimer = (timer: string) => { + const parts = timer.split(':'); + return parseInt(parts[0], 10) * 60 + parseInt(parts[1], 10); + }; + const timer1Parsed = parseTimer(timer1); + const timer2Parsed = parseTimer(timer2); + + if (Math.abs(timer1Parsed - timer2Parsed) > 1) { + throw new Error(`Multi-session timer synchronization failed: Primary=${timer1Parsed}, Secondary=${timer2Parsed}`); + } + + await multiSessionPage.close(); + }); + + test('should test idle state transition and dual timer', async () => { + await verifyCurrentState(page, USER_STATES.MEETING); + await page.waitForTimeout(2000); + consoleMessages.length = 0; + + await changeUserState(page, USER_STATES.LUNCH); + await verifyCurrentState(page, USER_STATES.LUNCH); + await page.waitForTimeout(3000); + + const found = await validateConsoleStateChange(page, USER_STATES.LUNCH, consoleMessages); + if (!found) throw new Error('Callback for Lunch state not successful'); + + await page.waitForTimeout(5000); + const dualTimer = await getStateElapsedTime(page); + + const timerParts = dualTimer.split(' / '); + if (timerParts.length !== 2) throw new Error('Dual timer format is incorrect'); + + const isValidFormat = timerParts.every((part) => /^(\d{1,2}:\d{2}(:\d{2})?)$/.test(part)); + if (!isValidFormat) throw new Error('Dual timer format is not valid'); + + const [firstTimer, secondTimer] = timerParts.map((part) => part.split(':').map(Number)); + if (firstTimer.length < 2 || secondTimer.length < 2) { + throw new Error('Dual timer does not have enough parts'); + } + + expect(firstTimer[0]).toBeGreaterThanOrEqual(0); + expect(firstTimer[1]).toBeGreaterThanOrEqual(0); + expect(secondTimer[0]).toBeGreaterThanOrEqual(0); + expect(secondTimer[1]).toBeGreaterThanOrEqual(0); + expect(firstTimer.length === 2 || firstTimer.length === 3).toBe(true); + expect(secondTimer.length === 2 || secondTimer.length === 3).toBe(true); + + await changeUserState(page, USER_STATES.AVAILABLE); + }); +}); 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 0effda765..cb4385670 100644 --- a/widgets-samples/cc/samples-cc-react-app/src/App.tsx +++ b/widgets-samples/cc/samples-cc-react-app/src/App.tsx @@ -329,14 +329,16 @@ const onTaskDeclined = (task,reason) => { }; }, []); - const onStateChange = (status) => { - console.log('onStateChange invoked', status); - if (!status || !status.name) return; - if (status.name !== 'RONA') { - setShowRejectedPopup(false); - setRejectedReason(''); - } - }; + const onStateChange = (status) => { + console.log('onStateChange invoked', status); + //adding a log to be used for automation + console.log('onStateChange invoked with state name:', status?.name); + if (!status || !status.name) return; + if (status.name !== 'RONA') { + setShowRejectedPopup(false); + setRejectedReason(''); + } +}; const stationLogout = () => { store.cc.stationLogout({logoutReason: 'User requested logout'}) @@ -420,6 +422,7 @@ const onTaskDeclined = (task,reason) => { )} {loginType === 'oauth' && ( )} @@ -526,6 +531,7 @@ const onTaskDeclined = (task,reason) => {  SDK Toggles