diff --git a/packages/contact-center/task/ai-docs/widgets/OutdialCall/ARCHITECTURE.md b/packages/contact-center/task/ai-docs/widgets/OutdialCall/ARCHITECTURE.md index 51dd637b5..248ad93f0 100644 --- a/packages/contact-center/task/ai-docs/widgets/OutdialCall/ARCHITECTURE.md +++ b/packages/contact-center/task/ai-docs/widgets/OutdialCall/ARCHITECTURE.md @@ -130,6 +130,81 @@ sequenceDiagram end ``` +### Post-Dial Flow by Login Mode + +After `cc.startOutdial()` succeeds, the platform establishes a first-leg call to the agent before dialing the customer (second leg). How the first leg connects depends on the agent's login mode. + +#### Desktop Mode -- Customer Rings Directly + +In Desktop mode, the agent is auto-connected. The customer's phone rings immediately, and once the customer answers, the agent reaches ENGAGED state. + +```mermaid +sequenceDiagram + participant A as Agent + participant W as Widget / Store + participant P as CC Platform + participant C as Customer + + A->>W: Click dial button + W->>P: cc.startOutdial(destination, origin) + P-->>W: TaskResponse + P->>C: Customer's phone rings (second leg) + C->>P: Customer answers + P->>W: Agent auto-connects → ENGAGED +``` + +The agent never needs to accept the incoming task. The Accept button is visible but disabled during the brief popup. + +#### Extension Mode -- Manual Answer Required + +The first-leg call rings on the agent's Webex Calling extension. The agent must answer it before the platform dials the customer. + +```mermaid +sequenceDiagram + participant A as Agent + participant W as Widget / Store + participant E as Webex Calling Extension + participant P as CC Platform + participant C as Customer + + A->>W: Click dial button + W->>P: cc.startOutdial(destination, origin) + P-->>W: TaskResponse + P->>E: First-leg rings on extension + Note over E: Answer button becomes enabled + A->>E: Answer call on extension + P->>C: Customer's phone rings (second leg) + C->>P: Customer answers + P->>W: Agent state → ENGAGED +``` + +The agent must answer the extension call before the customer is dialed. + +#### Dial Number (DN) Mode -- Manual Answer Required + +The first-leg call rings on the agent's DN phone. The agent must answer it before the platform dials the customer. + +```mermaid +sequenceDiagram + participant A as Agent + participant W as Widget / Store + participant D as Agent DN Phone + participant P as CC Platform + participant C as Customer + + A->>W: Click dial button + W->>P: cc.startOutdial(destination, origin) + P-->>W: TaskResponse + P->>D: First-leg rings on DN phone + Note over D: Answer button becomes enabled + A->>D: Answer call on DN phone + P->>C: Customer's phone rings (second leg) + C->>P: Customer answers + P->>W: Agent state → ENGAGED +``` + +The agent must answer the DN phone call before the customer is dialed. + ### Number Validation ```mermaid diff --git a/playwright/Utils/outdialUtils.ts b/playwright/Utils/outdialUtils.ts new file mode 100644 index 000000000..60cc60859 --- /dev/null +++ b/playwright/Utils/outdialUtils.ts @@ -0,0 +1,50 @@ +import {Page, expect} from '@playwright/test'; +import {ACCEPT_TASK_TIMEOUT, AWAIT_TIMEOUT, UI_SETTLE_TIMEOUT} from '../constants'; + +/** + * Enters a phone number into the outdial number input field. + * Prerequisite: Agent must be logged in and outdial-call-container must be visible. + * @param page Playwright Page object (agent widget page) + * @param number Phone number to dial (e.g., +14698041796) + */ +export async function enterOutdialNumber(page: Page, number: string): Promise { + await page.bringToFront(); + await expect(page.getByTestId('outdial-call-container')).toBeVisible({timeout: AWAIT_TIMEOUT}); + const input = page.getByTestId('outdial-number-input').locator('input'); + await input.fill(number, {timeout: AWAIT_TIMEOUT}); +} + +/** + * Clicks the outdial call button to initiate the outbound call. + * Prerequisite: A valid number must be entered in the outdial input. + * @param page Playwright Page object (agent widget page) + */ +export async function clickOutdialButton(page: Page): Promise { + await page.bringToFront(); + const dialButton = page.getByTestId('outdial-call-button'); + await expect(dialButton).toBeEnabled({timeout: AWAIT_TIMEOUT}); + await dialButton.click({timeout: AWAIT_TIMEOUT}); +} + +/** + * Accepts an incoming call on the customer's Webex Calling web client. + * Used for outdial scenarios where the customer receives the outbound call. + * @param customerPage Playwright Page object (customer's Webex Calling web client) + */ +export async function acceptCustomerCall(customerPage: Page): Promise { + await customerPage.bringToFront(); + await expect(customerPage.locator('#answer').first()).toBeEnabled({timeout: ACCEPT_TASK_TIMEOUT}); + await customerPage.waitForTimeout(UI_SETTLE_TIMEOUT); + await customerPage.locator('#answer').first().click({timeout: AWAIT_TIMEOUT}); +} + +/** + * Ends the call on the customer's Webex Calling web client. + * @param customerPage Playwright Page object (customer's Webex Calling web client) + */ +export async function endCustomerCall(customerPage: Page): Promise { + await customerPage.bringToFront(); + const endBtn = customerPage.locator('#end-call').first(); + await expect(endBtn).toBeEnabled({timeout: AWAIT_TIMEOUT}); + await endBtn.click({timeout: AWAIT_TIMEOUT}); +} diff --git a/playwright/ai-docs/ARCHITECTURE.md b/playwright/ai-docs/ARCHITECTURE.md index 193059aa0..fec545af7 100644 --- a/playwright/ai-docs/ARCHITECTURE.md +++ b/playwright/ai-docs/ARCHITECTURE.md @@ -52,6 +52,7 @@ playwright/ │ ├── basic-task-controls-test.spec.ts │ ├── advanced-task-controls-test.spec.ts │ ├── advance-task-control-combinations-test.spec.ts +│ ├── outdial-call-test.spec.ts │ ├── dial-number-task-control-test.spec.ts │ ├── tasklist-test.spec.ts │ ├── multiparty-conference-set-7-test.spec.ts @@ -66,6 +67,7 @@ playwright/ │ ├── userStateUtils.ts │ ├── taskControlUtils.ts │ ├── advancedTaskControlUtils.ts +│ ├── outdialUtils.ts │ └── wrapupUtils.ts ├── test-manager.ts ├── test-data.ts @@ -89,7 +91,7 @@ Keep this section aligned to real repository contents. | `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_6` | `dial-number-tests.spec.ts` | `dial-number-task-control-test.spec.ts`, `outdial-call-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` | @@ -150,7 +152,8 @@ These flags are part of baseline runtime behavior and should be preserved unless - `[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 +4. Optionally fetches customer outdial OAuth token (for outdial E2E tests) +5. Performs one final `.env` upsert in the same OAuth setup run Test files: @@ -232,6 +235,8 @@ When enabled by setup config/method, these page properties are created and avail | `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. | +| `setupForOutdialDesktop()` | Calls `setup()` with desktop agent1 + outdial customer login | +| `setupForOutdialExtension()` | Calls `setup()` with extension agent1 + outdial customer login | | `setupForMultipartyConference()` | Sets up 4 agents + caller for conference tests (agent1–4 pages + callerPage) | | `setupMultiSessionPage()` | Targeted helper to initialize only multi-session page when needed | @@ -263,6 +268,7 @@ When enabled by setup config/method, these page properties are created and avail | `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 | +| `outdialUtils.ts` | `enterOutdialNumber`, `clickOutdialButton`, `acceptCustomerCall`, `endCustomerCall` | Outdial call helpers for entering number, clicking dial, accepting/ending customer-side calls | | `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. | @@ -432,4 +438,4 @@ After a call ends, the Make Call button on the caller page may stay disabled. Cl --- -_Last Updated: 2026-03-09_ +_Last Updated: 2026-03-11_ diff --git a/playwright/suites/dial-number-tests.spec.ts b/playwright/suites/dial-number-tests.spec.ts index 4ea3b9736..b914ecab2 100644 --- a/playwright/suites/dial-number-tests.spec.ts +++ b/playwright/suites/dial-number-tests.spec.ts @@ -1,4 +1,6 @@ import {test} from '@playwright/test'; import createDialNumberTaskControlTests from '../tests/dial-number-task-control-test.spec'; +import createOutdialCallTests from '../tests/outdial-call-test.spec'; test.describe('Dial Number Task Control Tests', createDialNumberTaskControlTests); +test.describe('Outdial Call Tests', createOutdialCallTests); diff --git a/playwright/suites/station-login-user-state-tests.spec.ts b/playwright/suites/station-login-user-state-tests.spec.ts index 5a72d716f..3cc53dff2 100644 --- a/playwright/suites/station-login-user-state-tests.spec.ts +++ b/playwright/suites/station-login-user-state-tests.spec.ts @@ -1,4 +1,4 @@ -import {test} from '@playwright/test'; +import { test } from '@playwright/test'; import createStationLoginTests from '../tests/station-login-test.spec'; import createUserStateTests from '../tests/user-state-test.spec'; import createIncomingTelephonyTaskTests from '../tests/incoming-telephony-task-test.spec'; diff --git a/playwright/test-manager.ts b/playwright/test-manager.ts index 51986d993..d5d8318ab 100644 --- a/playwright/test-manager.ts +++ b/playwright/test-manager.ts @@ -520,6 +520,38 @@ export class TestManager { }); } + async setupForOutdialDesktop(browser: Browser): Promise { + await this.setup(browser, { + needsAgent1: true, + agent1LoginMode: LOGIN_MODE.DESKTOP, + }); + await this.setupOutdialCustomer(browser); + } + + async setupForOutdialExtension(browser: Browser): Promise { + await this.setup(browser, { + needsAgent1: true, + needsExtension: true, + agent1LoginMode: LOGIN_MODE.EXTENSION, + }); + await this.setupOutdialCustomer(browser); + } + + private async setupOutdialCustomer(browser: Browser): Promise { + const envTokens = this.getEnvTokens(); + const customerToken = envTokens.dialNumberLoginAccessToken; + if (!customerToken) { + throw new Error('Environment variable DIAL_NUMBER_LOGIN_ACCESS_TOKEN is missing or empty'); + } + const result = await this.createContextWithPage(browser, PAGE_TYPES.CALLER); + this.callerExtensionContext = result.context; + this.callerPage = result.page; + await this.retryOperation( + () => loginExtension(this.callerPage, customerToken), + 'outdial customer login' + ); + } + async setupForMultipartyConference(browser: Browser) { await this.setup(browser, { needsAgent1: true, diff --git a/playwright/tests/dial-number-task-control-test.spec.ts b/playwright/tests/dial-number-task-control-test.spec.ts index 74a2d2f05..8fed4fc85 100644 --- a/playwright/tests/dial-number-task-control-test.spec.ts +++ b/playwright/tests/dial-number-task-control-test.spec.ts @@ -1,4 +1,4 @@ -import {test, expect} from '@playwright/test'; +import { test, expect } from '@playwright/test'; import { cancelConsult, consultOrTransfer, @@ -8,7 +8,7 @@ import { verifyTransferSuccessLogs, verifyConsultEndSuccessLogs, } from '../Utils/advancedTaskControlUtils'; -import {changeUserState, verifyCurrentState} from '../Utils/userStateUtils'; +import { changeUserState, verifyCurrentState } from '../Utils/userStateUtils'; import { createCallTask, acceptIncomingTask, @@ -16,17 +16,17 @@ import { endCallTask, declineExtensionCall, } from '../Utils/incomingTaskUtils'; -import {submitWrapup} from '../Utils/wrapupUtils'; -import {USER_STATES, TASK_TYPES, WRAPUP_REASONS} from '../constants'; -import {waitForState, clearPendingCallAndWrapup, handleStrayTasks} from '../Utils/helperUtils'; -import {endTask, holdCallToggle, verifyHoldButtonIcon, verifyTaskControls} from '../Utils/taskControlUtils'; -import {TestManager} from '../test-manager'; +import { submitWrapup } from '../Utils/wrapupUtils'; +import { USER_STATES, TASK_TYPES, WRAPUP_REASONS } from '../constants'; +import { waitForState, clearPendingCallAndWrapup, handleStrayTasks } from '../Utils/helperUtils'; +import { endTask, holdCallToggle, verifyHoldButtonIcon, verifyTaskControls } from '../Utils/taskControlUtils'; +import { TestManager } from '../test-manager'; export default function createDialNumberTaskControlTests() { test.describe('Dial Number Task Control Tests ', () => { let testManager: TestManager; - test.beforeAll(async ({browser}, testInfo) => { + test.beforeAll(async ({ browser }, testInfo) => { const projectName = testInfo.project.name; testManager = new TestManager(projectName); await testManager.setupForDialNumber(browser); @@ -36,6 +36,13 @@ export default function createDialNumberTaskControlTests() { await handleStrayTasks(testManager.agent1Page); await handleStrayTasks(testManager.agent2Page); }); + + test.afterAll(async () => { + if (testManager) { + await testManager.cleanup(); + } + }); + test.describe('Dial Number Tests', () => { test.beforeAll(async () => { test.skip(!process.env.PW_DIAL_NUMBER_NAME, 'PW_DIAL_NUMBER_NAME not set'); @@ -104,7 +111,7 @@ export default function createDialNumberTaskControlTests() { await testManager.agent1Page.waitForTimeout(2000); await cancelConsult(testManager.agent1Page); // still needs to cancel even if declined await verifyTaskControls(testManager.agent1Page, TASK_TYPES.CALL); - await verifyHoldButtonIcon(testManager.agent1Page, {expectedIsHeld: true}); + await verifyHoldButtonIcon(testManager.agent1Page, { expectedIsHeld: true }); await holdCallToggle(testManager.agent1Page); await testManager.agent1Page.waitForTimeout(2000); await expect(testManager.agent1Page.getByTestId('cancel-consult-btn')).not.toBeVisible(); @@ -121,7 +128,7 @@ export default function createDialNumberTaskControlTests() { await verifyTaskControls(testManager.agent1Page, TASK_TYPES.CALL); await testManager.agent1Page.waitForTimeout(2000); verifyConsultEndSuccessLogs(); - await verifyHoldButtonIcon(testManager.agent1Page, {expectedIsHeld: true}); + await verifyHoldButtonIcon(testManager.agent1Page, { expectedIsHeld: true }); await holdCallToggle(testManager.agent1Page); // 4. Consult transfer @@ -155,11 +162,11 @@ export default function createDialNumberTaskControlTests() { // Open consult popover and switch to Dial Number const consultButton = testManager.agent1Page.getByTestId('call-control:consult').first(); - await consultButton.waitFor({state: 'visible', timeout: 10000}); + await consultButton.waitFor({ state: 'visible', timeout: 10000 }); await consultButton.click(); const popover = testManager.agent1Page.locator('.agent-popover-content'); - await expect(popover).toBeVisible({timeout: 10000}); - await popover.getByRole('button', {name: 'Dial Number'}).click(); + await expect(popover).toBeVisible({ timeout: 10000 }); + await popover.getByRole('button', { name: 'Dial Number' }).click(); // Perform search and wait for local filtering to reflect await popover.locator('#consult-search').fill(searchTerm); @@ -176,8 +183,8 @@ export default function createDialNumberTaskControlTests() { await testManager.agent1Page.keyboard.press('Escape'); await testManager.agent1Page .locator('.md-popover-backdrop') - .waitFor({state: 'hidden', timeout: 3000}) - .catch(() => {}); + .waitFor({ state: 'hidden', timeout: 3000 }) + .catch(() => { }); // End call and complete wrapup to clean up for next tests await endTask(testManager.agent1Page); diff --git a/playwright/tests/outdial-call-test.spec.ts b/playwright/tests/outdial-call-test.spec.ts new file mode 100644 index 000000000..88317340a --- /dev/null +++ b/playwright/tests/outdial-call-test.spec.ts @@ -0,0 +1,80 @@ +import {test, expect} from '@playwright/test'; +import {changeUserState, verifyCurrentState} from '../Utils/userStateUtils'; +import {acceptExtensionCall, endCallTask} from '../Utils/incomingTaskUtils'; +import {USER_STATES, WRAPUP_REASONS} from '../constants'; +import {submitWrapup, waitForWrapupAfterCallEnd} from '../Utils/wrapupUtils'; +import {waitForState} from '../Utils/helperUtils'; +import {TestManager} from '../test-manager'; +import {enterOutdialNumber, clickOutdialButton, acceptCustomerCall} from '../Utils/outdialUtils'; +import {endTask} from '../Utils/taskControlUtils'; + +export default function createOutdialCallTests() { + test.describe('Outdial Call - Desktop Mode', () => { + test.skip(!process.env.PW_DIAL_NUMBER || !process.env.DIAL_NUMBER_LOGIN_ACCESS_TOKEN, + 'Required outdial env vars not set'); + let testManager: TestManager; + + test.beforeAll(async ({browser}, testInfo) => { + const projectName = testInfo.project.name; + testManager = new TestManager(projectName); + await testManager.setupForOutdialDesktop(browser); + }); + + test('should make an outdial call in Desktop mode and complete wrapup', async () => { + await changeUserState(testManager.agent1Page, USER_STATES.AVAILABLE); + await expect(testManager.agent1Page.getByTestId('outdial-call-container')).toBeVisible(); + await enterOutdialNumber(testManager.agent1Page, process.env.PW_DIAL_NUMBER!); + await clickOutdialButton(testManager.agent1Page); + await acceptCustomerCall(testManager.callerPage); + await waitForState(testManager.agent1Page, USER_STATES.ENGAGED); + await verifyCurrentState(testManager.agent1Page, USER_STATES.ENGAGED); + await endTask(testManager.agent1Page); + await waitForWrapupAfterCallEnd(testManager.agent1Page); + await submitWrapup(testManager.agent1Page, WRAPUP_REASONS.SALE); + await waitForState(testManager.agent1Page, USER_STATES.AVAILABLE); + await verifyCurrentState(testManager.agent1Page, USER_STATES.AVAILABLE); + }); + + test.afterAll(async () => { + if (testManager) { + await testManager.cleanup(); + } + }); + }); + + test.describe('Outdial Call - Extension Mode', () => { + test.skip(!process.env.PW_DIAL_NUMBER || !process.env.DIAL_NUMBER_LOGIN_ACCESS_TOKEN, + 'Required outdial env vars not set'); + let testManager: TestManager; + + test.beforeAll(async ({browser}, testInfo) => { + const projectName = testInfo.project.name; + testManager = new TestManager(projectName); + await testManager.setupForOutdialExtension(browser); + }); + + test('should make an outdial call in Extension mode and complete wrapup', async () => { + await changeUserState(testManager.agent1Page, USER_STATES.AVAILABLE); + await expect(testManager.agent1Page.getByTestId('outdial-call-container')).toBeVisible(); + await enterOutdialNumber(testManager.agent1Page, process.env.PW_DIAL_NUMBER!); + await clickOutdialButton(testManager.agent1Page); + await expect(testManager.agent1ExtensionPage.locator('#answer').first()).toBeEnabled({timeout: 40000}); + await acceptExtensionCall(testManager.agent1ExtensionPage); + await acceptCustomerCall(testManager.callerPage); + await waitForState(testManager.agent1Page, USER_STATES.ENGAGED); + await verifyCurrentState(testManager.agent1Page, USER_STATES.ENGAGED); + await endCallTask(testManager.agent1ExtensionPage); + await waitForWrapupAfterCallEnd(testManager.agent1Page); + await submitWrapup(testManager.agent1Page, WRAPUP_REASONS.SALE); + await waitForState(testManager.agent1Page, USER_STATES.AVAILABLE); + await verifyCurrentState(testManager.agent1Page, USER_STATES.AVAILABLE); + }); + + test.afterAll(async () => { + if (testManager) { + await testManager.cleanup(); + } + }); + }); + +}