diff --git a/playwright.config.ts b/playwright.config.ts index 769c8a1c9..378f47490 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -9,7 +9,7 @@ const dummyAudioPath = path.resolve(__dirname, './playwright/wav/dummyAudio.wav' export default defineConfig({ testDir: './playwright', - timeout: 180000, + timeout: 220000, webServer: { command: 'yarn workspace samples-cc-react-app serve', url: 'http://localhost:3000', diff --git a/playwright/README.md b/playwright/README.md index a42a9896e..732322aef 100644 --- a/playwright/README.md +++ b/playwright/README.md @@ -11,7 +11,11 @@ playwright/ │ ├── 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 +│ ├── advanced-task-controls-tests.spec.ts # Advanced task controls orchestration +│ ├── dial-number-tests.spec.ts # Dial number task control orchestration +│ ├── multiparty-conference-set-7-tests.spec.ts # Multiparty conference set 7 orchestration +│ ├── multiparty-conference-set-8-tests.spec.ts # Multiparty conference set 8 orchestration +│ └── multiparty-conference-set-9-tests.spec.ts # Multiparty conference set 9 orchestration ├── tests/ # Individual test implementations ├── Utils/ # Utility functions ├── test-data.ts # **CENTRAL CONFIG** - Test data & suite mapping @@ -29,23 +33,49 @@ playwright/ - ✅ 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` | +| 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` | +| **SET_6** | Dial number scenarios | 9226 | `dial-number-tests.spec.ts` | +| **SET_7** | Multiparty conference (team 25-28) | 9227 | `multiparty-conference-set-7-tests.spec.ts` | +| **SET_8** | Multiparty conference (team 29-32) | 9228 | `multiparty-conference-set-8-tests.spec.ts` | +| **SET_9** | Multiparty conference (team 33-36) | 9229 | `multiparty-conference-set-9-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 | +| 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 | +| Dial number scenarios | SET_6 | Dial number flows | +| Multiparty conference | SET_7/8/9 | 4-agent conference coverage | + +## Multiparty Conference Consolidation + +To reduce runtime and repeated call initialization, conference scenarios are consolidated into combined tests while preserving scenario IDs in test names. + +- `SET_7` (`playwright/tests/multiparty-conference-set-7-test.spec.ts`) + - Combined: `CTS-MPC-01+02`, `CTS-MPC-03+04`, `CTS-MPC-07+09+10` + - Standalone: `CTS-MPC-05`, `CTS-MPC-06`, `CTS-SW-04` + - Combined: `CTS-SW-02+03` + - Skipped: `CTS-MPC-08` (>4 agents) +- `SET_8` (`playwright/tests/multiparty-conference-set-8-test.spec.ts`) + - Combined: `CTS-TC-09+10`, `CTS-TC-11+13`, `CTS-TC-14+15` + - Standalone: `CTS-TC-16`, `CTS-SW-07` + - Skipped: `CTS-TC-12` (feature-flag gated), `CTS-TC-17` (>4 agents), `CTS-TC-18` (EPDN) +- `SET_9` (`playwright/tests/multiparty-conference-set-9-test.spec.ts`) + - Combined: `CTS-TC-01+02+03`, `CTS-TC-04+05` + - Standalone: `CTS-TC-06`, `CTS-TC-07`, `CTS-TC-08` + - Split: `CTS-TC-06` and `CTS-TC-07` run as separate tests (queue routing won't re-route to RONA'd agent in same session) + - Combined: `CTS-SW-05+06` + - Skipped: `CTS-SW-01` (EP_DN), `CTS-SW-08` (>4 agents) ## 🧪 Adding New Tests @@ -139,7 +169,7 @@ test.describe('My New Feature Tests', createMyTests()); | Module | Key Functions | | ------------------- | ----------------------------------------------------------- | | `incomingTaskUtils` | `createChatTask()`, `acceptIncomingTask()`, `endChatTask()` | -| `taskControlUtils` | `holdTask()`, `resumeTask()`, `endTask()` | +| `taskControlUtils` | `holdCallToggle()`, `recordCallToggle()`, `endTask()` | | `userStateUtils` | `changeUserState()`, `verifyCurrentState()` | | `stationLoginUtils` | `telephonyLogin()`, `stationLogout()` | @@ -165,11 +195,21 @@ 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 +# ... PW_ENTRY_POINT3 ... PW_ENTRY_POINT9 ``` Test data is automatically handled by TestManager based on the running test set. +OAuth setup behavior (`playwright/global.setup.ts`): + +- expands `USER_SETS` into set-scoped env keys +- builds OAuth set groups dynamically from `USER_SETS` (chunk size `2`) +- runs one parallel OAuth worker per generated group +- each group uses `OAUTH_BATCH_SIZE=4` internally +- with current 9 sets this resolves to 5 groups: `[SET_1,SET_2]`, `[SET_3,SET_4]`, `[SET_5,SET_6]`, `[SET_7,SET_8]`, `[SET_9]` +- optionally collects dial-number token +- writes all token/env updates in one final upsert pass + ## 🚀 Running Tests ```bash @@ -182,6 +222,10 @@ 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 +yarn test:e2e suites/dial-number-tests.spec.ts +yarn test:e2e suites/multiparty-conference-set-7-tests.spec.ts +yarn test:e2e suites/multiparty-conference-set-8-tests.spec.ts +yarn test:e2e suites/multiparty-conference-set-9-tests.spec.ts # Run specific test sets (projects) - names match USER_SETS keys yarn test:e2e --project=SET_1 # Digital incoming tasks @@ -189,9 +233,13 @@ 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) +yarn test:e2e --project=SET_6 # Dial number scenarios +yarn test:e2e --project=SET_7 # Multiparty conference (team 25-28) +yarn test:e2e --project=SET_8 # Multiparty conference (team 29-32) +yarn test:e2e --project=SET_9 # Multiparty conference (team 33-36) # Development & debugging +yarn playwright test --config=playwright.config.ts --project="OAuth: Get Access Token" # Run OAuth setup only yarn test:e2e --ui # UI mode yarn test:e2e --debug # Debug mode yarn test:e2e --headed # Run with browser visible @@ -221,7 +269,7 @@ yarn test:e2e --headed # Run with browser visible **Common Issues:** - Browser launch fails → Check Chrome and ports 9221+ (auto-assigned) -- Auth errors → Verify OAuth in `global.setup.ts` +- Auth errors → Verify OAuth in `global.setup.ts` and confirm `OAUTH_BATCH_SIZE`/credentials are valid - 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` diff --git a/playwright/Utils/advancedTaskControlUtils.ts b/playwright/Utils/advancedTaskControlUtils.ts index cc9d401a5..125c90a3b 100644 --- a/playwright/Utils/advancedTaskControlUtils.ts +++ b/playwright/Utils/advancedTaskControlUtils.ts @@ -1,7 +1,8 @@ import {Page, expect} from '@playwright/test'; -import {loginExtension} from './incomingTaskUtils'; import {dismissOverlays} from './helperUtils'; -import {AWAIT_TIMEOUT, FORM_FIELD_TIMEOUT, EXTENSION_REGISTRATION_TIMEOUT} from '../constants'; +import {holdCallToggle, isCallHeld} from './taskControlUtils'; +import {hasAnyVisibleControl, hasAnyVisibleEnabledControl} from './controlUtils'; +import {AWAIT_TIMEOUT, FORM_FIELD_TIMEOUT} from '../constants'; /** * Utility functions for advanced task controls testing. @@ -14,6 +15,24 @@ import {AWAIT_TIMEOUT, FORM_FIELD_TIMEOUT, EXTENSION_REGISTRATION_TIMEOUT} from // Array to store captured console logs for verification let capturedAdvancedLogs: string[] = []; +export const ACTIVE_CONSULT_CONTROL_TEST_IDS = [ + 'cancel-consult-btn', + 'transfer-consult-btn', + 'conference-consult-btn', + 'switchToMainCall-consult-btn', + 'call-control:switch-to-consult', +]; + +export async function hasAnyVisibleControlFromList(page: Page, testIds: string[]): Promise { + for (const testId of testIds) { + if (await hasAnyVisibleControl(page, testId)) { + return true; + } + } + + return false; +} + /** * Sets up console logging to capture transfer and consult related callback logs. * Captures transfer success, consult start/end success, and related SDK messages. @@ -116,14 +135,15 @@ export async function consultOrTransfer( page: Page, type: 'agent' | 'queue' | 'dialNumber' | 'entryPoint', action: 'consult' | 'transfer', - value: string + value: string, + options?: {consultStartTimeout?: number} ): Promise { await page.bringToFront(); await openConsultOrTransferMenu(page, action); const popover = await getPopover(page); if (type === 'agent') { - await performAgentSelection(page, popover, value); + await performAgentSelection(page, popover, value, action); } else if (type === 'queue') { await performQueueSelection(page, popover, value); } else if (type === 'dialNumber') { @@ -132,9 +152,10 @@ export async function consultOrTransfer( await performEntryPointSelection(page, popover, value); } - await page.waitForTimeout(2000); + await waitForPopoverToClose(page); + if (action === 'consult') { - await expect(page.getByTestId('cancel-consult-btn')).toBeVisible({timeout: FORM_FIELD_TIMEOUT}); + await waitForConsultToStart(page, options?.consultStartTimeout); } } @@ -143,11 +164,27 @@ async function openConsultOrTransferMenu(page: Page, action: 'consult' | 'transf await page.bringToFront(); await dismissOverlays(page); - if (action === 'consult') { - await page.getByTestId('call-control:consult').first().click({timeout: AWAIT_TIMEOUT}); - } else { - await page.getByTestId('call-control:transfer').first().click({timeout: AWAIT_TIMEOUT}); + const testId = action === 'consult' ? 'call-control:consult' : 'call-control:transfer'; + const controls = page.getByTestId(testId); + const controlCount = await controls.count().catch(() => 0); + + for (let i = 0; i < controlCount; i++) { + const control = controls.nth(i); + const isVisible = await control.isVisible().catch(() => false); + if (!isVisible) { + continue; + } + + const isEnabled = await control.isEnabled().catch(() => false); + if (!isEnabled) { + continue; + } + + await control.click({timeout: AWAIT_TIMEOUT}); + return; } + + throw new Error(`No visible enabled ${action} control found`); } async function getPopover(page: Page) { @@ -156,14 +193,35 @@ async function getPopover(page: Page) { return popover; } +async function waitForPopoverToClose(page: Page): Promise { + await expect + .poll( + async () => { + const backdropVisible = await page.locator('.md-popover-backdrop').isVisible().catch(() => false); + const popoverVisible = await page.locator('.agent-popover-content').isVisible().catch(() => false); + return !backdropVisible && !popoverVisible; + }, + {timeout: AWAIT_TIMEOUT, intervals: [100, 250, 500]} + ) + .toBeTruthy(); +} + +async function waitForConsultToStart(page: Page, timeout: number = AWAIT_TIMEOUT): Promise { + await expect + .poll(() => hasAnyVisibleControlFromList(page, ACTIVE_CONSULT_CONTROL_TEST_IDS), { + timeout, + intervals: [200, 500, 1000], + }) + .toBeTruthy(); +} + async function clickCategory( - page: Page, popover: ReturnType, name: 'Agents' | 'Queues' | 'Dial Number' | 'Entry Point' ): Promise { const button = popover.getByRole('button', {name, exact: true}); await button.click({timeout: AWAIT_TIMEOUT}); - await page.waitForTimeout(200); + await expect(popover.locator('#consult-search')).toBeVisible({timeout: FORM_FIELD_TIMEOUT}); } async function clickListItemPrimaryButton( @@ -205,13 +263,75 @@ async function clickListItemPrimaryButton( .catch(() => {}); } -async function performAgentSelection(page: Page, popover: ReturnType, value: string): Promise { - await clickCategory(page, popover, 'Agents'); - await clickListItemPrimaryButton(page, popover, value, 'Agent'); +async function performAgentSelection( + page: Page, + popover: ReturnType, + value: string, + action: 'consult' | 'transfer' +): Promise { + const agentFirstName = value.split(' ')[0]; + + let currentPopover = popover; + for (let attempt = 0; attempt < 3; attempt++) { + if (attempt === 0) { + await clickCategory(currentPopover, 'Agents'); + } + + const searchBox = currentPopover.locator('#consult-search'); + await searchBox.fill(agentFirstName); + + const listItem = currentPopover.locator(`[role="listitem"][aria-label="${value}"]`).first(); + const isVisible = await expect + .poll(() => listItem.isVisible().catch(() => false), { + timeout: 2500, + intervals: [200, 400, 800], + }) + .toBeTruthy() + .then(() => true) + .catch(() => false); + + if (isVisible) { + await clickListItemPrimaryButton(page, currentPopover, value, 'Agent'); + return; + } + + await page.keyboard.press('Escape'); + await waitForPopoverToClose(page); + await openConsultOrTransferMenu(page, action); + currentPopover = await getPopover(page); + await clickCategory(currentPopover, 'Agents'); + } + + await clickListItemPrimaryButton(page, currentPopover, value, 'Agent'); +} + +export async function waitForPrimaryCallAfterConsult(page: Page): Promise { + const consultControlsGone = async () => !(await hasAnyVisibleControlFromList(page, ACTIVE_CONSULT_CONTROL_TEST_IDS)); + + await expect + .poll(consultControlsGone, {timeout: AWAIT_TIMEOUT, intervals: [200, 500, 1000]}) + .toBeTruthy(); + + if (await isCallHeld(page)) { + await holdCallToggle(page); + await expect.poll(() => isCallHeld(page), {timeout: AWAIT_TIMEOUT, intervals: [200, 500, 1000]}).toBeFalsy(); + } + + await expect + .poll( + async () => { + const consultVisible = await hasAnyVisibleEnabledControl(page, 'call-control:consult'); + const endVisible = await hasAnyVisibleControl(page, 'call-control:end-call'); + const endEnabled = await hasAnyVisibleEnabledControl(page, 'call-control:end-call'); + return consultVisible && endVisible && endEnabled; + }, + {timeout: AWAIT_TIMEOUT, intervals: [200, 500, 1000]} + ) + .toBeTruthy(); } async function performQueueSelection(page: Page, popover: ReturnType, value: string): Promise { - await clickCategory(page, popover, 'Queues'); + await clickCategory(popover, 'Queues'); await clickListItemPrimaryButton(page, popover, value, 'Queue'); } @@ -225,7 +345,7 @@ async function performDialNumberSelection( 'PW_DIAL_NUMBER_NAME is not set. Please provide the Dial Number list item name (e.g., cypher_pstn).' ); } - await clickCategory(page, popover, 'Dial Number'); + await clickCategory(popover, 'Dial Number'); const search = popover.locator('#consult-search'); if (await search.isVisible({timeout: 500}).catch(() => false)) { await search.fill(value, {timeout: AWAIT_TIMEOUT}); @@ -242,7 +362,7 @@ async function performEntryPointSelection( popover: ReturnType, value: string ): Promise { - await clickCategory(page, popover, 'Entry Point'); + await clickCategory(popover, 'Entry Point'); if (value) { const search = popover.locator('#consult-search'); if (await search.isVisible({timeout: 500}).catch(() => false)) { @@ -259,6 +379,24 @@ async function performEntryPointSelection( * @returns Promise */ export async function cancelConsult(page: Page): Promise { - // Click cancel consult button - await page.getByTestId('cancel-consult-btn').click({timeout: AWAIT_TIMEOUT}); + const controls = page.getByTestId('cancel-consult-btn'); + const count = await controls.count().catch(() => 0); + + for (let i = 0; i < count; i++) { + const control = controls.nth(i); + const isVisible = await control.isVisible().catch(() => false); + if (!isVisible) { + continue; + } + + const isEnabled = await control.isEnabled().catch(() => false); + if (!isEnabled) { + continue; + } + + await control.click({timeout: AWAIT_TIMEOUT}); + return; + } + + throw new Error('No visible enabled cancel consult control found'); } diff --git a/playwright/Utils/conferenceUtils.ts b/playwright/Utils/conferenceUtils.ts new file mode 100644 index 000000000..bff3abf88 --- /dev/null +++ b/playwright/Utils/conferenceUtils.ts @@ -0,0 +1,345 @@ +import {expect, Page} from '@playwright/test'; +import { + ACTIVE_CONSULT_CONTROL_TEST_IDS, + cancelConsult, + consultOrTransfer, + hasAnyVisibleControlFromList, +} from './advancedTaskControlUtils'; +import {hasAnyVisibleControl, hasAnyVisibleEnabledControl} from './controlUtils'; +import {acceptIncomingTask, createCallTask, getIncomingTaskLocator} from './incomingTaskUtils'; +import {handleStrayTasks, waitForState} from './helperUtils'; +import {endTask} from './taskControlUtils'; +import {changeUserState, verifyCurrentState} from './userStateUtils'; +import {submitWrapup} from './wrapupUtils'; +import { + ACCEPT_TASK_TIMEOUT, + AWAIT_TIMEOUT, + CONFERENCE_ACTION_SETTLE_TIMEOUT, + CONFERENCE_END_TASK_SETTLE_TIMEOUT, + CONFERENCE_SWITCH_TOGGLE_TIMEOUT, + TASK_TYPES, + USER_STATES, + WRAPUP_REASONS, + WRAPUP_TIMEOUT, +} from '../constants'; + +export type AgentId = 1 | 2 | 3 | 4; + +export const CONFERENCE_AGENT_IDS: AgentId[] = [1, 2, 3, 4]; + +type GetAgentPage = (agentId: AgentId) => Page; +type GetRequiredEnv = (suffix: string) => string; +type GetAgentName = (agentId: AgentId) => string; + +interface CleanupConferenceStateOptions { + getAgentPage: GetAgentPage; + callerPage?: Page; + agentIds?: AgentId[]; + cleanupTaskTimeout?: number; +} + +interface StartConferenceCallOptions { + getAgentPage: GetAgentPage; + callerPage?: Page; + getRequiredEnv: GetRequiredEnv; + agentIds?: AgentId[]; + acceptTimeout?: number; + waitForAvailableBeforeDial?: boolean; +} + +interface ConferenceConsultOptions { + fromAgent: AgentId; + toAgent: AgentId; + getAgentPage: GetAgentPage; + getAgentName: GetAgentName; + acceptTimeout?: number; +} + +interface QueueConsultOptions { + fromAgent: AgentId; + toAgent: AgentId; + getAgentPage: GetAgentPage; + getRequiredEnv: GetRequiredEnv; + acceptTimeout?: number; +} + +interface SingleAgentActionOptions { + fromAgent: AgentId; + getAgentPage: GetAgentPage; + acceptTimeout?: number; +} + +export const getConferenceRequiredEnv = (projectName: string, suffix: string): string => { + const value = process.env[`${projectName}_${suffix}`]; + if (!value) { + throw new Error(`Missing env key: ${projectName}_${suffix}`); + } + return value; +}; + +export const getConferenceAgentName = (projectName: string, agentId: AgentId): string => + getConferenceRequiredEnv(projectName, `AGENT${agentId}_NAME`); + +export const cleanupConferencePageWithTimeout = async ( + page?: Page, + auxiliaryPage?: Page, + cleanupTaskTimeout: number = 60000 +) => { + if (!page || page.isClosed()) { + return; + } + + const validAuxiliaryPage = auxiliaryPage && !auxiliaryPage.isClosed() ? auxiliaryPage : undefined; + let timeoutHandle: ReturnType | undefined; + const timeoutPromise = new Promise((_, reject) => { + timeoutHandle = setTimeout(() => reject(new Error('conference cleanup timeout')), cleanupTaskTimeout); + }); + + try { + await Promise.race([handleStrayTasks(page, validAuxiliaryPage), timeoutPromise]); + } catch { + // Ignore cleanup errors/timeouts so hook teardown does not fail test execution. + } finally { + if (timeoutHandle) { + clearTimeout(timeoutHandle); + } + } +}; + +export const setConferenceBaselineAvailability = async ( + getAgentPage: GetAgentPage, + availableAgents: AgentId[], + agentIds: AgentId[] = CONFERENCE_AGENT_IDS +) => { + for (const agentId of agentIds) { + const page = getAgentPage(agentId); + const state = availableAgents.includes(agentId) ? USER_STATES.AVAILABLE : USER_STATES.MEETING; + await changeUserState(page, state); + } +}; + +export const setConferenceAgentsAvailable = async (getAgentPage: GetAgentPage, agentIds: AgentId[]) => { + for (const agentId of agentIds) { + const page = getAgentPage(agentId); + await changeUserState(page, USER_STATES.AVAILABLE); + await verifyCurrentState(page, USER_STATES.AVAILABLE); + } +}; + +export const waitForConferenceControlReady = async ( + getAgentPage: GetAgentPage, + agentId: AgentId, + controlTestId: string, + timeout: number = ACCEPT_TASK_TIMEOUT +) => { + const page = getAgentPage(agentId); + await expect + .poll(() => hasAnyVisibleEnabledControl(page, controlTestId), { + timeout, + intervals: [250, 500, 1000, 2000], + }) + .toBeTruthy(); +}; + +export const resetCallerPageForNextConferenceCall = async (callerPage?: Page) => { + if (!callerPage || callerPage.isClosed()) return; + const endBtn = callerPage.getByTestId('end'); + const isEnabled = await endBtn.isEnabled({timeout: 1000}).catch(() => false); + if (isEnabled) { + await endBtn.click({timeout: AWAIT_TIMEOUT}); + await callerPage.waitForTimeout(1000); + } + await callerPage.locator('#sd-get-media-streams').click({timeout: AWAIT_TIMEOUT}); + await callerPage.waitForTimeout(500); +}; + +export const cleanupConferenceState = async ({ + getAgentPage, + callerPage, + agentIds = CONFERENCE_AGENT_IDS, + cleanupTaskTimeout = 60000, +}: CleanupConferenceStateOptions) => { + await resetCallerPageForNextConferenceCall(callerPage); + for (const agentId of agentIds) { + await cleanupConferencePageWithTimeout(getAgentPage(agentId), callerPage, cleanupTaskTimeout); + } + await cleanupConferencePageWithTimeout(callerPage, undefined, cleanupTaskTimeout); +}; + +export const startBaselineCallOnAgent1 = async ({ + getAgentPage, + callerPage, + getRequiredEnv, + agentIds = CONFERENCE_AGENT_IDS, + acceptTimeout = ACCEPT_TASK_TIMEOUT, + waitForAvailableBeforeDial = true, +}: StartConferenceCallOptions) => { + await setConferenceBaselineAvailability(getAgentPage, [1], agentIds); + if (waitForAvailableBeforeDial) { + await waitForState(getAgentPage(1), USER_STATES.AVAILABLE); + await verifyCurrentState(getAgentPage(1), USER_STATES.AVAILABLE); + } + + if (!callerPage || callerPage.isClosed()) { + throw new Error('Caller page is not available for conference call setup'); + } + + await createCallTask(callerPage, getRequiredEnv('ENTRY_POINT')); + await acceptIncomingTask(getAgentPage(1), TASK_TYPES.CALL, acceptTimeout); + await waitForState(getAgentPage(1), USER_STATES.ENGAGED); + await waitForConferenceControlReady(getAgentPage, 1, 'call-control:end-call', acceptTimeout); +}; + +export const consultAgentAndAcceptCall = async ({ + fromAgent, + toAgent, + getAgentPage, + getAgentName, + acceptTimeout = ACCEPT_TASK_TIMEOUT, +}: ConferenceConsultOptions) => { + const fromAgentPage = getAgentPage(fromAgent); + const toAgentPage = getAgentPage(toAgent); + const firstAttemptTimeout = Math.min(15000, acceptTimeout); + let lastError: unknown; + + const waitForConferenceConsultToSettle = async () => { + await expect + .poll( + async () => { + const consultReady = await hasAnyVisibleEnabledControl(fromAgentPage, 'call-control:consult'); + const hasActiveConsultControls = await hasAnyVisibleControlFromList( + fromAgentPage, + ACTIVE_CONSULT_CONTROL_TEST_IDS + ); + + return consultReady && !hasActiveConsultControls; + }, + {timeout: acceptTimeout, intervals: [250, 500, 1000, 2000]} + ) + .toBeTruthy(); + }; + + const waitForConsultToStart = async () => { + await expect + .poll( + async () => { + const sourceConsultVisible = await hasAnyVisibleControlFromList(fromAgentPage, ACTIVE_CONSULT_CONTROL_TEST_IDS); + const incomingTaskVisible = await getIncomingTaskLocator(toAgentPage, TASK_TYPES.CALL) + .isVisible() + .catch(() => false); + + return sourceConsultVisible || incomingTaskVisible; + }, + { + timeout: Math.min(10000, acceptTimeout), + intervals: [250, 500, 1000], + } + ) + .toBeTruthy(); + }; + + for (const currentAcceptTimeout of [firstAttemptTimeout, acceptTimeout]) { + await waitForState(fromAgentPage, USER_STATES.ENGAGED); + await setConferenceAgentsAvailable(getAgentPage, [toAgent]); + await waitForConferenceConsultToSettle(); + try { + await consultOrTransfer(fromAgentPage, 'agent', 'consult', getAgentName(toAgent), { + consultStartTimeout: currentAcceptTimeout, + }); + await waitForConsultToStart(); + } catch (error) { + lastError = error; + continue; + } + + try { + await acceptIncomingTask(toAgentPage, TASK_TYPES.CALL, currentAcceptTimeout); + await verifyCurrentState(toAgentPage, USER_STATES.ENGAGED); + return; + } catch (error) { + lastError = error; + + const cancelConsultVisible = await hasAnyVisibleControl(fromAgentPage, 'cancel-consult-btn'); + + if (cancelConsultVisible) { + await cancelConsult(fromAgentPage); + await fromAgentPage.waitForTimeout(CONFERENCE_ACTION_SETTLE_TIMEOUT); + } + } + } + + throw lastError; +}; + +export const consultQueueAndAcceptCall = async ({ + fromAgent, + toAgent, + getAgentPage, + getRequiredEnv, + acceptTimeout = ACCEPT_TASK_TIMEOUT, +}: QueueConsultOptions) => { + await setConferenceAgentsAvailable(getAgentPage, [toAgent]); + await waitForConferenceControlReady(getAgentPage, fromAgent, 'call-control:consult', acceptTimeout); + await consultOrTransfer(getAgentPage(fromAgent), 'queue', 'consult', getRequiredEnv('QUEUE_NAME')); + await acceptIncomingTask(getAgentPage(toAgent), TASK_TYPES.CALL, acceptTimeout); + await verifyCurrentState(getAgentPage(toAgent), USER_STATES.ENGAGED); +}; + +export const mergeConsultIntoConference = async ({ + fromAgent, + getAgentPage, + acceptTimeout = ACCEPT_TASK_TIMEOUT, +}: SingleAgentActionOptions) => { + const page = getAgentPage(fromAgent); + const mergeButton = page.getByTestId('conference-consult-btn'); + await expect(mergeButton).toBeVisible({timeout: acceptTimeout}); + await mergeButton.click(); + await page.waitForTimeout(CONFERENCE_ACTION_SETTLE_TIMEOUT); +}; + +export const transferConsultAndSubmitWrapup = async ({ + fromAgent, + getAgentPage, + acceptTimeout = ACCEPT_TASK_TIMEOUT, +}: SingleAgentActionOptions) => { + const page = getAgentPage(fromAgent); + await expect(page.getByTestId('transfer-consult-btn')).toBeVisible({timeout: acceptTimeout}); + await page.getByTestId('transfer-consult-btn').click(); + await page.waitForTimeout(CONFERENCE_ACTION_SETTLE_TIMEOUT); + await submitWrapup(page, WRAPUP_REASONS.SALE); +}; + +export const toggleConferenceLegIfSwitchAvailable = async (getAgentPage: GetAgentPage, agentId: AgentId) => { + const page = getAgentPage(agentId); + const switchButton = page.getByTestId('switchToMainCall-consult-btn'); + const canToggle = await switchButton.isVisible().catch(() => false); + if (!canToggle) { + return false; + } + await switchButton.click(); + await page.waitForTimeout(CONFERENCE_SWITCH_TOGGLE_TIMEOUT); + return true; +}; + +export const exitConferenceParticipantAndWrapup = async (getAgentPage: GetAgentPage, agentId: AgentId) => { + const page = getAgentPage(agentId); + const exitButton = page.getByTestId('call-control:exit-conference').first(); + await expect(exitButton).toBeVisible({timeout: AWAIT_TIMEOUT}); + await exitButton.click(); + await page.waitForTimeout(CONFERENCE_ACTION_SETTLE_TIMEOUT); + const wrapupBox = page.getByTestId('call-control:wrapup-button').first(); + const needsWrapup = await wrapupBox + .waitFor({state: 'visible', timeout: WRAPUP_TIMEOUT}) + .then(() => true) + .catch(() => false); + if (needsWrapup) { + await submitWrapup(page, WRAPUP_REASONS.SALE); + } +}; + +export const endConferenceTaskAndWrapup = async (getAgentPage: GetAgentPage, agentId: AgentId) => { + const page = getAgentPage(agentId); + await endTask(page); + await page.waitForTimeout(CONFERENCE_END_TASK_SETTLE_TIMEOUT); + await submitWrapup(page, WRAPUP_REASONS.SALE); +}; diff --git a/playwright/Utils/controlUtils.ts b/playwright/Utils/controlUtils.ts new file mode 100644 index 000000000..1196a34cd --- /dev/null +++ b/playwright/Utils/controlUtils.ts @@ -0,0 +1,70 @@ +import {Page} from '@playwright/test'; +import {AWAIT_TIMEOUT} from '../constants'; + +export async function findFirstVisibleControlIndex(page: Page, testId: string): Promise { + const controls = page.getByTestId(testId); + const count = await controls.count().catch(() => 0); + + for (let i = 0; i < count; i++) { + const control = controls.nth(i); + if (await control.isVisible().catch(() => false)) { + return i; + } + } + + return -1; +} + +export async function findFirstVisibleEnabledControlIndex(page: Page, testId: string): Promise { + const controls = page.getByTestId(testId); + const count = await controls.count().catch(() => 0); + + for (let i = 0; i < count; i++) { + const control = controls.nth(i); + const isVisible = await control.isVisible().catch(() => false); + if (!isVisible) { + continue; + } + + if (await control.isEnabled().catch(() => false)) { + return i; + } + } + + return -1; +} + +export async function hasAnyVisibleControl(page: Page, testId: string): Promise { + return (await findFirstVisibleControlIndex(page, testId)) !== -1; +} + +export async function hasAnyVisibleEnabledControl(page: Page, testId: string): Promise { + return (await findFirstVisibleEnabledControlIndex(page, testId)) !== -1; +} + +export async function clickFirstVisibleEnabledControl(page: Page, testId: string): Promise { + const startedAt = Date.now(); + let lastError: unknown; + + while (Date.now() - startedAt < AWAIT_TIMEOUT) { + const enabledIndex = await findFirstVisibleEnabledControlIndex(page, testId); + if (enabledIndex === -1) { + await page.waitForTimeout(200); + continue; + } + + try { + await page.getByTestId(testId).nth(enabledIndex).click({timeout: AWAIT_TIMEOUT}); + return; + } catch (error) { + lastError = error; + await page.waitForTimeout(200); + } + } + + if (lastError instanceof Error) { + throw lastError; + } + + throw new Error(`No enabled visible control found for ${testId}`); +} diff --git a/playwright/Utils/helperUtils.ts b/playwright/Utils/helperUtils.ts index 6809fc6e6..d30aaf896 100644 --- a/playwright/Utils/helperUtils.ts +++ b/playwright/Utils/helperUtils.ts @@ -1,4 +1,4 @@ -import {Page} from '@playwright/test'; +import {Page, expect} from '@playwright/test'; import {getCurrentState, changeUserState} from './userStateUtils'; import { WRAPUP_REASONS, @@ -10,9 +10,11 @@ import { userState, WrapupReason, AWAIT_TIMEOUT, + OPERATION_TIMEOUT, + EXTENSION_REGISTRATION_TIMEOUT, } from '../constants'; import {submitWrapup} from './wrapupUtils'; -import {holdCallToggle} from './taskControlUtils'; +import {holdCallToggle, isCallHeld} from './taskControlUtils'; import {acceptExtensionCall, submitRonaPopup} from './incomingTaskUtils'; import { loginViaAccessToken, @@ -122,18 +124,14 @@ export async function waitForWebSocketReconnection( export const waitForState = async (page: Page, expectedState: userState): Promise => { try { await page.bringToFront(); - await page.waitForFunction( - async (expectedStateArg) => { - // Re-import getCurrentState in the browser context - const stateSelect = document.querySelector('[data-test="state-select"]') as HTMLSelectElement; - if (!stateSelect) return false; - - const currentState = stateSelect.value?.trim(); - return currentState === expectedStateArg; - }, - expectedState, - {timeout: 10000, polling: 'raf'} // Use requestAnimationFrame for optimal performance - ); + await expect + .poll( + async () => { + return await getCurrentState(page); + }, + {timeout: 10000, intervals: [200, 400, 800, 1200]} + ) + .toBe(expectedState); } catch (error) { // Get current state for better error message const currentState = await getCurrentState(page); @@ -366,27 +364,94 @@ export const handleStrayTasks = async ( } // ============================================ - // STEP 3: Check for end button (end active calls before accepting new ones) + // STEP 3a: Check for exit-conference button // ============================================ - const endButton = page.getByTestId('call-control:end-call').first(); - const endButtonVisible = await endButton.isVisible().catch(() => false); + const exitConferenceButton = page.getByTestId('call-control:exit-conference').first(); + const exitConferenceVisible = await exitConferenceButton.isVisible().catch(() => false); - if (endButtonVisible) { - let endButtonEnabled = await endButton.isEnabled().catch(() => false); + if (exitConferenceVisible) { + try { + await exitConferenceButton.click({timeout: AWAIT_TIMEOUT}); + await page.waitForTimeout(500); + const wrapupAfterExit = await wrapupButton.isVisible().catch(() => false); + if (wrapupAfterExit) { + await submitWrapup(page, WRAPUP_REASONS.SALE); + tasksHandled++; + } + actionTaken = true; + await page.waitForTimeout(300); + continue; + } catch (e) { + } + } + // ============================================ + // STEP 3b: Check for end button (end active calls before accepting new ones) + // ============================================ + // Conference pages may render two call control groups (simple + CAD). + // Find an enabled end-call button rather than always using .first(). + const allEndButtons = page.getByTestId('call-control:end-call'); + const endButtonCount = await allEndButtons.count().catch(() => 0); + let endButton = allEndButtons.first(); + let endButtonVisible = false; + let endButtonEnabled = false; + + for (let i = 0; i < endButtonCount; i++) { + const btn = allEndButtons.nth(i); + const visible = await btn.isVisible().catch(() => false); + if (!visible) continue; + endButtonVisible = true; + const enabled = await btn.isEnabled().catch(() => false); + if (enabled) { + endButton = btn; + endButtonEnabled = true; + break; + } + endButton = btn; + } + + if (endButtonVisible) { if (!endButtonEnabled) { - // End button disabled - try to resume from hold first - const holdToggle = page.getByTestId('call-control:hold-toggle').first(); - const holdToggleVisible = await holdToggle.isVisible().catch(() => false); + // End button disabled - try to cancel consult first + const cancelConsultBtn = page.getByTestId('cancel-consult-btn').first(); + let cancelConsultVisible = await cancelConsultBtn.isVisible().catch(() => false); + + // Cancel-consult may be hidden if on the main call leg — switch to consult leg first + if (!cancelConsultVisible) { + const switchBtn = page.getByTestId('switchToMainCall-consult-btn').first(); + const switchVisible = await switchBtn.isVisible().catch(() => false); + if (switchVisible) { + try { + await switchBtn.click({timeout: AWAIT_TIMEOUT}); + await page.waitForTimeout(1000); + cancelConsultVisible = await cancelConsultBtn.isVisible().catch(() => false); + } catch (e) { + } + } + } - if (holdToggleVisible) { + if (cancelConsultVisible) { try { - await holdCallToggle(page); - await page.waitForTimeout(500); + await cancelConsultBtn.click({timeout: AWAIT_TIMEOUT}); + await page.waitForTimeout(1000); endButtonEnabled = await endButton.isEnabled().catch(() => false); } catch (e) { } - } else { + } + + // Still disabled - resume only if the visible control state says the call is held + if (!endButtonEnabled) { + const holdToggle = page.getByTestId('call-control:hold-toggle').first(); + const holdToggleVisible = await holdToggle.isVisible().catch(() => false); + + if (holdToggleVisible && (await isCallHeld(page))) { + try { + await holdCallToggle(page); + await page.waitForTimeout(500); + endButtonEnabled = await endButton.isEnabled().catch(() => false); + } catch (e) { + } + } } } @@ -615,8 +680,8 @@ export async function clearPendingCallAndWrapup(page: Page): Promise { if (endVisible) { let endEnabled = await endBtn.isEnabled().catch(() => false); - // If disabled, try to resume from hold - if (!endEnabled) { + // If disabled, try to resume only when the visible hold control indicates the call is held + if (!endEnabled && (await isCallHeld(page))) { try { await holdCallToggle(page); await page.waitForTimeout(500); @@ -705,19 +770,36 @@ export const pageSetup = async ( return; // Skip further setup for multi-session tests } - let loginButtonExists = await page - .getByTestId('login-button') - .isVisible() - .catch(() => false); + const stateSelect = page.getByTestId('state-select'); + const loginButton = page.getByTestId('login-button'); + const isStateAlreadyVisible = await stateSelect.isVisible().catch(() => false); + + if (!isStateAlreadyVisible) { + let loginButtonExists = await loginButton.isVisible().catch(() => false); + + if (!loginButtonExists) { + await stationLogout(page, false); // Best-effort logout if still logged in from previous run. + loginButtonExists = await loginButton.isVisible().catch(() => false); + if (!loginButtonExists) { + await loginButton.waitFor({state: 'visible', timeout: OPERATION_TIMEOUT}); + } + } - if (loginButtonExists) { - await telephonyLogin(page, loginMode, extensionNumber); - } else { - await stationLogout(page, false); // Don't throw during setup - just try to logout await telephonyLogin(page, loginMode, extensionNumber); } - await page.getByTestId('state-select').waitFor({state: 'visible', timeout: 30000}); + const isStateVisible = await stateSelect + .waitFor({state: 'visible', timeout: EXTENSION_REGISTRATION_TIMEOUT}) + .then(() => true) + .catch(() => false); + + if (!isStateVisible) { + // Single bounded recovery for stale station/device registration state. + await stationLogout(page, false); + await loginButton.waitFor({state: 'visible', timeout: OPERATION_TIMEOUT}); + await telephonyLogin(page, loginMode, extensionNumber); + await stateSelect.waitFor({state: 'visible', timeout: EXTENSION_REGISTRATION_TIMEOUT}); + } }; /** diff --git a/playwright/Utils/stationLoginUtils.ts b/playwright/Utils/stationLoginUtils.ts index 57837c755..8bdb3b96c 100644 --- a/playwright/Utils/stationLoginUtils.ts +++ b/playwright/Utils/stationLoginUtils.ts @@ -6,6 +6,7 @@ import { AWAIT_TIMEOUT, DROPDOWN_SETTLE_TIMEOUT, OPERATION_TIMEOUT, + STATION_LOGOUT_UNREGISTER_SETTLE_TIMEOUT, } from '../constants'; import {handleStrayTasks} from './helperUtils'; @@ -124,6 +125,15 @@ export const dialLogin = async (page: Page, dialNumber?: string): Promise * ``` */ export const stationLogout = async (page: Page, throwOnFailure: boolean = true): Promise => { + const waitForPostLogoutSettle = async (): Promise => { + // Wait until station-login controls are back, then wait for SDK unregister to settle. + await page + .getByTestId('login-button') + .waitFor({state: 'visible', timeout: OPERATION_TIMEOUT}) + .catch(() => {}); + await page.waitForTimeout(STATION_LOGOUT_UNREGISTER_SETTLE_TIMEOUT); + }; + // Wait for the logout button to be visible before clicking const logoutButton = page.getByTestId('samples:station-logout-button'); const isVisible = await logoutButton @@ -162,13 +172,16 @@ export const stationLogout = async (page: Page, throwOnFailure: boolean = true): if (!isLogoutSuccessfulAfterRetry && throwOnFailure) { throw new Error('Station logout button is still visible after retry attempt'); } + if (isLogoutSuccessfulAfterRetry) { + await waitForPostLogoutSettle(); + } } catch (e) { if (throwOnFailure) { throw new Error(`Station logout failed: ${e instanceof Error ? e.message : 'Unknown error'}`); } } } else { - await page.waitForTimeout(2000); + await waitForPostLogoutSettle(); } }; diff --git a/playwright/Utils/taskControlUtils.ts b/playwright/Utils/taskControlUtils.ts index 10c88d029..bbf2d7d9b 100644 --- a/playwright/Utils/taskControlUtils.ts +++ b/playwright/Utils/taskControlUtils.ts @@ -1,5 +1,10 @@ import {Page, expect} from '@playwright/test'; import {TASK_TYPES, AWAIT_TIMEOUT, OPERATION_TIMEOUT} from '../constants'; +import { + clickFirstVisibleEnabledControl, + findFirstVisibleControlIndex, + findFirstVisibleEnabledControlIndex, +} from './controlUtils'; /** * Utility functions for task controls testing. @@ -8,6 +13,21 @@ import {TASK_TYPES, AWAIT_TIMEOUT, OPERATION_TIMEOUT} from '../constants'; * @packageDocumentation */ +async function getVisibleControlIconName(page: Page, testId: string): Promise { + const controlIndex = await findFirstVisibleControlIndex(page, testId); + if (controlIndex === -1) { + return null; + } + + const iconElement = page.getByTestId(testId).nth(controlIndex).locator('mdc-icon').nth(0); + const isVisible = await iconElement.isVisible().catch(() => false); + if (!isVisible) { + return null; + } + + return iconElement.getAttribute('name'); +} + /** * Verifies that all call task control buttons are visible and accessible. * Checks for hold, recording, transfer, consult, and end buttons. @@ -97,12 +117,13 @@ export async function verifyTaskControls(page: Page, taskType: string): Promise< * @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}); + await expect(page.getByTestId('call-control:hold-toggle').first()).toBeVisible({timeout: AWAIT_TIMEOUT}); + await clickFirstVisibleEnabledControl(page, 'call-control:hold-toggle'); +} - // Click the hold toggle button - await holdButton.click({timeout: AWAIT_TIMEOUT}); +export async function isCallHeld(page: Page): Promise { + const iconName = await getVisibleControlIconName(page, 'call-control:hold-toggle'); + return iconName === 'play-bold'; } /** @@ -112,12 +133,8 @@ export async function holdCallToggle(page: Page): Promise { * @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}); + await expect(page.getByTestId('call-control:recording-toggle').first()).toBeVisible({timeout: AWAIT_TIMEOUT}); + await clickFirstVisibleEnabledControl(page, 'call-control:recording-toggle'); } /** @@ -160,18 +177,17 @@ export async function verifyHoldTimer( * @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) { + try { + await expect + .poll(() => getVisibleControlIconName(page, 'call-control:hold-toggle'), { + timeout: AWAIT_TIMEOUT, + intervals: [200, 500, 1000], + }) + .toBe(expectedIcon); + } catch { + const actualIcon = await getVisibleControlIconName(page, 'call-control:hold-toggle'); throw new Error( `Hold button icon mismatch. Expected: '${expectedIcon}' (isHeld: ${expectedIsHeld}), but found: '${actualIcon}'` ); @@ -192,18 +208,17 @@ 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) { + try { + await expect + .poll(() => getVisibleControlIconName(page, 'call-control:recording-toggle'), { + timeout: AWAIT_TIMEOUT, + intervals: [200, 500, 1000], + }) + .toBe(expectedIcon); + } catch { + const actualIcon = await getVisibleControlIconName(page, 'call-control:recording-toggle'); throw new Error( `Record button icon mismatch. Expected: '${expectedIcon}' (isRecording: ${expectedIsRecording}), but found: '${actualIcon}'` ); @@ -255,26 +270,45 @@ export function clearCapturedLogs(): void { * @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') - ); +export async function verifyHoldLogs({expectedIsHeld}: {expectedIsHeld: boolean}): Promise { + const expectedStatus = 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)}` - ); - } + try { + await expect + .poll( + () => { + const holdResumeLogs = capturedLogs.filter((log) => log.includes('onHoldResume invoked')); + const statusLogs = capturedLogs.filter((log) => log.includes(expectedStatus)); + const lastHoldLog = holdResumeLogs[holdResumeLogs.length - 1] ?? ''; + return ( + holdResumeLogs.length > 0 && statusLogs.length > 0 && lastHoldLog.includes(`isHeld: ${expectedIsHeld}`) + ); + }, + {timeout: OPERATION_TIMEOUT, intervals: [200, 400, 800, 1200]} + ) + .toBeTruthy(); + } catch { + const holdResumeLogs = capturedLogs.filter((log) => log.includes('onHoldResume invoked')); + const statusLogs = capturedLogs.filter((log) => log.includes(expectedStatus)); + const lastHoldLog = holdResumeLogs[holdResumeLogs.length - 1]; + + 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)}`); - } + if (statusLogs.length === 0) { + throw new Error(`No '${expectedStatus}' logs found. Captured logs: ${JSON.stringify(capturedLogs)}`); + } + + if (!lastHoldLog?.includes(`isHeld: ${expectedIsHeld}`)) { + throw new Error(`Expected 'isHeld: ${expectedIsHeld}' in log but found: ${lastHoldLog}`); + } - const lastHoldLog = holdResumeLogs[holdResumeLogs.length - 1]; - if (!lastHoldLog.includes(`isHeld: ${expectedIsHeld}`)) { - throw new Error(`Expected 'isHeld: ${expectedIsHeld}' in log but found: ${lastHoldLog}`); + throw new Error( + `Timed out validating hold logs for isHeld: ${expectedIsHeld}. Captured logs: ${JSON.stringify(capturedLogs)}` + ); } } @@ -284,30 +318,49 @@ export function verifyHoldLogs({expectedIsHeld}: {expectedIsHeld: boolean}): voi * @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' - ) - ); +export async function verifyRecordingLogs({expectedIsRecording}: {expectedIsRecording: boolean}): Promise { + const expectedStatus = 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)}` - ); - } + try { + await expect + .poll( + () => { + const recordingLogs = capturedLogs.filter((log) => log.includes('onRecordingToggle invoked')); + const statusLogs = capturedLogs.filter((log) => log.includes(expectedStatus)); + const lastRecordingLog = recordingLogs[recordingLogs.length - 1] ?? ''; + return ( + recordingLogs.length > 0 && + statusLogs.length > 0 && + lastRecordingLog.includes(`isRecording: ${expectedIsRecording}`) + ); + }, + {timeout: OPERATION_TIMEOUT, intervals: [200, 400, 800, 1200]} + ) + .toBeTruthy(); + } catch { + const recordingLogs = capturedLogs.filter((log) => log.includes('onRecordingToggle invoked')); + const statusLogs = capturedLogs.filter((log) => log.includes(expectedStatus)); + const lastRecordingLog = recordingLogs[recordingLogs.length - 1]; + + 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)}`); - } + if (statusLogs.length === 0) { + 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}`); + if (!lastRecordingLog?.includes(`isRecording: ${expectedIsRecording}`)) { + throw new Error(`Expected 'isRecording: ${expectedIsRecording}' in log but found: ${lastRecordingLog}`); + } + + throw new Error( + `Timed out validating recording logs for isRecording: ${expectedIsRecording}. Captured logs: ${JSON.stringify(capturedLogs)}` + ); } } @@ -326,68 +379,89 @@ export function verifyEndLogs(): void { /** * 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 + * Verifies that exactly 1 live audio MediaStreamTrack is attached and the audio element is playing. * @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(); + await expect + .poll( + async () => { + return page.evaluate(() => { + const audioElem = document.querySelector('#remote-audio') as HTMLAudioElement | null; + + if (!audioElem) { + return { + hasAudioElement: false, + hasSrcObject: false, + trackCount: 0, + tracks: [], + }; + } + + const mediaStream = audioElem.srcObject as MediaStream | null; + const audioTracks = mediaStream?.getAudioTracks() ?? []; + + return { + hasAudioElement: true, + hasSrcObject: Boolean(mediaStream), + paused: audioElem.paused, + trackCount: audioTracks.length, + tracks: audioTracks.map((track, index) => ({ + index, + kind: track.kind, + id: track.id, + label: track.label, + enabled: track.enabled, + muted: track.muted, + readyState: track.readyState, + })), + }; + }); + }, + {timeout: OPERATION_TIMEOUT, intervals: [250, 500, 1000, 2000]} + ) + .toMatchObject({ + hasAudioElement: true, + hasSrcObject: true, + paused: false, + trackCount: 1, + tracks: [ + { + kind: 'audio', + enabled: true, + readyState: 'live', + }, + ], + }); + } catch (error) { + const debugState = await page + .evaluate(() => { + const audioElem = document.querySelector('#remote-audio') as HTMLAudioElement | null; + const mediaStream = audioElem?.srcObject as MediaStream | null; + 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, + hasAudioElement: Boolean(audioElem), + hasSrcObject: Boolean(mediaStream), + paused: audioElem?.paused ?? null, + trackCount: audioTracks.length, + tracks: audioTracks.map((track, index) => ({ + index, + kind: track.kind, + id: track.id, + label: track.label, + enabled: track.enabled, + muted: track.muted, + readyState: track.readyState, + })), }; - }); - - 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]; + }) + .catch(() => ({pageEvaluationFailed: true})); - // 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}`); + throw new Error(`❌ Audio transfer verification failed: ${error.message}. Debug state: ${JSON.stringify(debugState)}`); } } @@ -435,15 +509,21 @@ export async function verifyHoldMusicElement(page: Page): Promise { * @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 expect + .poll( + async () => { + const wrapupVisible = await page.getByTestId('call-control:wrapup-button').first().isVisible().catch(() => false); + const enabledEndIndex = await findFirstVisibleEnabledControlIndex(page, 'call-control:end-call'); + return wrapupVisible || enabledEndIndex !== -1; + }, + {timeout: OPERATION_TIMEOUT, intervals: [250, 500, 1000]} + ) + .toBeTruthy(); + + const wrapupVisible = await page.getByTestId('call-control:wrapup-button').first().isVisible().catch(() => false); + if (wrapupVisible && (await findFirstVisibleEnabledControlIndex(page, 'call-control:end-call')) === -1) { + return; } - await endButton.click({timeout: AWAIT_TIMEOUT}); + await clickFirstVisibleEnabledControl(page, 'call-control:end-call'); } diff --git a/playwright/Utils/wrapupUtils.ts b/playwright/Utils/wrapupUtils.ts index acee77d96..b9346c9e9 100644 --- a/playwright/Utils/wrapupUtils.ts +++ b/playwright/Utils/wrapupUtils.ts @@ -1,6 +1,51 @@ import {expect, Page} from '@playwright/test'; import {WrapupReason, AWAIT_TIMEOUT, UI_SETTLE_TIMEOUT, WRAPUP_TIMEOUT} from '../constants'; +async function findFirstVisibleWrapupIndex(page: Page): Promise { + const wrapupButtons = page.getByTestId('call-control:wrapup-button'); + const count = await wrapupButtons.count().catch(() => 0); + + for (let i = 0; i < count; i++) { + const button = wrapupButtons.nth(i); + if (await button.isVisible().catch(() => false)) { + return i; + } + } + + return -1; +} + +export async function waitForWrapupAfterCallEnd(page: Page): Promise { + await page.bringToFront(); + + await expect + .poll( + async () => { + const endControls = page.getByTestId('call-control:end-call'); + const endCount = await endControls.count().catch(() => 0); + let hasVisibleEndControl = false; + + for (let i = 0; i < endCount; i++) { + if (await endControls.nth(i).isVisible().catch(() => false)) { + hasVisibleEndControl = true; + break; + } + } + + const wrapupIndex = await findFirstVisibleWrapupIndex(page); + return { + hasVisibleEndControl, + hasVisibleWrapup: wrapupIndex !== -1, + }; + }, + {timeout: WRAPUP_TIMEOUT, intervals: [250, 500, 1000, 2000]} + ) + .toMatchObject({ + hasVisibleEndControl: false, + hasVisibleWrapup: true, + }); +} + /** * Submits the wrap-up popup for a task in the UI. * @@ -18,18 +63,20 @@ export async function submitWrapup(page: Page, reason: WrapupReason): Promise true) - .catch(() => false); - if (!isWrapupBoxVisible) throw new Error('Wrapup box is not visible'); + await expect + .poll(() => findFirstVisibleWrapupIndex(page), { + timeout: WRAPUP_TIMEOUT, + intervals: [250, 500, 1000, 2000], + }) + .not.toBe(-1); + + const wrapupIndex = await findFirstVisibleWrapupIndex(page); + const wrapupBox = page.getByTestId('call-control:wrapup-button').nth(wrapupIndex); // Check if dropdown is already open (aria-expanded="true") - const isAlreadyOpen = (await wrapupBox.first().getAttribute('aria-expanded')) === 'true'; + const isAlreadyOpen = (await wrapupBox.getAttribute('aria-expanded')) === 'true'; if (!isAlreadyOpen) { - await wrapupBox.first().click({timeout: AWAIT_TIMEOUT}); + await wrapupBox.click({timeout: AWAIT_TIMEOUT}); await page.waitForTimeout(UI_SETTLE_TIMEOUT); } await expect(page.getByTestId('call-control:wrapup-select').first()).toBeVisible({timeout: AWAIT_TIMEOUT}); diff --git a/playwright/ai-docs/AGENTS.md b/playwright/ai-docs/AGENTS.md index 1e956e047..499475779 100644 --- a/playwright/ai-docs/AGENTS.md +++ b/playwright/ai-docs/AGENTS.md @@ -33,6 +33,9 @@ At the time of this doc update, the baseline suites are: - `basic-advanced-task-controls-tests.spec.ts` - `advanced-task-controls-tests.spec.ts` - `dial-number-tests.spec.ts` +- `multiparty-conference-set-7-tests.spec.ts` +- `multiparty-conference-set-8-tests.spec.ts` +- `multiparty-conference-set-9-tests.spec.ts` Do not assume additional sets/suites exist unless they are present in code. @@ -94,6 +97,23 @@ yarn test:e2e --project=SET_1 --- +## OAuth Setup Model + +- `playwright/global.setup.ts` runs one `OAuth` setup test. +- Inside that test, token collection groups are generated dynamically from `USER_SETS` using chunk size `2`. +- One parallel OAuth worker runs per generated group. +- With current `SET_1..SET_9`, this resolves to 5 groups: + - `[SET_1, SET_2]` + - `[SET_3, SET_4]` + - `[SET_5, SET_6]` + - `[SET_7, SET_8]` + - `[SET_9]` +- Each group uses batch size 4 internally (`OAUTH_BATCH_SIZE=4`). +- Dial-number token is collected when configured. +- All env/token updates are written once via single `.env` upsert. + +--- + ## Documentation Rules When Playwright behavior changes: @@ -105,4 +125,42 @@ When Playwright behavior changes: --- -_Last Updated: 2026-03-04_ +## Flakiness Guardrails + +- `pageSetup` has a single bounded station logout/re-login recovery if `state-select` does not appear after telephony login. +- Multi-incoming digital scenarios should create/accept chat/email sequentially to reduce avoidable RONA races. +- `afterAll` cleanup in chained-call suites should guard state reads when setup never reached user-state visibility. +- Conference suites should run conference-state cleanup sequentially across shared-call agents (not in parallel) to avoid leg ownership races. + +--- + +## Conference Coverage (SET_7, SET_8, SET_9) + +- Multiparty conference scenarios are split across: + - `playwright/tests/multiparty-conference-set-7-test.spec.ts` + - `playwright/tests/multiparty-conference-set-8-test.spec.ts` + - `playwright/tests/multiparty-conference-set-9-test.spec.ts` +- Scenario IDs use prefixes: + - `CTS-MPC-*` (Multi-Party Conference matrix) + - `CTS-TC-*` (Transfer Conference scenarios) + - `CTS-SW-*` (Switch Conference scenarios) +- Skip policy used in implementation: + - `EP_DN`/`EPDN` scenarios are retained as `test.skip(...)` + - scenarios requiring more than 4 agents are retained as `test.skip(...)` +- Consolidation policy used in implementation: + - repeated call-init flows are merged into single tests when scenario steps are sequentially compatible + - consolidated IDs remain explicit in test names for traceability (for example `CTS-TC-09 and CTS-TC-10 ...`) + - current combined groups include: + - `SET_7`: `CTS-MPC-01+02`, `CTS-MPC-03+04`, `CTS-MPC-07+09+10`, `CTS-SW-02+03` + - `SET_8`: `CTS-TC-09+10`, `CTS-TC-11+13`, `CTS-TC-14+15` + - `SET_9`: `CTS-TC-01+02+03`, `CTS-TC-04+05`, `CTS-SW-05+06` + - standalone conference-only scenarios are intentionally distributed for runtime parity: + - `SET_7`: `CTS-SW-04` + - `SET_8`: `CTS-SW-07` + - `SET_9`: `CTS-TC-06`, `CTS-TC-07`, `CTS-TC-08` + - scenario split note: + - `CTS-TC-06` and `CTS-TC-07` run as separate tests (queue routing will not reliably re-route to an agent that RONA'd in the same session) + +--- + +_Last Updated: 2026-03-09_ diff --git a/playwright/ai-docs/ARCHITECTURE.md b/playwright/ai-docs/ARCHITECTURE.md index f08a51938..193059aa0 100644 --- a/playwright/ai-docs/ARCHITECTURE.md +++ b/playwright/ai-docs/ARCHITECTURE.md @@ -39,7 +39,10 @@ playwright/ │ ├── station-login-user-state-tests.spec.ts │ ├── basic-advanced-task-controls-tests.spec.ts │ ├── advanced-task-controls-tests.spec.ts -│ └── dial-number-tests.spec.ts +│ ├── dial-number-tests.spec.ts +│ ├── multiparty-conference-set-7-tests.spec.ts +│ ├── multiparty-conference-set-8-tests.spec.ts +│ └── multiparty-conference-set-9-tests.spec.ts ├── tests/ │ ├── digital-incoming-task-and-task-controls.spec.ts │ ├── incoming-task-and-controls-multi-session.spec.ts @@ -50,8 +53,12 @@ playwright/ │ ├── advanced-task-controls-test.spec.ts │ ├── advance-task-control-combinations-test.spec.ts │ ├── dial-number-task-control-test.spec.ts -│ └── tasklist-test.spec.ts +│ ├── tasklist-test.spec.ts +│ ├── multiparty-conference-set-7-test.spec.ts +│ ├── multiparty-conference-set-8-test.spec.ts +│ └── multiparty-conference-set-9-test.spec.ts ├── Utils/ +│ ├── controlUtils.ts │ ├── initUtils.ts │ ├── helperUtils.ts │ ├── incomingTaskUtils.ts @@ -75,17 +82,22 @@ Keep this section aligned to real repository contents. ## Set -> Suite -> Test Mapping -| Set | Suite File (`TEST_SUITE`) | Test Files Imported By Suite | -| --- | --- | --- | -| `SET_1` | `digital-incoming-task-tests.spec.ts` | `digital-incoming-task-and-task-controls.spec.ts`, `dial-number-task-control-test.spec.ts` | -| `SET_2` | `task-list-multi-session-tests.spec.ts` | `incoming-task-and-controls-multi-session.spec.ts`, `tasklist-test.spec.ts` | -| `SET_3` | `station-login-user-state-tests.spec.ts` | `station-login-test.spec.ts`, `user-state-test.spec.ts`, `incoming-telephony-task-test.spec.ts` | -| `SET_4` | `basic-advanced-task-controls-tests.spec.ts` | `basic-task-controls-test.spec.ts`, `advance-task-control-combinations-test.spec.ts` | -| `SET_5` | `advanced-task-controls-tests.spec.ts` | `advanced-task-controls-test.spec.ts` | -| `SET_6` | `dial-number-tests.spec.ts` | `dial-number-task-control-test.spec.ts` | +| Set | Suite File (`TEST_SUITE`) | Test Files Imported By Suite | +| ------- | -------------------------------------------- | ----------------------------------------------------------------------------------------------- | +| `SET_1` | `digital-incoming-task-tests.spec.ts` | `digital-incoming-task-and-task-controls.spec.ts`, `dial-number-task-control-test.spec.ts` | +| `SET_2` | `task-list-multi-session-tests.spec.ts` | `incoming-task-and-controls-multi-session.spec.ts`, `tasklist-test.spec.ts` | +| `SET_3` | `station-login-user-state-tests.spec.ts` | `station-login-test.spec.ts`, `user-state-test.spec.ts`, `incoming-telephony-task-test.spec.ts` | +| `SET_4` | `basic-advanced-task-controls-tests.spec.ts` | `basic-task-controls-test.spec.ts`, `advance-task-control-combinations-test.spec.ts` | +| `SET_5` | `advanced-task-controls-tests.spec.ts` | `advanced-task-controls-test.spec.ts` | +| `SET_6` | `dial-number-tests.spec.ts` | `dial-number-task-control-test.spec.ts` | +| `SET_7` | `multiparty-conference-set-7-tests.spec.ts` | `multiparty-conference-set-7-test.spec.ts` | +| `SET_8` | `multiparty-conference-set-8-tests.spec.ts` | `multiparty-conference-set-8-test.spec.ts` | +| `SET_9` | `multiparty-conference-set-9-tests.spec.ts` | `multiparty-conference-set-9-test.spec.ts` | Use this mapping to decide where new tests should be added and wired. +Conference scenario consolidation is implemented inside the SET_7/SET_8/SET_9 test files to reduce repeated call setup while preserving scenario ID traceability in test titles. + --- ## Dynamic Project Generation @@ -129,8 +141,16 @@ These flags are part of baseline runtime behavior and should be preserved unless `global.setup.ts`: 1. Expands `USER_SETS` into set-scoped env keys (`_...`) -2. Fetches OAuth tokens for agents in each set -3. Writes token/env updates to `.env` +2. Builds OAuth groups dynamically from `USER_SETS` with group size `2` and runs them in parallel. + Each group uses batch size 4 internally (`OAUTH_BATCH_SIZE=4`). + With current `SET_1..SET_9`, this resolves to 5 groups: + - `[SET_1, SET_2]` + - `[SET_3, SET_4]` + - `[SET_5, SET_6]` + - `[SET_7, SET_8]` + - `[SET_9]` +3. Optionally fetches dial-number OAuth token +4. Performs one final `.env` upsert in the same OAuth setup run Test files: @@ -184,6 +204,8 @@ When enabled by setup config/method, these page properties are created and avail - `agent1Page` - `agent2Page` +- `agent3Page` (conference sets only) +- `agent4Page` (conference sets only) - `callerPage` - `agent1ExtensionPage` - `chatPage` @@ -200,26 +222,31 @@ When enabled by setup config/method, these page properties are created and avail ### Convenience Methods -| Method | Behavior | -| --- | --- | -| `basicSetup()` | Calls `setup()` with desktop agent1 defaults | -| `setupForAdvancedTaskControls()` | Calls `setup()` with agent1+agent2+caller+extension and advanced logging | -| `setupForAdvancedCombinations()` | Calls `setup()` with agent1+agent2+caller and advanced logging | -| `setupForDialNumber()` | Calls `setup()` with dial-number login enabled | -| `setupForIncomingTaskDesktop()` | Calls `setup()` for desktop incoming-task flow | -| `setupForIncomingTaskExtension()` | Calls `setup()` for extension incoming-task flow | -| `setupForIncomingTaskMultiSession()` | Calls `setup()` for multi-session incoming-task flow | -| `setupForStationLogin()` | Custom path (does not call `setup()`), purpose-built station-login + multi-login bootstrap | -| `setupMultiSessionPage()` | Targeted helper to initialize only multi-session page when needed | +| Method | Behavior | +| ------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `basicSetup()` | Calls `setup()` with desktop agent1 defaults | +| `setupForAdvancedTaskControls()` | Calls `setup()` with agent1+agent2+caller+extension and advanced logging | +| `setupForAdvancedCombinations()` | Calls `setup()` with agent1+agent2+caller and advanced logging | +| `setupForDialNumber()` | Calls `setup()` with dial-number login enabled | +| `setupForIncomingTaskDesktop()` | Calls `setup()` for desktop incoming-task flow | +| `setupForIncomingTaskExtension()` | Calls `setup()` for extension incoming-task flow | +| `setupForIncomingTaskMultiSession()` | Calls `setup()` for multi-session incoming-task flow | +| `setupForStationLogin()` | Custom path (does not call `setup()`), purpose-built station-login + multi-login bootstrap. Station-login page initialization runs sequentially (main then multi-session) to reduce init contention. | +| `setupForMultipartyConference()` | Sets up 4 agents + caller for conference tests (agent1–4 pages + callerPage) | +| `setupMultiSessionPage()` | Targeted helper to initialize only multi-session page when needed | ### Cleanup - `softCleanup()`: - Handles stray tasks only (`handleStrayTasks`) + - Covers `agent1Page`, `multiSessionAgent1Page`, `agent2Page`, `agent3Page`, `agent4Page`, and `callerPage` + - Uses best-effort timeout guards so one stuck page does not block suite teardown - Intended for between-file cleanup via `afterAll` - `cleanup()`: - Runs `softCleanup()` first - - Performs station logout where applicable + - Performs station logout where applicable (including `multiSessionAgent1Page`) + - Uses best-effort timeout guards for logout operations + - Waits for post-logout settle (`login-button` visible + unregister settle timeout) before closing contexts - Closes all created pages/contexts - Intended for end-of-suite full cleanup @@ -227,16 +254,18 @@ When enabled by setup config/method, these page properties are created and avail ## Utils Reference -| File | Key Exports | Purpose | -| --- | --- | --- | -| `initUtils.ts` | `loginViaAccessToken`, `oauthLogin`, `enableAllWidgets`, `enableMultiLogin`, `initialiseWidgets`, `agentRelogin`, `setupMultiLoginPage` | Auth/bootstrap/widget init helpers | -| `stationLoginUtils.ts` | `desktopLogin`, `extensionLogin`, `dialLogin`, `telephonyLogin`, `stationLogout`, `verifyLoginMode`, `ensureUserStateVisible` | Station login/logout validation for Desktop/Extension/Dial Number | -| `userStateUtils.ts` | `changeUserState`, `getCurrentState`, `verifyCurrentState`, `getStateElapsedTime`, `validateConsoleStateChange`, `checkCallbackSequence` | User-state actions and console/state validation | -| `taskControlUtils.ts` | `holdCallToggle`, `recordCallToggle`, `endTask`, `verifyHoldTimer`, `verifyHoldButtonIcon`, `verifyRecordButtonIcon`, `setupConsoleLogging`, `verifyHoldLogs`, `verifyRecordingLogs`, `verifyEndLogs`, `verifyRemoteAudioTracks` | Basic call control actions + callback/event log assertions | -| `advancedTaskControlUtils.ts` | `consultOrTransfer`, `cancelConsult`, `setupAdvancedConsoleLogging`, `verifyTransferSuccessLogs`, `verifyConsultStartSuccessLogs`, `verifyConsultEndSuccessLogs`, `verifyConsultTransferredLogs` | Consult/transfer operations + advanced callback/event log assertions | -| `incomingTaskUtils.ts` | `createCallTask`, `createChatTask`, `createEmailTask`, `waitForIncomingTask`, `acceptIncomingTask`, `declineIncomingTask`, `acceptExtensionCall`, `loginExtension`, `submitRonaPopup` | Incoming task creation/acceptance/decline and extension helpers | -| `wrapupUtils.ts` | `submitWrapup` | Wrapup submission | -| `helperUtils.ts` | `handleStrayTasks`, `pageSetup`, `waitForState`, `waitForStateLogs`, `waitForWebSocketDisconnection`, `waitForWebSocketReconnection`, `clearPendingCallAndWrapup`, `dismissOverlays` | Shared setup/cleanup/state polling/network-watch helpers | +| File | Key Exports | Purpose | +| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `initUtils.ts` | `loginViaAccessToken`, `oauthLogin`, `enableAllWidgets`, `enableMultiLogin`, `initialiseWidgets`, `agentRelogin`, `setupMultiLoginPage` | Auth/bootstrap/widget init helpers | +| `stationLoginUtils.ts` | `desktopLogin`, `extensionLogin`, `dialLogin`, `telephonyLogin`, `stationLogout`, `verifyLoginMode`, `ensureUserStateVisible` | Station login/logout validation for Desktop/Extension/Dial Number | +| `userStateUtils.ts` | `changeUserState`, `getCurrentState`, `verifyCurrentState`, `getStateElapsedTime`, `validateConsoleStateChange`, `checkCallbackSequence` | User-state actions and console/state validation | +| `controlUtils.ts` | `findFirstVisibleControlIndex`, `findFirstVisibleEnabledControlIndex`, `hasAnyVisibleControl`, `hasAnyVisibleEnabledControl`, `clickFirstVisibleEnabledControl` | Shared control scanning/click helpers for duplicated call-control groups across task, conference, and advanced consult flows. | +| `taskControlUtils.ts` | `holdCallToggle`, `recordCallToggle`, `isCallHeld`, `endTask`, `verifyHoldTimer`, `verifyHoldButtonIcon`, `verifyRecordButtonIcon`, `setupConsoleLogging`, `verifyHoldLogs`, `verifyRecordingLogs`, `verifyEndLogs`, `verifyRemoteAudioTracks` | Basic call control actions + callback/event log assertions. `endTask` now stays generic and assumes the caller has already restored the page to a normal endable state. | +| `advancedTaskControlUtils.ts` | `consultOrTransfer`, `cancelConsult`, `waitForPrimaryCallAfterConsult`, `setupAdvancedConsoleLogging`, `verifyTransferSuccessLogs`, `verifyConsultStartSuccessLogs`, `verifyConsultEndSuccessLogs`, `verifyConsultTransferredLogs`, `ACTIVE_CONSULT_CONTROL_TEST_IDS` | Consult/transfer operations + advanced callback/event log assertions. Includes consult-state polling and post-consult primary-call restoration before generic end-task operations. | +| `incomingTaskUtils.ts` | `createCallTask`, `createChatTask`, `createEmailTask`, `waitForIncomingTask`, `acceptIncomingTask`, `declineIncomingTask`, `acceptExtensionCall`, `loginExtension`, `submitRonaPopup` | Incoming task creation/acceptance/decline and extension helpers | +| `wrapupUtils.ts` | `submitWrapup` | Wrapup submission | +| `helperUtils.ts` | `handleStrayTasks`, `pageSetup`, `waitForState`, `waitForStateLogs`, `waitForWebSocketDisconnection`, `waitForWebSocketReconnection`, `clearPendingCallAndWrapup`, `dismissOverlays` | Shared setup/cleanup/state polling/network-watch helpers. `waitForState` polls visible state text (`state-name`) to align with `verifyCurrentState`. `pageSetup` includes one bounded station logout/re-login recovery if `state-select` is still missing after login. `handleStrayTasks` handles exit-conference, dual call control groups (iterates all end-call buttons to find enabled one), cancel-consult with switch-leg fallback. | +| `conferenceUtils.ts` | `cleanupConferenceState`, `startBaselineCallOnAgent1`, `consultAgentAndAcceptCall`, `consultQueueAndAcceptCall`, `mergeConsultIntoConference`, `transferConsultAndSubmitWrapup`, `toggleConferenceLegIfSwitchAvailable`, `exitConferenceParticipantAndWrapup`, `endConferenceTaskAndWrapup` | Shared conference helpers used by Set 7, Set 8, and Set 9 to keep call setup/cleanup and consult-transfer flows consistent and reusable. Conference callers now choose explicit exit-vs-end behavior instead of using one mixed helper. | Use existing helpers first; add new utilities only when behavior is not already covered. @@ -246,32 +275,39 @@ Use existing helpers first; add new utilities only when behavior is not already ### Key Enums/Objects -| Constant | Values (Current) | Used For | -| --- | --- | --- | -| `USER_STATES` | `MEETING`, `AVAILABLE`, `LUNCH` (`Lunch Break`), `RONA`, `ENGAGED`, `AGENT_DECLINED` | Agent state change/validation | -| `LOGIN_MODE` | `DESKTOP` (`Desktop`), `EXTENSION` (`Extension`), `DIAL_NUMBER` (`Dial Number`) | Station login mode selection | -| `PAGE_TYPES` | `AGENT1`, `AGENT2`, `CALLER`, `EXTENSION`, `CHAT`, `MULTI_SESSION`, `DIAL_NUMBER` | TestManager page/context identity | -| `TASK_TYPES` | `CALL`, `CHAT`, `EMAIL`, `SOCIAL` | Incoming task typing | -| `WRAPUP_REASONS` | `SALE`, `RESOLVED` | Wrapup flow | -| `RONA_OPTIONS` | `AVAILABLE`, `IDLE` | RONA popup next-state selection | -| `CONSOLE_PATTERNS` | `SDK_STATE_CHANGE_SUCCESS`, `ON_STATE_CHANGE_REGEX`, `ON_STATE_CHANGE_KEYWORDS` | State-change console pattern matching | +| Constant | Values (Current) | Used For | +| ------------------ | ------------------------------------------------------------------------------------ | ------------------------------------- | +| `USER_STATES` | `MEETING`, `AVAILABLE`, `LUNCH` (`Lunch Break`), `RONA`, `ENGAGED`, `AGENT_DECLINED` | Agent state change/validation | +| `LOGIN_MODE` | `DESKTOP` (`Desktop`), `EXTENSION` (`Extension`), `DIAL_NUMBER` (`Dial Number`) | Station login mode selection | +| `PAGE_TYPES` | `AGENT1`, `AGENT2`, `CALLER`, `EXTENSION`, `CHAT`, `MULTI_SESSION`, `DIAL_NUMBER` | TestManager page/context identity | +| `TASK_TYPES` | `CALL`, `CHAT`, `EMAIL`, `SOCIAL` | Incoming task typing | +| `WRAPUP_REASONS` | `SALE`, `RESOLVED` | Wrapup flow | +| `RONA_OPTIONS` | `AVAILABLE`, `IDLE` | RONA popup next-state selection | +| `CONSOLE_PATTERNS` | `SDK_STATE_CHANGE_SUCCESS`, `ON_STATE_CHANGE_REGEX`, `ON_STATE_CHANGE_KEYWORDS` | State-change console pattern matching | ### Timeout Hierarchy -| Constant | Value | Typical Use | -| --- | --- | --- | -| `DROPDOWN_SETTLE_TIMEOUT` | `200` ms | Dropdown animation settle | -| `UI_SETTLE_TIMEOUT` | `2000` ms | Generic UI settle | -| `DEFAULT_TIMEOUT` | `5000` ms | Default visibility/check timeout | -| `AWAIT_TIMEOUT` | `10000` ms | Standard element interactions | -| `WRAPUP_TIMEOUT` | `15000` ms | Wrapup UI timing | -| `FORM_FIELD_TIMEOUT` | `20000` ms | Popover/form field loading | -| `OPERATION_TIMEOUT` | `30000` ms | Longer user operations (for example logout checks) | -| `EXTENSION_REGISTRATION_TIMEOUT` | `40000` ms | Extension registration waits | -| `NETWORK_OPERATION_TIMEOUT` | `40000` ms | Network-dependent operations | -| `WIDGET_INIT_TIMEOUT` | `50000` ms | Widget initialization | -| `CHAT_LAUNCHER_TIMEOUT` | `60000` ms | Chat launcher iframe loading | -| `ACCEPT_TASK_TIMEOUT` | `60000` ms | Incoming-task acceptance waits | +| Constant | Value | Typical Use | +| ------------------------------------------ | ---------- | ------------------------------------------------------------------- | +| `DROPDOWN_SETTLE_TIMEOUT` | `200` ms | Dropdown animation settle | +| `UI_SETTLE_TIMEOUT` | `2000` ms | Generic UI settle | +| `DEFAULT_TIMEOUT` | `5000` ms | Default visibility/check timeout | +| `AWAIT_TIMEOUT` | `10000` ms | Standard element interactions | +| `WRAPUP_TIMEOUT` | `15000` ms | Wrapup UI timing | +| `FORM_FIELD_TIMEOUT` | `20000` ms | Popover/form field loading | +| `OPERATION_TIMEOUT` | `30000` ms | Longer user operations (for example logout checks) | +| `STATION_LOGOUT_UNREGISTER_SETTLE_TIMEOUT` | `4000` ms | Post-logout wait for backend unregister to settle before next login | +| `EXTENSION_REGISTRATION_TIMEOUT` | `40000` ms | Extension registration waits | +| `NETWORK_OPERATION_TIMEOUT` | `40000` ms | Network-dependent operations | +| `WIDGET_INIT_TIMEOUT` | `50000` ms | Widget initialization | +| `CHAT_LAUNCHER_TIMEOUT` | `60000` ms | Chat launcher iframe loading | +| `ACCEPT_TASK_TIMEOUT` | `60000` ms | Incoming-task acceptance waits | +| `CONFERENCE_SWITCH_TOGGLE_TIMEOUT` | `1000` ms | Wait after switching conference call legs | +| `CONFERENCE_END_TASK_SETTLE_TIMEOUT` | `1500` ms | Wait after ending task in conference | +| `CONFERENCE_ACTION_SETTLE_TIMEOUT` | `2000` ms | Wait after conference merge/exit actions | +| `CONFERENCE_CUSTOMER_DISCONNECT_TIMEOUT` | `3000` ms | Wait for customer disconnect propagation | +| `CONFERENCE_RECONNECT_SETTLE_TIMEOUT` | `4000` ms | Wait after network reconnect in conference | +| `CONSULT_NO_ANSWER_TIMEOUT` | `12000` ms | Wait for consult no-answer (RONA) scenario | Choose the smallest fitting timeout and document reasons for any increases. @@ -343,6 +379,39 @@ Do not document future files/sets before they exist in code. --- +## Conference-Specific Patterns + +### handleStrayTasks cleanup order for conference state + +`handleStrayTasks` handles conference teardown in this priority: + +1. Exit-conference button → click → check wrapup → submit if visible +2. End-call button → iterate all instances to find enabled one (conference pages render dual control groups: simple + CAD) +3. Cancel-consult → if end-call is disabled, try cancel-consult; if not visible, switch leg first via `switchToMainCall-consult-btn` +4. Resume from hold → if end-call still disabled after cancel-consult attempts + +### Queue routing constraint + +Queue routing will **not** re-route to an agent who RONA'd (Ring On No Answer) in the same call session. When designing tests that share a queue across scenarios, use different agents for queue-routed consults after a RONA test. + +### Conference State Cleanup + +Conference suites use guarded cleanup wrappers (`cleanupConferencePageWithTimeout`) with closed-page checks and per-page timeout limits. For shared-call conference flows, cleanup runs sequentially across agents to avoid ownership/leg race conditions; each task remains best-effort to avoid failing hooks when teardown has already started. + +### Agent popover propagation + +After setting an agent to Available, the agent may not immediately appear in the consult/transfer popover. `performAgentSelection` retries up to 3 times — closing and reopening the popover to force a fresh agent list fetch from the backend. + +Conference helpers should verify target-agent state is `Available` before opening consult/transfer popovers, and verify consult control visibility/enabled state on the source agent before launching consult. + +After transferring a consult lobby leg, participant states can settle asynchronously. Tests should wait for `Engaged` via state polling before strict equality assertions. + +### resetCallerPage workaround + +After a call ends, the Make Call button on the caller page may stay disabled. Clicking `#sd-get-media-streams` re-enables it. This is a known workaround (marked with TODO). + +--- + ## Stability Principles - Prefer explicit state assertions over blind waits @@ -350,6 +419,8 @@ Do not document future files/sets before they exist in code. - Keep setup and cleanup deterministic - Reuse existing utilities to avoid divergent selectors/flows - Keep tests independently runnable by set/suite +- For mixed incoming digital load, create and accept tasks sequentially (not burst `Promise.all`) to reduce avoidable RONA transitions +- For parallel set execution, pre-start the web server to avoid port 3000 race conditions --- @@ -361,4 +432,4 @@ Do not document future files/sets before they exist in code. --- -_Last Updated: 2026-03-05_ +_Last Updated: 2026-03-09_ diff --git a/playwright/constants.ts b/playwright/constants.ts index dbbc0dfc8..6f3fd8482 100644 --- a/playwright/constants.ts +++ b/playwright/constants.ts @@ -50,11 +50,20 @@ export const ACCEPT_TASK_TIMEOUT = 60000; // Widget initialization timeouts export const WIDGET_INIT_TIMEOUT = 50000; +// Conference scenario timing controls +export const CONFERENCE_ACTION_SETTLE_TIMEOUT = 2000; +export const CONFERENCE_SWITCH_TOGGLE_TIMEOUT = 1000; +export const CONFERENCE_END_TASK_SETTLE_TIMEOUT = 1500; +export const CONFERENCE_CUSTOMER_DISCONNECT_TIMEOUT = 3000; +export const CONFERENCE_RECONNECT_SETTLE_TIMEOUT = 4000; +export const CONSULT_NO_ANSWER_TIMEOUT = 12000; + // Wrapup timeouts export const WRAPUP_TIMEOUT = 15000; // Station login timeouts export const DROPDOWN_SETTLE_TIMEOUT = 200; +export const STATION_LOGOUT_UNREGISTER_SETTLE_TIMEOUT = 4000; // Console log patterns for state changes export const CONSOLE_PATTERNS = { @@ -67,6 +76,8 @@ export const CONSOLE_PATTERNS = { export const PAGE_TYPES = { AGENT1: 'agent1', AGENT2: 'agent2', + AGENT3: 'agent3', + AGENT4: 'agent4', CALLER: 'caller', EXTENSION: 'extension', CHAT: 'chat', diff --git a/playwright/global.setup.ts b/playwright/global.setup.ts index d31a87066..8a4a51c84 100644 --- a/playwright/global.setup.ts +++ b/playwright/global.setup.ts @@ -1,134 +1,177 @@ -import {test as setup} from '@playwright/test'; +import {test as setup, Browser} from '@playwright/test'; import {oauthLogin} from './Utils/initUtils'; import {USER_SETS} from './test-data'; const fs = require('fs'); const path = require('path'); -export const UpdateENVWithUserSets = () => { - // Constants - const DOMAIN = process.env.PW_SANDBOX; - const envPath = path.resolve(__dirname, '../.env'); +const ENV_PATH = path.resolve(__dirname, '../.env'); +const OAUTH_BATCH_SIZE = 4; +const OAUTH_SET_GROUP_SIZE = 2; - let envContent = ''; - if (fs.existsSync(envPath)) { - envContent = fs.readFileSync(envPath, 'utf8'); - } +type EnvUpdateMap = Record; - // Dynamically set environment variables for all user sets - Object.keys(USER_SETS).forEach((setKey) => { - const userSet = USER_SETS[setKey]; +interface OAuthTask { + envKey: string; + username: string; + password?: string; +} - // Set agent usernames and extensions - access agents through userSet.AGENTS - Object.keys(userSet.AGENTS).forEach((agentKey) => { - const agent = userSet.AGENTS[agentKey]; +type UserSetKey = keyof typeof USER_SETS; - // Remove existing lines for this agent if they exist - const usernamePattern = new RegExp(`^${setKey}_${agentKey}_USERNAME=.*$\\n?`, 'm'); - const extensionPattern = new RegExp(`^${setKey}_${agentKey}_EXTENSION_NUMBER=.*$\\n?`, 'm'); - const namePattern = new RegExp(`^${setKey}_${agentKey}_NAME=.*$\\n?`, 'm'); +const readEnvFile = (): string => { + if (!fs.existsSync(ENV_PATH)) { + return ''; + } + return fs.readFileSync(ENV_PATH, 'utf8'); +}; - envContent = envContent.replace(usernamePattern, ''); - envContent = envContent.replace(extensionPattern, ''); - envContent = envContent.replace(namePattern, ''); +const escapeRegExp = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - // Add new lines - if (!envContent.endsWith('\n') && envContent.length > 0) envContent += '\n'; - envContent += `${setKey}_${agentKey}_USERNAME=${agent.username}@${DOMAIN}\n`; - envContent += `${setKey}_${agentKey}_EXTENSION_NUMBER=${agent.extension}\n`; - envContent += `${setKey}_${agentKey}_NAME=${agent.agentName || ''}\n`; - }); +const upsertEnvVariables = (updates: EnvUpdateMap): void => { + let envContent = readEnvFile(); - // Map to corresponding SET environment variables - const dialPattern = new RegExp(`^${setKey}_ENTRY_POINT=.*$\\n?`, 'm'); - const emailPattern = new RegExp(`^${setKey}_EMAIL_ENTRY_POINT=.*$\\n?`, 'm'); - const queuePattern = new RegExp(`^${setKey}_QUEUE_NAME=.*$\\n?`, 'm'); - const chatPattern = new RegExp(`^${setKey}_CHAT_URL=.*$\\n?`, 'm'); - - envContent = envContent.replace(dialPattern, ''); - envContent = envContent.replace(emailPattern, ''); - envContent = envContent.replace(queuePattern, ''); - envContent = envContent.replace(chatPattern, ''); - - if (!envContent.endsWith('\n') && envContent.length > 0) envContent += '\n'; - envContent += `${setKey}_ENTRY_POINT=${userSet.ENTRY_POINT || ''}\n`; - envContent += `${setKey}_EMAIL_ENTRY_POINT=${userSet.EMAIL_ENTRY_POINT || ''}\n`; - envContent += `${setKey}_QUEUE_NAME=${userSet.QUEUE_NAME || ''}\n`; - envContent += `${setKey}_CHAT_URL=${userSet.CHAT_URL || ''}\n`; - }); + for (const [key, value] of Object.entries(updates)) { + const keyPattern = new RegExp(`^${escapeRegExp(key)}=.*$\\n?`, 'm'); + envContent = envContent.replace(keyPattern, ''); + + if (!envContent.endsWith('\n') && envContent.length > 0) { + envContent += '\n'; + } + envContent += `${key}=${value}\n`; + process.env[key] = value; + } - // Write the updated content back to .env file - // Clean up multiple consecutive empty lines envContent = envContent.replace(/\n{3,}/g, '\n\n'); - fs.writeFileSync(envPath, envContent, 'utf8'); + fs.writeFileSync(ENV_PATH, envContent, 'utf8'); }; -setup('OAuth', async ({browser}) => { - // Update environment variables with user sets before starting OAuth - UpdateENVWithUserSets(); - - // 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 page = await browser.newPage(); +const buildOAuthTasksForSet = (setKey: UserSetKey): OAuthTask[] => { + const domain = process.env.PW_SANDBOX; + const tasks: OAuthTask[] = []; + const userSet = USER_SETS[setKey]; - // Construct the OAuth agent ID directly - const oauthAgentId = `${userSet.AGENTS[agentKey].username}@${process.env.PW_SANDBOX}`; - - await oauthLogin(page, oauthAgentId); + for (const agentKey of Object.keys(userSet.AGENTS)) { + const username = `${userSet.AGENTS[agentKey].username}@${domain}`; + tasks.push({ + envKey: `${setKey}_${agentKey}_ACCESS_TOKEN`, + username, + }); + } - await page.getByRole('textbox').click(); - const accessToken = await page.getByRole('textbox').inputValue(); + return tasks; +}; - const envPath = path.resolve(__dirname, '../.env'); - 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, ''); +const buildOAuthTasksForSetGroup = (setKeys: UserSetKey[]): OAuthTask[] => { + return setKeys.flatMap((setKey) => buildOAuthTasksForSet(setKey)); +}; - // 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'); +const buildSetGroups = (setKeys: UserSetKey[], groupSize: number): UserSetKey[][] => { + if (groupSize <= 0) { + throw new Error('groupSize must be greater than 0'); + } - await page.close(); - } + const groups: UserSetKey[][] = []; + for (let index = 0; index < setKeys.length; index += groupSize) { + groups.push(setKeys.slice(index, index + groupSize)); } + return groups; +}; - // OAuth for Dial Number Login user +const buildDialNumberTask = (): OAuthTask | null => { const dialNumberUsername = process.env.PW_DIAL_NUMBER_LOGIN_USERNAME; const dialNumberPassword = process.env.PW_DIAL_NUMBER_LOGIN_PASSWORD; if (dialNumberUsername && dialNumberPassword) { - const page = await browser.newPage(); + return { + envKey: 'DIAL_NUMBER_LOGIN_ACCESS_TOKEN', + username: dialNumberUsername, + password: dialNumberPassword, + }; + } + + return null; +}; - await oauthLogin(page, dialNumberUsername, dialNumberPassword); +const fetchOAuthAccessToken = async (browser: Browser, username: string, password?: string): Promise => { + const context = await browser.newContext({ignoreHTTPSErrors: true}); + const page = await context.newPage(); + try { + await oauthLogin(page, username, password); await page.getByRole('textbox').click(); - const accessToken = await page.getByRole('textbox').inputValue(); - - const envPath = path.resolve(__dirname, '../.env'); - let envContent = ''; - if (fs.existsSync(envPath)) { - envContent = fs.readFileSync(envPath, 'utf8'); - // Remove any existing DIAL_NUMBER_LOGIN_ACCESS_TOKEN line - const accessTokenPattern = new RegExp(`^DIAL_NUMBER_LOGIN_ACCESS_TOKEN=.*$\\n?`, 'm'); - envContent = envContent.replace(accessTokenPattern, ''); - - // Ensure trailing newline - if (!envContent.endsWith('\n')) envContent += '\n'; - } - envContent += `DIAL_NUMBER_LOGIN_ACCESS_TOKEN=${accessToken}\n`; - // Clean up multiple consecutive empty lines - envContent = envContent.replace(/\n{3,}/g, '\n\n'); - fs.writeFileSync(envPath, envContent, 'utf8'); + return await page.getByRole('textbox').inputValue(); + } finally { + await context.close().catch(() => {}); + } +}; + +const collectTokensInBatches = async (browser: Browser, tasks: OAuthTask[]): Promise => { + const tokenUpdates: EnvUpdateMap = {}; + + for (let index = 0; index < tasks.length; index += OAUTH_BATCH_SIZE) { + const batch = tasks.slice(index, index + OAUTH_BATCH_SIZE); + const batchTokens = await Promise.all( + batch.map((task) => fetchOAuthAccessToken(browser, task.username, task.password)) + ); + + batch.forEach((task, batchIndex) => { + tokenUpdates[task.envKey] = batchTokens[batchIndex]; + }); + } + + return tokenUpdates; +}; + +export const UpdateENVWithUserSets = () => { + // Constants + const DOMAIN = process.env.PW_SANDBOX; + const updates: EnvUpdateMap = {}; + + // Dynamically set environment variables for all user sets + Object.keys(USER_SETS).forEach((setKey) => { + const userSet = USER_SETS[setKey]; + + // Set agent usernames and extensions - access agents through userSet.AGENTS + Object.keys(userSet.AGENTS).forEach((agentKey) => { + const agent = userSet.AGENTS[agentKey]; + + updates[`${setKey}_${agentKey}_USERNAME`] = `${agent.username}@${DOMAIN}`; + updates[`${setKey}_${agentKey}_EXTENSION_NUMBER`] = agent.extension; + updates[`${setKey}_${agentKey}_NAME`] = agent.agentName || ''; + }); + + updates[`${setKey}_ENTRY_POINT`] = userSet.ENTRY_POINT || ''; + updates[`${setKey}_EMAIL_ENTRY_POINT`] = userSet.EMAIL_ENTRY_POINT || ''; + updates[`${setKey}_QUEUE_NAME`] = userSet.QUEUE_NAME || ''; + updates[`${setKey}_CHAT_URL`] = userSet.CHAT_URL || ''; + }); + + return updates; +}; + +const runOAuthSetGroup = async (browser: Browser, setGroup: UserSetKey[]) => { + const tasks = buildOAuthTasksForSetGroup(setGroup); + return collectTokensInBatches(browser, tasks); +}; - await page.close(); +setup('OAuth', async ({browser}) => { + const userSetKeys = Object.keys(USER_SETS) as UserSetKey[]; + const oauthSetGroups = buildSetGroups(userSetKeys, OAUTH_SET_GROUP_SIZE); + + // Collect all environment updates + const userSetUpdates = UpdateENVWithUserSets(); + const groupedTokenUpdates = await Promise.all(oauthSetGroups.map((setGroup) => runOAuthSetGroup(browser, setGroup))); + const tokenUpdates = groupedTokenUpdates.reduce((acc, groupTokens) => ({...acc, ...groupTokens}), {}); + + // Fetch dial number token (if configured) + const dialNumberTask = buildDialNumberTask(); + if (dialNumberTask) { + const dialNumberToken = await fetchOAuthAccessToken(browser, dialNumberTask.username, dialNumberTask.password); + tokenUpdates[dialNumberTask.envKey] = dialNumberToken; } + + const allUpdates = {...userSetUpdates, ...tokenUpdates}; + + // Write everything at once + upsertEnvVariables(allUpdates); }); diff --git a/playwright/suites/multiparty-conference-set-7-tests.spec.ts b/playwright/suites/multiparty-conference-set-7-tests.spec.ts new file mode 100644 index 000000000..02e3e2c5e --- /dev/null +++ b/playwright/suites/multiparty-conference-set-7-tests.spec.ts @@ -0,0 +1,4 @@ +import {test} from '@playwright/test'; +import createMultipartyConferenceSet7Tests from '../tests/multiparty-conference-set-7-test.spec'; + +test.describe('Multiparty Conference Tests - Set 7', createMultipartyConferenceSet7Tests); diff --git a/playwright/suites/multiparty-conference-set-8-tests.spec.ts b/playwright/suites/multiparty-conference-set-8-tests.spec.ts new file mode 100644 index 000000000..df2e75c21 --- /dev/null +++ b/playwright/suites/multiparty-conference-set-8-tests.spec.ts @@ -0,0 +1,4 @@ +import {test} from '@playwright/test'; +import createMultipartyConferenceSet8Tests from '../tests/multiparty-conference-set-8-test.spec'; + +test.describe('Multiparty Conference Tests - Set 8', createMultipartyConferenceSet8Tests); diff --git a/playwright/suites/multiparty-conference-set-9-tests.spec.ts b/playwright/suites/multiparty-conference-set-9-tests.spec.ts new file mode 100644 index 000000000..18e055447 --- /dev/null +++ b/playwright/suites/multiparty-conference-set-9-tests.spec.ts @@ -0,0 +1,4 @@ +import {test} from '@playwright/test'; +import createMultipartyConferenceSet9Tests from '../tests/multiparty-conference-set-9-test.spec'; + +test.describe('Multiparty Conference Tests - Set 9', createMultipartyConferenceSet9Tests); diff --git a/playwright/test-data.ts b/playwright/test-data.ts index 491cff4d6..168d4b951 100644 --- a/playwright/test-data.ts +++ b/playwright/test-data.ts @@ -69,4 +69,43 @@ export const USER_SETS = { ENTRY_POINT: env.PW_ENTRY_POINT6, TEST_SUITE: 'dial-number-tests.spec.ts', }, + SET_7: { + AGENTS: { + AGENT1: {username: 'user25', extension: '1025', agentName: 'User25 Agent25'}, + AGENT2: {username: 'user26', extension: '1026', agentName: 'User26 Agent26'}, + AGENT3: {username: 'user27', extension: '1027', agentName: 'User27 Agent27'}, + AGENT4: {username: 'user28', extension: '1028', agentName: 'User28 Agent28'}, + }, + QUEUE_NAME: 'Queue e2e 7', + CHAT_URL: `${env.PW_CHAT_URL}-e2e-7.html`, + EMAIL_ENTRY_POINT: `${env.PW_SANDBOX}.e2e7@gmail.com`, + ENTRY_POINT: env.PW_ENTRY_POINT7, + TEST_SUITE: 'multiparty-conference-set-7-tests.spec.ts', + }, + SET_8: { + AGENTS: { + AGENT1: {username: 'user29', extension: '1029', agentName: 'User29 Agent29'}, + AGENT2: {username: 'user30', extension: '1030', agentName: 'User30 Agent30'}, + AGENT3: {username: 'user31', extension: '1031', agentName: 'User31 Agent31'}, + AGENT4: {username: 'user32', extension: '1032', agentName: 'User32 Agent32'}, + }, + QUEUE_NAME: 'Queue e2e 8', + CHAT_URL: `${env.PW_CHAT_URL}-e2e-8.html`, + EMAIL_ENTRY_POINT: `${env.PW_SANDBOX}.e2e8@gmail.com`, + ENTRY_POINT: env.PW_ENTRY_POINT8, + TEST_SUITE: 'multiparty-conference-set-8-tests.spec.ts', + }, + SET_9: { + AGENTS: { + AGENT1: {username: 'user33', extension: '1033', agentName: 'User33 Agent33'}, + AGENT2: {username: 'user34', extension: '1034', agentName: 'User34 Agent34'}, + AGENT3: {username: 'user35', extension: '1035', agentName: 'User35 Agent35'}, + AGENT4: {username: 'user36', extension: '1036', agentName: 'User36 Agent36'}, + }, + QUEUE_NAME: 'Queue e2e 9', + CHAT_URL: `${env.PW_CHAT_URL}-e2e-9.html`, + EMAIL_ENTRY_POINT: `${env.PW_SANDBOX}.e2e9@gmail.com`, + ENTRY_POINT: env.PW_ENTRY_POINT9, + TEST_SUITE: 'multiparty-conference-set-9-tests.spec.ts', + }, }; diff --git a/playwright/test-manager.ts b/playwright/test-manager.ts index c824d28bf..51986d993 100644 --- a/playwright/test-manager.ts +++ b/playwright/test-manager.ts @@ -10,6 +10,7 @@ import { LoginMode, DEFAULT_MAX_RETRIES, DEFAULT_TIMEOUT, + OPERATION_TIMEOUT, UI_SETTLE_TIMEOUT, AWAIT_TIMEOUT, PAGE_TYPES, @@ -22,6 +23,8 @@ interface SetupConfig { // Core requirements needsAgent1?: boolean; needsAgent2?: boolean; + needsAgent3?: boolean; + needsAgent4?: boolean; needsCaller?: boolean; needsExtension?: boolean; needsChat?: boolean; @@ -40,8 +43,12 @@ interface SetupConfig { interface EnvTokens { agent1AccessToken: string; agent2AccessToken: string; + agent3AccessToken: string; + agent4AccessToken: string; agent1Username: string; agent2Username: string; + agent3Username: string; + agent4Username: string; agent1ExtensionNumber: string; password: string; dialNumberLoginAccessToken?: string; @@ -68,6 +75,14 @@ export class TestManager { public agent2Page: Page; public agent2Context: BrowserContext; + // Agent 3 main widget page (Agent 3 login) + public agent3Page: Page; + public agent3Context: BrowserContext; + + // Agent 4 main widget page (Agent 4 login) + public agent4Page: Page; + public agent4Context: BrowserContext; + // Caller extension page (Agent 2 for making calls) public callerPage: Page; public callerExtensionContext: BrowserContext; @@ -100,8 +115,12 @@ export class TestManager { return { agent1AccessToken: process.env[`${this.projectName}_AGENT1_ACCESS_TOKEN`] ?? '', agent2AccessToken: process.env[`${this.projectName}_AGENT2_ACCESS_TOKEN`] ?? '', + agent3AccessToken: process.env[`${this.projectName}_AGENT3_ACCESS_TOKEN`] ?? '', + agent4AccessToken: process.env[`${this.projectName}_AGENT4_ACCESS_TOKEN`] ?? '', agent1Username: process.env[`${this.projectName}_AGENT1_USERNAME`] ?? '', agent2Username: process.env[`${this.projectName}_AGENT2_USERNAME`] ?? '', + agent3Username: process.env[`${this.projectName}_AGENT3_USERNAME`] ?? '', + agent4Username: process.env[`${this.projectName}_AGENT4_USERNAME`] ?? '', agent1ExtensionNumber: process.env[`${this.projectName}_AGENT1_EXTENSION_NUMBER`] ?? '', password: process.env.PW_SANDBOX_PASSWORD ?? '', dialNumberLoginAccessToken: process.env.DIAL_NUMBER_LOGIN_ACCESS_TOKEN ?? '', @@ -155,12 +174,40 @@ export class TestManager { } } + // Best-effort guard to prevent cleanup/setup hooks from hanging indefinitely. + private async runBestEffortWithTimeout(operation: () => Promise, timeout: number = OPERATION_TIMEOUT): Promise { + const guardedOperation = operation().catch(() => {}); + const timeoutGuard = new Promise((resolve) => setTimeout(resolve, timeout)); + await Promise.race([guardedOperation, timeoutGuard]); + } + + private async safeHandleStrayTasks(page?: Page, extensionPage: Page | null = null): Promise { + if (!page || page.isClosed()) { + return; + } + const validExtension = extensionPage && !extensionPage.isClosed() ? extensionPage : null; + await this.runBestEffortWithTimeout(() => handleStrayTasks(page, validExtension)); + } + + private async safeStationLogout(page?: Page): Promise { + if (!page || page.isClosed()) { + return; + } + const hasLogoutButton = await this.isLogoutButtonVisible(page); + if (!hasLogoutButton) { + return; + } + await this.runBestEffortWithTimeout(() => stationLogout(page, false), OPERATION_TIMEOUT + UI_SETTLE_TIMEOUT * 10); + } + // 🎯 Universal Setup Method - Handles all test scenarios (Parallelized) async setup(browser: Browser, config: SetupConfig = {}): Promise { // Default configuration const defaults: SetupConfig = { needsAgent1: true, needsAgent2: false, + needsAgent3: false, + needsAgent4: false, needsCaller: false, needsExtension: false, needsChat: false, @@ -201,6 +248,12 @@ export class TestManager { if (config.needsAgent2) { promises.push(this.createContextWithPage(browser, PAGE_TYPES.AGENT2)); } + if (config.needsAgent3) { + promises.push(this.createContextWithPage(browser, PAGE_TYPES.AGENT3)); + } + if (config.needsAgent4) { + promises.push(this.createContextWithPage(browser, PAGE_TYPES.AGENT4)); + } if (config.needsCaller) { promises.push(this.createContextWithPage(browser, PAGE_TYPES.CALLER)); } @@ -240,6 +293,16 @@ export class TestManager { this.agent2Page = result.page; this.setupPageConsoleLogging(this.agent2Page, config.enableConsoleLogging); break; + case PAGE_TYPES.AGENT3: + this.agent3Context = result.context; + this.agent3Page = result.page; + this.setupPageConsoleLogging(this.agent3Page, config.enableConsoleLogging); + break; + case PAGE_TYPES.AGENT4: + this.agent4Context = result.context; + this.agent4Page = result.page; + this.setupPageConsoleLogging(this.agent4Page, config.enableConsoleLogging); + break; case PAGE_TYPES.CALLER: this.callerExtensionContext = result.context; this.callerPage = result.page; @@ -280,6 +343,16 @@ export class TestManager { setupPromises.push(this.setupAgent2(envTokens)); } + // Agent3 setup + if (config.needsAgent3) { + setupPromises.push(this.setupAgent3(envTokens)); + } + + // Agent4 setup + if (config.needsAgent4) { + setupPromises.push(this.setupAgent4(envTokens)); + } + // Caller extension setup if (config.needsCaller && this.callerPage) { setupPromises.push(this.setupCaller(envTokens)); @@ -318,6 +391,16 @@ export class TestManager { await pageSetup(this.agent2Page, LOGIN_MODE.DESKTOP, envTokens.agent2AccessToken); } + // Helper method for Agent3 setup + private async setupAgent3(envTokens: EnvTokens): Promise { + await pageSetup(this.agent3Page, LOGIN_MODE.DESKTOP, envTokens.agent3AccessToken); + } + + // Helper method for Agent4 setup + private async setupAgent4(envTokens: EnvTokens): Promise { + await pageSetup(this.agent4Page, LOGIN_MODE.DESKTOP, envTokens.agent4AccessToken); + } + // Helper method for Dial Number setup private async setupDialNumber(envTokens: EnvTokens): Promise { await this.retryOperation( @@ -370,6 +453,22 @@ export class TestManager { setupOperations.push(() => setupAdvancedConsoleLogging(this.agent2Page)); } + if (config.enableConsoleLogging && config.needsAgent3) { + setupOperations.push(() => setupConsoleLogging(this.agent3Page)); + } + + if (config.enableAdvancedLogging && config.needsAgent3) { + setupOperations.push(() => setupAdvancedConsoleLogging(this.agent3Page)); + } + + if (config.enableConsoleLogging && config.needsAgent4) { + setupOperations.push(() => setupConsoleLogging(this.agent4Page)); + } + + if (config.enableAdvancedLogging && config.needsAgent4) { + setupOperations.push(() => setupAdvancedConsoleLogging(this.agent4Page)); + } + // Execute all setup operations synchronously since they don't return promises setupOperations.forEach((operation) => operation()); } @@ -421,6 +520,20 @@ export class TestManager { }); } + async setupForMultipartyConference(browser: Browser) { + await this.setup(browser, { + needsAgent1: true, + needsAgent2: true, + needsAgent3: true, + needsAgent4: true, + needsCaller: true, + needDialNumberLogin: false, + agent1LoginMode: LOGIN_MODE.DESKTOP, + enableConsoleLogging: true, + enableAdvancedLogging: true, + }); + } + async setupForStationLogin(browser: Browser, isDesktopMode: boolean = false): Promise { const envTokens = this.getEnvTokens(); @@ -434,20 +547,12 @@ export class TestManager { this.multiSessionContext = await browser.newContext({ignoreHTTPSErrors: true}); 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 + // Run station-login widget initialization sequentially to avoid multi-session init contention. + await this.setupPageWithWidgets(this.agent1Page, envTokens.agent1AccessToken); if (!isDesktopMode) { - pageSetupOperations.push(this.setupPageWithWidgets(this.multiSessionAgent1Page, envTokens.agent1AccessToken)); + await 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); @@ -458,6 +563,8 @@ export class TestManager { // Helper method to setup page with widgets private async setupPageWithWidgets(page: Page, accessToken: string): Promise { await loginViaAccessToken(page, accessToken); + await page.waitForLoadState('domcontentloaded'); + await expect(page.getByTestId('samples:init-widgets-button')).toBeVisible({timeout: OPERATION_TIMEOUT}); await enableMultiLogin(page); await enableAllWidgets(page); await initialiseWidgets(page); @@ -465,19 +572,15 @@ export class TestManager { // 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, false)); // Don't throw during setup cleanup + await stationLogout(this.agent1Page, false); // Don't throw during setup cleanup } // Logout from station if already logged in on multi-session page if (!isDesktopMode && (await this.isLogoutButtonVisible(this.multiSessionAgent1Page))) { - logoutOperations.push(stationLogout(this.multiSessionAgent1Page, false)); // Don't throw during setup cleanup + await stationLogout(this.multiSessionAgent1Page, false); // Don't throw during setup cleanup } - - await Promise.all(logoutOperations); } // Helper method to verify station login widgets @@ -554,10 +657,22 @@ export class TestManager { const cleanupOps: Promise[] = []; if (this.agent1Page) { - cleanupOps.push(handleStrayTasks(this.agent1Page, this.agent1ExtensionPage)); + cleanupOps.push(this.safeHandleStrayTasks(this.agent1Page, this.agent1ExtensionPage)); + } + if (this.multiSessionAgent1Page) { + cleanupOps.push(this.safeHandleStrayTasks(this.multiSessionAgent1Page, this.agent1ExtensionPage)); } if (this.agent2Page) { - cleanupOps.push(handleStrayTasks(this.agent2Page)); + cleanupOps.push(this.safeHandleStrayTasks(this.agent2Page)); + } + if (this.agent3Page) { + cleanupOps.push(this.safeHandleStrayTasks(this.agent3Page)); + } + if (this.agent4Page) { + cleanupOps.push(this.safeHandleStrayTasks(this.agent4Page)); + } + if (this.callerPage) { + cleanupOps.push(this.safeHandleStrayTasks(this.callerPage)); } await Promise.all(cleanupOps); @@ -574,12 +689,24 @@ export class TestManager { // Logout operations - can be done in parallel const logoutOperations: Promise[] = []; - if (this.agent1Page && (await this.isLogoutButtonVisible(this.agent1Page))) { - logoutOperations.push(stationLogout(this.agent1Page, false)); // Don't throw during cleanup + if (this.agent1Page) { + logoutOperations.push(this.safeStationLogout(this.agent1Page)); + } + + if (this.multiSessionAgent1Page) { + logoutOperations.push(this.safeStationLogout(this.multiSessionAgent1Page)); + } + + if (this.agent2Page) { + logoutOperations.push(this.safeStationLogout(this.agent2Page)); + } + + if (this.agent3Page) { + logoutOperations.push(this.safeStationLogout(this.agent3Page)); } - if (this.agent2Page && (await this.isLogoutButtonVisible(this.agent2Page))) { - logoutOperations.push(stationLogout(this.agent2Page, false)); // Don't throw during cleanup + if (this.agent4Page) { + logoutOperations.push(this.safeStationLogout(this.agent4Page)); } await Promise.all(logoutOperations); @@ -592,6 +719,8 @@ export class TestManager { this.agent1Page, this.multiSessionAgent1Page, this.agent2Page, + this.agent3Page, + this.agent4Page, this.callerPage, this.agent1ExtensionPage, this.chatPage, @@ -609,6 +738,8 @@ export class TestManager { this.agent1Context, this.multiSessionContext, this.agent2Context, + this.agent3Context, + this.agent4Context, this.callerExtensionContext, this.extensionContext, this.chatContext, diff --git a/playwright/tests/advance-task-control-combinations-test.spec.ts b/playwright/tests/advance-task-control-combinations-test.spec.ts index 30b5890e7..bcf43213c 100644 --- a/playwright/tests/advance-task-control-combinations-test.spec.ts +++ b/playwright/tests/advance-task-control-combinations-test.spec.ts @@ -3,6 +3,7 @@ import { cancelConsult, consultOrTransfer, clearAdvancedCapturedLogs, + waitForPrimaryCallAfterConsult, verifyConsultStartSuccessLogs, } from '../Utils/advancedTaskControlUtils'; import {changeUserState, verifyCurrentState} from '../Utils/userStateUtils'; @@ -10,7 +11,7 @@ import {createCallTask, acceptIncomingTask} from '../Utils/incomingTaskUtils'; import {submitWrapup} from '../Utils/wrapupUtils'; import {USER_STATES, TASK_TYPES, WRAPUP_REASONS} from '../constants'; import {waitForState, handleStrayTasks} from '../Utils/helperUtils'; -import {endTask, holdCallToggle} from '../Utils/taskControlUtils'; +import {endTask} from '../Utils/taskControlUtils'; import {TestManager} from '../test-manager'; export default function createAdvanceCombinationsTests() { @@ -225,9 +226,11 @@ export default function createAdvanceCombinationsTests() { await expect(testManager.agent1Page.getByTestId('cancel-consult-btn')).toBeVisible(); await expect(testManager.agent1Page.getByTestId('transfer-consult-btn')).toBeVisible(); await cancelConsult(testManager.agent1Page); + await waitForPrimaryCallAfterConsult(testManager.agent1Page); + await expect(testManager.agent1Page.getByTestId('cancel-consult-btn')).toBeHidden(); + await expect(testManager.agent1Page.getByTestId('transfer-consult-btn')).toBeHidden(); await expect(testManager.agent1Page.getByTestId('call-control:consult').first()).toBeVisible(); await verifyCurrentState(testManager.agent1Page, USER_STATES.ENGAGED); - await holdCallToggle(testManager.agent1Page); await endTask(testManager.agent1Page); await testManager.agent1Page.waitForTimeout(3000); await submitWrapup(testManager.agent1Page, WRAPUP_REASONS.RESOLVED); diff --git a/playwright/tests/basic-task-controls-test.spec.ts b/playwright/tests/basic-task-controls-test.spec.ts index 4094fa0d7..c8a534169 100644 --- a/playwright/tests/basic-task-controls-test.spec.ts +++ b/playwright/tests/basic-task-controls-test.spec.ts @@ -37,7 +37,11 @@ export default function createCallTaskControlsTests() { }); afterAll(async () => { - if ((await getCurrentState(testManager.agent1Page)) === USER_STATES.ENGAGED) { + const isStateWidgetVisible = await testManager.agent1Page + .getByTestId('state-select') + .isVisible() + .catch(() => false); + if (isStateWidgetVisible && (await getCurrentState(testManager.agent1Page)) === USER_STATES.ENGAGED) { // If still engaged, end the call to clean up await endTask(testManager.agent1Page); await testManager.agent1Page.waitForTimeout(3000); @@ -50,9 +54,12 @@ export default function createCallTaskControlsTests() { }); test('Call task - create call and verify all control buttons are visible', async () => { + // Ensure routable state before creating call task. + await changeUserState(testManager.agent1Page, USER_STATES.AVAILABLE); + await verifyCurrentState(testManager.agent1Page, USER_STATES.AVAILABLE); + // Create call task await createCallTask(testManager.callerPage!, process.env[`${testManager.projectName}_ENTRY_POINT`]!); - await changeUserState(testManager.agent1Page, USER_STATES.AVAILABLE); // Accept the incoming call (waits for task to be visible) await acceptIncomingTask(testManager.agent1Page, TASK_TYPES.CALL, ACCEPT_TASK_TIMEOUT); @@ -97,7 +104,7 @@ export default function createCallTaskControlsTests() { await testManager.agent1Page.waitForTimeout(3000); // Allow time for hold to take effect // Verify hold callback logs - verifyHoldLogs({expectedIsHeld: true}); + await verifyHoldLogs({expectedIsHeld: true}); // Verify hold button icon changed to play icon (when call is on hold) await verifyHoldButtonIcon(testManager.agent1Page, {expectedIsHeld: true}); @@ -116,7 +123,7 @@ export default function createCallTaskControlsTests() { await testManager.agent1Page.waitForTimeout(2000); // Verify resume callback logs - verifyHoldLogs({expectedIsHeld: false}); + await verifyHoldLogs({expectedIsHeld: false}); // Verify hold button icon changed back to pause icon (when call is active) await verifyHoldButtonIcon(testManager.agent1Page, {expectedIsHeld: false}); @@ -142,7 +149,7 @@ export default function createCallTaskControlsTests() { await testManager.agent1Page.waitForTimeout(2000); // Verify pause recording callback logs - verifyRecordingLogs({expectedIsRecording: false}); + await verifyRecordingLogs({expectedIsRecording: false}); // Verify record button icon changed to record icon (when recording is paused) await verifyRecordButtonIcon(testManager.agent1Page, {expectedIsRecording: false}); @@ -154,7 +161,7 @@ export default function createCallTaskControlsTests() { await testManager.agent1Page.waitForTimeout(2000); // Verify resume recording callback logs - verifyRecordingLogs({expectedIsRecording: true}); + await verifyRecordingLogs({expectedIsRecording: true}); // Verify record button icon changed back to pause icon (when recording is active) await verifyRecordButtonIcon(testManager.agent1Page, {expectedIsRecording: true}); diff --git a/playwright/tests/digital-incoming-task-and-task-controls.spec.ts b/playwright/tests/digital-incoming-task-and-task-controls.spec.ts index b3d9fe7e7..f0ce8cd58 100644 --- a/playwright/tests/digital-incoming-task-and-task-controls.spec.ts +++ b/playwright/tests/digital-incoming-task-and-task-controls.spec.ts @@ -327,15 +327,31 @@ export default function createDigitalIncomingTaskAndTaskControlsTests() { await acceptExtensionCall(testManager.agent1ExtensionPage); await testManager.agent1Page.waitForTimeout(3000); - // Now create chat and email tasks - await Promise.all([ - createChatTask(testManager.chatPage, process.env[`${testManager.projectName}_CHAT_URL`]!), - createEmailTask(process.env[`${testManager.projectName}_EMAIL_ENTRY_POINT`]!), - ]); + // Create and accept chat/email sequentially to reduce RONA during burst arrivals. + await createChatTask(testManager.chatPage, process.env[`${testManager.projectName}_CHAT_URL`]!); await incomingChatTaskDiv.waitFor({state: 'visible', timeout: 40000}); await acceptIncomingTask(testManager.agent1Page, TASK_TYPES.CHAT); + + await createEmailTask(process.env[`${testManager.projectName}_EMAIL_ENTRY_POINT`]!); await incomingEmailTaskDiv.waitFor({state: 'visible', timeout: 40000}); await acceptIncomingTask(testManager.agent1Page, TASK_TYPES.EMAIL); + + const isRonaPopupVisible = await testManager.agent1Page + .getByTestId('samples:rona-popup') + .isVisible() + .catch(() => false); + if (isRonaPopupVisible) { + await submitRonaPopup(testManager.agent1Page, RONA_OPTIONS.AVAILABLE); + const hasChatTask = await incomingChatTaskDiv.isVisible().catch(() => false); + const hasEmailTask = await incomingEmailTaskDiv.isVisible().catch(() => false); + if (hasChatTask) { + await acceptIncomingTask(testManager.agent1Page, TASK_TYPES.CHAT); + } + if (hasEmailTask) { + await acceptIncomingTask(testManager.agent1Page, TASK_TYPES.EMAIL); + } + } + await waitForState(testManager.agent1Page, USER_STATES.ENGAGED); await verifyCurrentState(testManager.agent1Page, USER_STATES.ENGAGED); await waitForStateLogs(capturedLogs, USER_STATES.ENGAGED); @@ -355,7 +371,7 @@ export default function createDigitalIncomingTaskAndTaskControlsTests() { await endButton.click({timeout: 5000}); await submitWrapup(testManager.agent1Page, WRAPUP_REASONS.SALE); } else { - const wrapupBox = testManager.agent1Page.getByTestId('wrapup-button').first(); + const wrapupBox = testManager.agent1Page.getByTestId('call-control:wrapup-button').first(); const isWrapupBoxVisible = await wrapupBox .waitFor({state: 'visible', timeout: 2000}) .then(() => true) diff --git a/playwright/tests/incoming-telephony-task-test.spec.ts b/playwright/tests/incoming-telephony-task-test.spec.ts index d75a0aaa8..5aaaeb819 100644 --- a/playwright/tests/incoming-telephony-task-test.spec.ts +++ b/playwright/tests/incoming-telephony-task-test.spec.ts @@ -11,7 +11,7 @@ import { waitForIncomingTask, } from '../Utils/incomingTaskUtils'; import {TASK_TYPES, USER_STATES, THEME_COLORS, WRAPUP_REASONS, RONA_OPTIONS} from '../constants'; -import {submitWrapup} from '../Utils/wrapupUtils'; +import {submitWrapup, waitForWrapupAfterCallEnd} from '../Utils/wrapupUtils'; import { waitForState, waitForStateLogs, @@ -269,7 +269,7 @@ export default function createIncomingTelephonyTaskTests() { await waitForStateLogs(capturedLogs, USER_STATES.ENGAGED); expect(await getLastStateFromLogs(capturedLogs)).toBe(USER_STATES.ENGAGED); await endCallTask(testManager.agent1ExtensionPage); - await testManager.agent1Page.waitForTimeout(5000); + await waitForWrapupAfterCallEnd(testManager.agent1Page); await submitWrapup(testManager.agent1Page, WRAPUP_REASONS.SALE); await waitForState(testManager.agent1Page, USER_STATES.AVAILABLE); await waitForStateLogs(capturedLogs, USER_STATES.AVAILABLE); diff --git a/playwright/tests/multiparty-conference-set-7-test.spec.ts b/playwright/tests/multiparty-conference-set-7-test.spec.ts new file mode 100644 index 000000000..8fc3d1510 --- /dev/null +++ b/playwright/tests/multiparty-conference-set-7-test.spec.ts @@ -0,0 +1,253 @@ +import {test, expect, Page} from '@playwright/test'; +import {TestManager} from '../test-manager'; +import {verifyCurrentState} from '../Utils/userStateUtils'; +import {endCallTask} from '../Utils/incomingTaskUtils'; +import {waitForState} from '../Utils/helperUtils'; +import { + AgentId, + CONFERENCE_AGENT_IDS, + cleanupConferenceState, + consultAgentAndAcceptCall, + exitConferenceParticipantAndWrapup, + getConferenceAgentName, + getConferenceRequiredEnv, + mergeConsultIntoConference as mergeConsultIntoConferenceUtil, + startBaselineCallOnAgent1 as startBaselineCallOnAgent1Util, + toggleConferenceLegIfSwitchAvailable, + transferConsultAndSubmitWrapup, +} from '../Utils/conferenceUtils'; +import { + ACCEPT_TASK_TIMEOUT, + CONFERENCE_ACTION_SETTLE_TIMEOUT, + CONFERENCE_CUSTOMER_DISCONNECT_TIMEOUT, + CONFERENCE_RECONNECT_SETTLE_TIMEOUT, + CONFERENCE_SWITCH_TOGGLE_TIMEOUT, + USER_STATES, +} from '../constants'; + +export default function createMultipartyConferenceSet7Tests() { + let testManager: TestManager; + + const getAgentPage = (agentId: AgentId): Page => { + switch (agentId) { + case 1: + return testManager.agent1Page; + case 2: + return testManager.agent2Page; + case 3: + return testManager.agent3Page; + case 4: + return testManager.agent4Page; + default: + throw new Error(`Unsupported agentId: ${agentId}`); + } + }; + + const getRequiredEnv = (suffix: string): string => { + return getConferenceRequiredEnv(testManager.projectName, suffix); + }; + + const getAgentName = (agentId: AgentId): string => getConferenceAgentName(testManager.projectName, agentId); + + const cleanupConferenceStateForTest = async () => { + await cleanupConferenceState({ + getAgentPage, + callerPage: testManager.callerPage, + agentIds: CONFERENCE_AGENT_IDS, + }); + }; + + const startBaselineCallOnAgent1 = async () => { + await startBaselineCallOnAgent1Util({ + getAgentPage, + callerPage: testManager.callerPage, + getRequiredEnv, + agentIds: CONFERENCE_AGENT_IDS, + }); + }; + + const consultAgentAndAccept = async (fromAgent: AgentId, toAgent: AgentId) => { + await consultAgentAndAcceptCall({ + fromAgent, + toAgent, + getAgentPage, + getAgentName, + }); + }; + + const mergeConsultIntoConference = async (fromAgent: AgentId) => { + await mergeConsultIntoConferenceUtil({fromAgent, getAgentPage}); + }; + + const transferConsultAndWrapup = async (fromAgent: AgentId) => { + await transferConsultAndSubmitWrapup({fromAgent, getAgentPage}); + }; + + const toggleConferenceLegIfVisible = async (agentId: AgentId) => { + return toggleConferenceLegIfSwitchAvailable(getAgentPage, agentId); + }; + + const removeConferenceParticipant = async (agentId: AgentId) => + exitConferenceParticipantAndWrapup(getAgentPage, agentId); + + const expectPostCustomerLeaveControls = async (agentId: AgentId) => { + const page = getAgentPage(agentId); + // Depending on backend event ordering, agent may see either active end-call controls + // or transition directly into wrapup after customer disconnect. + await expect + .poll( + async () => { + const hasEnd = await page + .getByTestId('call-control:end-call') + .first() + .isVisible() + .catch(() => false); + const hasWrapup = await page + .getByTestId('call-control:wrapup-button') + .first() + .isVisible() + .catch(() => false); + return hasEnd || hasWrapup; + }, + {timeout: ACCEPT_TASK_TIMEOUT, intervals: [CONFERENCE_CUSTOMER_DISCONNECT_TIMEOUT]} + ) + .toBeTruthy(); + }; + + test.beforeAll(async ({browser}, testInfo) => { + testManager = new TestManager(testInfo.project.name); + await testManager.setupForMultipartyConference(browser); + }); + + test.beforeEach(async () => { + await cleanupConferenceStateForTest(); + }); + + test.afterEach(async () => { + await cleanupConferenceStateForTest(); + }); + + test.afterAll(async () => { + if (testManager) { + await testManager.cleanup(); + } + }); + + test.describe('Multi-Party Conference Feature Test Matrix', () => { + test('CTS-MPC-01 and CTS-MPC-02 should initiate a 3-agent conference and continue after one participant leaves', async () => { + await startBaselineCallOnAgent1(); + await consultAgentAndAccept(1, 2); + await mergeConsultIntoConference(1); + await consultAgentAndAccept(1, 3); + await mergeConsultIntoConference(1); + + await verifyCurrentState(getAgentPage(1), USER_STATES.ENGAGED); + await verifyCurrentState(getAgentPage(2), USER_STATES.ENGAGED); + await verifyCurrentState(getAgentPage(3), USER_STATES.ENGAGED); + + await removeConferenceParticipant(3); + await verifyCurrentState(getAgentPage(1), USER_STATES.ENGAGED); + await verifyCurrentState(getAgentPage(2), USER_STATES.ENGAGED); + }); + + test('CTS-MPC-03 and CTS-MPC-04 should support owner handoff and participant replacement without restarting call', async () => { + await startBaselineCallOnAgent1(); + await consultAgentAndAccept(1, 2); + await mergeConsultIntoConference(1); + await consultAgentAndAccept(1, 3); + await mergeConsultIntoConference(1); + + await removeConferenceParticipant(1); + await verifyCurrentState(getAgentPage(2), USER_STATES.ENGAGED); + + await consultAgentAndAccept(2, 4); + await mergeConsultIntoConference(2); + await verifyCurrentState(getAgentPage(2), USER_STATES.ENGAGED); + await verifyCurrentState(getAgentPage(4), USER_STATES.ENGAGED); + }); + + test('CTS-MPC-05 should transfer conference to another available agent', async () => { + await startBaselineCallOnAgent1(); + await consultAgentAndAccept(1, 2); + await mergeConsultIntoConference(1); + + await consultAgentAndAccept(1, 3); + await transferConsultAndWrapup(1); + await verifyCurrentState(getAgentPage(2), USER_STATES.ENGAGED); + await verifyCurrentState(getAgentPage(3), USER_STATES.ENGAGED); + }); + + test('CTS-MPC-06 should switch between consult and main conference call legs', async () => { + await startBaselineCallOnAgent1(); + await consultAgentAndAccept(1, 2); + await mergeConsultIntoConference(1); + await consultAgentAndAccept(1, 3); + + const switchButton = getAgentPage(1).getByTestId('switchToMainCall-consult-btn'); + await expect(switchButton).toBeVisible({timeout: ACCEPT_TASK_TIMEOUT}); + const firstToggle = await toggleConferenceLegIfVisible(1); + const secondToggle = await toggleConferenceLegIfVisible(1); + expect(firstToggle || secondToggle).toBeTruthy(); + await verifyCurrentState(getAgentPage(1), USER_STATES.ENGAGED); + }); + + test('CTS-MPC-07, CTS-MPC-09 and CTS-MPC-10 should recover/rejoin after reconnect and handle customer leave', async () => { + await startBaselineCallOnAgent1(); + await consultAgentAndAccept(1, 2); + await mergeConsultIntoConference(1); + + await getAgentPage(2).context().setOffline(true); + await getAgentPage(2).waitForTimeout(CONFERENCE_ACTION_SETTLE_TIMEOUT); + await getAgentPage(2).context().setOffline(false); + await getAgentPage(2).waitForTimeout(CONFERENCE_RECONNECT_SETTLE_TIMEOUT); + + await verifyCurrentState(getAgentPage(1), USER_STATES.ENGAGED); + await verifyCurrentState(getAgentPage(2), USER_STATES.ENGAGED); + + await endCallTask(testManager.callerPage, true); + await expectPostCustomerLeaveControls(1); + }); + + test.skip('CTS-MPC-08 should validate conference participant limit (>4 agents required)', async () => {}); + }); + + test.describe('Switch Conference - Set 7', () => { + test('CTS-SW-02 and CTS-SW-03 should switch legs and merge lobby participants into conference', async () => { + await startBaselineCallOnAgent1(); + await consultAgentAndAccept(1, 2); + await mergeConsultIntoConference(1); + await consultAgentAndAccept(1, 3); + + const page = getAgentPage(1); + const switchToMainButton = page.getByTestId('switchToMainCall-consult-btn'); + const switchToConsultButton = page.getByTestId('call-control:switch-to-consult').first(); + + await expect(switchToMainButton).toBeVisible({timeout: ACCEPT_TASK_TIMEOUT}); + await switchToMainButton.click(); + await page.waitForTimeout(CONFERENCE_SWITCH_TOGGLE_TIMEOUT); + + await expect(switchToConsultButton).toBeVisible({timeout: ACCEPT_TASK_TIMEOUT}); + await switchToConsultButton.click(); + await page.waitForTimeout(CONFERENCE_SWITCH_TOGGLE_TIMEOUT); + + await mergeConsultIntoConference(1); + await verifyCurrentState(getAgentPage(1), USER_STATES.ENGAGED); + await verifyCurrentState(getAgentPage(3), USER_STATES.ENGAGED); + }); + + test('CTS-SW-04 should transfer from consult lobby to conference participant', async () => { + await startBaselineCallOnAgent1(); + await consultAgentAndAccept(1, 2); + await mergeConsultIntoConference(1); + await waitForState(getAgentPage(1), USER_STATES.ENGAGED); + await waitForState(getAgentPage(2), USER_STATES.ENGAGED); + await consultAgentAndAccept(1, 3); + await transferConsultAndWrapup(1); + + await waitForState(getAgentPage(2), USER_STATES.ENGAGED); + await waitForState(getAgentPage(3), USER_STATES.ENGAGED); + await verifyCurrentState(getAgentPage(2), USER_STATES.ENGAGED); + await verifyCurrentState(getAgentPage(3), USER_STATES.ENGAGED); + }); + }); +} diff --git a/playwright/tests/multiparty-conference-set-8-test.spec.ts b/playwright/tests/multiparty-conference-set-8-test.spec.ts new file mode 100644 index 000000000..5adde4cd5 --- /dev/null +++ b/playwright/tests/multiparty-conference-set-8-test.spec.ts @@ -0,0 +1,193 @@ +import {test, expect, Page} from '@playwright/test'; +import {TestManager} from '../test-manager'; +import {verifyCurrentState} from '../Utils/userStateUtils'; +import {acceptIncomingTask, endCallTask} from '../Utils/incomingTaskUtils'; +import {consultOrTransfer, cancelConsult} from '../Utils/advancedTaskControlUtils'; +import {submitWrapup} from '../Utils/wrapupUtils'; +import { + AgentId, + CONFERENCE_AGENT_IDS, + cleanupConferenceState, + consultAgentAndAcceptCall, + consultQueueAndAcceptCall, + exitConferenceParticipantAndWrapup, + getConferenceAgentName, + getConferenceRequiredEnv, + mergeConsultIntoConference as mergeConsultIntoConferenceUtil, + setConferenceAgentsAvailable, + startBaselineCallOnAgent1 as startBaselineCallOnAgent1Util, + transferConsultAndSubmitWrapup, +} from '../Utils/conferenceUtils'; +import {ACCEPT_TASK_TIMEOUT, TASK_TYPES, USER_STATES, WRAPUP_REASONS} from '../constants'; + +export default function createMultipartyConferenceSet8Tests() { + let testManager: TestManager; + + const getAgentPage = (agentId: AgentId): Page => { + switch (agentId) { + case 1: + return testManager.agent1Page; + case 2: + return testManager.agent2Page; + case 3: + return testManager.agent3Page; + case 4: + return testManager.agent4Page; + default: + throw new Error(`Unsupported agentId: ${agentId}`); + } + }; + + const getRequiredEnv = (suffix: string): string => { + return getConferenceRequiredEnv(testManager.projectName, suffix); + }; + + const getAgentName = (agentId: AgentId): string => getConferenceAgentName(testManager.projectName, agentId); + const setConsultTargetAgentsAvailable = async (agentIds: AgentId[]) => + setConferenceAgentsAvailable(getAgentPage, agentIds); + + const cleanupConferenceStateForTest = async () => { + await cleanupConferenceState({ + getAgentPage, + callerPage: testManager.callerPage, + agentIds: CONFERENCE_AGENT_IDS, + }); + }; + + const startBaselineCallOnAgent1 = async () => { + await startBaselineCallOnAgent1Util({ + getAgentPage, + callerPage: testManager.callerPage, + getRequiredEnv, + agentIds: CONFERENCE_AGENT_IDS, + }); + }; + + const consultAgentAndAccept = async (fromAgent: AgentId, toAgent: AgentId) => { + await consultAgentAndAcceptCall({ + fromAgent, + toAgent, + getAgentPage, + getAgentName, + }); + }; + + const consultQueueAndAcceptIncomingCall = async (fromAgent: AgentId, toAgent: AgentId) => { + await consultQueueAndAcceptCall({ + fromAgent, + toAgent, + getAgentPage, + getRequiredEnv, + }); + }; + + const mergeConsultIntoConference = async (fromAgent: AgentId) => { + await mergeConsultIntoConferenceUtil({fromAgent, getAgentPage}); + }; + + const transferConsultAndWrapup = async (fromAgent: AgentId) => { + await transferConsultAndSubmitWrapup({fromAgent, getAgentPage}); + }; + + const removeConferenceParticipant = async (agentId: AgentId) => + exitConferenceParticipantAndWrapup(getAgentPage, agentId); + + test.beforeAll(async ({browser}, testInfo) => { + testManager = new TestManager(testInfo.project.name); + await testManager.setupForMultipartyConference(browser); + }); + + test.beforeEach(async () => { + await cleanupConferenceStateForTest(); + }); + + test.afterEach(async () => { + await cleanupConferenceStateForTest(); + }); + + test.afterAll(async () => { + if (testManager) { + await testManager.cleanup(); + } + }); + + test.describe('TRANSFER CONFERENCE SCENARIOS - Set 8', () => { + test('CTS-TC-09 and CTS-TC-10 should validate queue consult resume and customer-end wrapup in one call', async () => { + await startBaselineCallOnAgent1(); + await setConsultTargetAgentsAvailable([2]); + + await consultQueueAndAcceptIncomingCall(1, 2); + await cancelConsult(getAgentPage(1)); + await verifyCurrentState(getAgentPage(1), USER_STATES.ENGAGED); + + await consultOrTransfer(getAgentPage(1), 'queue', 'consult', getRequiredEnv('QUEUE_NAME')); + await endCallTask(testManager.callerPage, true); + + await expect(getAgentPage(1).getByTestId('call-control:wrapup-button').first()).toBeVisible({ + timeout: ACCEPT_TASK_TIMEOUT, + }); + }); + + test('CTS-TC-11 and CTS-TC-13 should allow consult cancel, then queue consult transfer', async () => { + await startBaselineCallOnAgent1(); + await consultAgentAndAccept(1, 2); + await cancelConsult(getAgentPage(1)); + await verifyCurrentState(getAgentPage(1), USER_STATES.ENGAGED); + + await consultQueueAndAcceptIncomingCall(1, 2); + await transferConsultAndWrapup(1); + + await verifyCurrentState(getAgentPage(2), USER_STATES.ENGAGED); + }); + + test.skip('CTS-TC-12 should allow agent2 to end consult when isEndConsultEnabled flag is enabled', async () => {}); + + test('CTS-TC-14 and CTS-TC-15 should transfer back to agent1 via agent and queue paths', async () => { + await startBaselineCallOnAgent1(); + await setConsultTargetAgentsAvailable([2]); + + await consultOrTransfer(getAgentPage(1), 'agent', 'transfer', getAgentName(2)); + await acceptIncomingTask(getAgentPage(2), TASK_TYPES.CALL, ACCEPT_TASK_TIMEOUT); + await submitWrapup(getAgentPage(1), WRAPUP_REASONS.SALE); + + await setConsultTargetAgentsAvailable([1]); + await consultOrTransfer(getAgentPage(2), 'queue', 'transfer', getRequiredEnv('QUEUE_NAME')); + await acceptIncomingTask(getAgentPage(1), TASK_TYPES.CALL, ACCEPT_TASK_TIMEOUT); + await submitWrapup(getAgentPage(2), WRAPUP_REASONS.SALE); + + await verifyCurrentState(getAgentPage(1), USER_STATES.ENGAGED); + }); + + test('CTS-TC-16 should chain add/remove/add participant flow without restarting call', async () => { + await startBaselineCallOnAgent1(); + + await consultAgentAndAccept(1, 2); + await mergeConsultIntoConference(1); + await consultAgentAndAccept(1, 3); + await mergeConsultIntoConference(1); + + await removeConferenceParticipant(3); + await consultAgentAndAccept(1, 4); + await mergeConsultIntoConference(1); + await verifyCurrentState(getAgentPage(4), USER_STATES.ENGAGED); + }); + + test.skip('CTS-TC-17 should validate >4 agent transfer conference flows (future enable)', async () => {}); + test.skip('CTS-TC-18 should validate EPDN transfer conference handoff flow', async () => {}); + }); + + test.describe('Switch Conference - Set 8', () => { + test('CTS-SW-07 should keep queue-based switch flow stable with consult cancel/accept actions', async () => { + await startBaselineCallOnAgent1(); + await setConsultTargetAgentsAvailable([2]); + + await consultQueueAndAcceptIncomingCall(1, 2); + await cancelConsult(getAgentPage(1)); + await verifyCurrentState(getAgentPage(1), USER_STATES.ENGAGED); + + await consultQueueAndAcceptIncomingCall(1, 2); + await mergeConsultIntoConference(1); + await verifyCurrentState(getAgentPage(2), USER_STATES.ENGAGED); + }); + }); +} diff --git a/playwright/tests/multiparty-conference-set-9-test.spec.ts b/playwright/tests/multiparty-conference-set-9-test.spec.ts new file mode 100644 index 000000000..e208cc42d --- /dev/null +++ b/playwright/tests/multiparty-conference-set-9-test.spec.ts @@ -0,0 +1,204 @@ +import {test, expect, Page} from '@playwright/test'; +import {TestManager} from '../test-manager'; +import {verifyCurrentState} from '../Utils/userStateUtils'; +import {acceptIncomingTask, declineIncomingTask, waitForIncomingTask} from '../Utils/incomingTaskUtils'; +import {cancelConsult, consultOrTransfer} from '../Utils/advancedTaskControlUtils'; +import {submitWrapup} from '../Utils/wrapupUtils'; +import { + AgentId, + CONFERENCE_AGENT_IDS, + cleanupConferencePageWithTimeout, + cleanupConferenceState, + consultAgentAndAcceptCall, + consultQueueAndAcceptCall, + getConferenceAgentName, + getConferenceRequiredEnv, + mergeConsultIntoConference as mergeConsultIntoConferenceUtil, + setConferenceAgentsAvailable, + startBaselineCallOnAgent1 as startBaselineCallOnAgent1Util, + transferConsultAndSubmitWrapup, +} from '../Utils/conferenceUtils'; +import {ACCEPT_TASK_TIMEOUT, CONSULT_NO_ANSWER_TIMEOUT, TASK_TYPES, USER_STATES, WRAPUP_REASONS} from '../constants'; + +export default function createMultipartyConferenceSet9Tests() { + let testManager: TestManager; + + const getAgentPage = (agentId: AgentId): Page => { + switch (agentId) { + case 1: + return testManager.agent1Page; + case 2: + return testManager.agent2Page; + case 3: + return testManager.agent3Page; + case 4: + return testManager.agent4Page; + default: + throw new Error(`Unsupported agentId: ${agentId}`); + } + }; + + const getRequiredEnv = (suffix: string): string => getConferenceRequiredEnv(testManager.projectName, suffix); + const getAgentName = (agentId: AgentId): string => getConferenceAgentName(testManager.projectName, agentId); + const setConsultTargetAgentsAvailable = async (agentIds: AgentId[]) => + setConferenceAgentsAvailable(getAgentPage, agentIds); + + const cleanupConferenceStateForTest = async () => { + await cleanupConferenceState({ + getAgentPage, + callerPage: testManager.callerPage, + agentIds: CONFERENCE_AGENT_IDS, + }); + }; + + const startBaselineCallOnAgent1 = async () => { + await startBaselineCallOnAgent1Util({ + getAgentPage, + callerPage: testManager.callerPage, + getRequiredEnv, + agentIds: CONFERENCE_AGENT_IDS, + }); + }; + + const consultAgentAndAccept = async (fromAgent: AgentId, toAgent: AgentId) => { + await consultAgentAndAcceptCall({ + fromAgent, + toAgent, + getAgentPage, + getAgentName, + }); + }; + + const consultQueueAndAcceptIncomingCall = async (fromAgent: AgentId, toAgent: AgentId) => { + await consultQueueAndAcceptCall({ + fromAgent, + toAgent, + getAgentPage, + getRequiredEnv, + }); + }; + + const mergeConsultIntoConference = async (fromAgent: AgentId) => { + await mergeConsultIntoConferenceUtil({fromAgent, getAgentPage}); + }; + + const transferConsultAndWrapup = async (fromAgent: AgentId) => { + await transferConsultAndSubmitWrapup({fromAgent, getAgentPage}); + }; + + test.beforeAll(async ({browser}, testInfo) => { + testManager = new TestManager(testInfo.project.name); + await testManager.setupForMultipartyConference(browser); + }); + + test.beforeEach(async () => { + await cleanupConferenceStateForTest(); + }); + + test.afterEach(async () => { + await cleanupConferenceStateForTest(); + }); + + test.afterAll(async () => { + if (testManager) { + await testManager.cleanup(); + } + }); + + test.describe('TRANSFER CONFERENCE SCENARIOS - Set 9', () => { + test('CTS-TC-01, CTS-TC-02 and CTS-TC-03 should handle blind transfer to agent, then queue transfer in one call session', async () => { + await startBaselineCallOnAgent1(); + await setConsultTargetAgentsAvailable([2]); + + await consultOrTransfer(getAgentPage(1), 'agent', 'transfer', getAgentName(2)); + await acceptIncomingTask(getAgentPage(2), TASK_TYPES.CALL, ACCEPT_TASK_TIMEOUT * 2); + await submitWrapup(getAgentPage(1), WRAPUP_REASONS.SALE); + await verifyCurrentState(getAgentPage(2), USER_STATES.ENGAGED); + + await setConsultTargetAgentsAvailable([1]); + await consultOrTransfer(getAgentPage(2), 'queue', 'transfer', getRequiredEnv('QUEUE_NAME')); + await acceptIncomingTask(getAgentPage(1), TASK_TYPES.CALL, ACCEPT_TASK_TIMEOUT * 2); + await submitWrapup(getAgentPage(2), WRAPUP_REASONS.SALE); + + await verifyCurrentState(getAgentPage(1), USER_STATES.ENGAGED); + }); + + test('CTS-TC-04 and CTS-TC-05 should run consult accepted and declined flows in one call session', async () => { + await startBaselineCallOnAgent1(); + + await consultAgentAndAccept(1, 3); + await cancelConsult(getAgentPage(3)); + await verifyCurrentState(getAgentPage(1), USER_STATES.ENGAGED); + + await consultOrTransfer(getAgentPage(1), 'agent', 'consult', getAgentName(3)); + await waitForIncomingTask(getAgentPage(3), TASK_TYPES.CALL, ACCEPT_TASK_TIMEOUT); + await declineIncomingTask(getAgentPage(3), TASK_TYPES.CALL); + await verifyCurrentState(getAgentPage(1), USER_STATES.ENGAGED); + }); + + // Split: queue routing won't re-route to an agent who RONA'd in the same session + test('CTS-TC-06 should handle not-picked agent consult', async () => { + await startBaselineCallOnAgent1(); + await setConsultTargetAgentsAvailable([2]); + + await consultOrTransfer(getAgentPage(1), 'agent', 'consult', getAgentName(2)); + await getAgentPage(1).waitForTimeout(CONSULT_NO_ANSWER_TIMEOUT); + + const cancelConsultButton = getAgentPage(1).getByTestId('cancel-consult-btn'); + const canCancelConsult = await cancelConsultButton.isVisible().catch(() => false); + if (canCancelConsult) { + await cancelConsult(getAgentPage(1)); + } + await verifyCurrentState(getAgentPage(1), USER_STATES.ENGAGED); + }); + + // Use agent 3 instead of agent 2 — queue routing won't re-route to an + // agent who RONA'd (CTS-TC-06) in the same session. + test('CTS-TC-07 should handle queue-consult cancel', async () => { + await startBaselineCallOnAgent1(); + await consultQueueAndAcceptIncomingCall(1, 3); + await cancelConsult(getAgentPage(1)); + await verifyCurrentState(getAgentPage(1), USER_STATES.ENGAGED); + }); + + // Use agent 3 instead of agent 2 — agent 2 RONA'd in CTS-TC-06 and may + // not reliably receive consults for the remainder of the session. + test('CTS-TC-08 should support multi-stage consult transfer between A1 and A3', async () => { + await startBaselineCallOnAgent1(); + + await consultAgentAndAccept(1, 3); + await transferConsultAndWrapup(1); + await verifyCurrentState(getAgentPage(3), USER_STATES.ENGAGED); + + await cleanupConferencePageWithTimeout(getAgentPage(1), testManager.callerPage); + await consultAgentAndAccept(3, 1); + await transferConsultAndWrapup(3); + await verifyCurrentState(getAgentPage(1), USER_STATES.ENGAGED); + }); + }); + + test.describe('Switch Conference - Set 9', () => { + test.skip('CTS-SW-01 should switch conference via EP_DN handoff', async () => {}); + + test('CTS-SW-05 and CTS-SW-06 should block consult during active consult and restore it after lobby closes', async () => { + await startBaselineCallOnAgent1(); + await consultAgentAndAccept(1, 2); + await mergeConsultIntoConference(1); + + await consultAgentAndAccept(1, 3); + const agent2Consult = getAgentPage(2).getByTestId('call-control:consult').first(); + const consultVisible = await agent2Consult.isVisible().catch(() => false); + + if (consultVisible) { + await expect(agent2Consult).toBeDisabled(); + } else { + expect(consultVisible).toBeFalsy(); + } + + await mergeConsultIntoConference(1); + await expect(agent2Consult).toBeVisible({timeout: ACCEPT_TASK_TIMEOUT}); + }); + + test.skip('CTS-SW-08 should validate switch conference flows with >4 agents', async () => {}); + }); +}