Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@
"clean:dist": "yarn workspaces foreach --all --topological --parallel run clean:dist",
"test:unit": "yarn run test:tooling && yarn run test:cc-widgets && yarn run test:meetings-widget",
"test:e2e": "yarn playwright test",
"test:e2e:cc": "yarn workspace @webex/cc-widgets run test:e2e",
"test:e2e:meetings": "yarn workspace @webex/widgets run test:e2e",
Comment on lines +57 to +58

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve the documented root E2E command

Replacing test:e2e with only the two suffixed scripts leaves no root script named test:e2e, while the modified Playwright README still uses yarn test:e2e for every all-suite, single-suite, project, UI, debug, and headed invocation. Those documented local commands now fail during script resolution before Playwright starts; retain a compatible alias or update the documented callers to use test:e2e:cc.

AGENTS.md reference: AGENTS.md:L78-L78

Useful? React with 👍 / 👎.

"test:styles": "yarn workspaces foreach --all --exclude webex-widgets run test:styles",
"test:tooling": "NODE_ENV=test jest --coverage",
"test:cc-widgets": "yarn workspaces foreach --all --exclude webex-widgets --exclude samples-cc-wc-app --exclude samples-cc-react-app run test:unit",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,11 @@ const RealTimeTranscriptComponent: React.FC<RealTimeTranscriptComponentProps> =
) : null}
</Text>
) : null}
<div className="real-time-transcript__item" data-testid="real-time-transcript:item">
<div
className="real-time-transcript__item"
data-testid="real-time-transcript:item"
data-speaker-role={entry.isCustomer ? 'customer' : 'agent'}
Comment on lines +51 to +52

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Document the new transcript speaker-role contract

The new E2E helpers depend on data-speaker-role as a stable selector, making this externally observable markup part of the transcript component's behavior, but the commit leaves packages/contact-center/cc-components/ai-docs/cc-components-spec.md—especially CC-COMPONENTS-R-011—unchanged. Document the attribute and its agent/customer mapping in the module spec in this change so future component refactors do not silently invalidate the E2E contract.

AGENTS.md reference: AGENTS.md:L68-L68

Useful? React with 👍 / 👎.

