diff --git a/docs/samples/contact-center/app.js b/docs/samples/contact-center/app.js index 73f25ed71b2..54775cc15ef 100644 --- a/docs/samples/contact-center/app.js +++ b/docs/samples/contact-center/app.js @@ -98,17 +98,17 @@ function isIncomingTask(task, agentId) { ); }; -// Store and Grab `access-token` from sessionStorage -if (sessionStorage.getItem('date') > new Date().getTime()) { - tokenElm.value = sessionStorage.getItem('access-token'); +// Store and Grab `access-token` from localStorage +if (localStorage.getItem('date') > new Date().getTime()) { + tokenElm.value = localStorage.getItem('access-token'); } else { - sessionStorage.removeItem('access-token'); + localStorage.removeItem('access-token'); } -tokenElm.addEventListener('change', (event) => { - sessionStorage.setItem('access-token', event.target.value); - sessionStorage.setItem('date', new Date().getTime() + (12 * 60 * 60 * 1000)); +tokenElm.addEventListener('input', (event) => { + localStorage.setItem('access-token', event.target.value); + localStorage.setItem('date', new Date().getTime() + (12 * 60 * 60 * 1000)); }); setAgentStateButton.addEventListener('click', () => { @@ -135,6 +135,8 @@ async function uploadLogs() { function changeAuthType() { switch (authTypeElm.value) { case 'accessToken': + tokenElm.readOnly = false; + saveElm.disabled = false; toggleDisplay('credentials', true); toggleDisplay('oauth', false); break; @@ -192,10 +194,15 @@ function initOauth() { .concat(additionalScopes)) ).join(' '); + // Use environment variable if available (via build process) or global variable (via test injection), otherwise fallback to hardcoded + const clientId = (typeof process !== 'undefined' && process.env && process.env.CLIENT_ID) || + (window.WEBEX_CLIENT_ID) || + 'C04ef08ffce356c3161bb66b15dbdd98d26b6c683c5ce1a1a89efad545fdadd74'; + webex = window.webex = Webex.init({ config: generateWebexConfig({ credentials: { - client_id: 'C04ef08ffce356c3161bb66b15dbdd98d26b6c683c5ce1a1a89efad545fdadd74', + client_id: clientId, redirect_uri: redirectUri, scope: requestedScopes, } @@ -205,14 +212,51 @@ function initOauth() { localStorage.setItem('OAuth', true); webex.once('ready', () => { + const syncAccessTokenFromSupertoken = () => { + const accessToken = webex?.credentials?.supertoken?.access_token; + if (!accessToken) { + return; + } + + tokenElm.value = accessToken; + localStorage.setItem('access-token', accessToken); + localStorage.setItem('date', new Date().getTime() + (12 * 60 * 60 * 1000)); + + tokenElm.readOnly = true; + saveElm.disabled = true; + authStatusElm.innerText = 'Saved access token!'; + registerStatus.innerHTML = 'Not Subscribed'; + registerBtn.disabled = false; + initializeEngageWidget(); + }; + + webex.credentials.on('change:supertoken', syncAccessTokenFromSupertoken); + oauthFormElm.addEventListener('submit', (event) => { event.preventDefault(); - // initiate the login sequence if not authenticated. - webex.authorization.initiateLogin(); + const isIframe = window !== window.parent; + + // When loaded inside an iframe, pop OAuth in a new window so redirect works. + if (isIframe) { + webex.authorization.initiateLogin({ + separateWindow: { + width: 800, + height: 600, + menubar: 'no', + toolbar: 'no', + location: 'yes' + } + }); + } + else { + // initiate the login sequence if not authenticated. + webex.authorization.initiateLogin(); + } }); if (webex.canAuthorize) { oauthStatusElm.innerText = 'Authenticated'; + syncAccessTokenFromSupertoken(); } }); } @@ -1351,7 +1395,7 @@ function initWebex(e) { e.preventDefault(); console.log('Authentication#initWebex()'); - tokenElm.disabled = true; + tokenElm.readOnly = true; saveElm.disabled = true; authStatusElm.innerText = 'initializing...'; @@ -1947,9 +1991,9 @@ if (window.location.hash) { const expiresIn = urlParams.get('expires_in'); if (accessToken) { - sessionStorage.setItem('access-token', accessToken); - sessionStorage.setItem('date', new Date().getTime() + parseInt(expiresIn, 10)); - tokenElm.disabled = true; + localStorage.setItem('access-token', accessToken); + localStorage.setItem('date', new Date().getTime() + parseInt(expiresIn, 10)); + tokenElm.readOnly = true; saveElm.disabled = true; authStatusElm.innerText = 'Saved access token!'; registerStatus.innerHTML = 'Not Subscribed'; @@ -2354,4 +2398,3 @@ updateLoginOptionElm.addEventListener('change', updateApplyButtonState); updateDialNumberElm.addEventListener('input', updateApplyButtonState); updateApplyButtonState(); - diff --git a/packages/@webex/contact-center/package.json b/packages/@webex/contact-center/package.json index dd702346640..b73e3587ed7 100644 --- a/packages/@webex/contact-center/package.json +++ b/packages/@webex/contact-center/package.json @@ -4,6 +4,7 @@ "license": "Cisco's General Terms (https://www.cisco.com/site/us/en/about/legal/contract-experience/index.html)", "contributors": [ "Adhwaith Menon ", + "Arun Ganeshan ", "Bharath Balan ", "Kesava Krishnan Madavan ", "Priya Kesari ", @@ -41,6 +42,17 @@ "test": "yarn test:style && yarn test:unit", "test:style": "eslint 'src/**/*.ts'", "test:unit": "webex-legacy-tools test --unit --runner jest", + "fetch:e2e:tokens": "node ./scripts/fetch-e2e-access-tokens.js", + "pretest:e2e": "yarn run -T fetch:e2e:tokens", + "test:e2e": "playwright test", + "test:e2e:smoke": "playwright test smoke", + "test:e2e:headed": "playwright test --headed", + "test:e2e:ui": "playwright test --ui", + "test:e2e:debug": "playwright test --debug", + "test:e2e:chromium": "playwright test --project=chromium", + "test:e2e:firefox": "playwright test --project=firefox", + "test:e2e:report": "playwright show-report test/e2e/playwright-report", + "test:e2e:codegen": "playwright codegen http://localhost:8080/contact-center/", "deploy:npm": "yarn npm publish" }, "dependencies": { @@ -58,6 +70,7 @@ "devDependencies": { "@babel/core": "^7.22.11", "@babel/preset-typescript": "7.22.11", + "@playwright/test": "^1.57.0", "@types/jest": "27.4.1", "@typescript-eslint/eslint-plugin": "5.38.1", "@typescript-eslint/parser": "5.38.1", @@ -66,6 +79,7 @@ "@webex/jest-config-legacy": "workspace:*", "@webex/legacy-tools": "workspace:*", "@webex/test-helper-mock-webex": "workspace:*", + "dotenv": "^17.2.3", "eslint": "^8.24.0", "eslint-config-airbnb-base": "15.0.0", "eslint-config-prettier": "8.3.0", @@ -76,6 +90,7 @@ "eslint-plugin-tsdoc": "0.2.14", "jest": "27.5.1", "jest-junit": "13.0.0", + "nodemailer": "^6.9.13", "prettier": "2.5.1", "typedoc": "^0.25.0", "typescript": "4.9.5" diff --git a/packages/@webex/contact-center/playwright.config.ts b/packages/@webex/contact-center/playwright.config.ts new file mode 100644 index 00000000000..8cdd4b26b0a --- /dev/null +++ b/packages/@webex/contact-center/playwright.config.ts @@ -0,0 +1,97 @@ +import {defineConfig, devices} from '@playwright/test'; +import dotenv from 'dotenv'; +import path from 'path'; +import {USER_SETS} from './test/e2e/playwright/test-data'; + +dotenv.config({path: path.resolve(__dirname, './test/e2e/.env.contact-center.e2e')}); + +const dummyAudioPath = path.resolve(__dirname, './test/e2e/playwright/wav/dummyAudio.wav'); + +/** + * Playwright configuration for the Contact Center E2E suites. + * Each USER_SET in test-data.ts becomes its own project so we can parallelize the + * multi-agent scenarios without clashing credentials. + */ +export default defineConfig({ + testDir: './test/e2e/playwright/suites', + outputDir: './test/e2e/test-results', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 1 : 0, + workers: Object.keys(USER_SETS).length || 1, + timeout: 3 * 60 * 1000, + expect: { + timeout: 10 * 1000, + }, + reporter: [ + [ + 'html', + { + outputFolder: './test/e2e/playwright-report', + open: process.env.CI ? 'never' : 'on-failure', + }, + ], + [ + 'junit', + { + outputFile: './test/e2e/junit.xml', + }, + ], + ['list'], + ], + use: { + baseURL: 'https://localhost:8000', + trace: process.env.CI ? 'on-first-retry' : 'retain-on-failure', + video: process.env.CI ? 'retain-on-failure' : 'on', + screenshot: 'only-on-failure', + viewport: {width: 1280, height: 720}, + ignoreHTTPSErrors: true, + launchOptions: { + args: [ + '--use-fake-ui-for-media-stream', + '--use-fake-device-for-media-stream', + '--allow-insecure-localhost', + ], + }, + }, + projects: [ + { + name: 'OAuth: Get Access Token', + testDir: './test/e2e/playwright', + testMatch: /global\.setup\.ts/, + }, + ...Object.entries(USER_SETS).map(([setName, setData], index) => ({ + name: setName, + testMatch: [`**/suites/${setData.TEST_SUITE}`], + dependencies: ['OAuth: Get Access Token'], + retries: process.env.CI ? 1 : 0, + use: { + ...devices['Desktop Chrome'], + channel: 'chrome', + baseURL: 'https://localhost:8000', + launchOptions: { + args: [ + '--use-fake-ui-for-media-stream', + '--use-fake-device-for-media-stream', + '--allow-insecure-localhost', + `--use-file-for-fake-audio-capture=${dummyAudioPath}`, + `--remote-debugging-port=${9221 + index}`, + '--disable-site-isolation-trials', + '--disable-web-security', + '--no-sandbox', + '--disable-features=WebRtcHideLocalIpsWithMdns', + '--allow-file-access-from-files', + `--window-position=${index * 1400},0`, + '--window-size=1280,720', + ], + }, + }, + })), + ], + webServer: { + command: 'cd ../../.. && npx webpack serve --color --env NODE_ENV=development --host localhost', + port: 8000, + reuseExistingServer: !process.env.CI, + timeout: 2 * 60 * 1000, + }, +}); diff --git a/packages/@webex/contact-center/test/e2e/.env.contact-center.e2e b/packages/@webex/contact-center/test/e2e/.env.contact-center.e2e new file mode 100644 index 00000000000..6c579269d7d --- /dev/null +++ b/packages/@webex/contact-center/test/e2e/.env.contact-center.e2e @@ -0,0 +1,44 @@ +# Contact Center E2E Test Environment Variables + +# ============================================================================ +# AGENT 1 CREDENTIALS +# ============================================================================ +E2E_AGENT1_USERNAME=user15@ccsdk.wbx.ai +E2E_AGENT1_PASSWORD=$rrA%Z1zB6 +E2E_AGENT1_TOKEN= +E2E_AGENT1_EMAIL=user15@ccsdk.wbx.ai +E2E_AGENT1_EXTENSION=1015 + +# ============================================================================ +# AGENT 2 CREDENTIALS +# ============================================================================ +E2E_AGENT2_USERNAME=user16@ccsdk.wbx.ai +E2E_AGENT2_PASSWORD=$rrA%Z1zB6 +E2E_AGENT2_TOKEN= +E2E_AGENT2_EMAIL=user16@ccsdk.wbx.ai +E2E_AGENT2_EXTENSION=1016 + +# ============================================================================ +# CONTACT CENTER CONFIGURATION +# ============================================================================ +E2E_ORG_ID=d9ec32d3-2e8d-411a-bcce-eb2e5e2eb79c +E2E_TENANT_ID= +E2E_DATACENTER=us1 +E2E_CLIENT_ID=C70599433db154842e919ad9e18273d835945ff198251c82204b236b157b3a213 +E2E_CLIENT_SECRET=575ba9f5034f8a28dfef2770870c50bfc6e0b2b749f14e6a14845a1a47622f87 +E2E_REDIRECT_URI=https://localhost:8000/ +E2E_OAUTH_SCOPE="cjp:config_read cjp:config_write cjp:config cjp:user spark:webrtc_calling spark:calls_read spark:calls_write spark:xsi spark:kms" + +# ============================================================================ +# TEST RESOURCES +# ============================================================================ +E2E_QUEUE_NAME=Queue e2e 1 +E2E_ENTRY_POINT=+16266820030 +E2E_EMAIL_ENTRY_POINT=ccsdk.wbx.ai.e2e@gmail.com +E2E_CHAT_URL=https://widgets.webex.com/chat-client-e2e.html + +# ============================================================================ +# TEST CONFIGURATION +# ============================================================================ +E2E_TEST_TIMEOUT=120000 +E2E_WAIT_FOR_TASK_TIMEOUT=60000 diff --git a/packages/@webex/contact-center/test/e2e/playwright/README.md b/packages/@webex/contact-center/test/e2e/playwright/README.md new file mode 100644 index 00000000000..a42a9896e27 --- /dev/null +++ b/packages/@webex/contact-center/test/e2e/playwright/README.md @@ -0,0 +1,275 @@ +# Playwright E2E Testing Framework + +E2E testing framework for CC Widgets with **dynamic** parallel test execution. Test sets are automatically configured based on `test-data.ts`. + +## ๐Ÿ“ Structure + +``` +playwright/ +โ”œโ”€โ”€ suites/ # Test suite orchestration files +โ”‚ โ”œโ”€โ”€ digital-incoming-task-tests.spec.ts # Digital incoming task orchestration +โ”‚ โ”œโ”€โ”€ task-list-multi-session-tests.spec.ts # Task list and multi-session orchestration +โ”‚ โ”œโ”€โ”€ station-login-user-state-tests.spec.ts # Station login and user state orchestration +โ”‚ โ”œโ”€โ”€ basic-advanced-task-controls-tests.spec.ts # Basic and advanced task controls orchestration +โ”‚ โ””โ”€โ”€ advanced-task-controls-tests.spec.ts # Advanced task controls orchestration +โ”œโ”€โ”€ tests/ # Individual test implementations +โ”œโ”€โ”€ Utils/ # Utility functions +โ”œโ”€โ”€ test-data.ts # **CENTRAL CONFIG** - Test data & suite mapping +โ”œโ”€โ”€ test-manager.ts # Core test management +โ””โ”€โ”€ constants.ts # Shared constants +``` + +## ๐ŸŽฏ Dynamic Test Configuration + +**All test configuration is now centralized in `test-data.ts`**. The framework automatically: + +- โœ… Generates test projects from `USER_SETS` +- โœ… Sets worker count to match number of test sets +- โœ… Assigns unique debug ports (9221+) +- โœ… Positions browser windows automatically +- โœ… Maps test suites to user sets + +| Set | Focus | Port | Suite File | +| --------- | --------------------------------- | ---- | -------------------------------------------- | +| **SET_1** | Digital incoming tasks & controls | 9221 | `digital-incoming-task-tests.spec.ts` | +| **SET_2** | Task lists & multi-session | 9222 | `task-list-multi-session-tests.spec.ts` | +| **SET_3** | Authentication & user management | 9223 | `station-login-user-state-tests.spec.ts` | +| **SET_4** | Task controls & combinations | 9224 | `basic-advanced-task-controls-tests.spec.ts` | +| **SET_5** | Advanced task operations | 9225 | `advanced-task-controls-tests.spec.ts` | + +### Where to Add New Tests? + +| Test Type | Use Set | Why | +| ---------------------------- | ------- | --------------------------- | +| Digital channels tasks | SET_1 | Digital channels configured | +| Task list operations | SET_2 | Task list focus | +| Authentication/User states | SET_3 | User management | +| Basic/Advanced task controls | SET_4 | Task control operations | +| Complex advanced scenarios | SET_5 | Advanced operations | + +## ๐Ÿงช Adding New Tests + +### 1. Create Test File (in `tests/` folder) + +```typescript +// tests/my-feature-test.spec.ts +import {test, Page} from '@playwright/test'; +import {TestManager} from '../test-manager'; + +export default function createMyTests() { + return () => { + let testManager: TestManager; + let page: Page; + + test.beforeEach(async ({browser}) => { + testManager = new TestManager(browser); + const setup = await testManager.setupTest({ + needsAgent1: true, + enableConsoleLogging: true, + }); + page = setup.page; + }); + + test.afterEach(async () => { + await testManager.cleanup(); + }); + + test('should test my feature @myfeature', async () => { + // Your test code + }); + }; +} +``` + +### 2. Add to Test Set + +```typescript +// suites/advanced-task-controls-tests.spec.ts (choose appropriate set) +import createMyTests from '../tests/my-feature-test.spec'; + +test.describe('My Feature Tests', createMyTests()); +``` + +## โž• Adding New Test Set (Fully Automated) + +### 1. Add to `test-data.ts` + +```typescript +// test-data.ts - Just add your new set here! +export const USER_SETS = { + // ... existing sets + SET_6: { + AGENTS: { + AGENT1: {username: 'user27', extension: '1027', agentName: 'User27 Agent27'}, + AGENT2: {username: 'user28', extension: '1028', agentName: 'User28 Agent28'}, + }, + QUEUE_NAME: 'Queue e2e 6', + CHAT_URL: `${env.PW_CHAT_URL}-e2e-6.html`, + EMAIL_ENTRY_POINT: `${env.PW_SANDBOX}.e2e6@gmail.com`, + ENTRY_POINT: env.PW_ENTRY_POINT6, + TEST_SUITE: 'my-new-feature-tests.spec.ts', // ๐ŸŽฏ Key: maps to your test file + }, +}; +``` + +### 2. Create Test Suite File + +```typescript +// suites/my-new-feature-tests.spec.ts +import {test} from '@playwright/test'; +import createMyTests from '../tests/my-feature-test.spec'; + +test.describe('My New Feature Tests', createMyTests()); +``` + +**That's it!** The framework will automatically: + +- โœ… Add `SET_6` as a new project +- โœ… Assign debug port `9226` +- โœ… Position browser at `6500,0` +- โœ… Set workers to `6` +- โœ… Map to `my-new-feature-tests.spec.ts` + +### 3. ~~Manual Project Config~~ โŒ **NO LONGER NEEDED!** + +~~The old manual approach of editing `playwright.config.ts` is eliminated.~~ + +## ๐Ÿ”ง Key Utilities + +| Module | Key Functions | +| ------------------- | ----------------------------------------------------------- | +| `incomingTaskUtils` | `createChatTask()`, `acceptIncomingTask()`, `endChatTask()` | +| `taskControlUtils` | `holdTask()`, `resumeTask()`, `endTask()` | +| `userStateUtils` | `changeUserState()`, `verifyCurrentState()` | +| `stationLoginUtils` | `telephonyLogin()`, `stationLogout()` | + +### Common Usage + +```typescript +// Task management +await createChatTask(page, 'Customer message'); +await acceptIncomingTask(page); +await endTask(page); + +// State management +await changeUserState(page, USER_STATES.AVAILABLE); +await verifyCurrentState(page, USER_STATES.AVAILABLE); +``` + +## ๐Ÿ“Š Environment Setup + +Create `.env` file in project root: + +```env +PW_CHAT_URL=https://your-chat-url +PW_SANDBOX=your-sandbox-name +PW_ENTRY_POINT1=entry-point-1 +PW_ENTRY_POINT2=entry-point-2 +# ... PW_ENTRY_POINT3, 4, 5 +``` + +Test data is automatically handled by TestManager based on the running test set. + +## ๐Ÿš€ Running Tests + +```bash +# Run all tests (workers automatically set to USER_SETS.length) +yarn test:e2e + +# Run specific test suites +yarn test:e2e suites/digital-incoming-task-tests.spec.ts +yarn test:e2e suites/task-list-multi-session-tests.spec.ts +yarn test:e2e suites/station-login-user-state-tests.spec.ts +yarn test:e2e suites/basic-advanced-task-controls-tests.spec.ts +yarn test:e2e suites/advanced-task-controls-tests.spec.ts + +# Run specific test sets (projects) - names match USER_SETS keys +yarn test:e2e --project=SET_1 # Digital incoming tasks +yarn test:e2e --project=SET_2 # Task list & multi-session +yarn test:e2e --project=SET_3 # Station login & user state +yarn test:e2e --project=SET_4 # Basic & advanced task controls +yarn test:e2e --project=SET_5 # Advanced task controls +# yarn test:e2e --project=SET_6 # Your new set (auto-available) + +# Development & debugging +yarn test:e2e --ui # UI mode +yarn test:e2e --debug # Debug mode +yarn test:e2e --headed # Run with browser visible +``` + +## ๐Ÿ—๏ธ Architecture Benefits + +### Before (Manual) + +- โŒ Manual project configuration in `playwright.config.ts` +- โŒ Hard-coded worker count +- โŒ Manual port/position assignment +- โŒ Separate mapping files +- โŒ Error-prone when adding new sets + +### After (Dynamic) + +- โœ… **Single source of truth**: `test-data.ts` +- โœ… **Auto-scaling workers**: `Object.keys(USER_SETS).length` +- โœ… **Auto port assignment**: `9221 + index` +- โœ… **Auto positioning**: `index * 1300, 0` +- โœ… **Zero manual config**: Just add to `USER_SETS` +- โœ… **Type-safe**: Full TypeScript support + +## ๐Ÿ” Troubleshooting + +**Common Issues:** + +- Browser launch fails โ†’ Check Chrome and ports 9221+ (auto-assigned) +- Auth errors โ†’ Verify OAuth in `global.setup.ts` +- Widget timeouts โ†’ Increase `WIDGET_INIT_TIMEOUT` +- Test conflicts โ†’ Ports/positions are auto-managed per `USER_SETS` +- New set not appearing โ†’ Check `TEST_SUITE` property in `test-data.ts` + +**Debug logging:** + +```typescript +// Add to test setup +capturedLogs = []; +page.on('console', (msg) => capturedLogs.push(msg.text())); +``` + +## ๐ŸŽ›๏ธ Configuration Reference + +### Current Dynamic Setup + +```typescript +// playwright.config.ts - Auto-generated projects +workers: Object.keys(USER_SETS).length, // Scales automatically + +// Auto-generated per USER_SETS entry: +projects: [ + // ... OAuth setup + ...Object.entries(USER_SETS).map(([setName, setData], index) => ({ + name: setName, // SET_1, SET_2, etc. + testMatch: [`**/suites/${setData.TEST_SUITE}`], // From test-data.ts + debugPort: 9221 + index, // 9221, 9222, 9223... + windowPosition: `${index * 1300},0`, // 0,0 1300,0 2600,0... + })) +] +``` + +### test-data.ts Structure + +```typescript +export const USER_SETS = { + SET_X: { + // Agent configuration + AGENTS: { AGENT1: {...}, AGENT2: {...} }, + + // Environment configuration + QUEUE_NAME: 'Queue e2e X', + CHAT_URL: '...', + EMAIL_ENTRY_POINT: '...', + ENTRY_POINT: '...', + + // ๐ŸŽฏ NEW: Test suite mapping + TEST_SUITE: 'your-test-file.spec.ts', // Links to suite file + } +}; +``` diff --git a/packages/@webex/contact-center/test/e2e/playwright/Utils/advancedTaskControlUtils.ts b/packages/@webex/contact-center/test/e2e/playwright/Utils/advancedTaskControlUtils.ts new file mode 100644 index 00000000000..d227b95f6fc --- /dev/null +++ b/packages/@webex/contact-center/test/e2e/playwright/Utils/advancedTaskControlUtils.ts @@ -0,0 +1,244 @@ +import {Page, expect} from '@playwright/test'; +import {loginExtension} from './incomingTaskUtils'; +import {dismissOverlays} from './helperUtils'; +import {AWAIT_TIMEOUT, FORM_FIELD_TIMEOUT} from '../constants'; + +/** + * Utility functions for advanced task controls testing. + * Provides functions for consult operations, transfer operations, and end consult actions. + * These utilities handle complex multi-agent scenarios and task state transitions. + * + * @packageDocumentation + */ + +// Array to store captured console logs for verification +let capturedAdvancedLogs: string[] = []; + +/** + * Sets up console logging to capture transfer and consult related callback logs. + * Captures transfer success, consult start/end success, and related SDK messages. + * @param page - The agent's main page + * @returns Function to remove the console handler + */ +export function setupAdvancedConsoleLogging(page: Page): () => void { + capturedAdvancedLogs.length = 0; + + const consoleHandler = (msg) => { + const logText = msg.text(); + if ( + logText.includes('WXCC_SDK_TASK_TRANSFER_SUCCESS') || + logText.includes('WXCC_SDK_TASK_CONSULT_START_SUCCESS') || + logText.includes('WXCC_SDK_TASK_CONSULT_END_SUCCESS') || + logText.includes('AgentConsultTransferred') || + logText.includes('onEnd invoked') || + logText.includes('onTransfer invoked') || + logText.includes('onConsult invoked') + ) { + capturedAdvancedLogs.push(logText); + } + }; + + page.on('console', consoleHandler); + return () => page.off('console', consoleHandler); +} + +/** + * Clears the captured advanced logs array. + * Should be called before each test or verification to ensure clean state. + */ +export function clearAdvancedCapturedLogs(): void { + capturedAdvancedLogs.length = 0; +} + +/** + * Verifies that transfer success logs are present. + * @throws Error if verification fails with detailed error message + */ +export function verifyTransferSuccessLogs(): void { + const transferLogs = capturedAdvancedLogs.filter((log) => log.includes('WXCC_SDK_TASK_TRANSFER_SUCCESS')); + + if (transferLogs.length === 0) { + throw new Error( + `No 'WXCC_SDK_TASK_TRANSFER_SUCCESS' logs found. Captured logs: ${JSON.stringify(capturedAdvancedLogs)}` + ); + } +} + +/** + * Verifies that consult start success logs are present. + * @throws Error if verification fails with detailed error message + */ +export function verifyConsultStartSuccessLogs(): void { + const consultStartLogs = capturedAdvancedLogs.filter((log) => log.includes('WXCC_SDK_TASK_CONSULT_START_SUCCESS')); + + if (consultStartLogs.length === 0) { + throw new Error( + `No 'WXCC_SDK_TASK_CONSULT_START_SUCCESS' logs found. Captured logs: ${JSON.stringify(capturedAdvancedLogs)}` + ); + } +} + +/** + * Verifies that consult end success logs are present. + * @throws Error if verification fails with detailed error message + */ +export function verifyConsultEndSuccessLogs(): void { + const consultEndLogs = capturedAdvancedLogs.filter((log) => log.includes('WXCC_SDK_TASK_CONSULT_END_SUCCESS')); + + if (consultEndLogs.length === 0) { + throw new Error( + `No 'WXCC_SDK_TASK_CONSULT_END_SUCCESS' logs found. Captured logs: ${JSON.stringify(capturedAdvancedLogs)}` + ); + } +} + +/** + * Verifies that agent consult transferred logs are present (when consult is converted to transfer). + * @throws Error if verification fails with detailed error message + */ +export function verifyConsultTransferredLogs(): void { + const consultTransferredLogs = capturedAdvancedLogs.filter((log) => log.includes('AgentConsultTransferred')); + + if (consultTransferredLogs.length === 0) { + throw new Error(`No 'AgentConsultTransferred' logs found. Captured logs: ${JSON.stringify(capturedAdvancedLogs)}`); + } +} + +/** + * Unified function to handle consult and transfer actions for agent, queue, and dial number. + * @param page - The agent's main page + * @param type - 'agent' | 'queue' | 'dialNumber' + * @param action - 'consult' | 'transfer' + * @param value - agentName, queueName, or phoneNumber + * @returns Promise + */ +export async function consultOrTransfer( + page: Page, + type: 'agent' | 'queue' | 'dialNumber' | 'entryPoint', + action: 'consult' | 'transfer', + value: string +): Promise { + if (!value || value.trim() === '') { + throw new Error('Destination value is required for consult/transfer'); + } + + await openConsultOrTransferMenu(page, action); + await selectDestinationType(page, action, type); + await fillDestination(page, action, value); + await submitConsultOrTransfer(page, action); + + await page.waitForTimeout(2000); + if (action === 'consult') { + await expect(page.getByTestId('cancel-consult-btn')).toBeVisible({timeout: FORM_FIELD_TIMEOUT}); + } +} + +// ===== Internal helper functions ===== +const DESTINATION_TYPE_MAP: Record<'agent' | 'queue' | 'dialNumber' | 'entryPoint', string> = { + agent: 'agent', + queue: 'queue', + dialNumber: 'dialNumber', + entryPoint: 'entryPoint', +}; + +const CONSULT_SELECTORS = { + openButton: 'call-control:consult', + dialog: '#initiate-consult-dialog', + typeSelect: '#consult-destination-type', + destinationHolder: '#consult-destination-holder', + submit: '#initate-consult', +}; + +const TRANSFER_SELECTORS = { + openButton: 'call-control:transfer', + panel: '#transfer-options', + typeSelect: '#transfer-destination-type', + destinationHolder: '#transfer-destination-holder', + submit: '#initiate-transfer', +}; + +async function openConsultOrTransferMenu(page: Page, action: 'consult' | 'transfer'): Promise { + if (action === 'consult') { + await dismissOverlays(page); + await page.getByTestId(CONSULT_SELECTORS.openButton).click({timeout: AWAIT_TIMEOUT}); + await expect(page.locator(CONSULT_SELECTORS.dialog)).toBeVisible({timeout: FORM_FIELD_TIMEOUT}); + return; + } + await page.getByTestId(TRANSFER_SELECTORS.openButton).click({timeout: AWAIT_TIMEOUT}); + await expect(page.locator(TRANSFER_SELECTORS.panel)).toBeVisible({timeout: FORM_FIELD_TIMEOUT}); +} + +async function selectDestinationType( + page: Page, + action: 'consult' | 'transfer', + type: 'agent' | 'queue' | 'dialNumber' | 'entryPoint' +): Promise { + const value = DESTINATION_TYPE_MAP[type]; + if (!value) { + throw new Error(`Unsupported destination type: ${type}`); + } + const selector = action === 'consult' ? CONSULT_SELECTORS.typeSelect : TRANSFER_SELECTORS.typeSelect; + const select = page.locator(selector); + await select.waitFor({state: 'visible', timeout: FORM_FIELD_TIMEOUT}); + await select.selectOption({value}); + await page.waitForTimeout(200); +} + +async function fillDestination(page: Page, action: 'consult' | 'transfer', value: string): Promise { + const holderSelector = + action === 'consult' ? CONSULT_SELECTORS.destinationHolder : TRANSFER_SELECTORS.destinationHolder; + const holder = page.locator(holderSelector); + const control = holder.locator('input, select').first(); + await control.waitFor({state: 'visible', timeout: FORM_FIELD_TIMEOUT}); + const tag = await control.evaluate((el) => el.tagName.toLowerCase()); + if (tag === 'select') { + try { + await control.selectOption({label: value}); + } catch (error) { + await control.selectOption({value}); + } + } else { + await control.fill(value, {timeout: AWAIT_TIMEOUT}); + } +} + +async function submitConsultOrTransfer(page: Page, action: 'consult' | 'transfer'): Promise { + const selector = action === 'consult' ? CONSULT_SELECTORS.submit : TRANSFER_SELECTORS.submit; + await page.locator(selector).click({timeout: AWAIT_TIMEOUT}); +} + +/** + * Cancels an ongoing consult and resumes the original call. + * @param page - The agent's main page + * @returns Promise + */ +export async function cancelConsult(page: Page): Promise { + // Click cancel consult button + await page.getByTestId('cancel-consult-btn').click({timeout: AWAIT_TIMEOUT}); +} + +/** + * Ensures the Dial Number softphone (web.webex.com) page is logged in using env creds. + * Uses: PW_DIAL_NUMBER_LOGIN_USERNAME, PW_DIAL_NUMBER_LOGIN_PASSWORD + * Also dismisses any stale overlays/popovers (e.g., "Media failed") that might block interaction. + */ +export async function ensureDialNumberLoggedIn(page: Page): Promise { + const currentUrl = page?.url?.() || ''; + if (!/\.webex\.com\/calling/.test(currentUrl)) { + const user = process.env.PW_DIAL_NUMBER_LOGIN_USERNAME; + const pass = process.env.PW_DIAL_NUMBER_LOGIN_PASSWORD; + if (user && pass) { + await loginExtension(page, user, pass); + } + } + + // Dismiss any stale overlays/popovers (e.g., "Media failed" from previous calls) + await dismissOverlays(page); + + // Ensure the dial number page is in the foreground to avoid background throttling + await page.bringToFront(); + + // Wait for the incoming call to appear on the dial number page + // Use extended timeout to handle network routing delays and test interference + await page.locator('[data-test="right-action-button"]').waitFor({state: 'visible', timeout: AWAIT_TIMEOUT * 2.5}); +} diff --git a/packages/@webex/contact-center/test/e2e/playwright/Utils/helperUtils.ts b/packages/@webex/contact-center/test/e2e/playwright/Utils/helperUtils.ts new file mode 100644 index 00000000000..8f99410e7cb --- /dev/null +++ b/packages/@webex/contact-center/test/e2e/playwright/Utils/helperUtils.ts @@ -0,0 +1,606 @@ +import {Page, expect} from '@playwright/test'; +import {getCurrentState, changeUserState} from './userStateUtils'; +import { + WRAPUP_REASONS, + USER_STATES, + RONA_OPTIONS, + LOGIN_MODE, + LoginMode, + ThemeColor, + userState, + WrapupReason, + AWAIT_TIMEOUT, + NETWORK_OPERATION_TIMEOUT, +} from '../constants'; +import {submitWrapup} from './wrapupUtils'; +import {holdCallToggle} from './taskControlUtils'; +import {acceptExtensionCall, submitRonaPopup} from './incomingTaskUtils'; +import {loginViaAccessToken} 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) + * @returns Total number of seconds + * @example + * ```typescript + * parseTimeString("01:30"); // Returns 90 (1 minute 30 seconds) + * parseTimeString("00:45"); // Returns 45 (45 seconds) + * parseTimeString("10:00"); // Returns 600 (10 minutes) + * ``` + */ +export function parseTimeString(timeString: string): number { + const parts = timeString.split(':'); + const minutes = parseInt(parts[0], 10) || 0; + const seconds = parseInt(parts[1], 10) || 0; + return minutes * 60 + seconds; +} + +/** + * Waits for WebSocket disconnection by monitoring console messages for specific disconnection indicators + * @param consoleMessages - Array of console messages to monitor + * @param timeoutMs - Maximum time to wait for disconnection in milliseconds (default: 15000) + * @returns Promise - True if disconnection is detected, false if timeout is reached + * @description Monitors for network disconnection messages or WebSocket offline status changes + * @example + * ```typescript + * consoleMessages.length = 0; // Clear existing messages + * await page.context().setOffline(true); + * const isDisconnected = await waitForWebSocketDisconnection(consoleMessages); + * expect(isDisconnected).toBe(true); + * ``` + */ +export async function waitForWebSocketDisconnection( + consoleMessages: string[], + timeoutMs: number = 15000 +): Promise { + const startTime = Date.now(); + while (Date.now() - startTime < timeoutMs) { + const webSocketDisconnectLog = consoleMessages.find( + (msg) => + msg.includes('Failed to load resource: net::ERR_INTERNET_DISCONNECTED') || + msg.includes('[WebSocketStatus] event=checkOnlineStatus | online status= false') + ); + if (webSocketDisconnectLog) { + return true; + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + return false; +} + +/** + * Waits for WebSocket reconnection by monitoring console messages for online status changes + * @param consoleMessages - Array of console messages to monitor + * @param timeoutMs - Maximum time to wait for reconnection in milliseconds (default: 15000) + * @returns Promise - True if reconnection is detected, false if timeout is reached + * @description Monitors for WebSocket online status change messages indicating successful reconnection + * @example + * ```typescript + * consoleMessages.length = 0; // Clear existing messages + * await page.context().setOffline(false); + * const isReconnected = await waitForWebSocketReconnection(consoleMessages); + * expect(isReconnected).toBe(true); + * ``` + */ +export async function waitForWebSocketReconnection( + consoleMessages: string[], + timeoutMs: number = 15000 +): Promise { + const startTime = Date.now(); + while (Date.now() - startTime < timeoutMs) { + const webSocketReconnectLog = consoleMessages.find((msg) => + msg.includes('[WebSocketStatus] event=checkOnlineStatus | online status= true') + ); + if (webSocketReconnectLog) { + return true; + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + return false; +} + +/** + * 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 => { + try { + await page.waitForFunction( + async (expectedStateArg) => { + const stateName = document.querySelector('[data-testid="state-name"]') as HTMLElement | null; + const currentState = stateName?.textContent?.trim(); + return currentState === expectedStateArg; + }, + expectedState, + {timeout: 10000, polling: 'raf'} // Use requestAnimationFrame for optimal performance + ); + } catch (error) { + // Get current state for better error message + const currentState = await getCurrentState(page); + throw new Error(`Timed out waiting for state "${expectedState}", last state was "${currentState}"`); + } +}; + +/** + * 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) + * @param maxIterations - Maximum number of task handling iterations to prevent infinite loops (default: 10) + * @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, + maxIterations: number = 10 +): Promise => { + await page.waitForTimeout(1000); + + 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: AWAIT_TIMEOUT}) + .then(() => true) + .catch(() => false); + + if (ronapopupVisible) { + await submitRonaPopup(page, RONA_OPTIONS.AVAILABLE); + } + + await changeUserState(page, USER_STATES.AVAILABLE); + await page.waitForTimeout(4000); + } + const incomingTaskDiv = page.getByTestId(/^samples:incoming-task(-\w+)?$/); + + let iterations = 0; + while (iterations < maxIterations) { + iterations++; + let flag1 = false; + let flag2 = true; + + // Check if there's actually anything to handle before processing + const hasIncomingTask = await incomingTaskDiv + .first() + .isVisible() + .catch(() => false); + const hasEndButton = await page + .getByTestId('call-control:end-call') + .first() + .isVisible() + .catch(() => false); + const hasWrapupButton = await page + .getByTestId('call-control:wrapup-button') + .first() + .isVisible() + .catch(() => false); + + if (!hasIncomingTask && !hasEndButton && !hasWrapupButton) { + // Nothing to handle, exit early + break; + } + + // Inner task acceptance loop with timeout protection + let taskAttempts = 0; + const maxTaskAttempts = 5; + + while (taskAttempts < maxTaskAttempts) { + taskAttempts++; + 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) { + if (!extensionPage) { + throw new Error('Extension page is not available for handling extension call'); + } + const extensionCallVisible = await extensionPage + .locator('[data-test="right-action-button"]') + .waitFor({state: 'visible', timeout: 40000}) // Restored original timeout + .then(() => true) + .catch(() => false); + if (extensionCallVisible) { + await acceptExtensionCall(extensionPage); + flag1 = true; + } else { + console.warn('Extension call timeout - skipping task'); + break; // Skip this task instead of throwing error + } + } else { + try { + await acceptButton.click({timeout: AWAIT_TIMEOUT}); + flag1 = true; + } catch (error) { + console.warn('Failed to click accept button:', error); + } + } + 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: AWAIT_TIMEOUT}); + 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; + } + } + + console.log(`Completed stray task handling after ${iterations} iterations`); +}; + +/** + * Clears any pending call UI on the page by ending the call and/or submitting wrapup if visible. + * Does nothing if neither end-call nor wrapup controls are present. + */ +export async function clearPendingCallAndWrapup(page: Page): Promise { + // If wrapup is available, submit it + const wrapupBtn = page.getByTestId('call-control:wrapup-button').first(); + const wrapupVisible = await wrapupBtn.isVisible().catch(() => false); + if (wrapupVisible) { + await submitWrapup(page, WRAPUP_REASONS.SALE); + await page.waitForTimeout(500); + return; + } + + const endBtn = page.getByTestId('call-control:end-call').first(); + const endVisible = await endBtn.isVisible().catch(() => false); + if (endVisible) { + const endEnabled = await endBtn.isEnabled().catch(() => false); + if (endEnabled) { + try { + await endBtn.click({timeout: AWAIT_TIMEOUT}); + await submitWrapup(page, WRAPUP_REASONS.SALE); + await page.waitForTimeout(500); + } catch {} + } else { + // If end button is disabled, try resuming from hold then end; otherwise, see if wrapup is available + try { + await holdCallToggle(page); + } catch {} + const endEnabledAfterResume = await endBtn.isEnabled().catch(() => false); + if (endEnabledAfterResume) { + try { + await endBtn.click({timeout: AWAIT_TIMEOUT}); + await submitWrapup(page, WRAPUP_REASONS.SALE); + await page.waitForTimeout(500); + return; + } catch {} + } + + // If resume path failed, see if wrapup became available instead; otherwise, skip silently + const wrapupNowVisible = await wrapupBtn.isVisible().catch(() => false); + if (wrapupNowVisible) { + try { + await submitWrapup(page, WRAPUP_REASONS.SALE); + await page.waitForTimeout(500); + } catch {} + } + } + } +} + +/* +/ * 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, + accessToken: string, + extensionPage: Page | null = null, + extensionNumber?: string, + isMultiSession: boolean = false +) => { + await loginViaAccessToken(page, accessToken); + await registerAgent(page); + + if (isMultiSession) { + return; // Skip further setup for multi-session tests + } + + let loginButtonExists = await page + .getByTestId('login-button') + .isVisible() + .catch(() => false); + + if (loginButtonExists) { + await telephonyLogin(page, loginMode, extensionNumber); + } else { + await stationLogout(page); + await telephonyLogin(page, loginMode, extensionNumber); + } + + await page.getByTestId('state-select').waitFor({state: 'visible', timeout: 30000}); +}; + +export const registerAgent = async (page: Page): Promise => { + const statusLocator = page.locator('#ws-connection-status'); + const currentStatus = (await statusLocator.textContent().catch(() => '')) ?? ''; + const isSubscribed = currentStatus.toLowerCase().includes('subscribed'); + + const registerButton = page.locator('#webexcc-register'); + await registerButton.waitFor({state: 'visible', timeout: NETWORK_OPERATION_TIMEOUT}); + const deregisterButton = page.locator('#webexcc-deregister'); + await deregisterButton.waitFor({state: 'visible', timeout: NETWORK_OPERATION_TIMEOUT}); + + if (!isSubscribed) { + await expect(registerButton).toBeEnabled({timeout: NETWORK_OPERATION_TIMEOUT}); + await expect(deregisterButton).toBeDisabled({timeout: NETWORK_OPERATION_TIMEOUT}); + await registerButton.click({timeout: AWAIT_TIMEOUT}); + await registerButton.waitFor({state: 'disabled', timeout: NETWORK_OPERATION_TIMEOUT}); + } + + await page.waitForFunction( + () => { + const text = document.querySelector('#ws-connection-status')?.textContent ?? ''; + return text.toLowerCase().includes('subscribed'); + }, + null, + {timeout: NETWORK_OPERATION_TIMEOUT} + ); + await expect(registerButton).toBeDisabled({timeout: NETWORK_OPERATION_TIMEOUT}); + await page.waitForFunction( + () => { + const select = document.querySelector('#teamsDropdown') as HTMLSelectElement | null; + return Boolean(select && select.options.length > 0 && select.value); + }, + null, + {timeout: NETWORK_OPERATION_TIMEOUT} + ); + await page.waitForFunction( + () => { + const select = document.querySelector('#AgentLogin') as HTMLSelectElement | null; + if (!select) return false; + const values = Array.from(select.options).map((option) => option.value); + return ['EXTENSION', 'BROWSER', 'AGENT_DN'].every((value) => values.includes(value)); + }, + null, + {timeout: NETWORK_OPERATION_TIMEOUT} + ); +}; + +/** + * Dismisses any visible popover/tooltips/backdrops that might intercept pointer events. + * Attempts ESC presses and quick background clicks. + */ +export async function dismissOverlays(page: Page): Promise { + const isVisibleWithin = async (locator: any, timeoutMs: number = 500): Promise => { + try { + await locator.waitFor({state: 'visible', timeout: timeoutMs}); + return true; + } catch { + return false; + } + }; + + for (let i = 0; i < 3; i++) { + // If a Momentum popover backdrop is visible, try ESC to close (with bounded timeout) + const backdropVisible = await isVisibleWithin(page.locator('.md-popover-backdrop'), 500); + const tippyVisible = await isVisibleWithin(page.locator('[id^="tippy-"]').first(), 500); + if (!backdropVisible && !tippyVisible) return; + try { + await page.keyboard.press('Escape'); + } catch {} + // Small click near top-left to blur active elements + try { + await page.mouse.click(5, 5); + } catch {} + await page.waitForTimeout(200); + } +} diff --git a/packages/@webex/contact-center/test/e2e/playwright/Utils/incomingTaskUtils.ts b/packages/@webex/contact-center/test/e2e/playwright/Utils/incomingTaskUtils.ts new file mode 100644 index 00000000000..1100b3b2b63 --- /dev/null +++ b/packages/@webex/contact-center/test/e2e/playwright/Utils/incomingTaskUtils.ts @@ -0,0 +1,447 @@ +import {Page, expect} from '@playwright/test'; +import { + CALL_URL, + RonaOption, + AWAIT_TIMEOUT, + TASK_TYPES, + TaskType, + DEFAULT_MAX_RETRIES, + CHAT_LAUNCHER_TIMEOUT, + FORM_FIELD_TIMEOUT, + OPERATION_TIMEOUT, + NETWORK_OPERATION_TIMEOUT, + TEST_DATA, + UI_SETTLE_TIMEOUT, +} from '../constants'; +import nodemailer from 'nodemailer'; +import {dismissOverlays} from './helperUtils'; + +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_SENDER_EMAIL_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_ENTRY_POINT env variable) + */ +export async function createCallTask(page: Page, number: string) { + 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.'); + } + + // Ensure page is foregrounded and clean of overlays + await page.bringToFront(); + await dismissOverlays(page); + + const endBtn = page.locator('[data-test="call-end"]'); + if (await endBtn.isVisible({timeout: 500}).catch(() => false)) { + await endBtn.click({timeout: AWAIT_TIMEOUT}); + await page.waitForTimeout(500); + } + + await page + .locator('[data-test="statusMessage"]') + .waitFor({state: 'hidden', timeout: NETWORK_OPERATION_TIMEOUT}) + .catch(() => {}); + + await page.getByRole('textbox', {name: 'Dial'}).waitFor({state: 'visible', timeout: AWAIT_TIMEOUT}); + await page.getByRole('textbox', {name: 'Dial'}).fill(number, {timeout: AWAIT_TIMEOUT}); + + const callButton = page.locator('[data-test="calling-ui-keypad-control"]').getByRole('button', {name: 'Call'}); + await expect(callButton).toBeVisible({timeout: AWAIT_TIMEOUT}); + // Ensure button is enabled before clicking + await callButton.waitFor({state: 'visible', timeout: AWAIT_TIMEOUT}); + await callButton.click({timeout: AWAIT_TIMEOUT}); + await page.waitForTimeout(2000); +} + +/** + * 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: AWAIT_TIMEOUT}); + 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, chatURL: string) { + for (let i = 0; i < DEFAULT_MAX_RETRIES; i++) { + try { + await page.goto(chatURL); + await page.waitForTimeout(UI_SETTLE_TIMEOUT); + await page + .locator('iframe[name="Livechat launcher icon"]') + .contentFrame() + .getByRole('button', {name: 'Livechat Button - 0 unread'}) + .waitFor({state: 'visible', timeout: CHAT_LAUNCHER_TIMEOUT}); + await page + .locator('iframe[name="Livechat launcher icon"]') + .contentFrame() + .getByRole('button', {name: 'Livechat Button - 0 unread'}) + .click({timeout: AWAIT_TIMEOUT}); + await page + .locator('iframe[name="Conversation Window"]') + .contentFrame() + .getByRole('button', {name: 'Hit Us Up!'}) + .waitFor({state: 'visible', timeout: FORM_FIELD_TIMEOUT}); + await page + .locator('iframe[name="Conversation Window"]') + .contentFrame() + .getByRole('button', {name: 'Hit Us Up!'}) + .click({timeout: AWAIT_TIMEOUT}); + await page + .locator('iframe[name="Conversation Window"]') + .contentFrame() + .getByRole('textbox', {name: 'Name'}) + .waitFor({state: 'visible', timeout: AWAIT_TIMEOUT}); + await page + .locator('iframe[name="Conversation Window"]') + .contentFrame() + .getByRole('textbox', {name: 'Name'}) + .click({timeout: AWAIT_TIMEOUT}); + await page + .locator('iframe[name="Conversation Window"]') + .contentFrame() + .getByRole('textbox', {name: 'Name'}) + .fill(TEST_DATA.CHAT_NAME, {timeout: AWAIT_TIMEOUT}); + await page + .locator('iframe[name="Conversation Window"]') + .contentFrame() + .getByRole('textbox', {name: 'Name'}) + .fill(TEST_DATA.CHAT_NAME, {timeout: AWAIT_TIMEOUT}); + await page + .locator('iframe[name="Conversation Window"]') + .contentFrame() + .getByRole('button', {name: 'Submit Name'}) + .waitFor({state: 'visible', timeout: AWAIT_TIMEOUT}); + await page + .locator('iframe[name="Conversation Window"]') + .contentFrame() + .getByRole('button', {name: 'Submit Name'}) + .click({timeout: AWAIT_TIMEOUT}); + 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: AWAIT_TIMEOUT}); + await page + .locator('iframe[name="Conversation Window"]') + .contentFrame() + .getByRole('textbox', {name: 'Email*'}) + .fill(TEST_DATA.CHAT_EMAIL, {timeout: AWAIT_TIMEOUT}); + 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: AWAIT_TIMEOUT}); + break; + } catch (error) { + if (i === DEFAULT_MAX_RETRIES - 1) { + throw new Error(`Failed to load chat client after ${DEFAULT_MAX_RETRIES} 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: AWAIT_TIMEOUT}); + await page.waitForTimeout(500); + await expect(page.locator('iframe[name="Conversation Window"]').contentFrame().getByText('End chat')).toBeVisible({ + timeout: AWAIT_TIMEOUT, + }); + await page + .locator('iframe[name="Conversation Window"]') + .contentFrame() + .getByText('End chat') + .click({timeout: AWAIT_TIMEOUT}); + 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: AWAIT_TIMEOUT}); + await page.waitForTimeout(1000); +} + +/** + * Sends a test email to trigger an incoming email task. + * @throws Error if sending fails + */ +export async function createEmailTask(to: string) { + const from = process.env.PW_SENDER_EMAIL; + const subject = `Playwright Test Email - ${new Date().toISOString()}`; + const text = TEST_DATA.EMAIL_TEXT; + + 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) { + await page.waitForTimeout(2000); + let incomingTaskDiv; + if (type === TASK_TYPES.CALL) { + incomingTaskDiv = page.getByTestId('samples:incoming-task-telephony').first(); + const isExtensionCall = await (await incomingTaskDiv.innerText()).includes(TEST_DATA.EXTENSION_CALL_INDICATOR); + 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.getByTestId('samples:incoming-task-social').first(); + } + if (!incomingTaskDiv) { + throw new Error(`Unsupported task type: ${type}`); + } + incomingTaskDiv = incomingTaskDiv.first(); + await expect(incomingTaskDiv).toBeVisible({timeout: AWAIT_TIMEOUT}); + const acceptButton = incomingTaskDiv.getByTestId('task:accept-button').first(); + if (!(await acceptButton.isVisible())) { + throw new Error('Accept button not found'); + } + + // Wait for button to be enabled and clickable + await acceptButton.waitFor({state: 'visible', timeout: AWAIT_TIMEOUT}); + await expect(acceptButton).toBeEnabled({timeout: AWAIT_TIMEOUT}); + + // Use force click as backup or add retry logic + try { + await acceptButton.click({timeout: AWAIT_TIMEOUT}); + } catch (error) { + // Retry with force click if normal click fails + await acceptButton.click({force: true, timeout: AWAIT_TIMEOUT}); + } + await page.waitForTimeout(2000); +} + +/** + * 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(TEST_DATA.EXTENSION_CALL_INDICATOR); + 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.getByTestId('samples:incoming-task-social').first(); + } + if (!incomingTaskDiv) { + throw new Error(`Unsupported task type: ${type}`); + } + incomingTaskDiv = await incomingTaskDiv.first(); + await expect(incomingTaskDiv).toBeVisible({timeout: AWAIT_TIMEOUT}); + const declineButton = incomingTaskDiv.getByTestId('task:decline-button').first(); + if (!(await declineButton.isVisible())) { + throw new Error('Decline button not found'); + } + await declineButton.click({timeout: AWAIT_TIMEOUT}); + await incomingTaskDiv.waitFor({state: 'hidden', timeout: AWAIT_TIMEOUT}); +} + +/** + * 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 page.waitForTimeout(2000); + 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: AWAIT_TIMEOUT}); + await page.locator('[data-test="right-action-button"]').click({timeout: AWAIT_TIMEOUT}); + await page.waitForTimeout(500); +} + +/** + * Declines an incoming extension call by clicking the left action button. + * @param page Playwright Page object + */ +export async function declineExtensionCall(page: Page) { + try { + await page.waitForTimeout(2000); + 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: AWAIT_TIMEOUT}); + await page.locator('[data-test="left-action-button"]').click({timeout: AWAIT_TIMEOUT}); +} + +/** + * 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 page.waitForTimeout(2000); + 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: AWAIT_TIMEOUT}); + await page.locator('[data-test="end-call"]').click({timeout: AWAIT_TIMEOUT}); + 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 < DEFAULT_MAX_RETRIES; i++) { + try { + await page.goto(CALL_URL); + break; + } catch (error) { + if (i === DEFAULT_MAX_RETRIES - 1) { + throw new Error(`Failed to login via extension after ${DEFAULT_MAX_RETRIES} attempts: ${error}`); + } + } + } + const isLoginPageVisible = await page + .getByRole('textbox', {name: 'Email address (required)'}) + .waitFor({state: 'visible', timeout: OPERATION_TIMEOUT}) + .then(() => true) + .catch(() => false); + if (!isLoginPageVisible) { + await expect(page.getByRole('button', {name: 'Back to sign in'})).toBeVisible({timeout: AWAIT_TIMEOUT}); + await page.getByRole('button', {name: 'Back to sign in'}).click({timeout: AWAIT_TIMEOUT}); + await page.getByRole('button', {name: 'Sign in'}).waitFor({state: 'visible', timeout: AWAIT_TIMEOUT}); + await page.getByRole('button', {name: 'Sign in'}).click({timeout: AWAIT_TIMEOUT}); + } + await page + .getByRole('textbox', {name: 'Email address (required)'}) + .waitFor({state: 'visible', timeout: FORM_FIELD_TIMEOUT}); + await page.getByRole('textbox', {name: 'Email address (required)'}).fill(email, {timeout: AWAIT_TIMEOUT}); + await page.getByRole('textbox', {name: 'Email address (required)'}).press('Enter', {timeout: AWAIT_TIMEOUT}); + await page.getByRole('textbox', {name: 'Password'}).waitFor({state: 'visible', timeout: FORM_FIELD_TIMEOUT}); + await page.getByRole('textbox', {name: 'Password'}).fill(password, {timeout: AWAIT_TIMEOUT}); + await page.getByRole('textbox', {name: 'Password'}).press('Enter', {timeout: AWAIT_TIMEOUT}); + await page.getByRole('textbox', {name: 'Dial'}).waitFor({state: 'visible', timeout: NETWORK_OPERATION_TIMEOUT}); + try { + await page.locator('[data-test="statusMessage"]').waitFor({state: 'hidden', timeout: NETWORK_OPERATION_TIMEOUT}); + } 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: AWAIT_TIMEOUT}); + await page.waitForTimeout(1000); + const stateSelect = page.getByTestId('samples:rona-select-state'); + await expect(stateSelect).toBeVisible({timeout: AWAIT_TIMEOUT}); + await stateSelect.selectOption({label: nextState.toString()}); + await expect(page.getByTestId('samples:rona-button-confirm')).toBeVisible({timeout: AWAIT_TIMEOUT}); + await page.getByTestId('samples:rona-button-confirm').click({timeout: AWAIT_TIMEOUT}); + await page.waitForTimeout(1000); +} diff --git a/packages/@webex/contact-center/test/e2e/playwright/Utils/initUtils.ts b/packages/@webex/contact-center/test/e2e/playwright/Utils/initUtils.ts new file mode 100644 index 00000000000..bbe59fc77f9 --- /dev/null +++ b/packages/@webex/contact-center/test/e2e/playwright/Utils/initUtils.ts @@ -0,0 +1,239 @@ +import {Page, expect, BrowserContext} from '@playwright/test'; +import dotenv from 'dotenv'; +import {BASE_URL, AWAIT_TIMEOUT, WIDGET_INIT_TIMEOUT, UI_SETTLE_TIMEOUT} from '../constants'; +import {injectContactCenterTestIds} from './sample-instrumentation'; + +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, accessToken: string): Promise => { + await injectContactCenterTestIds(page); + await page.goto(BASE_URL, {waitUntil: 'domcontentloaded'}); + if (!accessToken) { + throw new Error('ACCESS_TOKEN is not defined, OAuth failed'); + } + await page.locator('#auth-type').selectOption('accessToken'); + await page.locator('#access-token').fill(accessToken, {timeout: AWAIT_TIMEOUT}); + const saveButton = page.locator('#access-token-save'); + await expect(saveButton).toBeEnabled({timeout: AWAIT_TIMEOUT}); + await saveButton.click({timeout: AWAIT_TIMEOUT}); + await expect(saveButton).toBeDisabled({timeout: AWAIT_TIMEOUT}); + await expect(page.locator('#access-token-status')).toContainText('Saved access token', { + timeout: AWAIT_TIMEOUT, + }); +}; + +/** + * 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., 'SET_1_AGENT1', 'SET_2_AGENT2') + * @description Uses the provided password (typically sourced from per-agent env vars in global.setup) + * @throws {Error} When the password is not provided + * @example + * ```typescript + * // OAuth login with explicit credentials + * await oauthLogin(page, 'user15@ccsdk.wbx.ai', process.env.SET_1_AGENT1_PASSWORD); + * await oauthLogin(page, 'user16@ccsdk.wbx.ai', process.env.SET_1_AGENT2_PASSWORD); + * ``` + */ +export const oauthLogin = async (page: Page, username: string, password?: string): Promise => { + if (!username || !username.trim()) { + throw new Error('Username parameter is required'); + } + + const resolvedPassword = password; + if (!resolvedPassword) { + throw new Error('OAuth password must be provided (set per-agent password env vars)'); + } + + await injectContactCenterTestIds(page); + await page.goto(BASE_URL, {waitUntil: 'domcontentloaded'}); + await page.locator('#auth-type').selectOption('oauth'); + + const popupPromise = page.waitForEvent('popup', {timeout: 1000}).catch(() => null); + await page.locator('#oauth-login-btn').click({timeout: AWAIT_TIMEOUT}); + + const popup = await popupPromise; + if (popup) { + await popup.waitForLoadState('domcontentloaded'); + await completeIdBrokerLogin(popup, username, resolvedPassword); + await popup.waitForEvent('close', {timeout: 120000}).catch(() => {}); + } else { + await page.waitForURL(/(idbroker|idb)/i, {timeout: 60000}); + await completeIdBrokerLogin(page, username, resolvedPassword); + await page.waitForURL(/samples\/contact-center/i, {timeout: 120000}); + } + + await expect(page.locator('#oauth-status')).toContainText(/Authenticated/i, {timeout: 60000}); + await expect(page.locator('#access-token-status')).toContainText('Saved access token', {timeout: 60000}); +}; + +const completeIdBrokerLogin = async (targetPage: Page, username: string, password: string) => { + await targetPage.waitForLoadState('domcontentloaded'); + + const emailInput = targetPage.locator('#IDToken1'); + const legacyEmailVisible = await emailInput.isVisible().catch(() => false); + const modernEmailInput = targetPage.getByRole('textbox', {name: /name@example\.com/i}); + const modernEmailVisible = !legacyEmailVisible && (await modernEmailInput.isVisible().catch(() => false)); + + if (legacyEmailVisible) { + const isReadOnly = await emailInput.evaluate((el) => el.hasAttribute('readonly')).catch(() => false); + if (!isReadOnly) { + await emailInput.fill(username); + const nextButton = targetPage.locator('#IDButton2'); + if (await nextButton.isVisible().catch(() => false)) { + await nextButton.click(); + } + } + } else if (modernEmailVisible) { + await modernEmailInput.fill(username); + const signInLink = targetPage.getByRole('link', {name: /sign in/i}); + const signInButton = targetPage.getByRole('button', {name: /sign in/i}); + if (await signInLink.isVisible().catch(() => false)) { + await signInLink.click(); + } else if (await signInButton.isVisible().catch(() => false)) { + await signInButton.click(); + } else { + await modernEmailInput.press('Enter'); + } + } + + const passwordInput = targetPage.locator('#IDToken2'); + const legacyPasswordVisible = await passwordInput.isVisible().catch(() => false); + const modernPasswordInput = targetPage.getByRole('textbox', {name: /welcome|password/i}); + const modernPasswordVisible = !legacyPasswordVisible && (await modernPasswordInput.isVisible().catch(() => false)); + + if (legacyPasswordVisible) { + await passwordInput.fill(password); + const submitButton = targetPage.locator('#Button1'); + await submitButton.click(); + } else { + const fallbackPasswordInput = modernPasswordVisible + ? modernPasswordInput + : targetPage.locator('input[type="password"]'); + await fallbackPasswordInput.waitFor({state: 'visible', timeout: 60000}); + await fallbackPasswordInput.fill(password); + const submitButton = targetPage.getByRole('button', {name: /sign in/i}); + if (await submitButton.isVisible().catch(() => false)) { + await submitButton.click(); + } else { + await fallbackPasswordInput.press('Enter'); + } + } + + const acceptButton = targetPage.locator('input[value="Accept"]'); + if (await acceptButton.isVisible({timeout: 5000}).catch(() => false)) { + await acceptButton.click(); + } + + await targetPage.waitForLoadState('networkidle', {timeout: 120000}).catch(() => {}); +}; + +/** + * 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 () => Promise.resolve(); + +/** + * 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 () => Promise.resolve(); + +/** + * 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 () => Promise.resolve(); + +/** + * 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. + * If the widget is not visible after the initial timeout, retries once more with another attempt. + * @throws {Error} When station-login widget is not visible after two initialization attempts + * @example + * ```typescript + * // Ensure station-login widget is checked first + * await page.getByTestId('samples:widget-stationLogin').check(); + * await initialiseWidgets(page); + * ``` + */ +export const initialiseWidgets = async () => Promise.resolve(); + +/** + * 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({waitUntil: 'domcontentloaded'}); + await page.waitForTimeout(UI_SETTLE_TIMEOUT); +}; + +/** + * 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 injectContactCenterTestIds(multiLoginPage); + await multiLoginPage.goto(BASE_URL, {waitUntil: 'domcontentloaded'}); + return multiLoginPage; +}; diff --git a/packages/@webex/contact-center/test/e2e/playwright/Utils/sample-instrumentation.ts b/packages/@webex/contact-center/test/e2e/playwright/Utils/sample-instrumentation.ts new file mode 100644 index 00000000000..b0710f2096a --- /dev/null +++ b/packages/@webex/contact-center/test/e2e/playwright/Utils/sample-instrumentation.ts @@ -0,0 +1,238 @@ +import {Page} from '@playwright/test'; + +// Adds runtime data-testid hooks to the Contact Center sample app so the +// cc-playwright-kit selectors can target a consistent surface without +// modifying the sample source files. + +/** + * Injects a script into the Contact Center sample application so that it exposes + * the data-testid hooks expected by the cc-playwright-kit suite. + */ +export async function injectContactCenterTestIds(page: Page) { + await page.addInitScript(() => { + const ready = (fn: () => void) => { + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', fn, {once: true}); + } else { + fn(); + } + }; + + const assignTestId = (selector: string, testId: string) => { + const el = document.querySelector(selector); + if (el) { + el.setAttribute('data-testid', testId); + } + }; + + const waitForGlobal = (prop: string, cb: (value: any) => void) => { + const globalAny = window as any; + if (typeof globalAny[prop] === 'function') { + cb(globalAny[prop]); + return; + } + const interval = window.setInterval(() => { + if (typeof globalAny[prop] === 'function') { + window.clearInterval(interval); + cb(globalAny[prop]); + } + }, 100); + }; + + const setupHideDesktopToggle = () => { + const agentLogin = document.getElementById('AgentLogin'); + const fieldset = agentLogin?.closest('fieldset'); + if (!fieldset || fieldset.querySelector('[data-testid="samples:hide-desktop-login-checkbox"]')) { + return; + } + const wrapper = document.createElement('label'); + wrapper.style.display = 'block'; + wrapper.style.marginTop = '8px'; + const checkbox = document.createElement('input'); + checkbox.type = 'checkbox'; + checkbox.setAttribute('data-testid', 'samples:hide-desktop-login-checkbox'); + wrapper.appendChild(checkbox); + wrapper.appendChild(document.createTextNode(' Hide Desktop login option')); + fieldset.appendChild(wrapper); + + checkbox.addEventListener('change', () => { + const select = document.getElementById('AgentLogin') as HTMLSelectElement | null; + if (!select) return; + Array.from(select.options).forEach((option) => { + if (option.value === 'BROWSER') { + option.hidden = checkbox.checked; + if (checkbox.checked && select.value === option.value) { + select.value = ''; + } + } + }); + }); + }; + + const setupStateIndicator = () => { + const idleDropdown = document.getElementById('idleCodesDropdown'); + if (!idleDropdown || document.querySelector('[data-testid="state-select"]')) { + return; + } + const wrapper = document.createElement('div'); + wrapper.setAttribute('data-testid', 'state-select'); + wrapper.style.cursor = 'pointer'; + wrapper.style.marginBottom = '8px'; + const stateLabel = document.createElement('span'); + stateLabel.setAttribute('data-testid', 'state-name'); + stateLabel.textContent = + (idleDropdown as HTMLSelectElement).selectedOptions?.[0]?.text ?? (idleDropdown as HTMLSelectElement).value ?? ''; + wrapper.appendChild(stateLabel); + idleDropdown.parentElement?.insertBefore(wrapper, idleDropdown); + + wrapper.addEventListener('click', () => { + const globalAny = window as any; + if (typeof globalAny.showAgentStatePopup === 'function') { + globalAny.showAgentStatePopup(''); + } + }); + + idleDropdown.addEventListener('change', () => { + const select = idleDropdown as HTMLSelectElement; + stateLabel.textContent = select.selectedOptions?.[0]?.text ?? select.value ?? ''; + }); + }; + + const patchStatePopup = () => { + const annotateStateOptions = () => { + const select = document.getElementById('agentStateSelect') as HTMLSelectElement | null; + if (!select) return; + Array.from(select.options).forEach((option) => { + const label = option.text?.trim() ?? option.value; + option.setAttribute('data-testid', `state-item-${label}`); + }); + }; + waitForGlobal('showAgentStatePopup', (original: (...args: any[]) => void) => { + const globalAny = window as any; + if (globalAny.__patchedShowAgentStatePopup) return; + globalAny.__patchedShowAgentStatePopup = true; + globalAny.showAgentStatePopup = (...args: any[]) => { + original(...args); + window.setTimeout(annotateStateOptions, 0); + }; + }); + }; + + const setupIncomingMirrors = () => { + const incomingRoot = document.getElementById('incomingsection'); + const statusNode = document.getElementById('incoming-task'); + if (!incomingRoot || !statusNode) return; + + const createMirror = (testId: string) => { + let mirror = incomingRoot.querySelector(`[data-testid="${testId}"]`); + if (!mirror) { + mirror = document.createElement('div'); + mirror.setAttribute('data-testid', testId); + mirror.style.display = 'none'; + mirror.style.marginTop = '4px'; + mirror.style.fontWeight = 'bold'; + incomingRoot.appendChild(mirror); + } + return mirror; + }; + + const mirrors = { + telephony: createMirror('samples:incoming-task-telephony'), + chat: createMirror('samples:incoming-task-chat'), + email: createMirror('samples:incoming-task-email'), + }; + + const updateMirrors = () => { + const text = statusNode.textContent ?? ''; + Object.values(mirrors).forEach((node) => { + node.style.display = 'none'; + }); + if (/Call from/i.test(text)) { + mirrors.telephony.textContent = text; + mirrors.telephony.style.display = 'block'; + } else if (/Chat from/i.test(text)) { + mirrors.chat.textContent = text; + mirrors.chat.style.display = 'block'; + } else if (/Email from/i.test(text)) { + mirrors.email.textContent = text; + mirrors.email.style.display = 'block'; + } + }; + + const observer = new MutationObserver(updateMirrors); + observer.observe(statusNode, {subtree: true, characterData: true, childList: true}); + updateMirrors(); + }; + + const observeTimer = () => { + const timer = document.getElementById('timerDisplay'); + if (!timer) return; + timer.setAttribute('data-testid', 'cc-cad:call-timer'); + let mirror = timer.nextElementSibling as HTMLElement | null; + if (!mirror || mirror.getAttribute('data-testid') !== 'elapsed-time') { + mirror = document.createElement('div'); + mirror.setAttribute('data-testid', 'elapsed-time'); + mirror.style.marginTop = '4px'; + timer.insertAdjacentElement('afterend', mirror); + } + const update = () => { + mirror!.textContent = timer.textContent ?? ''; + }; + const observer = new MutationObserver(update); + observer.observe(timer, {childList: true, characterData: true, subtree: true}); + update(); + }; + + const observeTaskList = () => { + const container = document.getElementById('taskList'); + if (!container) return; + container.setAttribute('data-testid', 'task-list'); + const annotate = () => { + container.querySelectorAll('.task-item').forEach((item) => { + const title = item.querySelector('p'); + title?.setAttribute('data-testid', 'task:title'); + item.querySelectorAll('button.accept-task').forEach((btn) => { + btn.setAttribute('data-testid', 'task:accept-button'); + }); + item.querySelectorAll('button.decline-task').forEach((btn) => { + btn.setAttribute('data-testid', 'task:decline-button'); + }); + }); + }; + const observer = new MutationObserver(annotate); + observer.observe(container, {childList: true, subtree: true}); + annotate(); + }; + + ready(() => { + assignTestId('#callcontrolsection', 'call-control-container'); + assignTestId('#consult', 'call-control:consult'); + assignTestId('#end', 'call-control:end-call'); + assignTestId('#hold-resume', 'call-control:hold-toggle'); + assignTestId('#pause-resume-recording', 'call-control:recording-toggle'); + assignTestId('#transfer', 'call-control:transfer'); + assignTestId('#wrapup', 'call-control:wrapup-button'); + assignTestId('#wrapupCodesDropdown', 'call-control:wrapup-select'); + assignTestId('#end-consult', 'cancel-consult-btn'); + assignTestId('#dialNumber', 'dial-number-input'); + assignTestId('#loginAgent', 'login-button'); + assignTestId('#AgentLogin', 'login-option-select'); + assignTestId('#teamsDropdown', 'teams-select-dropdown'); + assignTestId('#logoutAgent', 'samples:station-logout-button'); + assignTestId('#agentStatePopup', 'samples:rona-popup'); + assignTestId('#agentStateSelect', 'samples:rona-select-state'); + assignTestId('#setAgentState', 'samples:rona-button-confirm'); + assignTestId('#taskList', 'task-list'); + assignTestId('#consult-transfer', 'transfer-consult-btn'); + const stationFieldset = document.getElementById('loginAgent')?.closest('fieldset'); + stationFieldset?.setAttribute('data-testid', 'station-login-widget'); + + setupHideDesktopToggle(); + setupStateIndicator(); + patchStatePopup(); + setupIncomingMirrors(); + observeTimer(); + observeTaskList(); + }); + }); +} diff --git a/packages/@webex/contact-center/test/e2e/playwright/Utils/stationLoginUtils.ts b/packages/@webex/contact-center/test/e2e/playwright/Utils/stationLoginUtils.ts new file mode 100644 index 00000000000..b521d0b0a72 --- /dev/null +++ b/packages/@webex/contact-center/test/e2e/playwright/Utils/stationLoginUtils.ts @@ -0,0 +1,260 @@ +import {Page, expect} from '@playwright/test'; +import dotenv from 'dotenv'; +import {LOGIN_MODE, LONG_WAIT, AWAIT_TIMEOUT, OPERATION_TIMEOUT} from '../constants'; +import {handleStrayTasks} from './helperUtils'; + +dotenv.config(); + +const LOGIN_OPTION_MAP: Record = { + [LOGIN_MODE.DESKTOP]: 'BROWSER', + [LOGIN_MODE.EXTENSION]: 'EXTENSION', + [LOGIN_MODE.DIAL_NUMBER]: 'AGENT_DN', +}; + +const selectLoginOption = async (page: Page, mode: string): Promise => { + const value = LOGIN_OPTION_MAP[mode]; + if (!value) { + throw new Error(`Unsupported login mode: ${mode}`); + } + const select = page.locator('#AgentLogin'); + await select.waitFor({state: 'visible', timeout: AWAIT_TIMEOUT}); + await expect(select).toBeEnabled({timeout: AWAIT_TIMEOUT}); + await page.waitForFunction( + (optionValue) => { + const teams = document.querySelector('#teamsDropdown') as HTMLSelectElement | null; + const loginSelect = document.querySelector('#AgentLogin') as HTMLSelectElement | null; + if (!teams || !loginSelect) return false; + const hasTeam = teams.options.length > 0 && Boolean(teams.value); + const hasOption = Array.from(loginSelect.options).some((option) => option.value === optionValue); + return hasTeam && hasOption; + }, + value, + {timeout: AWAIT_TIMEOUT} + ); + await select.selectOption(value, {timeout: AWAIT_TIMEOUT}); +}; + +/** + * 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 selectLoginOption(page, LOGIN_MODE.DESKTOP); + await page.getByTestId('login-button').click({timeout: AWAIT_TIMEOUT}); +}; + +/** + * 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; + if (!number) { + throw new Error('extensionNumber must be provided'); + } + + if (number.trim() === '') { + throw new Error('Extension number is empty. Please provide a valid extension number.'); + } + + await selectLoginOption(page, LOGIN_MODE.EXTENSION); + await page.getByTestId('dial-number-input').locator('input').fill(number, {timeout: AWAIT_TIMEOUT}); + await page.getByTestId('login-button').click({timeout: AWAIT_TIMEOUT}); +}; + +/** + * Performs dial number-based login for contact center agents + * @param page - The Playwright page object + * @param dialNumber - Optional dial number. Falls back to PW_ENTRY_POINT 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 => { + if (!dialNumber) { + throw new Error('Dial number is required but not provided'); + } + + if (dialNumber.trim() === '') { + throw new Error('Dial number is empty. Please provide a valid dial number.'); + } + + await selectLoginOption(page, LOGIN_MODE.DIAL_NUMBER); + await page.getByTestId('dial-number-input').locator('input').fill(dialNumber, {timeout: AWAIT_TIMEOUT}); + await page.getByTestId('login-button').click({timeout: AWAIT_TIMEOUT}); +}; + +/** + * 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({timeout: AWAIT_TIMEOUT}); + //check if the station logout button is hidden after logouts + const isLogoutButtonHidden = await page + .getByTestId('samples:station-logout-button') + .waitFor({state: 'hidden', timeout: OPERATION_TIMEOUT}) + .then(() => true) + .catch(() => false); + if (!isLogoutButtonHidden) { + try { + await handleStrayTasks(page); + await page.getByTestId('samples:station-logout-button').click({timeout: AWAIT_TIMEOUT}); + // Verify logout was successful after retry + const isLogoutSuccessfulAfterRetry = await page + .getByTestId('samples:station-logout-button') + .waitFor({state: 'hidden', timeout: OPERATION_TIMEOUT}) + .then(() => true) + .catch(() => false); + if (!isLogoutSuccessfulAfterRetry) { + throw new Error('Station logout button is still visible after retry attempt'); + } + } catch (e) { + throw new Error(`Station logout failed: ${e instanceof Error ? e.message : 'Unknown error'}`); + } + } +}; + +/** + * 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(', ')}`); + } + await page.locator('#logoutAgent').waitFor({state: 'visible', timeout: LONG_WAIT}); +}; + +/** + * Verifies that the login mode selector displays the expected login mode + * @param page - The Playwright page object + * @param expectedMode - The expected login mode text to verify (e.g., 'Dial Number', 'Extension', 'Desktop') + * @description Checks the login option select element's trigger text to ensure it matches the expected mode + * @throws {Error} When the login mode doesn't match the expected value + * @example + * ```typescript + * await verifyLoginMode(page, LOGIN_MODE.DIAL_NUMBER); + * await verifyLoginMode(page, LOGIN_MODE.EXTENSION); + * await verifyLoginMode(page, LOGIN_MODE.DESKTOP); + * ``` + */ +export async function verifyLoginMode(page: Page, expectedMode: string): Promise { + const select = page.locator('#AgentLogin'); + await select.waitFor({state: 'visible', timeout: AWAIT_TIMEOUT}); + const value = await select.inputValue(); + const expectedValue = LOGIN_OPTION_MAP[expectedMode] ?? expectedMode; + expect(value).toBe(expectedValue); +} + +/** + * Ensures the user state widget is visible by checking its current state and logging in if necessary + * @param page - The Playwright page object + * @param loginMode - The login mode to use if login is required (from LOGIN_MODE constants) + * @param number - Optional number for Extension or Dial Number modes + * @description Checks if the state-select widget is visible; if not, performs telephony login and waits for it to appear + * @throws {Error} When telephony login fails or state widget doesn't become visible + * @example + * ```typescript + * await ensureUserStateVisible(page, LOGIN_MODE.DIAL_NUMBER, dialNumber); + * await ensureUserStateVisible(page, LOGIN_MODE.EXTENSION, extensionNumber); + * await ensureUserStateVisible(page, LOGIN_MODE.DESKTOP); + * ``` + */ +export async function ensureUserStateVisible(page: Page, loginMode: string, number?: string): Promise { + const isUserStateWidgetVisible = await page + .getByTestId('state-select') + .isVisible() + .catch(() => false); + if (!isUserStateWidgetVisible) { + await telephonyLogin(page, loginMode, number); + await expect(page.getByTestId('state-select')).toBeVisible({timeout: LONG_WAIT}); + } +} + +/** + * Verifies if Desktop login option is visible or hidden in the login dropdown + * @param page - The Playwright page object + * @param shouldBeVisible - Whether Desktop option should be visible (true) or hidden (false) + * @throws {Error} When the Desktop option visibility doesn't match expectation + * @example + * ```typescript + * // Verify Desktop is visible + * await verifyDesktopOptionVisibility(page, true); + * + * // Verify Desktop is hidden (when hideDesktopLogin is true) + * await verifyDesktopOptionVisibility(page, false); + * ``` + */ +export async function verifyDesktopOptionVisibility(page: Page, shouldBeVisible: boolean): Promise { + const desktopOption = await page + .locator('#AgentLogin option') + .filter({hasText: 'BROWSER'}) + .first(); + if (shouldBeVisible) { + await expect(desktopOption).toBeVisible({timeout: AWAIT_TIMEOUT}); + } else { + const isHidden = await desktopOption.evaluate((el) => (el as HTMLOptionElement).hidden).catch(() => false); + if (!isHidden) { + throw new Error('Desktop option should be hidden but is still visible'); + } + } +} diff --git a/packages/@webex/contact-center/test/e2e/playwright/Utils/taskControlUtils.ts b/packages/@webex/contact-center/test/e2e/playwright/Utils/taskControlUtils.ts new file mode 100644 index 00000000000..119773ae0e1 --- /dev/null +++ b/packages/@webex/contact-center/test/e2e/playwright/Utils/taskControlUtils.ts @@ -0,0 +1,445 @@ +import {Page, expect} from '@playwright/test'; +import {TASK_TYPES, AWAIT_TIMEOUT, OPERATION_TIMEOUT} from '../constants'; + +/** + * Utility functions for task controls testing. + * Verifies visibility of task control buttons for different task types. + * + * @packageDocumentation + */ + +/** + * Verifies that all call task control buttons are visible and accessible. + * Checks for hold, recording, transfer, consult, and end buttons. + * @param page - The agent's main page + * @returns Promise + */ +export async function callTaskControlCheck(page: Page): Promise { + // Verify call control container is visible + await expect(page.getByTestId('call-control-container').nth(0)).toBeVisible({timeout: OPERATION_TIMEOUT}); + + // Verify hold/resume toggle button is visible + await expect(page.getByTestId('call-control:hold-toggle').nth(0)).toBeVisible({timeout: AWAIT_TIMEOUT}); + + // Verify recording toggle button is visible + await expect(page.getByTestId('call-control:recording-toggle').nth(0)).toBeVisible({timeout: AWAIT_TIMEOUT}); + + // Verify transfer button is visible + await expect(page.getByTestId('call-control:transfer').nth(0)).toBeVisible({timeout: AWAIT_TIMEOUT}); + + // Verify consult button is visible + await expect(page.getByTestId('call-control:consult').nth(0)).toBeVisible({timeout: AWAIT_TIMEOUT}); + + // Verify end call button is visible + await expect(page.getByTestId('call-control:end-call').nth(0)).toBeVisible({timeout: AWAIT_TIMEOUT}); +} + +/** + * Verifies that chat task control buttons are visible and accessible. + * Checks for transfer and end buttons only. + * @param page - The agent's main page + * @returns Promise + */ +export async function chatTaskControlCheck(page: Page): Promise { + // Verify chat control container or equivalent is visible + await expect(page.getByTestId('call-control-container').nth(0)).toBeVisible({timeout: OPERATION_TIMEOUT}); + + // Verify transfer button is visible + await expect(page.getByTestId('call-control:transfer').nth(0)).toBeVisible({timeout: AWAIT_TIMEOUT}); + + // Verify end button is visible (for chat tasks) + await expect(page.getByTestId('call-control:end-call').nth(0)).toBeVisible({timeout: AWAIT_TIMEOUT}); +} + +/** + * Verifies that email task control buttons are visible and accessible. + * Checks for transfer and end buttons only. + * @param page - The agent's main page + * @returns Promise + */ +export async function emailTaskControlCheck(page: Page): Promise { + // Verify email control container or equivalent is visible + await expect(page.getByTestId('call-control-container').nth(0)).toBeVisible({timeout: OPERATION_TIMEOUT}); + + // Verify transfer button is visible + await expect(page.getByTestId('call-control:transfer').nth(0)).toBeVisible({timeout: AWAIT_TIMEOUT}); + + // Verify end button is visible (for email tasks) + await expect(page.getByTestId('call-control:end-call').nth(0)).toBeVisible({timeout: AWAIT_TIMEOUT}); +} + +/** + * Verifies task control buttons based on the task type. + * @param page - The agent's main page + * @param taskType - The type of the task (e.g., TASK_TYPES.CALL, TASK_TYPES.CHAT) + * @returns Promise + */ +export async function verifyTaskControls(page: Page, taskType: string): Promise { + switch (taskType) { + case TASK_TYPES.CALL: + await callTaskControlCheck(page); + break; + case TASK_TYPES.CHAT: + await chatTaskControlCheck(page); + break; + case TASK_TYPES.EMAIL: + await emailTaskControlCheck(page); + break; + default: + throw new Error(`Task control check not implemented for task type: ${taskType}`); + } +} + +/** + * Toggles the hold state of a call by clicking the hold/resume button. + * This function will put the call on hold if it's currently active, or resume it if it's on hold. + * @param page - The agent's main page + * @returns Promise + */ +export async function holdCallToggle(page: Page): Promise { + // Wait for hold toggle button to be visible and clickable + const holdButton = page.getByTestId('call-control:hold-toggle').nth(0); + await expect(holdButton).toBeVisible({timeout: AWAIT_TIMEOUT}); + + // Click the hold toggle button + await holdButton.click({timeout: AWAIT_TIMEOUT}); +} + +/** + * Toggles the recording state of a call by clicking the recording pause/resume button. + * This function will pause recording if it's currently active, or resume it if it's paused. + * @param page - The agent's main page + * @returns Promise + */ +export async function recordCallToggle(page: Page): Promise { + // Wait for recording toggle button to be visible and clickable + const recordButton = page.getByTestId('call-control:recording-toggle').nth(0); + await expect(recordButton).toBeVisible({timeout: AWAIT_TIMEOUT}); + + // Click the recording toggle button + await recordButton.click({timeout: AWAIT_TIMEOUT}); +} + +/** + * Verifies the hold timer visibility and content based on expected state. + * @param page - The agent's main page + * @param options - Configuration object + * @param options.shouldBeVisible - Whether the timer should be visible (true) or hidden (false) + * @param options.verifyContent - Whether to verify timer content (default: true when visible) + * @returns Promise + */ +export async function verifyHoldTimer( + page: Page, + {shouldBeVisible, verifyContent = shouldBeVisible}: {shouldBeVisible: boolean; verifyContent?: boolean} +): Promise { + const holdTimerContainer = page.locator('.on-hold-chip-text'); + + if (shouldBeVisible) { + await expect(holdTimerContainer).toBeVisible({timeout: AWAIT_TIMEOUT}); + + if (verifyContent) { + // Verify "On hold" text is present + await expect(holdTimerContainer).toContainText('On hold', {timeout: AWAIT_TIMEOUT}); + + // Verify timer format (should contain time like 00:XX) + await expect(holdTimerContainer).toContainText(/\d{2}:\d{2}/, {timeout: AWAIT_TIMEOUT}); + } + } else { + await expect(holdTimerContainer).toBeHidden({timeout: AWAIT_TIMEOUT}); + } +} + +/** + * Verifies the icon of the hold toggle button based on current hold state. + * - When call is NOT on hold: expects 'pause-bold' icon (to put call on hold) + * - When call IS on hold: expects 'play-bold' icon (to resume call) + * @param page - The agent's main page + * @param options - Configuration object + * @param options.expectedIsHeld - Expected hold state (true if call is on hold, false if active) + * @returns Promise + * @throws Error if icon verification fails + */ +export async function verifyHoldButtonIcon(page: Page, {expectedIsHeld}: {expectedIsHeld: boolean}): Promise { + const holdButton = page.getByTestId('call-control:hold-toggle').nth(0); + await expect(holdButton).toBeVisible({timeout: AWAIT_TIMEOUT}); + + // Get the icon element within the hold button + const iconElement = holdButton.locator('mdc-icon').nth(0); + await expect(iconElement).toBeVisible({timeout: AWAIT_TIMEOUT}); + + // Verify the correct icon based on hold state + const expectedIcon = expectedIsHeld ? 'play-bold' : 'pause-bold'; + const actualIcon = await iconElement.getAttribute('name'); + + if (actualIcon !== expectedIcon) { + throw new Error( + `Hold button icon mismatch. Expected: '${expectedIcon}' (isHeld: ${expectedIsHeld}), but found: '${actualIcon}'` + ); + } +} + +/** + * Verifies the icon of the record toggle button based on current recording state. + * - When recording is ACTIVE: expects 'record-paused-bold' icon (to pause recording) + * - When recording is PAUSED: expects 'record-bold' icon (to resume recording) + * @param page - The agent's main page + * @param options - Configuration object + * @param options.expectedIsRecording - Expected recording state (true if recording, false if paused) + * @returns Promise + * @throws Error if icon verification fails + */ +export async function verifyRecordButtonIcon( + page: Page, + {expectedIsRecording}: {expectedIsRecording: boolean} +): Promise { + const recordButton = page.getByTestId('call-control:recording-toggle').nth(0); + await expect(recordButton).toBeVisible({timeout: AWAIT_TIMEOUT}); + + // Get the icon element within the record button + const iconElement = recordButton.locator('mdc-icon').nth(0); + await expect(iconElement).toBeVisible({timeout: AWAIT_TIMEOUT}); + + // Verify the correct icon based on recording state + const expectedIcon = expectedIsRecording ? 'record-paused-bold' : 'record-bold'; + const actualIcon = await iconElement.getAttribute('name'); + + if (actualIcon !== expectedIcon) { + throw new Error( + `Record button icon mismatch. Expected: '${expectedIcon}' (isRecording: ${expectedIsRecording}), but found: '${actualIcon}'` + ); + } +} + +// Global variable to store captured logs +let capturedLogs: string[] = []; + +/** + * Sets up console logging to capture callback logs for task controls. + * Captures onHoldResume, onRecordingToggle, onEnd callbacks and SDK success messages. + * @param page - The agent's main page + * @returns Function to remove the console handler + */ +export function setupConsoleLogging(page: Page): () => void { + capturedLogs.length = 0; + + const consoleHandler = (msg) => { + const logText = msg.text(); + if ( + logText.includes('onHoldResume invoked') || + logText.includes('onRecordingToggle invoked') || + logText.includes('onEnd invoked') || + logText.includes('WXCC_SDK_TASK_HOLD_SUCCESS') || + logText.includes('WXCC_SDK_TASK_RESUME_SUCCESS') || + logText.includes('WXCC_SDK_TASK_PAUSE_RECORDING_SUCCESS') || + logText.includes('WXCC_SDK_TASK_RESUME_RECORDING_SUCCESS') + ) { + capturedLogs.push(logText); + } + }; + + page.on('console', consoleHandler); + return () => page.off('console', consoleHandler); +} + +/** + * Clears the captured logs array. + * Should be called before each test or verification to ensure clean state. + */ +export function clearCapturedLogs(): void { + capturedLogs.length = 0; +} + +/** + * Verifies that hold/resume callback logs are present and contain expected values. + * @param options - Configuration object + * @param options.expectedIsHeld - Expected hold state (true for hold, false for resume) + * @throws Error if verification fails with detailed error message + */ +export function verifyHoldLogs({expectedIsHeld}: {expectedIsHeld: boolean}): void { + const holdResumeLogs = capturedLogs.filter((log) => log.includes('onHoldResume invoked')); + const statusLogs = capturedLogs.filter((log) => + log.includes(expectedIsHeld ? 'WXCC_SDK_TASK_HOLD_SUCCESS' : 'WXCC_SDK_TASK_RESUME_SUCCESS') + ); + + if (holdResumeLogs.length === 0) { + throw new Error( + `No 'onHoldResume invoked' logs found. Expected logs for isHeld: ${expectedIsHeld}. Captured logs: ${JSON.stringify(capturedLogs)}` + ); + } + + if (statusLogs.length === 0) { + const expectedStatus = expectedIsHeld ? 'WXCC_SDK_TASK_HOLD_SUCCESS' : 'WXCC_SDK_TASK_RESUME_SUCCESS'; + throw new Error(`No '${expectedStatus}' logs found. Captured logs: ${JSON.stringify(capturedLogs)}`); + } + + const lastHoldLog = holdResumeLogs[holdResumeLogs.length - 1]; + if (!lastHoldLog.includes(`isHeld: ${expectedIsHeld}`)) { + throw new Error(`Expected 'isHeld: ${expectedIsHeld}' in log but found: ${lastHoldLog}`); + } +} + +/** + * Verifies that recording pause/resume callback logs are present and contain expected values. + * @param options - Configuration object + * @param options.expectedIsRecording - Expected recording state (true for recording, false for paused) + * @throws Error if verification fails with detailed error message + */ +export function verifyRecordingLogs({expectedIsRecording}: {expectedIsRecording: boolean}): void { + const recordingLogs = capturedLogs.filter((log) => log.includes('onRecordingToggle invoked')); + const statusLogs = capturedLogs.filter((log) => + log.includes( + expectedIsRecording ? 'WXCC_SDK_TASK_RESUME_RECORDING_SUCCESS' : 'WXCC_SDK_TASK_PAUSE_RECORDING_SUCCESS' + ) + ); + + if (recordingLogs.length === 0) { + throw new Error( + `No 'onRecordingToggle invoked' logs found. Expected logs for isRecording: ${expectedIsRecording}. Captured logs: ${JSON.stringify(capturedLogs)}` + ); + } + + if (statusLogs.length === 0) { + const expectedStatus = expectedIsRecording + ? 'WXCC_SDK_TASK_RESUME_RECORDING_SUCCESS' + : 'WXCC_SDK_TASK_PAUSE_RECORDING_SUCCESS'; + throw new Error(`No '${expectedStatus}' logs found. Captured logs: ${JSON.stringify(capturedLogs)}`); + } + + const lastRecordingLog = recordingLogs[recordingLogs.length - 1]; + if (!lastRecordingLog.includes(`isRecording: ${expectedIsRecording}`)) { + throw new Error(`Expected 'isRecording: ${expectedIsRecording}' in log but found: ${lastRecordingLog}`); + } +} + +/** + * Verifies that onEnd callback logs are present when tasks are ended. + * @throws Error if verification fails with detailed error message + */ +export function verifyEndLogs(): void { + const endLogs = capturedLogs.filter((log) => log.includes('onEnd invoked')); + + if (endLogs.length === 0) { + throw new Error(`No 'onEnd invoked' logs found. Captured logs: ${JSON.stringify(capturedLogs)}`); + } +} + +/** + * Verifies audio transfer from caller to browser by executing the exact console command. + * Executes: document.querySelector("#remote-audio").srcObject.getAudioTracks() + * Verifies that exactly 1 audio MediaStreamTrack is present with GUID label and proper properties + * @param page - The agent's main page (browser receiving audio) + * @returns Promise + * @throws Error if remote audio tracks verification fails + */ +export async function verifyRemoteAudioTracks(page: Page): Promise { + try { + // Execute the exact console command for audio tracks + const consoleResult = await page.evaluate(() => { + // This is the exact command from your console + const audioElem = document.querySelector('#remote-audio') as HTMLAudioElement; + + if (!audioElem) { + return []; + } + + if (!audioElem.srcObject) { + return []; + } + + const mediaStream = audioElem.srcObject as MediaStream; + const audioTracks = mediaStream.getAudioTracks(); + + // Convert MediaStreamTrack objects to serializable format (like console shows) + const result = audioTracks.map((track, index) => { + return { + index, + kind: track.kind, + id: track.id, + label: track.label, + enabled: track.enabled, + muted: track.muted, + readyState: track.readyState, + onended: track.onended, + onmute: track.onmute, + onunmute: track.onunmute, + }; + }); + + return result; + }); + + // Verify we got exactly 1 audio track (no more, no less) + expect(consoleResult.length).toBe(1); + + // Get the single audio track (since we verified there's exactly 1) + const audioTrack = consoleResult[0]; + + // Verify it's an audio track + if (audioTrack.kind !== 'audio') { + throw new Error( + `โŒ Expected audio track but found ${audioTrack.kind} track. Track details: { kind: "${audioTrack.kind}", label: "${audioTrack.label}", id: "${audioTrack.id}" }` + ); + } + + // Verify essential track properties for audio transfer + expect(audioTrack.kind).toBe('audio'); + expect(audioTrack.enabled).toBe(true); + expect(audioTrack.muted).toBe(false); + expect(audioTrack.readyState).toBe('live'); + } catch (error) { + throw new Error(`โŒ Audio transfer verification failed: ${error.message}`); + } +} + +/** + * Verifies the presence of hold music audio element with autoplay and loop attributes. + * Looks for: + * This is checked on the caller page when call is put on hold + * @param page - The caller's page (where hold music should be playing) + * @returns Promise + * @throws Error if hold music element verification fails + */ +export async function verifyHoldMusicElement(page: Page): Promise { + try { + const holdMusicExists = await page.evaluate(() => { + // Look for audio elements with both autoplay and loop attributes + const audioElements = document.querySelectorAll('audio[autoplay][loop]'); + + if (audioElements.length === 0) { + return false; + } + + // Check if at least one element has the correct attributes + return Array.from(audioElements).some((audio) => { + const a = audio as HTMLAudioElement; + return a.hasAttribute('autoplay') && a.hasAttribute('loop') && a.autoplay === true && a.loop === true; + }); + }); + + if (!holdMusicExists) { + throw new Error('โŒ No hold music audio elements found with autoplay and loop attributes'); + } + } catch (error) { + throw new Error(`โŒ Hold music element verification failed: ${error.message}`); + } +} + +/** + * Ends a task by clicking the end call button and waiting for it to be visible. + * This function can be used for any task type (call, chat, email) as they all use the same end button. + * @param page - The agent's main page + * @returns Promise + */ +export async function endTask(page: Page): Promise { + const endButton = page.getByTestId('call-control:end-call').nth(0); + await endButton.waitFor({state: 'visible', timeout: OPERATION_TIMEOUT}); + + // Check if button is disabled and wait for it to be enabled + const isDisabled = await endButton.isDisabled(); + if (isDisabled) { + await holdCallToggle(page); + await expect(endButton).toBeEnabled({timeout: AWAIT_TIMEOUT}); + } + + await endButton.click({timeout: AWAIT_TIMEOUT}); +} diff --git a/packages/@webex/contact-center/test/e2e/playwright/Utils/userStateUtils.ts b/packages/@webex/contact-center/test/e2e/playwright/Utils/userStateUtils.ts new file mode 100644 index 00000000000..d637b4143a7 --- /dev/null +++ b/packages/@webex/contact-center/test/e2e/playwright/Utils/userStateUtils.ts @@ -0,0 +1,241 @@ +import {Page, expect} from '@playwright/test'; +import dotenv from 'dotenv'; +import {USER_STATES, AWAIT_TIMEOUT, CONSOLE_PATTERNS} 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 with timeout, return early if not found + try { + const currentState = await page + .getByTestId('state-select') + .getByTestId('state-name') + .innerText({timeout: AWAIT_TIMEOUT}); + if (currentState.trim() === userState) { + return; + } + } catch (error) { + // Element not found, return without error + return; + } + + const idleDropdown = page.locator('#idleCodesDropdown'); + const idleDropdownVisible = await idleDropdown.isVisible().catch(() => false); + if (idleDropdownVisible) { + try { + await idleDropdown.selectOption({label: userState}, {timeout: AWAIT_TIMEOUT}); + const setAgentStatusButton = page.locator('#setAgentStatus'); + await setAgentStatusButton.waitFor({state: 'visible', timeout: AWAIT_TIMEOUT}); + await setAgentStatusButton.click({timeout: AWAIT_TIMEOUT}); + await page.waitForTimeout(1000); + return; + } catch (error) { + // Fall back to popup flow below if direct dropdown selection fails. + } + } + + await page.getByTestId('state-select').click({timeout: AWAIT_TIMEOUT}); + 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({timeout: AWAIT_TIMEOUT}); + const confirmButton = page.getByTestId('samples:rona-button-confirm'); + await confirmButton.waitFor({state: 'visible', timeout: AWAIT_TIMEOUT}); + await confirmButton.click({timeout: AWAIT_TIMEOUT}); + await page.waitForTimeout(1000); +}; + +/** + * 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({timeout: AWAIT_TIMEOUT}); + 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({timeout: AWAIT_TIMEOUT}); + 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(CONSOLE_PATTERNS.ON_STATE_CHANGE_REGEX)); + + if (!lastStateChangeMessage) { + throw new Error('No onStateChange log found in console messages'); + } + + const stateMatch = lastStateChangeMessage.match(CONSOLE_PATTERNS.ON_STATE_CHANGE_REGEX); + 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(CONSOLE_PATTERNS.SDK_STATE_CHANGE_SUCCESS) + ); + + // Find last index of onStateChange callback using reverse().findIndex() + const callbackReverseIndex = reversedMessages.findIndex( + (msg) => + msg.toLowerCase().includes(CONSOLE_PATTERNS.ON_STATE_CHANGE_KEYWORDS[0]) && + msg.toLowerCase().includes(CONSOLE_PATTERNS.ON_STATE_CHANGE_KEYWORDS[1]) + ); + + // 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(CONSOLE_PATTERNS.ON_STATE_CHANGE_REGEX)); + + if (!lastStateChangeMessage) { + throw new Error('No onStateChange log found in console messages'); + } + + const stateMatch = lastStateChangeMessage.match(CONSOLE_PATTERNS.ON_STATE_CHANGE_REGEX); + 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/packages/@webex/contact-center/test/e2e/playwright/Utils/wrapupUtils.ts b/packages/@webex/contact-center/test/e2e/playwright/Utils/wrapupUtils.ts new file mode 100644 index 00000000000..122383d15d5 --- /dev/null +++ b/packages/@webex/contact-center/test/e2e/playwright/Utils/wrapupUtils.ts @@ -0,0 +1,33 @@ +import {expect, Page} from '@playwright/test'; +import {WrapupReason, AWAIT_TIMEOUT, UI_SETTLE_TIMEOUT, WRAPUP_TIMEOUT} from '../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'); + const isWrapupBoxVisible = await wrapupBox + .first() + .waitFor({state: 'visible', timeout: WRAPUP_TIMEOUT}) + .then(() => true) + .catch(() => false); + if (!isWrapupBoxVisible) throw new Error('Wrapup box is not visible'); + const wrapupSelect = page.getByTestId('call-control:wrapup-select').first(); + await expect(wrapupSelect).toBeVisible({timeout: AWAIT_TIMEOUT}); + try { + await wrapupSelect.selectOption({label: reason.toString()}); + } catch (error) { + await wrapupSelect.selectOption({value: reason.toString()}); + } + await page.waitForTimeout(UI_SETTLE_TIMEOUT); + await expect(wrapupBox.first()).toBeEnabled({timeout: AWAIT_TIMEOUT}); + await wrapupBox.first().click({timeout: AWAIT_TIMEOUT}); + await page.waitForTimeout(UI_SETTLE_TIMEOUT); +} diff --git a/packages/@webex/contact-center/test/e2e/playwright/constants.ts b/packages/@webex/contact-center/test/e2e/playwright/constants.ts new file mode 100644 index 00000000000..0f7356872a1 --- /dev/null +++ b/packages/@webex/contact-center/test/e2e/playwright/constants.ts @@ -0,0 +1,109 @@ +export const BASE_URL = 'https://localhost:8000/samples/contact-center/'; + +export const USER_STATES = { + MEETING: 'Meeting', + AVAILABLE: 'Available', + LUNCH: 'Lunch Break', + RONA: 'RONA', + ENGAGED: 'Engaged', + AGENT_DECLINED: 'Agent_Declined', +}; + +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; + +// Universal timeout for all await operations in Playwright tests +export const AWAIT_TIMEOUT = 10000; + +// Test Manager Constants +export const DEFAULT_MAX_RETRIES = 3; +export const DEFAULT_TIMEOUT = 5000; + +// Consolidated timeout constants by duration and usage +export const UI_SETTLE_TIMEOUT = 2000; +export const FORM_FIELD_TIMEOUT = 20000; +export const OPERATION_TIMEOUT = 30000; +export const NETWORK_OPERATION_TIMEOUT = 35000; + +// Specific timeouts for incoming task operations +export const CHAT_LAUNCHER_TIMEOUT = 60000; + +// Widget initialization timeouts +export const WIDGET_INIT_TIMEOUT = 50000; + +// Wrapup timeouts +export const WRAPUP_TIMEOUT = 15000; + +// Station login timeouts +export const DROPDOWN_SETTLE_TIMEOUT = 200; + +// Console log patterns for state changes +export const CONSOLE_PATTERNS = { + SDK_STATE_CHANGE_SUCCESS: 'WXCC_SDK_AGENT_STATE_CHANGE_SUCCESS', + ON_STATE_CHANGE_REGEX: /onStateChange invoked with state name:\s*(.+)/i, + ON_STATE_CHANGE_KEYWORDS: ['onstatechange', 'invoked'], +}; + +// Page Types for Test Manager +export const PAGE_TYPES = { + AGENT1: 'agent1', + AGENT2: 'agent2', + CALLER: 'caller', + EXTENSION: 'extension', + CHAT: 'chat', + MULTI_SESSION: 'multiSession', + DIAL_NUMBER: 'dialNumber', +}; + +export type PageType = (typeof PAGE_TYPES)[keyof typeof PAGE_TYPES]; + +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 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]; + +// Test Data Constants +export const TEST_DATA = { + CHAT_NAME: 'Playwright Test', + CHAT_EMAIL: 'playwright@test.com', + EMAIL_TEXT: '--This Email is generated due to playwright automation test for incoming Tasks---', + EXTENSION_CALL_INDICATOR: 'Ringing...', +}; diff --git a/packages/@webex/contact-center/test/e2e/playwright/global.setup.ts b/packages/@webex/contact-center/test/e2e/playwright/global.setup.ts new file mode 100644 index 00000000000..e2db0b47401 --- /dev/null +++ b/packages/@webex/contact-center/test/e2e/playwright/global.setup.ts @@ -0,0 +1,61 @@ +import {test as setup} from '@playwright/test'; +import dotenv from 'dotenv'; +import {oauthLogin} from './Utils/initUtils'; +import {USER_SETS} from './test-data'; +const fs = require('fs'); +const path = require('path'); + +const envPath = path.resolve(__dirname, '../.env.contact-center.e2e'); +dotenv.config({path: envPath}); + +setup('OAuth', async ({browser}) => { + // Directly iterate through USER_SETS and their agents + for (const setKey of Object.keys(USER_SETS)) { + const userSet = USER_SETS[setKey]; + + for (const agentKey of Object.keys(userSet.AGENTS)) { + const accessTokenKey = `${setKey}_${agentKey}_ACCESS_TOKEN`; + const existingAccessToken = process.env[accessTokenKey]; + if (existingAccessToken && existingAccessToken.trim()) { + continue; + } + const page = await browser.newPage(); + + const usernameKey = `${setKey}_${agentKey}_USERNAME`; + const oauthAgentId = process.env[usernameKey]; + if (!oauthAgentId) { + throw new Error(`Missing OAuth username for ${setKey}/${agentKey}. Set ${usernameKey} in .env.contact-center.e2e.`); + } + const passwordKey = `${setKey}_${agentKey}_PASSWORD`; + const agentPassword = process.env[passwordKey]; + if (!agentPassword) { + throw new Error(`Missing OAuth password for ${oauthAgentId}. Set ${passwordKey} in .env.contact-center.e2e.`); + } + + await oauthLogin(page, oauthAgentId, agentPassword); + + await page.waitForFunction(() => { + const tokenInput = document.querySelector('#access-token'); + return Boolean(tokenInput && tokenInput.value && tokenInput.value.trim().length > 0); + }); + const accessToken = await page.locator('#access-token').inputValue(); + + let envContent = ''; + if (fs.existsSync(envPath)) { + envContent = fs.readFileSync(envPath, 'utf8'); + // Remove any existing ACCESS_TOKEN line for this set-agent combination + const accessTokenPattern = new RegExp(`^${setKey}_${agentKey}_ACCESS_TOKEN=.*$\\n?`, 'm'); + envContent = envContent.replace(accessTokenPattern, ''); + + // Ensure trailing newline + if (!envContent.endsWith('\n')) envContent += '\n'; + } + envContent += `${setKey}_${agentKey}_ACCESS_TOKEN=${accessToken}\n`; + // Clean up multiple consecutive empty lines + envContent = envContent.replace(/\n{3,}/g, '\n\n'); + fs.writeFileSync(envPath, envContent, 'utf8'); + + await page.close(); + } + } +}); diff --git a/packages/@webex/contact-center/test/e2e/playwright/suites/station-login-user-state-tests.spec.ts b/packages/@webex/contact-center/test/e2e/playwright/suites/station-login-user-state-tests.spec.ts new file mode 100644 index 00000000000..bfe65a6162a --- /dev/null +++ b/packages/@webex/contact-center/test/e2e/playwright/suites/station-login-user-state-tests.spec.ts @@ -0,0 +1,7 @@ +import {test} from '@playwright/test'; +import createStationLoginTests from '../tests/station-login-test.spec'; +import createUserStateTests from '../tests/user-state-test.spec'; + +test.describe('Station Login Tests', createStationLoginTests); +// test.describe('User State Tests', createUserStateTests); + diff --git a/packages/@webex/contact-center/test/e2e/playwright/test-data.ts b/packages/@webex/contact-center/test/e2e/playwright/test-data.ts new file mode 100644 index 00000000000..f60f1a0cb42 --- /dev/null +++ b/packages/@webex/contact-center/test/e2e/playwright/test-data.ts @@ -0,0 +1,61 @@ +import {env} from 'process'; + +require('dotenv').config(); + +export const USER_SETS = { + SET_1: { + AGENTS: { + AGENT1: {username: 'user15', extension: '1015', agentName: 'User15 Agent15'}, + AGENT2: {username: 'user16', extension: '1016', agentName: 'User16 Agent16'}, + }, + QUEUE_NAME: 'Queue e2e 1', + CHAT_URL: `${env.PW_CHAT_URL}-e2e.html`, + EMAIL_ENTRY_POINT: `${env.PW_SANDBOX}.e2e@gmail.com`, + ENTRY_POINT: env.PW_ENTRY_POINT1, + TEST_SUITE: 'digital-incoming-task-tests.spec.ts', + }, + SET_2: { + AGENTS: { + AGENT1: {username: 'user13', extension: '1013', agentName: 'User13 Agent13'}, + AGENT2: {username: 'user14', extension: '1014', agentName: 'User14 Agent14'}, + }, + QUEUE_NAME: 'Queue e2e 2', + CHAT_URL: `${env.PW_CHAT_URL}-e2e-2.html`, + EMAIL_ENTRY_POINT: `${env.PW_SANDBOX}.e2e2@gmail.com`, + ENTRY_POINT: env.PW_ENTRY_POINT2, + TEST_SUITE: 'task-list-multi-session-tests.spec.ts', + }, + SET_3: { + AGENTS: { + AGENT1: {username: 'user19', extension: '1019', agentName: 'User19 Agent19'}, + AGENT2: {username: 'user20', extension: '1020', agentName: 'User20 Agent20'}, + }, + QUEUE_NAME: 'Queue e2e 3', + CHAT_URL: `${env.PW_CHAT_URL}-e2e-3.html`, + EMAIL_ENTRY_POINT: `${env.PW_SANDBOX}.e2e3@gmail.com`, + ENTRY_POINT: env.PW_ENTRY_POINT3, + TEST_SUITE: 'station-login-user-state-tests.spec.ts', + }, + SET_4: { + AGENTS: { + AGENT1: {username: 'user21', extension: '1021', agentName: 'User21 Agent21'}, + AGENT2: {username: 'user22', extension: '1022', agentName: 'User22 Agent22'}, + }, + QUEUE_NAME: 'Queue e2e 4', + CHAT_URL: `${env.PW_CHAT_URL}-e2e-4.html`, + EMAIL_ENTRY_POINT: `${env.PW_SANDBOX}.e2e4@gmail.com`, + ENTRY_POINT: env.PW_ENTRY_POINT4, + TEST_SUITE: 'basic-advanced-task-controls-tests.spec.ts', + }, + SET_5: { + AGENTS: { + AGENT1: {username: 'user23', extension: '1023', agentName: 'User23 Agent23'}, + AGENT2: {username: 'user24', extension: '1024', agentName: 'User24 Agent24'}, + }, + QUEUE_NAME: 'Queue e2e 5', + CHAT_URL: `${env.PW_CHAT_URL}-e2e-5.html`, + EMAIL_ENTRY_POINT: `${env.PW_SANDBOX}.e2e5@gmail.com`, + ENTRY_POINT: env.PW_ENTRY_POINT5, + TEST_SUITE: 'advanced-task-controls-tests.spec.ts', + }, +}; diff --git a/packages/@webex/contact-center/test/e2e/playwright/test-manager.ts b/packages/@webex/contact-center/test/e2e/playwright/test-manager.ts new file mode 100644 index 00000000000..2e867b098e5 --- /dev/null +++ b/packages/@webex/contact-center/test/e2e/playwright/test-manager.ts @@ -0,0 +1,636 @@ +import {expect, Page, BrowserContext, Browser} from '@playwright/test'; +import {enableAllWidgets, enableMultiLogin, initialiseWidgets, loginViaAccessToken} from './Utils/initUtils'; +import {stationLogout, telephonyLogin} from './Utils/stationLoginUtils'; +import {loginExtension} from './Utils/incomingTaskUtils'; +import {setupConsoleLogging} from './Utils/taskControlUtils'; +import {setupAdvancedConsoleLogging} from './Utils/advancedTaskControlUtils'; +import {pageSetup, registerAgent} from './Utils/helperUtils'; +import { + LOGIN_MODE, + LoginMode, + DEFAULT_MAX_RETRIES, + DEFAULT_TIMEOUT, + UI_SETTLE_TIMEOUT, + AWAIT_TIMEOUT, + PAGE_TYPES, + PageType, + CALL_URL, +} from './constants'; + +// Configuration interfaces for setup options +interface SetupConfig { + // Core requirements + needsAgent1?: boolean; + needsAgent2?: boolean; + needsCaller?: boolean; + needsExtension?: boolean; + needsChat?: boolean; + needsMultiSession?: boolean; + + // Login modes + agent1LoginMode?: LoginMode; + + // Console logging + enableConsoleLogging?: boolean; + enableAdvancedLogging?: boolean; + needDialNumberLogin?: boolean; +} + +// Environment variable helper interface +interface EnvTokens { + agent1AccessToken: string; + agent2AccessToken: string; + agent1Username: string; + agent2Username: string; + agent1ExtensionNumber: string; + password: string; + dialNumberUsername?: string; + dialNumberPassword?: string; +} + +// Context creation result interface +interface ContextCreationResult { + context: BrowserContext; + page: Page; + type: PageType; +} + +// ๐Ÿ—๏ธ Simple Test Context Manager +export class TestManager { + // Main widget page (Agent 1 login) + public agent1Page!: Page; + public agent1Context!: BrowserContext; + + // Multi-session page (Agent 1 second session) + public multiSessionAgent1Page: Page; + public multiSessionContext: BrowserContext; + + // Agent 2 main widget page (Agent 2 login) + public agent2Page: Page; + public agent2Context: BrowserContext; + + // Caller extension page (Agent 2 for making calls) + public callerPage: Page; + public callerExtensionContext: BrowserContext; + + // Extension page (Agent 1 extension login) + public agent1ExtensionPage: Page; + public extensionContext: BrowserContext; + + // Chat page + public chatPage: Page; + public chatContext: BrowserContext; + + // Dial Number page + public dialNumberPage: Page; + public dialNumberContext: BrowserContext; + + // Console messages collected from pages + + public consoleMessages: string[] = []; + public readonly maxRetries: number; + public readonly projectName: string; + + constructor(projectName: string, maxRetries: number = DEFAULT_MAX_RETRIES) { + this.projectName = projectName; + this.maxRetries = maxRetries; + } + + // Helper method to get environment tokens + private getEnvTokens(): EnvTokens { + return { + agent1AccessToken: process.env[`${this.projectName}_AGENT1_ACCESS_TOKEN`] ?? '', + agent2AccessToken: process.env[`${this.projectName}_AGENT2_ACCESS_TOKEN`] ?? '', + agent1Username: process.env[`${this.projectName}_AGENT1_USERNAME`] ?? '', + agent2Username: process.env[`${this.projectName}_AGENT2_USERNAME`] ?? '', + agent1ExtensionNumber: process.env[`${this.projectName}_AGENT1_EXTENSION_NUMBER`] ?? '', + password: process.env.PW_SANDBOX_PASSWORD ?? '', + dialNumberUsername: process.env.PW_DIAL_NUMBER_LOGIN_USERNAME ?? '', + dialNumberPassword: process.env.PW_DIAL_NUMBER_LOGIN_PASSWORD ?? '', + }; + } + + // Helper method to create context with error handling + private async createContextWithPage(browser: Browser, type: PageType): Promise { + try { + const context = await browser.newContext(); + const page = await context.newPage(); + return {context, page, type}; + } catch (error) { + throw new Error(`Failed to create context for ${type}: ${error}`); + } + } + + // Helper method to setup console logging for a page + private setupPageConsoleLogging(page: Page, enableLogging: boolean = true): void { + if (enableLogging) { + page.on('console', (msg) => this.consoleMessages.push(msg.text())); + } + } + + // Helper method to retry operations with exponential backoff + private async retryOperation( + operation: () => Promise, + operationName: string, + maxRetries: number = this.maxRetries + ): Promise { + for (let attempt = 0; attempt < maxRetries; attempt++) { + try { + return await operation(); + } catch (error) { + if (attempt === maxRetries - 1) { + throw new Error(`Failed ${operationName} after ${maxRetries} attempts: ${error}`); + } + console.warn(`${operationName} attempt ${attempt + 1} failed, retrying...`); + // Simple exponential backoff + await new Promise((resolve) => setTimeout(resolve, Math.pow(2, attempt) * 1000)); + } + } + throw new Error(`Retry operation failed unexpectedly for ${operationName}`); + } + + // Helper method to check if logout button is visible + private async isLogoutButtonVisible(page: Page, timeout: number = DEFAULT_TIMEOUT): Promise { + try { + return await page.getByTestId('samples:station-logout-button').isVisible({timeout}); + } catch { + return false; + } + } + + // ๐ŸŽฏ Universal Setup Method - Handles all test scenarios (Parallelized) + async setup(browser: Browser, config: SetupConfig = {}): Promise { + // Default configuration + const defaults: SetupConfig = { + needsAgent1: true, + needsAgent2: false, + needsCaller: false, + needsExtension: false, + needsChat: false, + needsMultiSession: false, + agent1LoginMode: LOGIN_MODE.DESKTOP, + enableConsoleLogging: true, + enableAdvancedLogging: false, + needDialNumberLogin: false, + }; + + const finalConfig: Required = {...defaults, ...config} as Required; + const envTokens = this.getEnvTokens(); + + // ๐Ÿš€ Step 1: Create all required browser contexts in parallel + const contextCreationPromises = this.createContextsForConfig(browser, finalConfig); + await this.processContextCreations(contextCreationPromises, finalConfig); + + // ๐Ÿš€ Step 2: Setup login and widgets in parallel for independent pages + const setupPromises = this.createSetupPromises(finalConfig, envTokens); + await Promise.all(setupPromises); + + // Multi-session setup - Remove dependency wait, make it truly parallel + if (finalConfig.needsMultiSession && this.multiSessionAgent1Page) { + await this.setupMultiSessionFlow(finalConfig, envTokens); + } + + // ๐Ÿš€ Step 3: Setup console logging (can be done in parallel too) + await this.setupConsoleLogging(finalConfig); + } + + // Helper method to create context creation promises + private createContextsForConfig(browser: Browser, config: Required): Promise[] { + const promises: Promise[] = []; + + if (config.needsAgent1) { + promises.push(this.createContextWithPage(browser, PAGE_TYPES.AGENT1)); + } + if (config.needsAgent2) { + promises.push(this.createContextWithPage(browser, PAGE_TYPES.AGENT2)); + } + if (config.needsCaller) { + promises.push(this.createContextWithPage(browser, PAGE_TYPES.CALLER)); + } + if (config.needsExtension) { + promises.push(this.createContextWithPage(browser, PAGE_TYPES.EXTENSION)); + } + if (config.needDialNumberLogin) { + promises.push(this.createContextWithPage(browser, PAGE_TYPES.DIAL_NUMBER)); + } + if (config.needsChat) { + promises.push(this.createContextWithPage(browser, PAGE_TYPES.CHAT)); + } + if (config.needsMultiSession) { + promises.push(this.createContextWithPage(browser, PAGE_TYPES.MULTI_SESSION)); + } + + return promises; + } + + // Helper method to process context creations + private async processContextCreations( + promises: Promise[], + config: Required + ): Promise { + const results = await Promise.all(promises); + + for (const result of results) { + switch (result.type) { + case PAGE_TYPES.AGENT1: + this.agent1Context = result.context; + this.agent1Page = result.page; + this.consoleMessages = []; + this.setupPageConsoleLogging(this.agent1Page, true); + break; + case PAGE_TYPES.AGENT2: + this.agent2Context = result.context; + this.agent2Page = result.page; + this.setupPageConsoleLogging(this.agent2Page, config.enableConsoleLogging); + break; + case PAGE_TYPES.CALLER: + this.callerExtensionContext = result.context; + this.callerPage = result.page; + break; + case PAGE_TYPES.EXTENSION: + this.extensionContext = result.context; + this.agent1ExtensionPage = result.page; + break; + case PAGE_TYPES.CHAT: + this.chatContext = result.context; + this.chatPage = result.page; + break; + case PAGE_TYPES.MULTI_SESSION: + this.multiSessionContext = result.context; + this.multiSessionAgent1Page = result.page; + break; + case PAGE_TYPES.DIAL_NUMBER: + this.dialNumberContext = result.context; + this.dialNumberPage = result.page; + break; + default: + throw new Error(`Unknown page type: ${result.type}`); + } + } + } + + // Helper method to create setup promises + private createSetupPromises(config: Required, envTokens: EnvTokens): Promise[] { + const setupPromises: Promise[] = []; + + // Agent1 setup + if (config.needsAgent1) { + setupPromises.push(this.setupAgent1(config, envTokens)); + } + + // Agent2 setup + if (config.needsAgent2) { + setupPromises.push(this.setupAgent2(envTokens)); + } + + // Caller extension setup + if (config.needsCaller && this.callerPage) { + setupPromises.push(this.setupCaller(envTokens)); + } + + // Dial Number setup + if (config.needDialNumberLogin && this.dialNumberPage) { + setupPromises.push(this.setupDialNumber(envTokens)); + } + return setupPromises; + } + + // Helper method for Agent1 setup + private async setupAgent1(config: Required, envTokens: EnvTokens): Promise { + if (config.agent1LoginMode === LOGIN_MODE.DESKTOP) { + await pageSetup(this.agent1Page, LOGIN_MODE.DESKTOP, envTokens.agent1AccessToken); + } else if (config.agent1LoginMode === LOGIN_MODE.EXTENSION && this.agent1ExtensionPage) { + await Promise.all([ + pageSetup( + this.agent1Page, + LOGIN_MODE.EXTENSION, + envTokens.agent1AccessToken, + this.agent1ExtensionPage, + envTokens.agent1ExtensionNumber + ), + this.retryOperation( + () => loginExtension(this.agent1ExtensionPage, envTokens.agent1Username, envTokens.password), + 'agent1 extension login' + ), + ]); + } + } + + // Helper method for Agent2 setup + private async setupAgent2(envTokens: EnvTokens): Promise { + await pageSetup(this.agent2Page, LOGIN_MODE.DESKTOP, envTokens.agent2AccessToken); + } + + // Helper method for Dial Number setup + private async setupDialNumber(envTokens: EnvTokens): Promise { + await this.retryOperation( + () => loginExtension(this.dialNumberPage, envTokens.dialNumberUsername, envTokens.dialNumberPassword), + 'dial number login' + ); + // Ensure only one page remains in the Dial Number context to avoid duplicate web client instances + await this.enforceSingleDialNumberInOwnContext(); + } + + // Helper method for Caller setup + private async setupCaller(envTokens: EnvTokens): Promise { + await this.retryOperation( + () => loginExtension(this.callerPage!, envTokens.agent2Username, envTokens.password), + 'caller extension login' + ); + } + + // Helper method for multi-session setup + private async setupMultiSessionFlow(config: Required, envTokens: EnvTokens): Promise { + if (config.agent1LoginMode === LOGIN_MODE.EXTENSION) { + await pageSetup( + this.multiSessionAgent1Page!, + LOGIN_MODE.EXTENSION, + envTokens.agent1AccessToken, + this.agent1ExtensionPage, + envTokens.agent1ExtensionNumber, + true // Enable multi-session mode + ); + } + } + + // Helper method for console logging setup + private async setupConsoleLogging(config: Required): Promise { + const setupOperations: (() => void)[] = []; + + if (config.enableConsoleLogging && config.needsAgent1) { + setupOperations.push(() => setupConsoleLogging(this.agent1Page)); + } + + if (config.enableAdvancedLogging && config.needsAgent1) { + setupOperations.push(() => setupAdvancedConsoleLogging(this.agent1Page)); + } + + if (config.enableConsoleLogging && config.needsAgent2) { + setupOperations.push(() => setupConsoleLogging(this.agent2Page)); + } + + if (config.enableAdvancedLogging && config.needsAgent2) { + setupOperations.push(() => setupAdvancedConsoleLogging(this.agent2Page)); + } + + // Execute all setup operations synchronously since they don't return promises + setupOperations.forEach((operation) => operation()); + } + + async basicSetup(browser: Browser) { + await this.setup(browser, { + needsAgent1: true, + needsAgent2: false, + agent1LoginMode: LOGIN_MODE.DESKTOP, + enableConsoleLogging: true, + enableAdvancedLogging: false, + }); + } + + async setupForAdvancedTaskControls(browser: Browser) { + await this.setup(browser, { + needsAgent1: true, + needsAgent2: true, + needsExtension: true, + needsCaller: true, + agent1LoginMode: LOGIN_MODE.EXTENSION, + enableConsoleLogging: true, + enableAdvancedLogging: true, + needDialNumberLogin: true, + }); + } + + async setupForAdvancedCombinations(browser: Browser) { + await this.setup(browser, { + needsAgent1: true, + needsAgent2: true, + needsCaller: true, + needDialNumberLogin: true, + agent1LoginMode: LOGIN_MODE.DESKTOP, + enableConsoleLogging: true, + enableAdvancedLogging: true, + }); + } + + async setupForStationLogin(browser: Browser, isDesktopMode: boolean = false): Promise { + const envTokens = this.getEnvTokens(); + + // Create browser context and page + this.agent1Context = await browser.newContext(); + this.agent1Page = await this.agent1Context.newPage(); + this.consoleMessages = []; + this.setupPageConsoleLogging(this.agent1Page, true); + + // Create multi-session context and page for multi-login tests + this.multiSessionContext = await browser.newContext(); + this.multiSessionAgent1Page = await this.multiSessionContext.newPage(); + + // Define page setup operations + const pageSetupOperations: Promise[] = [ + // Main page setup + this.setupPageWithWidgets(this.agent1Page, envTokens.agent1AccessToken), + ]; + + // Add multi-session page setup only if not in desktop mode + if (!isDesktopMode) { + pageSetupOperations.push(this.setupPageWithWidgets(this.multiSessionAgent1Page, envTokens.agent1AccessToken)); + } + + // Execute page setups in parallel + await Promise.all(pageSetupOperations); + + // Handle station logout for both pages + await this.handleStationLogouts(isDesktopMode); + + // Ensure station login widget is visible on both pages + await this.verifyStationLoginWidgets(isDesktopMode); + } + + // Helper method to setup page with widgets + private async setupPageWithWidgets(page: Page, accessToken: string): Promise { + await loginViaAccessToken(page, accessToken); + await registerAgent(page); + await enableMultiLogin(page); + await enableAllWidgets(page); + await initialiseWidgets(page); + } + + // Helper method to handle station logouts + private async handleStationLogouts(isDesktopMode: boolean): Promise { + const logoutOperations: Promise[] = []; + + // Logout from station if already logged in on main page + if (await this.isLogoutButtonVisible(this.agent1Page)) { + logoutOperations.push(stationLogout(this.agent1Page)); + } + + // Logout from station if already logged in on multi-session page + if (!isDesktopMode && (await this.isLogoutButtonVisible(this.multiSessionAgent1Page))) { + logoutOperations.push(stationLogout(this.multiSessionAgent1Page)); + } + + await Promise.all(logoutOperations); + } + + // Helper method to verify station login widgets + private async verifyStationLoginWidgets(isDesktopMode: boolean): Promise { + const verificationPromises: Promise[] = [ + expect(this.agent1Page.getByTestId('station-login-widget')).toBeVisible({timeout: AWAIT_TIMEOUT}), + ]; + + if (!isDesktopMode) { + verificationPromises.push( + expect(this.multiSessionAgent1Page.getByTestId('station-login-widget')).toBeVisible({ + timeout: AWAIT_TIMEOUT, + }) + ); + } + + await Promise.all(verificationPromises); + } + + async setupMultiSessionPage(): Promise { + if (!this.multiSessionAgent1Page) { + return; + } + + const envTokens = this.getEnvTokens(); + + // Setup multi-session page with widgets - only called when needed for multi-session tests + await loginViaAccessToken(this.multiSessionAgent1Page, envTokens.agent1AccessToken); + await registerAgent(this.multiSessionAgent1Page); + + await Promise.all([enableMultiLogin(this.multiSessionAgent1Page), enableAllWidgets(this.multiSessionAgent1Page)]); + + await initialiseWidgets(this.multiSessionAgent1Page); + } + + // Specific setup methods that use the universal setup + async setupForIncomingTaskDesktop(browser: Browser) { + await this.setup(browser, { + needsAgent1: true, + needsCaller: true, + agent1LoginMode: LOGIN_MODE.DESKTOP, + needsChat: true, + enableConsoleLogging: true, + }); + } + + async setupForIncomingTaskExtension(browser: Browser) { + await this.setup(browser, { + needsAgent1: true, + needsCaller: true, + needsExtension: true, + needsChat: true, + agent1LoginMode: LOGIN_MODE.EXTENSION, + enableConsoleLogging: true, + }); + } + + async setupForIncomingTaskMultiSession(browser: Browser) { + await this.setup(browser, { + needsAgent1: true, + needsCaller: true, + needsExtension: true, + needsChat: true, + needsMultiSession: true, + agent1LoginMode: LOGIN_MODE.EXTENSION, + enableConsoleLogging: true, + }); + } + + async cleanup(): Promise { + // Logout operations - can be done in parallel + const logoutOperations: Promise[] = []; + + if (this.agent1Page && (await this.isLogoutButtonVisible(this.agent1Page))) { + logoutOperations.push(stationLogout(this.agent1Page)); + } + + if (this.agent2Page && (await this.isLogoutButtonVisible(this.agent2Page))) { + logoutOperations.push(stationLogout(this.agent2Page)); + } + + await Promise.all(logoutOperations); + + // Close pages and contexts in parallel + const cleanupOperations: Promise[] = []; + + // Close pages + const pagesToClose = [ + this.agent1Page, + this.multiSessionAgent1Page, + this.agent2Page, + this.callerPage, + this.agent1ExtensionPage, + this.chatPage, + this.dialNumberPage, + ].filter(Boolean); + + pagesToClose.forEach((page) => { + if (page) { + cleanupOperations.push(page.close().catch(() => {})); // Ignore errors during cleanup + } + }); + + // Close contexts + const contextsToClose = [ + this.agent1Context, + this.multiSessionContext, + this.agent2Context, + this.callerExtensionContext, + this.extensionContext, + this.chatContext, + this.dialNumberContext, + ].filter(Boolean); + + contextsToClose.forEach((context) => { + if (context) { + cleanupOperations.push(context.close().catch(() => {})); // Ignore errors during cleanup + } + }); + + await Promise.all(cleanupOperations); + } + + // Helper method to hard-reset the dial number login session + public async resetDialNumberSession(): Promise { + if (!this.dialNumberPage || !this.dialNumberContext) { + return; + } + const envTokens = this.getEnvTokens(); + try { + await this.dialNumberContext.clearCookies(); + await this.dialNumberPage.evaluate(() => { + try { + localStorage.clear(); + } catch {} + try { + sessionStorage.clear(); + } catch {} + }); + // Navigate fresh and login again + await this.dialNumberPage.goto(CALL_URL); + await loginExtension(this.dialNumberPage, envTokens.dialNumberUsername!, envTokens.dialNumberPassword!); + await this.enforceSingleDialNumberInOwnContext(); + } catch (error) { + throw new Error(`Failed to reset dial number session: ${error}`); + } + } + + // Ensures at most one page exists in the dedicated Dial Number context we manage. + // Closes any extra tabs/pages opened in that context to prevent multiple web.webex.com instances for the Dial Number user. + private async enforceSingleDialNumberInOwnContext(): Promise { + if (!this.dialNumberContext) return; + try { + const pages = this.dialNumberContext.pages(); + for (const p of pages) { + if (p !== this.dialNumberPage) { + try { + await p.close(); + } catch {} + } + } + } catch {} + } +} diff --git a/packages/@webex/contact-center/test/e2e/playwright/tests/incoming-telephony-task-test.spec.ts b/packages/@webex/contact-center/test/e2e/playwright/tests/incoming-telephony-task-test.spec.ts new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/@webex/contact-center/test/e2e/playwright/tests/station-login-test.spec.ts b/packages/@webex/contact-center/test/e2e/playwright/tests/station-login-test.spec.ts new file mode 100644 index 00000000000..5a1742bb0e9 --- /dev/null +++ b/packages/@webex/contact-center/test/e2e/playwright/tests/station-login-test.spec.ts @@ -0,0 +1,414 @@ +import {test, expect} from '@playwright/test'; +import {agentRelogin} from '../Utils/initUtils'; +import { + telephonyLogin, + verifyLoginMode, + ensureUserStateVisible, + verifyDesktopOptionVisibility, +} from '../Utils/stationLoginUtils'; +import {changeUserState, verifyCurrentState, getStateElapsedTime} from '../Utils/userStateUtils'; +import {parseTimeString, waitForWebSocketDisconnection, waitForWebSocketReconnection} from '../Utils/helperUtils'; +import {USER_STATES, LOGIN_MODE, LONG_WAIT} from '../constants'; +import {TestManager} from '../test-manager'; + +export default function createStationLoginTests() { + test.describe('Station Login Tests - Dial Number Mode', () => { + let testManager: TestManager; + + test.beforeAll(async ({browser}, testInfo) => { + const projectName = testInfo.project.name; + testManager = new TestManager(projectName); + await testManager.setupForStationLogin(browser); + }); + + test.afterAll(async () => { + if (testManager) { + await testManager.cleanup(); + } + }); + + test('should login with Dial Number mode and verify all fields are visible', async () => { + await expect(testManager.agent1Page.getByTestId('station-login-widget')).toBeVisible({timeout: 2000}); + const loginModeSelector = testManager.agent1Page.getByTestId('login-option-select'); + await expect(loginModeSelector).toBeVisible({timeout: 2000}); + const phoneNumberInput = testManager.agent1Page.getByTestId('dial-number-input'); + await expect(phoneNumberInput).toBeVisible({timeout: 2000}); + const teamSelectionDropdown = testManager.agent1Page.getByTestId('teams-select-dropdown'); + await expect(teamSelectionDropdown).toBeVisible({timeout: 2000}); + const loginButton = testManager.agent1Page.getByTestId('login-button'); + await expect(loginButton).toBeVisible({timeout: 2000}); + await expect(loginButton).toContainText('Login'); + await telephonyLogin( + testManager.agent1Page, + LOGIN_MODE.DIAL_NUMBER, + process.env[`${testManager.projectName}_ENTRY_POINT`] + ); + await expect(testManager.agent1Page.getByTestId('state-select')).toBeVisible({timeout: LONG_WAIT}); + await verifyLoginMode(testManager.agent1Page, 'Dial Number'); + }); + + test('should handle page reload and maintain Dial Number login state', async () => { + await ensureUserStateVisible( + testManager.agent1Page, + LOGIN_MODE.DIAL_NUMBER, + process.env[`${testManager.projectName}_ENTRY_POINT`] + ); + await agentRelogin(testManager.agent1Page); + await expect(testManager.agent1Page.getByTestId('station-login-widget')).toBeVisible({timeout: 2000}); + await verifyLoginMode(testManager.agent1Page, 'Dial Number'); + const dialNumber = process.env[`${testManager.projectName}_ENTRY_POINT`]; + if (dialNumber) { + await expect(testManager.agent1Page.getByTestId('dial-number-input').locator('input')).toHaveValue(dialNumber); + } + await expect(testManager.agent1Page.getByTestId('state-select')).toBeVisible({timeout: 2000}); + }); + + test('should retain user state timer and switch to Meeting state after network disconnection with Dial Number mode', async () => { + await ensureUserStateVisible( + testManager.agent1Page, + LOGIN_MODE.DIAL_NUMBER, + process.env[`${testManager.projectName}_ENTRY_POINT`] + ); + await changeUserState(testManager.agent1Page, USER_STATES.MEETING); + await verifyCurrentState(testManager.agent1Page, USER_STATES.MEETING); + const timerBeforeDisconnection = await getStateElapsedTime(testManager.agent1Page); + const secondsBeforeDisconnection = parseTimeString(timerBeforeDisconnection); + await testManager.agent1Page.waitForTimeout(3000); + testManager.consoleMessages.length = 0; + await testManager.agent1Page.context().setOffline(true); + await testManager.agent1Page.waitForTimeout(3000); + const isWebSocketDisconnected = await waitForWebSocketDisconnection(testManager.consoleMessages); + expect(isWebSocketDisconnected).toBe(true); + await expect(testManager.agent1Page.getByTestId('station-login-widget')).toBeVisible({timeout: 2000}); + await verifyLoginMode(testManager.agent1Page, 'Dial Number'); + testManager.consoleMessages.length = 0; + await testManager.agent1Page.context().setOffline(false); + await testManager.agent1Page.waitForTimeout(3000); + const isWebSocketReconnected = await waitForWebSocketReconnection(testManager.consoleMessages); + expect(isWebSocketReconnected).toBe(true); + await verifyCurrentState(testManager.agent1Page, USER_STATES.MEETING); + const timerAfterReconnection = await getStateElapsedTime(testManager.agent1Page); + const secondsAfterReconnection = parseTimeString(timerAfterReconnection); + expect(secondsAfterReconnection).toBeGreaterThan(secondsBeforeDisconnection); + await verifyLoginMode(testManager.agent1Page, 'Dial Number'); + }); + + // TODO: The bug of timer reset for Available state should be fixed before implementing this test case + test.skip('should reset user state timer and maintain Available state after network disconnection with Dial Number mode', async () => { + await ensureUserStateVisible( + testManager.agent1Page, + LOGIN_MODE.DIAL_NUMBER, + process.env[`${testManager.projectName}_ENTRY_POINT`] + ); + await changeUserState(testManager.agent1Page, USER_STATES.AVAILABLE); + await verifyCurrentState(testManager.agent1Page, USER_STATES.AVAILABLE); + await testManager.agent1Page.waitForTimeout(15000); + const timerBeforeDisconnection = await getStateElapsedTime(testManager.agent1Page); + const secondsBeforeDisconnection = parseTimeString(timerBeforeDisconnection); + testManager.consoleMessages.length = 0; + await testManager.agent1Page.context().setOffline(true); + const isWebSocketDisconnected = await waitForWebSocketDisconnection(testManager.consoleMessages); + expect(isWebSocketDisconnected).toBe(true); + await expect(testManager.agent1Page.getByTestId('station-login-widget')).toBeVisible({timeout: 2000}); + await verifyLoginMode(testManager.agent1Page, 'Dial Number'); + testManager.consoleMessages.length = 0; + await testManager.agent1Page.context().setOffline(false); + const isWebSocketReconnected = await waitForWebSocketReconnection(testManager.consoleMessages); + expect(isWebSocketReconnected).toBe(true); + await verifyCurrentState(testManager.agent1Page, USER_STATES.AVAILABLE); + const timerAfterReconnection = await getStateElapsedTime(testManager.agent1Page); + const secondsAfterReconnection = parseTimeString(timerAfterReconnection); + expect(secondsAfterReconnection).toBeLessThan(secondsBeforeDisconnection); + await testManager.agent1Page.waitForTimeout(10000); + const agentStateChangeLog = testManager.consoleMessages.find( + (msg) => msg.includes('AGENT Status set successfully') || msg.includes('Agent state changed successfully') + ); + expect(agentStateChangeLog).toBeTruthy(); + await verifyLoginMode(testManager.agent1Page, 'Dial Number'); + }); + + test('should support multi-login synchronization for Dial Number Mode ', async () => { + await ensureUserStateVisible( + testManager.agent1Page, + LOGIN_MODE.DIAL_NUMBER, + process.env[`${testManager.projectName}_ENTRY_POINT`] + ); + + const multiSessionPage = testManager.multiSessionAgent1Page!; + await verifyLoginMode(multiSessionPage, 'Dial Number'); + //Verify if signing out from one session logs out the other session + await multiSessionPage.getByTestId('samples:station-logout-button').click(); + await testManager.agent1Page.waitForTimeout(2000); + const isLogoutButtonVisible = await testManager.agent1Page + .getByTestId('samples:station-logout-button') + .isVisible() + .catch(() => false); + expect(isLogoutButtonVisible).toBe(false); + }); + }); + + test.describe.skip('Station Login Tests - Extension Mode', () => { + let testManager: TestManager; + + test.beforeAll(async ({browser}, testInfo) => { + const projectName = testInfo.project.name; + testManager = new TestManager(projectName); + await testManager.setupForStationLogin(browser); + }); + + test.afterAll(async () => { + if (testManager) { + await testManager.cleanup(); + } + }); + + test('should login with Extension mode and verify all fields are visible', async () => { + await expect(testManager.agent1Page.getByTestId('station-login-widget')).toBeVisible({timeout: 2000}); + const loginModeSelector = testManager.agent1Page.getByTestId('login-option-select'); + await expect(loginModeSelector).toBeVisible({timeout: 2000}); + const phoneNumberInput = testManager.agent1Page.getByTestId('dial-number-input'); + await expect(phoneNumberInput).toBeVisible({timeout: 2000}); + const teamSelectionDropdown = testManager.agent1Page.getByTestId('teams-select-dropdown'); + await expect(teamSelectionDropdown).toBeVisible({timeout: 2000}); + const loginButton = testManager.agent1Page.getByTestId('login-button'); + await expect(loginButton).toBeVisible({timeout: 2000}); + await expect(loginButton).toContainText('Login'); + await telephonyLogin( + testManager.agent1Page, + LOGIN_MODE.EXTENSION, + process.env[`${testManager.projectName}_AGENT1_EXTENSION_NUMBER`] + ); + await expect(testManager.agent1Page.getByTestId('state-select')).toBeVisible({timeout: LONG_WAIT}); + await verifyLoginMode(testManager.agent1Page, 'Extension'); + }); + + test('should handle page reload and maintain Extension login state', async () => { + await ensureUserStateVisible( + testManager.agent1Page, + LOGIN_MODE.EXTENSION, + process.env[`${testManager.projectName}_AGENT1_EXTENSION_NUMBER`] + ); + await agentRelogin(testManager.agent1Page); + await expect(testManager.agent1Page.getByTestId('station-login-widget')).toBeVisible({timeout: 2000}); + await verifyLoginMode(testManager.agent1Page, 'Extension'); + const extensionNumber = process.env[`${testManager.projectName}_AGENT1_EXTENSION_NUMBER`]; + if (extensionNumber) { + await expect(testManager.agent1Page.getByTestId('dial-number-input').locator('input')).toHaveValue( + extensionNumber + ); + } + await expect(testManager.agent1Page.getByTestId('state-select')).toBeVisible({timeout: 2000}); + }); + + test('should retain user state timer and switch to Meeting state after network disconnection with Extension mode', async () => { + await ensureUserStateVisible( + testManager.agent1Page, + LOGIN_MODE.EXTENSION, + process.env[`${testManager.projectName}_AGENT1_EXTENSION_NUMBER`] + ); + await changeUserState(testManager.agent1Page, USER_STATES.MEETING); + await verifyCurrentState(testManager.agent1Page, USER_STATES.MEETING); + const timerBeforeDisconnection = await getStateElapsedTime(testManager.agent1Page); + const secondsBeforeDisconnection = parseTimeString(timerBeforeDisconnection); + await testManager.agent1Page.waitForTimeout(3000); + testManager.consoleMessages.length = 0; + await testManager.agent1Page.context().setOffline(true); + await testManager.agent1Page.waitForTimeout(3000); + const isWebSocketDisconnected = await waitForWebSocketDisconnection(testManager.consoleMessages); + expect(isWebSocketDisconnected).toBe(true); + await expect(testManager.agent1Page.getByTestId('station-login-widget')).toBeVisible({timeout: 2000}); + await verifyLoginMode(testManager.agent1Page, 'Extension'); + testManager.consoleMessages.length = 0; + await testManager.agent1Page.context().setOffline(false); + await testManager.agent1Page.waitForTimeout(3000); + const isWebSocketReconnected = await waitForWebSocketReconnection(testManager.consoleMessages); + expect(isWebSocketReconnected).toBe(true); + await verifyCurrentState(testManager.agent1Page, USER_STATES.MEETING); + const timerAfterReconnection = await getStateElapsedTime(testManager.agent1Page); + const secondsAfterReconnection = parseTimeString(timerAfterReconnection); + expect(secondsAfterReconnection).toBeGreaterThan(secondsBeforeDisconnection); + await verifyLoginMode(testManager.agent1Page, 'Extension'); + }); + + // TODO: The bug of timer reset for Available state should be fixed before implementing this test case + test.skip('should reset user state timer and maintain Available state after network disconnection with Extension mode', async () => { + await ensureUserStateVisible( + testManager.agent1Page, + LOGIN_MODE.EXTENSION, + process.env[`${testManager.projectName}_AGENT1_EXTENSION_NUMBER`] + ); + await changeUserState(testManager.agent1Page, USER_STATES.AVAILABLE); + await verifyCurrentState(testManager.agent1Page, USER_STATES.AVAILABLE); + await testManager.agent1Page.waitForTimeout(15000); + const timerBeforeDisconnection = await getStateElapsedTime(testManager.agent1Page); + const secondsBeforeDisconnection = parseTimeString(timerBeforeDisconnection); + testManager.consoleMessages.length = 0; + await testManager.agent1Page.context().setOffline(true); + const isWebSocketDisconnected = await waitForWebSocketDisconnection(testManager.consoleMessages); + expect(isWebSocketDisconnected).toBe(true); + await expect(testManager.agent1Page.getByTestId('station-login-widget')).toBeVisible({timeout: 2000}); + await verifyLoginMode(testManager.agent1Page, 'Extension'); + testManager.consoleMessages.length = 0; + await testManager.agent1Page.context().setOffline(false); + const isWebSocketReconnected = await waitForWebSocketReconnection(testManager.consoleMessages); + expect(isWebSocketReconnected).toBe(true); + await verifyCurrentState(testManager.agent1Page, USER_STATES.AVAILABLE); + const timerAfterReconnection = await getStateElapsedTime(testManager.agent1Page); + const secondsAfterReconnection = parseTimeString(timerAfterReconnection); + expect(secondsAfterReconnection).toBeLessThan(secondsBeforeDisconnection); + await testManager.agent1Page.waitForTimeout(10000); + const agentStateChangeLog = testManager.consoleMessages.find( + (msg) => msg.includes('AGENT Status set successfully') || msg.includes('Agent state changed successfully') + ); + expect(agentStateChangeLog).toBeTruthy(); + await verifyLoginMode(testManager.agent1Page, 'Extension'); + }); + + test('should support multi-login synchronization for Extension Mode', async () => { + await ensureUserStateVisible( + testManager.agent1Page, + LOGIN_MODE.EXTENSION, + process.env[`${testManager.projectName}_AGENT1_EXTENSION_NUMBER`] + ); + + const multiSessionPage = testManager.multiSessionAgent1Page!; + await verifyLoginMode(multiSessionPage, 'Extension'); + await multiSessionPage.getByTestId('samples:station-logout-button').click(); + await testManager.agent1Page.waitForTimeout(2000); + const isLogoutButtonVisible = await testManager.agent1Page + .getByTestId('samples:station-logout-button') + .isVisible() + .catch(() => false); + expect(isLogoutButtonVisible).toBe(false); + }); + }); + + test.describe.skip('Station Login Tests - hideDesktopLogin Feature', () => { + let testManager: TestManager; + + test.beforeAll(async ({browser}, testInfo) => { + const projectName = testInfo.project.name; + testManager = new TestManager(projectName); + await testManager.setupForStationLogin(browser, false); + }); + + test.afterAll(async () => { + if (testManager) { + await testManager.cleanup(); + } + }); + + test('should toggle Desktop option visibility when hideDesktopLogin checkbox is toggled', async () => { + await expect(testManager.agent1Page.getByTestId('station-login-widget')).toBeVisible({timeout: 2000}); + const hideDesktopCheckbox = testManager.agent1Page.getByTestId('samples:hide-desktop-login-checkbox'); + + // Uncheck - Desktop should be visible + await hideDesktopCheckbox.click(); + await testManager.agent1Page.waitForTimeout(500); + await verifyDesktopOptionVisibility(testManager.agent1Page, true); + + // Check - Desktop should be hidden + await hideDesktopCheckbox.click(); + await testManager.agent1Page.waitForTimeout(500); + await verifyDesktopOptionVisibility(testManager.agent1Page, false); + + // Uncheck again - Desktop should be visible again + await hideDesktopCheckbox.click(); + await testManager.agent1Page.waitForTimeout(500); + await verifyDesktopOptionVisibility(testManager.agent1Page, true); + }); + }); + test.describe.skip('Station Login Tests - Desktop Mode', () => { + let testManager: TestManager; + + test.beforeAll(async ({browser}, testInfo) => { + const projectName = testInfo.project.name; + testManager = new TestManager(projectName); + await testManager.setupForStationLogin(browser, true); + }); + + test.afterAll(async () => { + if (testManager) { + await testManager.cleanup(); + } + }); + + test('should login with Desktop mode and verify all fields are visible', async () => { + await expect(testManager.agent1Page.getByTestId('station-login-widget')).toBeVisible({timeout: 2000}); + const loginModeSelector = testManager.agent1Page.getByTestId('login-option-select'); + await expect(loginModeSelector).toBeVisible({timeout: 2000}); + const teamSelectionDropdown = testManager.agent1Page.getByTestId('teams-select-dropdown'); + await expect(teamSelectionDropdown).toBeVisible({timeout: 2000}); + const loginButton = testManager.agent1Page.getByTestId('login-button'); + await expect(loginButton).toBeVisible({timeout: 2000}); + await expect(loginButton).toContainText('Login'); + await telephonyLogin(testManager.agent1Page, LOGIN_MODE.DESKTOP); + await expect(testManager.agent1Page.getByTestId('state-select')).toBeVisible({timeout: 3000}); + await verifyLoginMode(testManager.agent1Page, 'Desktop'); + }); + + test.skip('should handle page reload and maintain Desktop login state', async () => { + await ensureUserStateVisible(testManager.agent1Page, LOGIN_MODE.DESKTOP); + await agentRelogin(testManager.agent1Page); + await expect(testManager.agent1Page.getByTestId('station-login-widget')).toBeVisible({timeout: 2000}); + await verifyLoginMode(testManager.agent1Page, 'Desktop'); + await expect(testManager.agent1Page.getByTestId('state-select')).toBeVisible({timeout: 2000}); + }); + + test('should retain user state timer and switch to Meeting state after network disconnection with Desktop mode', async () => { + await ensureUserStateVisible(testManager.agent1Page, LOGIN_MODE.DESKTOP); + await changeUserState(testManager.agent1Page, USER_STATES.MEETING); + await verifyCurrentState(testManager.agent1Page, USER_STATES.MEETING); + const timerBeforeDisconnection = await getStateElapsedTime(testManager.agent1Page); + const secondsBeforeDisconnection = parseTimeString(timerBeforeDisconnection); + await testManager.agent1Page.waitForTimeout(3000); + testManager.consoleMessages.length = 0; + await testManager.agent1Page.context().setOffline(true); + const isWebSocketDisconnected = await waitForWebSocketDisconnection(testManager.consoleMessages); + expect(isWebSocketDisconnected).toBe(true); + await testManager.agent1Page.waitForTimeout(3000); + await expect(testManager.agent1Page.getByTestId('station-login-widget')).toBeVisible({timeout: 2000}); + await verifyLoginMode(testManager.agent1Page, 'Desktop'); + testManager.consoleMessages.length = 0; + await testManager.agent1Page.context().setOffline(false); + await testManager.agent1Page.waitForTimeout(3000); + const isWebSocketReconnected = await waitForWebSocketReconnection(testManager.consoleMessages); + expect(isWebSocketReconnected).toBe(true); + await verifyCurrentState(testManager.agent1Page, USER_STATES.MEETING); + const timerAfterReconnection = await getStateElapsedTime(testManager.agent1Page); + const secondsAfterReconnection = parseTimeString(timerAfterReconnection); + expect(secondsAfterReconnection).toBeGreaterThan(secondsBeforeDisconnection); + await verifyLoginMode(testManager.agent1Page, 'Desktop'); + }); + + // TODO: The bug of timer reset for Available state should be fixed before implementing this test case + test.skip('should reset user state timer and maintain Available state after network disconnection with Desktop mode', async () => { + await ensureUserStateVisible(testManager.agent1Page, LOGIN_MODE.DESKTOP); + await changeUserState(testManager.agent1Page, USER_STATES.AVAILABLE); + await verifyCurrentState(testManager.agent1Page, USER_STATES.AVAILABLE); + await testManager.agent1Page.waitForTimeout(15000); + const timerBeforeDisconnection = await getStateElapsedTime(testManager.agent1Page); + const secondsBeforeDisconnection = parseTimeString(timerBeforeDisconnection); + testManager.consoleMessages.length = 0; + await testManager.agent1Page.context().setOffline(true); + const isWebSocketDisconnected = await waitForWebSocketDisconnection(testManager.consoleMessages); + expect(isWebSocketDisconnected).toBe(true); + await expect(testManager.agent1Page.getByTestId('station-login-widget')).toBeVisible({timeout: 2000}); + await verifyLoginMode(testManager.agent1Page, 'Desktop'); + testManager.consoleMessages.length = 0; + await testManager.agent1Page.context().setOffline(false); + const isWebSocketReconnected = await waitForWebSocketReconnection(testManager.consoleMessages); + expect(isWebSocketReconnected).toBe(true); + await verifyCurrentState(testManager.agent1Page, USER_STATES.AVAILABLE); + const timerAfterReconnection = await getStateElapsedTime(testManager.agent1Page); + const secondsAfterReconnection = parseTimeString(timerAfterReconnection); + expect(secondsAfterReconnection).toBeLessThan(secondsBeforeDisconnection); + await testManager.agent1Page.waitForTimeout(10000); + const agentStateChangeLog = testManager.consoleMessages.find( + (msg) => msg.includes('AGENT Status set successfully') || msg.includes('Agent state changed successfully') + ); + expect(agentStateChangeLog).toBeTruthy(); + await verifyLoginMode(testManager.agent1Page, 'Desktop'); + }); + }); +} diff --git a/packages/@webex/contact-center/test/e2e/playwright/tests/user-state-test.spec.ts b/packages/@webex/contact-center/test/e2e/playwright/tests/user-state-test.spec.ts new file mode 100644 index 00000000000..31577b7fd70 --- /dev/null +++ b/packages/@webex/contact-center/test/e2e/playwright/tests/user-state-test.spec.ts @@ -0,0 +1,196 @@ +import {test, expect} from '@playwright/test'; +import {agentRelogin} 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 {TestManager} from '../test-manager'; + +// Shared login and setup before all tests + +export default function createUserStateTests() { + let testManager: TestManager; + + test.beforeAll(async ({browser}, testInfo) => { + const projectName = testInfo.project.name; + testManager = new TestManager(projectName); + await testManager.basicSetup(browser); + // Handle the station login manually like in the original + const loginButtonExists = await testManager.agent1Page + .getByTestId('login-button') + .isVisible() + .catch(() => false); + if (loginButtonExists) { + await telephonyLogin( + testManager.agent1Page, + LOGIN_MODE.EXTENSION, + process.env[`${testManager.projectName}_AGENT1_EXTENSION_NUMBER`] + ); + } else { + await stationLogout(testManager.agent1Page); + await telephonyLogin( + testManager.agent1Page, + LOGIN_MODE.EXTENSION, + process.env[`${testManager.projectName}_AGENT1_EXTENSION_NUMBER`] + ); + } + await expect(testManager.agent1Page.getByTestId('state-select')).toBeVisible(); + }); + + test.afterAll(async () => { + if (testManager) { + await testManager.cleanup(); + } + }); + + test('should verify initial state is Meeting', async () => { + const state = await getCurrentState(testManager.agent1Page); + if (state !== USER_STATES.MEETING) throw new Error('Initial state is not Meeting'); + }); + + test('should verify Meeting state theme color', async () => { + const meetingThemeElement = testManager.agent1Page.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(testManager.agent1Page, USER_STATES.MEETING); + await testManager.agent1Page.waitForTimeout(5000); + const timerBefore = await getStateElapsedTime(testManager.agent1Page); + await changeUserState(testManager.agent1Page, USER_STATES.AVAILABLE); + await testManager.agent1Page.waitForTimeout(3000); + const timerAfter = await getStateElapsedTime(testManager.agent1Page); + + 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 = testManager.agent1Page.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(testManager.agent1Page, USER_STATES.MEETING); + await testManager.agent1Page.waitForTimeout(3000); + testManager.consoleMessages.length = 0; + await changeUserState(testManager.agent1Page, USER_STATES.AVAILABLE); + await testManager.agent1Page.waitForTimeout(3000); + const isCallbackSuccessful = await checkCallbackSequence( + testManager.agent1Page, + USER_STATES.AVAILABLE, + testManager.consoleMessages + ); + if (!isCallbackSuccessful) throw new Error('Callback for Available state not successful'); + }); + + test('should verify state persistence after page reload', async () => { + await changeUserState(testManager.agent1Page, USER_STATES.AVAILABLE); + await verifyCurrentState(testManager.agent1Page, USER_STATES.AVAILABLE); + await testManager.agent1Page.waitForTimeout(3000); + + testManager.consoleMessages.length = 0; + await agentRelogin(testManager.agent1Page); + + const visible = await testManager.agent1Page.getByTestId('state-select').isVisible(); + if (!visible) throw new Error('State select not visible after reload'); + + await verifyCurrentState(testManager.agent1Page, USER_STATES.AVAILABLE); + const callbackTriggered = await validateConsoleStateChange( + testManager.agent1Page, + USER_STATES.AVAILABLE, + testManager.consoleMessages + ); + if (!callbackTriggered) throw new Error('Callback not triggered after reload'); + + const state = await getCurrentState(testManager.agent1Page); + if (state !== USER_STATES.AVAILABLE) throw new Error('State is not Available after reload'); + }); + + test.skip('should test multi-session synchronization', async () => { + // Create multi-session page since basicSetup doesn't include it + if (!testManager.multiSessionAgent1Page) { + if (!testManager.multiSessionContext) { + testManager.multiSessionContext = await testManager.agent1Context.browser()!.newContext(); + } + testManager.multiSessionAgent1Page = await testManager.multiSessionContext.newPage(); + } + + await testManager.setupMultiSessionPage(); + const multiSessionPage = testManager.multiSessionAgent1Page!; + + await changeUserState(testManager.agent1Page, USER_STATES.MEETING); + await verifyCurrentState(testManager.agent1Page, USER_STATES.MEETING); + await multiSessionPage.waitForTimeout(3000); + + await verifyCurrentState(multiSessionPage, USER_STATES.MEETING); + + await multiSessionPage.waitForTimeout(3000); + const [timer1, timer2] = await Promise.all([ + getStateElapsedTime(testManager.agent1Page), + 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}`); + } + }); + + test('should test idle state transition and dual timer', async () => { + await verifyCurrentState(testManager.agent1Page, USER_STATES.MEETING); + await testManager.agent1Page.waitForTimeout(2000); + testManager.consoleMessages.length = 0; + + await changeUserState(testManager.agent1Page, USER_STATES.LUNCH); + await verifyCurrentState(testManager.agent1Page, USER_STATES.LUNCH); + await testManager.agent1Page.waitForTimeout(3000); + + const found = await validateConsoleStateChange( + testManager.agent1Page, + USER_STATES.LUNCH, + testManager.consoleMessages + ); + if (!found) throw new Error('Callback for Lunch state not successful'); + + await testManager.agent1Page.waitForTimeout(5000); + const dualTimer = await getStateElapsedTime(testManager.agent1Page); + + 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(testManager.agent1Page, USER_STATES.AVAILABLE); + }); +} diff --git a/packages/@webex/contact-center/test/e2e/playwright/wav/dummyAudio.wav b/packages/@webex/contact-center/test/e2e/playwright/wav/dummyAudio.wav new file mode 100644 index 00000000000..ead42a0ad61 Binary files /dev/null and b/packages/@webex/contact-center/test/e2e/playwright/wav/dummyAudio.wav differ diff --git a/yarn.lock b/yarn.lock index ae6128df96e..7a68ee3a470 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4096,6 +4096,17 @@ __metadata: languageName: node linkType: hard +"@playwright/test@npm:^1.57.0": + version: 1.57.0 + resolution: "@playwright/test@npm:1.57.0" + dependencies: + playwright: 1.57.0 + bin: + playwright: cli.js + checksum: 1a84783a240d69c2c8081a127b446f812a8dc86fe6f60a9511dd501cc0e6229cbec7e7753972678f3f063ad2bebb2cedbe9caebc5faa41014aebed35773ea242 + languageName: node + linkType: hard + "@promptbook/utils@npm:0.69.5": version: 0.69.5 resolution: "@promptbook/utils@npm:0.69.5" @@ -7544,6 +7555,7 @@ __metadata: dependencies: "@babel/core": ^7.22.11 "@babel/preset-typescript": 7.22.11 + "@playwright/test": ^1.57.0 "@types/jest": 27.4.1 "@types/platform": 1.3.4 "@typescript-eslint/eslint-plugin": 5.38.1 @@ -7560,6 +7572,7 @@ __metadata: "@webex/plugin-logger": "workspace:*" "@webex/test-helper-mock-webex": "workspace:*" "@webex/webex-core": "workspace:*" + dotenv: ^17.2.3 eslint: ^8.24.0 eslint-config-airbnb-base: 15.0.0 eslint-config-prettier: 8.3.0 @@ -7572,6 +7585,7 @@ __metadata: jest-html-reporters: 3.0.11 jest-junit: 13.0.0 lodash: ^4.17.21 + nodemailer: ^6.9.13 prettier: 2.5.1 typedoc: ^0.25.0 typescript: 4.9.5 @@ -15452,6 +15466,13 @@ __metadata: languageName: node linkType: hard +"dotenv@npm:^17.2.3": + version: 17.2.3 + resolution: "dotenv@npm:17.2.3" + checksum: fde23eb88649041ec7a0f6a47bbe59cac3c454fc2007cf2e40b9c984aaf0636347218c56cfbbf067034b0a73f530a2698a19b4058695787eb650ec69fe234624 + languageName: node + linkType: hard + "dotenv@npm:^4.0.0": version: 4.0.0 resolution: "dotenv@npm:4.0.0" @@ -18372,6 +18393,16 @@ __metadata: languageName: node linkType: hard +"fsevents@npm:2.3.2": + version: 2.3.2 + resolution: "fsevents@npm:2.3.2" + dependencies: + node-gyp: latest + checksum: 97ade64e75091afee5265e6956cb72ba34db7819b4c3e94c431d4be2b19b8bb7a2d4116da417950c3425f17c8fe693d25e20212cac583ac1521ad066b77ae31f + conditions: os=darwin + languageName: node + linkType: hard + "fsevents@npm:^1.2.7": version: 1.2.13 resolution: "fsevents@npm:1.2.13" @@ -18393,6 +18424,15 @@ __metadata: languageName: node linkType: hard +"fsevents@patch:fsevents@2.3.2#~builtin": + version: 2.3.2 + resolution: "fsevents@patch:fsevents@npm%3A2.3.2#~builtin::version=2.3.2&hash=df0bf1" + dependencies: + node-gyp: latest + conditions: os=darwin + languageName: node + linkType: hard + "fsevents@patch:fsevents@^1.2.7#~builtin": version: 1.2.13 resolution: "fsevents@patch:fsevents@npm%3A1.2.13#~builtin::version=1.2.13&hash=d11327" @@ -26139,6 +26179,13 @@ __metadata: languageName: node linkType: hard +"nodemailer@npm:^6.9.13": + version: 6.10.1 + resolution: "nodemailer@npm:6.10.1" + checksum: 39e9208e13c40b58c59242205bce855e74def25d953070ab6a5b1c23b0bf37df0725b3c6dea52d4220e2e60dd1fffcd486a697dece79afdf98842aae6395d393 + languageName: node + linkType: hard + "nodemon@npm:^3.1.7": version: 3.1.11 resolution: "nodemon@npm:3.1.11" @@ -27537,6 +27584,30 @@ __metadata: languageName: node linkType: hard +"playwright-core@npm:1.57.0": + version: 1.57.0 + resolution: "playwright-core@npm:1.57.0" + bin: + playwright-core: cli.js + checksum: 960e80d6ec06305b11a3ca9e78e8e4201cc17f37dd37279cb6fece4df43d74bf589833f4f94535fadd284b427f98c5f1cf09368e22f0f00b6a9477571ce6b03b + languageName: node + linkType: hard + +"playwright@npm:1.57.0": + version: 1.57.0 + resolution: "playwright@npm:1.57.0" + dependencies: + fsevents: 2.3.2 + playwright-core: 1.57.0 + dependenciesMeta: + fsevents: + optional: true + bin: + playwright: cli.js + checksum: 176fd9fd890f390e0aa00d42697b70072d534243b15467d9430f3af329e77b3225b67a0afa12ea76fb440300dabd92d4cf040baf5edceee8eeff0ee1590ae5b7 + languageName: node + linkType: hard + "plist@npm:3.1.0": version: 3.1.0 resolution: "plist@npm:3.1.0"