>
<div className="real-time-transcript__avatar-wrap">
<Avatar
className="real-time-transcript__avatar-fallback"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ exports[`RealTimeTranscriptComponent snapshots matches snapshot with transcript
>
<div
class="real-time-transcript__item"
data-speaker-role="agent"
data-testid="real-time-transcript:item"
>
<div
Expand Down Expand Up @@ -50,6 +51,7 @@ exports[`RealTimeTranscriptComponent snapshots matches snapshot with transcript
</div>
<div
class="real-time-transcript__item"
data-speaker-role="customer"
data-testid="real-time-transcript:item"
>
<div
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,9 @@ describe('RealTimeTranscriptComponent', () => {
const messages = screen.getAllByTestId('real-time-transcript:item');
expect(messages).toHaveLength(2);
expect(messages[0]).toHaveTextContent('Agent message');
expect(messages[0]).toHaveAttribute('data-speaker-role', 'agent');
expect(messages[1]).toHaveTextContent('Customer message');
expect(messages[1]).toHaveAttribute('data-speaker-role', 'customer');
});

it('renders transcript event inline with timestamp', () => {
Expand Down
1 change: 1 addition & 0 deletions packages/contact-center/cc-widgets/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
"build:src": "yarn run clean:dist && webpack",
"build:watch": "webpack --watch",
"test:unit": "NODE_ENV=test jest --coverage",
"test:e2e": "yarn run -T playwright test --config ../../../playwright.config.ts",
"test:styles": "eslint",
"deploy:npm": "yarn npm publish"
},
Expand Down
50 changes: 30 additions & 20 deletions playwright/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,29 +33,39 @@ 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_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` |
| 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 |
| Dial number scenarios | SET_6 | Dial number flows |
| Multiparty conference | SET_7/8/9 | 4-agent conference coverage |
| 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; single agent + caller call |
| 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 |
| AI Assistant / Real-Time Transcript | SET_4 | Live call required for both; reuses SET_4's agent |

> **Note on AI Assistant / Real-Time Transcript coverage:** suggestion and transcript
> content is produced by a live backend AI/speech pipeline against the call's dummy
> audio, so it isn't deterministic. These tests (bundled into `SET_4`'s suite,
> `basic-advanced-task-controls-tests.spec.ts`) intentionally assert on structure and
> state transitions (landing to request view to listening mode, feedback controls
> toggling `data-active`, transcript entries appearing) rather than exact text. They
> reuse `SET_4`'s already-provisioned agent (`user21`) instead of a dedicated user set,
> since both widgets only need a single agent plus a caller for a live voice call.

## Multiparty Conference Consolidation

Expand Down
124 changes: 124 additions & 0 deletions playwright/Utils/aiAssistantUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import {Page, expect, Locator} from '@playwright/test';
import {AWAIT_TIMEOUT, AI_ASSIST_SUGGESTION_TIMEOUT} from '../constants';

/**
* Utility functions for exercising the AI Assistant widget (launcher, landing
* page, Real-Time Assist chat, and adaptive-card feedback controls) in e2e
* tests.
*
* The suggestion content itself is generated by a live backend AI pipeline
* during the test call, so these helpers deliberately avoid asserting on
* exact transcript/suggestion text and instead verify structure and state
* transitions (panel open, spinner clears, chat entries appear, feedback
* controls toggle).
*
* @packageDocumentation
*/

/**
* Enables the AI Assistant widget via its sample-app checkbox.
* Unlike most widgets, AI Assistant defaults to unchecked, so this must be
* called explicitly before the widget will render.
* @param page - The Playwright page object
*/
export async function enableAIAssistantWidget(page: Page): Promise<void> {
const checkbox = page.getByTestId('samples:widget-aiAssistant');
const isChecked = await checkbox.isChecked().catch(() => false);
if (!isChecked) {
await checkbox.check({timeout: AWAIT_TIMEOUT});
}
}

/**
* Opens the AI Assistant panel by clicking its launcher button.
* No-ops if the panel is already open (launcher only renders when closed).
* @param page - The Playwright page object
*/
export async function openAIAssistant(page: Page): Promise<void> {
const launcher = page.getByTestId('ai-assistant:launcher');
if (await launcher.isVisible().catch(() => false)) {
await launcher.click({timeout: AWAIT_TIMEOUT});
}
await expect(page.getByTestId('ai-assistant:panel')).toBeVisible({timeout: AWAIT_TIMEOUT});
}

/**
* Closes the AI Assistant panel via its header close button.
* @param page - The Playwright page object
*/
export async function closeAIAssistant(page: Page): Promise<void> {
const closeButton = page.getByTestId('ai-assistant:header-close');
if (await closeButton.isVisible().catch(() => false)) {
await closeButton.click({timeout: AWAIT_TIMEOUT});
}
}

/**
* Returns true while the landing page (feature disabled / no active
* interaction) is shown instead of the Real-Time Assist chat.
* @param page - The Playwright page object
*/
export async function isShowingLanding(page: Page): Promise<boolean> {
return page
.getByTestId('ai-assistant:landing')
.isVisible()
.catch(() => false);
}

/**
* Clicks "Get Suggestions" and waits for the request to settle: either the
* chat/context-form appears (success) or the inline error message appears
* (failure). Does not throw on failure - callers assert the outcome.
* @param page - The Playwright page object
*/
export async function requestRealTimeAssistSuggestions(page: Page): Promise<void> {
const getSuggestionsButton = page.getByTestId('ai-assistant:get-suggestions');
await expect(getSuggestionsButton).toBeVisible({timeout: AWAIT_TIMEOUT});
await getSuggestionsButton.click({timeout: AWAIT_TIMEOUT});

// The button is replaced by a spinner while the request is in flight;
// wait for the request to settle one way or another.
await Promise.race([
page.getByTestId('ai-assistant:context-form').waitFor({state: 'visible', timeout: AI_ASSIST_SUGGESTION_TIMEOUT}),
page.getByTestId('ai-assistant:error').waitFor({state: 'visible', timeout: AI_ASSIST_SUGGESTION_TIMEOUT}),
]).catch(() => {});
}

/**
* Waits for at least one assistant suggestion (adaptive card or greeting) to
* appear in the Real-Time Assist chat.
* @param page - The Playwright page object
* @returns Locator for the first assistant chat entry
*/
export async function waitForFirstSuggestion(page: Page): Promise<Locator> {
const assistantEntry = page.getByTestId('ai-assistant:chat-assistant').first();
await assistantEntry.waitFor({state: 'visible', timeout: AI_ASSIST_SUGGESTION_TIMEOUT});
return assistantEntry;
}

/**
* Clicks the like/dislike feedback control (identified by its accessible
* name) on the first rendered suggestion card and returns its locator so the
* caller can assert on the resulting `data-active` state.
* @param page - The Playwright page object
* @param kind - Which control to click
*/
export async function clickSuggestionFeedback(page: Page, kind: 'like' | 'dislike'): Promise<Locator> {
const label = kind === 'like' ? 'Like suggestion' : 'Dislike suggestion';
const control = page.getByLabel(label).first();
await expect(control).toBeVisible({timeout: AWAIT_TIMEOUT});
await control.click({timeout: AWAIT_TIMEOUT});
return control;
}

/**
* Clicks the copy control on the first rendered suggestion card.
* @param page - The Playwright page object
* @returns Locator for the copy control
*/
export async function clickSuggestionCopy(page: Page): Promise<Locator> {
const control = page.getByLabel('Copy suggestion').first();
await expect(control).toBeVisible({timeout: AWAIT_TIMEOUT});
await control.click({timeout: AWAIT_TIMEOUT});
return control;
}
143 changes: 143 additions & 0 deletions playwright/Utils/realTimeTranscriptUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import {Page, Locator} from '@playwright/test';
import {TRANSCRIPT_ENTRY_TIMEOUT} from '../constants';

/**
* Utility functions for exercising the Real-Time Transcript widget in e2e
* tests.
*
* The live speech-to-text pipeline (driven by the call's dummy audio) is
* non-deterministic, so `waitForTranscriptEntry` only verifies that *some*
* real transcription eventually arrives (an integration smoke check).
*
* For precise, deterministic verification of how the widget renders
* transcript content - in particular the word-by-word progressive rendering
* of a single utterance as multiple `REAL_TIME_TRANSCRIPTION` events arrive
* for the same `messageId` - use `dispatchRealtimeTranscriptionEvent` to
* inject a known mock event directly into the store, the same way the SDK's
* real event handler does. This requires the sample app's debug hook
* `window.store` (see `widgets-samples/cc/samples-cc-react-app/src/App.tsx`)
* and a currently active call (the transcript panel only mounts while
* `store.currentTask` is set).
*
* @packageDocumentation
*/

/**
* Waits for the Real-Time Transcript panel to be visible.
* The panel only renders while `store.currentTask` exists (an active call).
* @param page - The Playwright page object
* @param timeout - Optional timeout override in ms
*/
export async function waitForRealTimeTranscriptPanel(page: Page, timeout: number = TRANSCRIPT_ENTRY_TIMEOUT) {
const root = page.getByTestId('real-time-transcript:root');
await root.waitFor({state: 'visible', timeout});
return root;
}

export type TranscriptSpeakerRole = 'agent' | 'customer';

/**
* Waits for a transcript entry to appear in the live transcript feed and
* returns it.
* @param page - The Playwright page object
* @param role - Optional speaker leg to filter by (`data-speaker-role="agent"`
* for the agent leg, `"customer"` for the caller leg). When omitted, waits
* for the first entry of either role.
* @returns Locator for the first matching transcript item
*/
export async function waitForTranscriptEntry(page: Page, role?: TranscriptSpeakerRole): Promise<Locator> {
const selector = role
? `[data-testid="real-time-transcript:item"][data-speaker-role="${role}"]`
: '[data-testid="real-time-transcript:item"]';
const item = page.locator(selector).first();
await item.waitFor({state: 'visible', timeout: TRANSCRIPT_ENTRY_TIMEOUT});
return item;
}

/** SDK role values recognized by `getTranscriptSpeaker` in `task/src/helper.ts`. */
export type TranscriptionEventRole = 'agent' | 'caller';

/**
* A single, consistent mock "conversation" used to deterministically verify
* real-time transcript rendering. Sentences start with a distinctive,
* clearly-synthetic token so they can never collide with whatever the live
* speech-to-text pipeline happens to transcribe from the call's dummy audio
* in the background.
*/
export const MOCK_TRANSCRIPT_CONVERSATION: Record<
'agent' | 'customer',
{messageId: string; role: TranscriptionEventRole; sentence: string}
> = {
agent: {
messageId: 'e2e-mock-agent-message-1',
role: 'agent',
sentence: 'AgentE2EMock thank you for calling support how can I help you today',
},
customer: {
messageId: 'e2e-mock-customer-message-1',
role: 'caller',
sentence: 'CustomerE2EMock hi I am having trouble logging into my account',
},
};

/**
* Injects a single `REAL_TIME_TRANSCRIPTION` event by calling the store's
* real event handler directly (`window.store.handleRealtimeTranscription`) -
* the exact same method the SDK's live event listener invokes. Requires an
* active call so `store.currentTask` is set and the transcript panel is
* mounted.
* @param page - The Playwright page object (must be the agent's page)
* @param event - The mock transcription payload fields to send
*/
export async function dispatchRealtimeTranscriptionEvent(
page: Page,
event: {role: TranscriptionEventRole; content: string; isFinal: boolean; messageId: string; utteranceId?: string}
): Promise<void> {
await page.evaluate((evt) => {
const injectedStore = (
window as unknown as {
store?: {handleRealtimeTranscription?: (payload: unknown) => void};
}
).store;

if (!injectedStore?.handleRealtimeTranscription) {
throw new Error(
'window.store.handleRealtimeTranscription is not available - cannot inject a mock transcription event'
);
}

injectedStore.handleRealtimeTranscription({
agentId: 'e2e-agent',
orgId: 'e2e-org',
notifType: 'REAL_TIME_TRANSCRIPTION',
notifDetails: {actionEvent: 'REAL_TIME_TRANSCRIPTION'},
data: {
content: evt.content,
conversationId: 'e2e-conversation',
isFinal: evt.isFinal,
messageId: evt.messageId,
orgId: 'e2e-org',
publishTimestamp: Date.now(),
role: evt.role,
trackingId: 'e2e-tracking',
utteranceId: evt.utteranceId || evt.messageId,
},
});
}, event);
}

/**
* Locates the transcript message element for a mock utterance injected via
* `dispatchRealtimeTranscriptionEvent`/`MOCK_TRANSCRIPT_CONVERSATION`,
* identified by its distinctive leading token (e.g. `"AgentE2EMock"`) so it
* stays unambiguous even alongside unrelated live-transcribed entries.
* @param page - The Playwright page object
* @param role - Which leg's mock entry to locate
* @param leadingToken - The first word of the mock sentence for that role
*/
export function locateMockTranscriptMessage(page: Page, role: TranscriptSpeakerRole, leadingToken: string): Locator {
return page
.locator(`[data-testid="real-time-transcript:item"][data-speaker-role="${role}"]`)
.filter({hasText: leadingToken})
.locator('.real-time-transcript__message');
}
Loading
Loading