From 1e7cf7bb4a33e8372f8dab017eb98aea31f4e871 Mon Sep 17 00:00:00 2001 From: Ritesh Singh Date: Fri, 6 Mar 2026 01:28:57 +0530 Subject: [PATCH 01/10] docs(ai-docs): add docs for meetings widget --- packages/meetings/ai-docs/AGENTS.md | 287 ++++++++ packages/meetings/ai-docs/ARCHITECTURE.md | 777 ++++++++++++++++++++++ 2 files changed, 1064 insertions(+) create mode 100644 packages/meetings/ai-docs/AGENTS.md create mode 100644 packages/meetings/ai-docs/ARCHITECTURE.md diff --git a/packages/meetings/ai-docs/AGENTS.md b/packages/meetings/ai-docs/AGENTS.md new file mode 100644 index 000000000..f7d150041 --- /dev/null +++ b/packages/meetings/ai-docs/AGENTS.md @@ -0,0 +1,287 @@ +# Meetings Widget + +## Overview + +The Meetings Widget provides a full-featured Webex meeting experience as an embeddable component. It orchestrates three external repositories — `webex-js-sdk` for backend communication, `sdk-component-adapter` for reactive data binding, and `components` for the React UI. + +**Widget:** Meetings + +**Location:** `widgets/packages/meetings/` + +--- + +## Why and What is This Used For? + +### Purpose + +The Meetings Widget lets consuming applications embed a complete meeting experience without building any meeting logic themselves. It handles the entire lifecycle — from SDK initialization through meeting creation, joining, in-meeting controls, and leaving — by composing existing components and adapters together. + +### Key Capabilities + +- **Join Meetings** — Connect to a meeting via URL, SIP address, or Personal Meeting Room +- **Audio Controls** — Mute and unmute microphone with transitional states +- **Video Controls** — Start and stop camera with device switching +- **Screen Sharing** — Share screen, window, or tab with other participants +- **Member Roster** — View list of meeting participants +- **Device Settings** — Switch between cameras, microphones, and speakers +- **Guest/Host Authentication** — Password-protected meetings with host key support +- **Waiting for Host** — Automatic transition when host starts the meeting + +--- + +## Examples and Use Cases + +### Getting Started + +#### Basic Usage (React) + +```jsx +import Webex from 'webex'; +import WebexSDKAdapter from '@webex/sdk-component-adapter'; +import {WebexMeeting, AdapterContext} from '@webex/components'; + +function MeetingsWidget({accessToken, meetingDestination}) { + const [adapter, setAdapter] = useState(null); + const [meetingID, setMeetingID] = useState(null); + + useEffect(() => { + const webex = Webex.init({ + credentials: { access_token: accessToken } + }); + const sdkAdapter = new WebexSDKAdapter(webex); + + sdkAdapter.connect().then(() => { + setAdapter(sdkAdapter); + return sdkAdapter.meetingsAdapter.createMeeting(meetingDestination); + }).then((meeting) => { + setMeetingID(meeting.ID); + }); + + return () => sdkAdapter.disconnect(); + }, [accessToken, meetingDestination]); + + if (!adapter || !meetingID) return
Loading...
; + + return ( + + + + ); +} +``` + +### Common Use Cases + +#### 1. Password-Protected Meeting + +When a meeting requires a password, the `WebexMeeting` component detects `passwordRequired` from the adapter observable and renders the `WebexMeetingGuestAuthentication` modal. The user enters the password, and `JoinControl.action()` passes it to the SDK. + +**Key Points:** + +- `passwordRequired` is a boolean on the adapter meeting observable +- The component handles guest vs host authentication flows +- Wrong password triggers `invalidPassword` flag on the observable + +#### 2. Pre-Join Media Preview + +Before joining, the interstitial screen shows local media preview. The user can mute audio, stop video, or open settings before entering the meeting. + +**Key Points:** + +- `WebexInterstitialMeeting` renders when `state === 'NOT_JOINED'` +- Controls available pre-join: `mute-audio`, `mute-video`, `settings`, `join-meeting` +- `JoinControl.display()` shows a hint like "Unmuted, video on" based on current state + +#### 3. Device Switching Mid-Meeting + +During an active meeting, users can switch cameras, microphones, or speakers through the settings panel. + +**Key Points:** + +- `SettingsControl.action()` opens the `WebexSettings` modal +- `SwitchCameraControl.action({ meetingID, cameraId })` calls `switchCamera(meetingID, cameraId)` on the adapter +- The adapter acquires a new media stream with the selected device and emits an updated `localVideo.stream` + +#### 4. Screen Sharing + +The share button triggers the browser's native screen picker. The SDK handles `getDisplayMedia()` and negotiates the share stream with the backend. + +**Key Points:** + +- `ShareControl` checks `navigator.mediaDevices.getDisplayMedia` availability +- If unsupported, the control renders as DISABLED +- The adapter emits `localShare.stream` with the display stream when sharing starts + +--- + +## Three-Repository Architecture + +```mermaid +graph LR + subgraph "Widget" + W[Meetings Widget] + end + + subgraph "components" + C[WebexMeeting & UI] + end + + subgraph "sdk-component-adapter" + A[MeetingsSDKAdapter] + end + + subgraph "webex-js-sdk" + S[Webex Instance] + end + + W -->|renders| C + W -->|creates| A + W -->|initializes| S + C -->|uses via AdapterContext| A + A -->|wraps| S + + style W fill:#e1f5ff,color:#000 + style C fill:#d4edda,color:#000 + style A fill:#fff4e1,color:#000 + style S fill:#ffe1e1,color:#000 +``` + + + + +| Repository | Role | Key Exports Used | +| ----------------------- | ----------------------------------------- | ------------------------------------------------------------------- | +| `webex-js-sdk` | Core SDK for Webex backend communication | `Webex.init()`, `webex.meetings`, meeting methods | +| `sdk-component-adapter` | Reactive adapter layer (RxJS observables) | `WebexSDKAdapter`, `MeetingsSDKAdapter`, all Control classes | +| `components` | React UI components + hooks | `WebexMeeting`, `AdapterContext`, `useMeeting`, `useMeetingControl` | + + +--- + +## Dependencies + +**Note:** For exact versions, see [package.json](../package.json) + +### Runtime Dependencies + + +| Package | Purpose | +| ------------------------------ | ----------------------------------------------------- | +| `webex` | Core Webex JavaScript SDK for backend communication | +| `@webex/sdk-component-adapter` | Reactive adapter that wraps SDK into RxJS observables | +| `@webex/components` | React UI components for meeting views and controls | + + +### Peer Dependencies + + +| Package | Purpose | +| ----------- | ------------------- | +| `react` | React framework | +| `react-dom` | React DOM rendering | + + +--- + +## API Reference + +### WebexMeeting Component Props + + +| Prop | Type | Required | Default | Description | +| ---------------------- | ------------- | -------- | ------- | -------------------------------------------- | +| `meetingID` | `string` | No | — | The meeting ID returned by `createMeeting()` | +| `meetingPasswordOrPin` | `string` | No | — | Password or host pin for protected meetings | +| `participantName` | `string` | No | — | Display name for guest participants | +| `controls` | `Function` | No | — | Function returning control IDs to render | +| `layout` | `string` | No | — | Meeting layout variant | +| `logo` | `JSX.Element` | No | — | Custom logo for loading state | +| `className` | `string` | No | — | CSS class for the root element | +| `style` | `object` | No | — | Inline styles for the root element | + + +The `WebexMeeting` component also requires an `AdapterContext.Provider` ancestor with a valid `WebexSDKAdapter` instance as its value. + +### Hooks (from `components`) + + +| Hook | Parameters | Returns | Description | +| ------------------------------------------- | --------------------------------- | ---------------------------------------------- | ------------------------------------------------------------------- | +| `useMeeting(meetingID)` | `meetingID: string` | Meeting object (see ARCHITECTURE.md for shape) | Subscribes to the adapter's meeting observable | +| `useMeetingControl(type, meetingID)` | `type: string, meetingID: string` | `[action, display]` (array) | Returns action function and display state for a control | +| `useMeetingDestination(meetingDestination)` | `meetingDestination: string` | Meeting object | Creates a meeting from destination and subscribes to its observable | + + +### WebexSDKAdapter Methods (top-level adapter) + + +| Method | Returns | Description | +| -------------- | --------------- | --------------------------------------------------------------------------------------------------------------- | +| `connect()` | `Promise` | Calls `sdk.internal.device.register()` → `sdk.internal.mercury.connect()` → `meetingsAdapter.connect()` | +| `disconnect()` | `Promise` | Calls `meetingsAdapter.disconnect()` → `sdk.internal.mercury.disconnect()` → `sdk.internal.device.unregister()` | + + +### MeetingsSDKAdapter Methods + + +| Method | Parameters | Returns | Description | +| ------------------------------------ | ------------------------------------------------------ | --------------------- | ------------------------------------------------------- | +| `connect()` | — | `Promise` | Calls `meetings.register()` + `meetings.syncMeetings()` | +| `disconnect()` | — | `Promise` | Calls `meetings.unregister()` | +| `createMeeting(destination)` | `destination: string` | `Observable` | Creates a meeting from URL, SIP, or PMR | +| `joinMeeting(ID, options)` | `ID: string, { password?, name?, hostKey?, captcha? }` | `Promise` | Joins the meeting | +| `leaveMeeting(ID)` | `ID: string` | `Promise` | Leaves and cleans up the meeting | +| `handleLocalAudio(ID)` | `ID: string` | `Promise` | Toggles audio mute/unmute | +| `handleLocalVideo(ID)` | `ID: string` | `Promise` | Toggles video on/off | +| `handleLocalShare(ID)` | `ID: string` | `Promise` | Toggles screen share on/off | +| `toggleRoster(ID)` | `ID: string` | `void` | Toggles member roster panel (client-side only) | +| `toggleSettings(ID)` | `ID: string` | `Promise` | Toggles settings modal; applies device changes on close | +| `switchCamera(ID, cameraID)` | `ID, cameraID: string` | `Promise` | Switches to a different camera device | +| `switchMicrophone(ID, microphoneID)` | `ID, microphoneID: string` | `Promise` | Switches to a different microphone | +| `switchSpeaker(ID, speakerID)` | `ID, speakerID: string` | `Promise` | Switches to a different speaker (client-side only) | + + +### Control Action Parameters + +All control `action()` methods take a **destructured object**, not a plain string. + + +| Control | `action()` Parameters | Adapter Method Called | +| ------------------------- | ------------------------------------------------------ | -------------------------------------------- | +| `AudioControl` | `{ meetingID }` | `handleLocalAudio(meetingID)` | +| `VideoControl` | `{ meetingID }` | `handleLocalVideo(meetingID)` | +| `ShareControl` | `{ meetingID }` | `handleLocalShare(meetingID)` | +| `JoinControl` | `{ meetingID, meetingPasswordOrPin, participantName }` | `joinMeeting(meetingID, { password, name })` | +| `ExitControl` | `{ meetingID }` | `leaveMeeting(meetingID)` | +| `RosterControl` | `{ meetingID }` | `toggleRoster(meetingID)` | +| `SettingsControl` | `{ meetingID }` | `toggleSettings(meetingID)` | +| `SwitchCameraControl` | `{ meetingID, cameraId }` | `switchCamera(meetingID, cameraId)` | +| `SwitchMicrophoneControl` | `{ meetingID, microphoneId }` | `switchMicrophone(meetingID, microphoneId)` | +| `SwitchSpeakerControl` | `{ meetingID, speakerId }` | `switchSpeaker(meetingID, speakerId)` | + + +### Control IDs for WebexMeetingControlBar + + +| Control ID | Class | Type | Available | +| ------------------- | ------------------------- | ----------- | --------------------- | +| `mute-audio` | `AudioControl` | TOGGLE | Pre-join + In-meeting | +| `mute-video` | `VideoControl` | TOGGLE | Pre-join + In-meeting | +| `share-screen` | `ShareControl` | TOGGLE | In-meeting only | +| `join-meeting` | `JoinControl` | JOIN | Pre-join only | +| `leave-meeting` | `ExitControl` | CANCEL | In-meeting only | +| `member-roster` | `RosterControl` | TOGGLE | In-meeting only | +| `settings` | `SettingsControl` | TOGGLE | Pre-join + In-meeting | +| `switch-camera` | `SwitchCameraControl` | MULTISELECT | Settings panel | +| `switch-microphone` | `SwitchMicrophoneControl` | MULTISELECT | Settings panel | +| `switch-speaker` | `SwitchSpeakerControl` | MULTISELECT | Settings panel | + + +--- + +## Additional Resources + +For detailed architecture, event flows, data structures, and troubleshooting, see [ARCHITECTURE.md](./ARCHITECTURE.md). + +--- + diff --git a/packages/meetings/ai-docs/ARCHITECTURE.md b/packages/meetings/ai-docs/ARCHITECTURE.md new file mode 100644 index 000000000..f9f7c5f06 --- /dev/null +++ b/packages/meetings/ai-docs/ARCHITECTURE.md @@ -0,0 +1,777 @@ +# Meetings Widget - Architecture + +## Component Overview + +The Meetings Widget composes three external repositories into an embeddable meeting experience. The widget initializes `webex-js-sdk`, wraps it with `sdk-component-adapter`, and renders `components` repo UI via `AdapterContext`. + +### Layer Architecture + +```mermaid +graph TB + subgraph "Widget Layer" + W[Widget Entry Point] + end + + subgraph "UI Layer (components repo)" + WM[WebexMeeting] + WIM[WebexInterstitialMeeting] + WIN[WebexInMeeting] + MCB[WebexMeetingControlBar] + WLM[WebexLocalMedia] + WRM[WebexRemoteMedia] + end + + subgraph "Adapter Layer (sdk-component-adapter)" + ADAPT[MeetingsSDKAdapter] + AC[AudioControl] + VC[VideoControl] + SC[ShareControl] + JC[JoinControl] + EC[ExitControl] + end + + subgraph "SDK Layer (webex-js-sdk)" + SDK[Webex Instance] + end + + subgraph "Backend" + BE[Backend] + end + + W -->|creates| SDK + W -->|creates| ADAPT + W -->|AdapterContext| WM + WM --> WIM + WM --> WIN + WM --> MCB + WIN --> WLM + WIN --> WRM + MCB --> AC & VC & SC & JC & EC + AC & VC & SC & JC & EC --> ADAPT + ADAPT --> SDK + SDK --> BE + + style W fill:#e1f5ff,color:#000 + style ADAPT fill:#fff4e1,color:#000 + style SDK fill:#ffe1e1,color:#000 + style BE fill:#f0f0f0,color:#000 +``` + + + +### Component Table + + +| Component | Source | Purpose | Data Source | +| --------------------------------- | ------------------------------------------------------------ | ----------------------------------------------------------------- | ------------------------------------------ | +| `WebexMeeting` | `components/src/components/WebexMeeting/` | Master orchestrator — renders correct view based on meeting state | `useMeeting(meetingID)` | +| `WebexInterstitialMeeting` | `components/src/components/WebexInterstitialMeeting/` | Pre-join lobby with local media preview | state=NOT_JOINED | +| `WebexInMeeting` | `components/src/components/WebexInMeeting/` | Active meeting view with remote + local media | state=JOINED | +| `WebexWaitingForHost` | `components/src/components/WebexWaitingForHost/` | Waiting room when host hasn't started | state is else (not JOINED/NOT_JOINED/LEFT) | +| `WebexMeetingControlBar` | `components/src/components/WebexMeetingControlBar/` | Renders meeting control buttons | Maps control IDs to Control classes | +| `WebexMeetingControl` | `components/src/components/WebexMeetingControl/` | Individual control button | `useMeetingControl(controlID)` | +| `WebexLocalMedia` | `components/src/components/WebexLocalMedia/` | Local camera preview | `localVideo.stream` | +| `WebexRemoteMedia` | `components/src/components/WebexRemoteMedia/` | Remote participant video | `remoteVideo` / `remoteShare` | +| `WebexMemberRoster` | `components/src/components/WebexMemberRoster/` | Participant list panel | `showRoster` flag | +| `WebexSettings` | `components/src/components/WebexSettings/` | Audio/video device settings modal | `settings.visible` flag | +| `WebexMeetingGuestAuthentication` | `components/src/components/WebexMeetingGuestAuthentication/` | Guest password entry | `passwordRequired` flag | +| `WebexMeetingHostAuthentication` | `components/src/components/WebexMeetingHostAuthentication/` | Host pin entry | `passwordRequired` flag | + + +--- + +## SDK Integration + + +| Area | SDK Methods | Adapter Methods | Control Class | +| ----------------- | ---------------------------------------------------------------------------- | --------------------------------------------------------------------------------- | ------------------------- | +| Initialization | `Webex.init()`, `device.register()`, `mercury.connect()` | `sdkAdapter.connect()` → calls `meetings.register()` + `syncMeetings()` | — | +| Meeting creation | `webex.meetings.create(destination)` | `adapter.meetingsAdapter.createMeeting(dest)` | — | +| Join | `sdkMeeting.verifyPassword()`, `sdkMeeting.join({ pin, moderator, alias })` | `adapter.meetingsAdapter.joinMeeting(ID, options)` | `JoinControl` | +| Leave | `sdkMeeting.leave()` | `adapter.meetingsAdapter.leaveMeeting(ID)` (also calls `removeMedia`) | `ExitControl` | +| Mute/Unmute Audio | `sdkMeeting.muteAudio()`, `sdkMeeting.unmuteAudio()` | `adapter.meetingsAdapter.handleLocalAudio(ID)` | `AudioControl` | +| Mute/Unmute Video | `sdkMeeting.muteVideo()`, `sdkMeeting.unmuteVideo()` | `adapter.meetingsAdapter.handleLocalVideo(ID)` | `VideoControl` | +| Screen Share | `sdkMeeting.getMediaStreams()`, `sdkMeeting.updateShare()` * | `adapter.meetingsAdapter.handleLocalShare(ID)` | `ShareControl` | +| Toggle Roster | — (client-side) | `adapter.meetingsAdapter.toggleRoster(ID)` | `RosterControl` | +| Toggle Settings | `sdkMeeting.updateVideo()`, `sdkMeeting.updateAudio()` (on close, if joined) | `adapter.meetingsAdapter.toggleSettings(ID)` | `SettingsControl` | +| Switch Camera | `sdkMeeting.getMediaStreams()` * | `adapter.switchCamera(ID, cameraID)` | `SwitchCameraControl` | +| Switch Microphone | `sdkMeeting.getMediaStreams()` * | `adapter.switchMicrophone(ID, microphoneID)` | `SwitchMicrophoneControl` | +| Switch Speaker | — (client-side, updates meeting state only) | `adapter.switchSpeaker(ID, speakerID)` | `SwitchSpeakerControl` | +| Cleanup | `meetings.unregister()`, `mercury.disconnect()`, `device.unregister()` | `sdkAdapter.disconnect()` → calls `meetingsAdapter.disconnect()` then SDK cleanup | — | + + +** `getMediaStreams()` and `updateShare()` are the SDK methods invoked by the adapter source code. In newer SDK versions, equivalent functionality is provided by `media.getUserMedia()`, `addMedia()`, `publishStreams()`, and `updateMedia()`.* + +--- + +## Data Flow + +### Outbound (User Action → Backend) + +``` +User clicks control button + → Component (WebexMeetingControl) + → useMeetingControl hook + → Control.action({ meetingID }) + → sdk-component-adapter method + → webex-js-sdk meeting method + → Backend (REST/WebSocket) +``` + +### Inbound (Backend → UI Update) + +``` +Backend processes request + → WebSocket event delivered to webex-js-sdk + → sdk-component-adapter detects change + → RxJS BehaviorSubject emits new meeting state + → useMeeting hook receives update + → Component re-renders +``` + +--- + +## Adapter Meeting Object (from `createMeeting` + runtime updates) + +This is the real shape emitted by `adapter.meetingsAdapter.getMeeting(ID)`: + +``` +{ + ID: string + title: string + state: 'NOT_JOINED' | 'JOINED' | 'LEFT' + + localAudio: { + stream: MediaStream | null + permission: string | null // 'ALLOWED' | 'ERROR' | null + muting: boolean | undefined // true = muting in progress, false = unmuting, undefined = idle + } + localVideo: { + stream: MediaStream | null + permission: string | null + muting: boolean | undefined + error: string | null // e.g. 'Video not supported on iOS 15.1' + } + localShare: { + stream: MediaStream | null + } + + remoteAudio: MediaStream | null + remoteVideo: MediaStream | null + remoteShare: MediaStream | null + + disabledLocalAudio: MediaStream | null // stores the stream when audio is muted + disabledLocalVideo: MediaStream | null // stores the stream when video is muted + + showRoster: boolean | null + settings: { + visible: boolean + preview: { + audio: MediaStream | null + video: MediaStream | null + } + } + + passwordRequired: boolean + requiredCaptcha: object + cameraID: string | null + microphoneID: string | null + speakerID: string +} +``` + +--- + +## Event Flows + +### 1. SDK Initialization + +```mermaid +sequenceDiagram + participant User + participant Component as WebexMeeting + participant Adapter as sdk-component-adapter + participant SDK as webex-js-sdk + participant Backend + + User->>Component: Mount widget with accessToken + Component->>SDK: Webex.init({ credentials: { access_token } }) + Component->>Adapter: new WebexSDKAdapter(webex) + Adapter->>Adapter: Create MeetingsSDKAdapter(webex) with controls + + Component->>Adapter: sdkAdapter.connect() + Adapter->>SDK: sdk.internal.device.register() + SDK->>Backend: Register device + Backend-->>SDK: Device registered + Adapter->>SDK: sdk.internal.mercury.connect() + SDK->>Backend: Open WebSocket + Backend-->>SDK: WebSocket connected + Adapter->>SDK: webex.meetings.register() + syncMeetings() + SDK-->>Adapter: Meetings ready + + Component->>Component: Render with AdapterContext.Provider +``` + + + +--- + +### 2. Meeting Creation & Interstitial + +```mermaid +sequenceDiagram + participant User + participant Component as WebexMeeting + participant Adapter as sdk-component-adapter + participant SDK as webex-js-sdk + participant Backend + + User->>Component: Provide meeting destination (URL/SIP/PMR) + Component->>Adapter: createMeeting(destination) + Adapter->>SDK: webex.meetings.create(destination) + SDK->>Backend: Resolve meeting info, check active sessions, get user profile + Backend-->>SDK: Meeting info (title, sipUri), user profile + + Note over SDK: Meeting object created with state=NOT_JOINED + + SDK-->>Adapter: Meeting object + Adapter->>Adapter: Create meeting observable (RxJS) + Adapter-->>Component: meetingID + + Component->>Component: Render WebexInterstitialMeeting + Component->>Component: Show local media preview + Component->>Component: Show controls [mute-audio, mute-video, settings, join-meeting] +``` + + + +--- + +### 3. Join Meeting + +```mermaid +sequenceDiagram + participant User + participant Component as WebexMeetingControlBar + participant Adapter as JoinControl + participant SDK as webex-js-sdk + participant Backend + + User->>Component: Click "Join Meeting" button + Component->>Adapter: action({ meetingID, meetingPasswordOrPin, participantName }) + Adapter->>Adapter: joinMeeting(ID, { password, name }) + + alt Password Required + Adapter->>SDK: sdkMeeting.verifyPassword(password, captcha) + SDK->>Backend: Verify password + Backend-->>SDK: Verified + end + + Adapter->>SDK: sdkMeeting.join({ pin, moderator, alias }) + SDK->>Backend: Join meeting session + Backend-->>SDK: Session joined, media connections ready + + SDK->>SDK: Negotiate media (SDP offer/answer) + SDK->>Backend: Send local media description + Backend-->>SDK: Media established (audio + video active) + + SDK-->>Adapter: Meeting state updated + Adapter->>Adapter: Emit observable { state: JOINED } + Adapter-->>Component: Observable emits + + Component->>Component: Transition: WebexInterstitialMeeting → WebexInMeeting + Component->>Component: Update controls [mute-audio, mute-video, share-screen, member-roster, settings, leave-meeting] +``` + + + +--- + +### 4. Mute / Unmute Audio + +```mermaid +sequenceDiagram + participant User + participant Component as WebexMeetingControlBar + participant Adapter as AudioControl + participant SDK as webex-js-sdk + participant Backend + + Note over User: Audio is currently UNMUTED + + User->>Component: Click microphone button + Component->>Adapter: action({ meetingID }) + Adapter->>Adapter: handleLocalAudio(ID) + Adapter->>Adapter: Set localAudio.muting = true + Adapter->>SDK: sdkMeeting.muteAudio() + SDK->>Backend: Update media state (audio → receive-only) + Backend-->>SDK: Confirmed + + Adapter->>Adapter: Emit { disabledLocalAudio: stream, localAudio.stream: null } + Adapter-->>Component: display() emits { icon: microphone-muted, text: Unmute, state: ACTIVE } + Component->>Component: Re-render with muted icon + + Note over User: Audio is now MUTED — click again to unmute + + User->>Component: Click microphone button + Component->>Adapter: action({ meetingID }) + Adapter->>Adapter: handleLocalAudio(ID) + Adapter->>Adapter: Set localAudio.muting = false + Adapter->>SDK: sdkMeeting.unmuteAudio() + SDK->>Backend: Update media state (audio → send+receive) + Backend-->>SDK: Confirmed + + Adapter->>Adapter: Emit { disabledLocalAudio: null, localAudio.stream: stream } + Adapter-->>Component: display() emits { icon: microphone, text: Mute, state: INACTIVE } + Component->>Component: Re-render with unmuted icon +``` + + + +--- + +### 5. Start / Stop Video + +```mermaid +sequenceDiagram + participant User + participant Component as WebexMeetingControlBar + participant Adapter as VideoControl + participant SDK as webex-js-sdk + participant Backend + + Note over User: Video is currently ON + + User->>Component: Click camera button + Component->>Adapter: action({ meetingID }) + Adapter->>Adapter: handleLocalVideo(ID) + Adapter->>Adapter: Set localVideo.muting = true + Adapter->>SDK: sdkMeeting.muteVideo() + SDK->>Backend: Update media state (video → receive-only) + Backend-->>SDK: Confirmed + + Adapter->>Adapter: Emit { disabledLocalVideo: stream, localVideo.stream: null } + Adapter-->>Component: display() emits { icon: camera-muted, text: Start video, state: ACTIVE } + + Note over User: Video is now OFF — click again to start + + User->>Component: Click camera button + Component->>Adapter: action({ meetingID }) + Adapter->>Adapter: handleLocalVideo(ID) + Adapter->>Adapter: Set localVideo.muting = false + Adapter->>SDK: sdkMeeting.unmuteVideo() + SDK->>Backend: Update media state (video → send+receive) + Backend-->>SDK: Confirmed + + Adapter->>Adapter: Emit { disabledLocalVideo: null, localVideo.stream: stream } + Adapter-->>Component: display() emits { icon: camera, text: Stop video, state: INACTIVE } +``` + + + +--- + +### 6. Start / Stop Screen Share + +```mermaid +sequenceDiagram + participant User + participant Component as WebexMeetingControlBar + participant Adapter as ShareControl + participant SDK as webex-js-sdk + participant Backend + + User->>Component: Click share screen button + Component->>Adapter: action({ meetingID }) + Adapter->>Adapter: handleLocalShare(ID) + Adapter->>SDK: sdkMeeting.getMediaStreams({ sendShare: true }) + SDK->>User: Browser screen picker dialog (getDisplayMedia) + User->>SDK: Select screen/window/tab + SDK-->>Adapter: [, localShareStream] + Adapter->>SDK: sdkMeeting.updateShare({ stream, sendShare: true, receiveShare: true }) + SDK->>Backend: Update media state (share → send+receive) + Backend-->>SDK: Confirmed + + Adapter->>Adapter: Emit { localShare.stream: localShareStream } + Adapter-->>Component: display() emits { text: Stop sharing, state: ACTIVE } + + Note over User: Sharing active — click again to stop + + User->>Component: Click stop sharing + Component->>Adapter: action({ meetingID }) + Adapter->>Adapter: handleLocalShare(ID) + Adapter->>Adapter: stopStream(localShare.stream) + Adapter->>SDK: sdkMeeting.updateShare({ sendShare: false, receiveShare: true }) + SDK->>Backend: Update media state (share → receive-only) + Backend-->>SDK: Confirmed + + Adapter->>Adapter: Emit { localShare.stream: null } + Adapter-->>Component: display() emits { text: Start sharing, state: INACTIVE } +``` + + + +--- + +### 7. Toggle Member Roster + +```mermaid +sequenceDiagram + participant User + participant Component as WebexMeeting + participant Adapter as RosterControl + + Note over Adapter: Client-side only — no Backend call + + User->>Component: Click roster button + Component->>Adapter: action({ meetingID }) + Adapter->>Adapter: toggleRoster(ID) + Adapter->>Adapter: meeting.showRoster = !meeting.showRoster + Adapter->>Adapter: Emit observable { showRoster: true } + Adapter-->>Component: Observable emits + Component->>Component: Render WebexMemberRoster panel + + User->>Component: Click roster button (close) + Component->>Adapter: action({ meetingID }) + Adapter->>Adapter: toggleRoster(ID) + Adapter->>Adapter: Emit { showRoster: false } + Adapter-->>Component: Observable emits + Component->>Component: Remove WebexMemberRoster panel +``` + + + +--- + +### 8. Toggle Settings & Switch Camera + +```mermaid +sequenceDiagram + participant User + participant Component as WebexMeeting + participant Adapter as sdk-component-adapter + participant SDK as webex-js-sdk + + User->>Component: Click settings button + Component->>Adapter: SettingsControl.action({ meetingID }) + Adapter->>Adapter: toggleSettings(ID) + Adapter->>Adapter: Clone current streams to settings.preview + Adapter->>Adapter: Emit { settings.visible: true } + Adapter-->>Component: Observable emits + Component->>Component: Open WebexSettings modal + + Note over User: User selects a different camera + + User->>Component: Select new camera from dropdown + Component->>Adapter: SwitchCameraControl.action({ meetingID, cameraId }) + Adapter->>Adapter: switchCamera(ID, cameraId) + Adapter->>SDK: sdkMeeting.getMediaStreams({ sendVideo: true }, { video: { deviceId } }) + SDK->>SDK: getUserMedia with new deviceId + SDK-->>Adapter: New video MediaStream + Adapter->>Adapter: Emit { settings.preview.video: newStream, cameraID } + Adapter-->>Component: Settings preview re-renders with new camera + + User->>Component: Close settings modal + Component->>Adapter: SettingsControl.action({ meetingID }) + Adapter->>Adapter: toggleSettings(ID) + Adapter->>Adapter: Replace meeting streams with preview streams + + alt Meeting is joined + Adapter->>SDK: sdkMeeting.updateVideo({ stream, receiveVideo, sendVideo }) + Adapter->>SDK: sdkMeeting.updateAudio({ stream, receiveAudio, sendAudio }) + end + + Adapter->>Adapter: Emit { settings.visible: false } + Component->>Component: Close modal +``` + + + +--- + +### 9. Leave Meeting + +```mermaid +sequenceDiagram + participant User + participant Component as WebexMeetingControlBar + participant Adapter as ExitControl + participant SDK as webex-js-sdk + participant Backend + + User->>Component: Click leave meeting button + Component->>Adapter: action({ meetingID }) + Adapter->>Adapter: leaveMeeting(ID) + Adapter->>Adapter: removeMedia(ID) — stop all local streams + Adapter->>SDK: sdkMeeting.leave() + SDK->>Backend: Leave session + Backend-->>SDK: Confirmed + + SDK-->>Adapter: Meeting state updated + Adapter->>Adapter: Emit { state: LEFT } + Adapter-->>Component: Observable emits + + Component->>Component: Show "You've successfully left the meeting" +``` + + + +--- + +### 10. Guest/Host Authentication + +```mermaid +sequenceDiagram + participant User + participant Component as WebexMeeting + participant Adapter as JoinControl + participant SDK as webex-js-sdk + participant Backend + + Note over Component: Meeting has passwordRequired=true + + Component->>Component: Detect passwordRequired from observable + Component->>Component: Open WebexMeetingGuestAuthentication modal + + User->>Component: Enter password, click "Join as Guest" + Component->>Adapter: action({ meetingID, meetingPasswordOrPin: password }) + Adapter->>SDK: joinMeeting(ID, { password }) + SDK->>Backend: Verify password and join + Backend-->>SDK: Result + + alt Password Correct + SDK-->>Adapter: state → JOINED + Adapter-->>Component: Observable emits + Component->>Component: Close auth modal, show in-meeting view + else Password Incorrect + SDK-->>Adapter: Error / invalidPassword flag + Adapter-->>Component: Observable emits { invalidPassword: true } + Component->>Component: Show error in auth modal + end + + Note over User: Alternative: "I'm the host" + + User->>Component: Click "I'm the host" + Component->>Component: Switch to WebexMeetingHostAuthentication modal + User->>Component: Enter host pin, click "Start Meeting" + Component->>Adapter: action({ meetingID, meetingPasswordOrPin: hostPin }) + Adapter->>SDK: joinMeeting(ID, { hostKey: hostPin }) +``` + + + +--- + +### 11. Waiting for Host + +```mermaid +sequenceDiagram + participant User + participant Component as WebexMeeting + participant Adapter as sdk-component-adapter + participant SDK as webex-js-sdk + participant Backend + + Note over Component: Meeting joined but host not yet present + + Component->>Component: state is not JOINED, NOT_JOINED, or LEFT + Component->>Component: Render WebexWaitingForHost + Component->>User: Show "Waiting for the host to start the meeting" + + Note over Backend: Host joins the meeting + + Backend-->>SDK: WebSocket event — host joined, meeting started + SDK-->>Adapter: Meeting state updated + Adapter->>Adapter: Emit { state: JOINED } + Adapter-->>Component: Observable emits + + Component->>Component: Transition: WebexWaitingForHost → WebexInMeeting +``` + + + +--- + +## Meeting State Machine + +```mermaid +stateDiagram-v2 + [*] --> NOT_JOINED: SDK + Adapter ready, meeting created + + NOT_JOINED --> JOINED: User joins (JoinControl) + + JOINED --> LEFT: User leaves (ExitControl) + + LEFT --> [*]: Widget unmounts +``` + + + +*These are the three states emitted by the adapter's meeting observable. The `WebexMeeting` component also handles a falsy state (loading) and an else catch-all (WebexWaitingForHost).* + +--- + +## Control Display States (from actual source) + +### AudioControl + + +| State | Icon | Text | Tooltip | Control State | +| ------------ | ------------------ | ------------- | ----------------------- | ------------- | +| unmuted | `microphone` | Mute | Mute audio | INACTIVE | +| muted | `microphone-muted` | Unmute | Unmute audio | ACTIVE | +| muting | `microphone` | Muting... | Muting audio | DISABLED | +| unmuting | `microphone-muted` | Unmuting... | Unmuting audio | DISABLED | +| noMicrophone | `microphone-muted` | No microphone | No microphone available | DISABLED | + + +### VideoControl + + +| State | Icon | Text | Tooltip | Control State | +| -------- | -------------- | ----------- | ------------------- | ------------- | +| unmuted | `camera` | Stop video | Stop video | INACTIVE | +| muted | `camera-muted` | Start video | Start video | ACTIVE | +| muting | `camera` | Stopping... | Stopping video | DISABLED | +| unmuting | `camera-muted` | Starting... | Starting video | DISABLED | +| noCamera | `camera-muted` | No camera | No camera available | DISABLED | + + +### ShareControl + + +| State | Icon | Text | Tooltip | Control State | Type | +| ------------ | ------------------------------ | ------------- | -------------------------- | ------------- | ------ | +| inactive | `share-screen-presence-stroke` | Start sharing | Start sharing content | INACTIVE | TOGGLE | +| active | `share-screen-presence-stroke` | Stop sharing | Stop sharing content | ACTIVE | TOGGLE | +| notSupported | `share-screen-presence-stroke` | Start sharing | Share screen not supported | DISABLED | TOGGLE | + + +### JoinControl + + +| Text | Tooltip | Hint | Control State | Type | +| ------------ | ------------ | ------------------------------- | --------------------------------- | ---- | +| Join meeting | Join meeting | {Muted/Unmuted}, {video on/off} | ACTIVE (if NOT_JOINED) / DISABLED | JOIN | + + +### ExitControl + +Renders as a CANCEL type button. + +--- + +## Troubleshooting Guide + +### 1. Widget Stuck on Loading + +**Symptoms:** Loading state never resolves, no meeting UI appears + +**Possible Causes:** + +- Invalid or expired access token +- Network connectivity to backend +- Device registration failure + +**Solutions:** + +- Verify the access token is valid and not expired +- Check network connectivity (browser dev tools network tab) +- Check browser console for SDK error messages + +--- + +### 2. Audio/Video Not Working After Join + +**Symptoms:** Joined meeting but no audio/video, controls show "No camera" or "No microphone" + +**Possible Causes:** + +- Browser denied `getUserMedia` permissions +- Media negotiation (SDP/ROAP) failed +- Media server unreachable + +**Solutions:** + +- Check browser permission prompts for camera/microphone +- Verify `getUserMedia` works in browser console +- Check for errors in SDK logs + +--- + +### 3. Screen Share Not Available + +**Symptoms:** Share button disabled, shows "Share screen not supported" + +**Possible Causes:** + +- Browser doesn't support `getDisplayMedia` +- Running over HTTP instead of HTTPS +- `navigator.mediaDevices.getDisplayMedia` is undefined + +**Solutions:** + +- Verify HTTPS is being used +- Check browser compatibility +- `ShareControl` checks `navigator.mediaDevices.getDisplayMedia` availability before enabling + +--- + +### 4. Meeting State Not Updating + +**Symptoms:** UI doesn't change after control actions + +**Possible Causes:** + +- WebSocket connection dropped +- Observable subscription lost +- Adapter not emitting updates + +**Solutions:** + +- Check WebSocket status in network tab +- Verify the observable subscription is active +- Look for WebSocket events in the network inspector + +--- + +### 5. Multiple Meeting Instances Created + +**Symptoms:** Widget creates duplicate meetings + +**Possible Causes:** + +- React strict mode causing double initialization +- Missing cleanup on prop changes +- Missing dependency array in useEffect + +**Solutions:** + +- Use a ref to track initialization state +- Implement proper cleanup in useEffect return +- Guard against re-initialization + +--- + +### 6. AdapterContext Not Provided + +**Symptoms:** Components crash with "Cannot read property of undefined" + +**Possible Causes:** + +- `AdapterContext.Provider` not wrapping `WebexMeeting` +- Adapter not yet initialized when components render + +**Solutions:** + +- Ensure `` wraps all components +- Wait for adapter to be ready before rendering + +--- + +## Related Documentation + +- [Agent Documentation](./AGENTS.md) - Widget usage and API reference + +--- + From b7796b048a075e14496dbf90a79017ef054e9c86 Mon Sep 17 00:00:00 2001 From: Ritesh Singh Date: Fri, 6 Mar 2026 01:40:57 +0530 Subject: [PATCH 02/10] docs(ai-docs): add docs for meetings widget --- packages/{meetings => @webex/widgets}/ai-docs/AGENTS.md | 0 packages/{meetings => @webex/widgets}/ai-docs/ARCHITECTURE.md | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename packages/{meetings => @webex/widgets}/ai-docs/AGENTS.md (100%) rename packages/{meetings => @webex/widgets}/ai-docs/ARCHITECTURE.md (100%) diff --git a/packages/meetings/ai-docs/AGENTS.md b/packages/@webex/widgets/ai-docs/AGENTS.md similarity index 100% rename from packages/meetings/ai-docs/AGENTS.md rename to packages/@webex/widgets/ai-docs/AGENTS.md diff --git a/packages/meetings/ai-docs/ARCHITECTURE.md b/packages/@webex/widgets/ai-docs/ARCHITECTURE.md similarity index 100% rename from packages/meetings/ai-docs/ARCHITECTURE.md rename to packages/@webex/widgets/ai-docs/ARCHITECTURE.md From 6630b60c5d23ada9ff2933d257a3ba662186dd5e Mon Sep 17 00:00:00 2001 From: Ritesh Singh Date: Fri, 6 Mar 2026 10:14:30 +0530 Subject: [PATCH 03/10] docs(ai-docs): update AGENTS.md --- packages/@webex/widgets/ai-docs/AGENTS.md | 110 ++++++++++++++-------- 1 file changed, 70 insertions(+), 40 deletions(-) diff --git a/packages/@webex/widgets/ai-docs/AGENTS.md b/packages/@webex/widgets/ai-docs/AGENTS.md index f7d150041..0f8606aa6 100644 --- a/packages/@webex/widgets/ai-docs/AGENTS.md +++ b/packages/@webex/widgets/ai-docs/AGENTS.md @@ -6,7 +6,9 @@ The Meetings Widget provides a full-featured Webex meeting experience as an embe **Widget:** Meetings -**Location:** `widgets/packages/meetings/` +**Package:** `@webex/widgets` + +**Location:** `packages/@webex/widgets` --- @@ -35,40 +37,46 @@ The Meetings Widget lets consuming applications embed a complete meeting experie #### Basic Usage (React) +The widget handles SDK initialization, adapter creation, meeting creation, and all internal wiring via the `withAdapter` and `withMeeting` HOCs. Consumers just import and render with props: + ```jsx -import Webex from 'webex'; -import WebexSDKAdapter from '@webex/sdk-component-adapter'; -import {WebexMeeting, AdapterContext} from '@webex/components'; +import {WebexMeetingsWidget} from '@webex/widgets'; -function MeetingsWidget({accessToken, meetingDestination}) { - const [adapter, setAdapter] = useState(null); - const [meetingID, setMeetingID] = useState(null); +function App() { + return ( + + ); +} +``` - useEffect(() => { - const webex = Webex.init({ - credentials: { access_token: accessToken } - }); - const sdkAdapter = new WebexSDKAdapter(webex); +#### With All Optional Props - sdkAdapter.connect().then(() => { - setAdapter(sdkAdapter); - return sdkAdapter.meetingsAdapter.createMeeting(meetingDestination); - }).then((meeting) => { - setMeetingID(meeting.ID); - }); +```jsx + +``` - return () => sdkAdapter.disconnect(); - }, [accessToken, meetingDestination]); +#### What Happens Internally - if (!adapter || !meetingID) return
Loading...
; +When `WebexMeetingsWidget` mounts, the `withAdapter` HOC: - return ( - - - - ); -} -``` +1. Creates a `Webex` instance using the `accessToken` prop +2. Wraps it in a `WebexSDKAdapter` +3. Calls `adapter.connect()` (registers device, opens WebSocket, syncs meetings) +4. Provides the adapter via `AdapterContext` + +The `withMeeting` HOC then creates a meeting from `meetingDestination` and passes the meeting object as a prop. The widget renders the appropriate view based on meeting state. ### Common Use Cases @@ -185,22 +193,44 @@ graph LR ## API Reference -### WebexMeeting Component Props +### WebexMeetingsWidget Props (Public API) + +These are the props consumers pass when using the widget. The widget handles SDK/adapter setup internally. + + +| Prop | Type | Required | Default | Description | +| --------------------------- | ---------- | -------- | ----------- | -------------------------------------------------------------- | +| `accessToken` | `string` | **Yes** | — | Webex access token for authentication | +| `meetingDestination` | `string` | **Yes** | — | Meeting URL, SIP address, email, or Personal Meeting Room link | +| `meetingPasswordOrPin` | `string` | No | `''` | Password or host pin for protected meetings | +| `participantName` | `string` | No | `''` | Display name for guest participants | +| `fedramp` | `bool` | No | `false` | Enable FedRAMP-compliant environment | +| `layout` | `string` | No | `'Grid'` | Remote video layout (`Grid`, `Stack`, `Overlay`, `Prominent`, `Focus`) | +| `controls` | `Function` | No | `undefined` | Function returning control IDs to render | +| `controlsCollapseRangeStart`| `number` | No | `undefined` | Zero-based index of the first collapsible control | +| `controlsCollapseRangeEnd` | `number` | No | `undefined` | Zero-based index before the last collapsible control | +| `className` | `string` | No | `''` | Custom CSS class for the root element | +| `style` | `object` | No | `{}` | Inline styles for the root element | + + +**Source:** `src/widgets/WebexMeetings/WebexMeetings.jsx` (see `WebexMeetingsWidget.propTypes` and `WebexMeetingsWidget.defaultProps`) + +### Internal Component Props (WebexMeeting from @webex/components) +These are passed internally by `WebexMeetingsWidget` to the `WebexMeeting` component from `@webex/components`. Consumers do not interact with these directly. -| Prop | Type | Required | Default | Description | -| ---------------------- | ------------- | -------- | ------- | -------------------------------------------- | -| `meetingID` | `string` | No | — | The meeting ID returned by `createMeeting()` | -| `meetingPasswordOrPin` | `string` | No | — | Password or host pin for protected meetings | -| `participantName` | `string` | No | — | Display name for guest participants | -| `controls` | `Function` | No | — | Function returning control IDs to render | -| `layout` | `string` | No | — | Meeting layout variant | -| `logo` | `JSX.Element` | No | — | Custom logo for loading state | -| `className` | `string` | No | — | CSS class for the root element | -| `style` | `object` | No | — | Inline styles for the root element | +| Prop | Type | Description | +| ---------------------- | ------------- | -------------------------------------------------------------- | +| `meetingID` | `string` | Injected by `withMeeting` HOC from `meetingDestination` | +| `meetingPasswordOrPin` | `string` | Forwarded from widget prop | +| `participantName` | `string` | Forwarded from widget prop | +| `controls` | `Function` | Forwarded from widget prop | +| `layout` | `string` | Forwarded from widget prop | +| `logo` | `JSX.Element` | Hard-coded `` SVG | +| `className` | `string` | Always `'webex-meetings-widget__content'` | -The `WebexMeeting` component also requires an `AdapterContext.Provider` ancestor with a valid `WebexSDKAdapter` instance as its value. +The `WebexMeeting` component receives its adapter via `AdapterContext.Provider`, which is set up by the `withAdapter` HOC wrapping the widget. ### Hooks (from `components`) From 1a32332f3815252de0e9c88c081f995bd448633a Mon Sep 17 00:00:00 2001 From: Ritesh Singh Date: Mon, 9 Mar 2026 16:05:16 +0530 Subject: [PATCH 04/10] feat(meetings): add unit tests --- .github/workflows/pull-request.yml | 3 + package.json | 3 +- packages/@webex/widgets/jest.config.js | 14 + .../WebexMeetings/WebexMeetings.test.jsx | 558 ++++++++++++++++++ yarn.lock | 46 +- 5 files changed, 598 insertions(+), 26 deletions(-) create mode 100644 packages/@webex/widgets/jest.config.js create mode 100644 packages/@webex/widgets/tests/WebexMeetings/WebexMeetings.test.jsx diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index ad143ca36..c734a32e5 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -281,3 +281,6 @@ jobs: - name: Test CC Widgets run: yarn run test:cc-widgets + + - name: Test Meetings Widget + run: yarn run test:meetings-widget diff --git a/package.json b/package.json index 78c839418..44a4242cd 100644 --- a/package.json +++ b/package.json @@ -51,11 +51,12 @@ "scripts": { "clean": "yarn workspaces foreach --all --topological --parallel run clean && rm -rf node_modules", "clean:dist": "yarn workspaces foreach --all --topological --parallel run clean:dist", - "test:unit": "yarn run test:tooling && yarn run test:cc-widgets", + "test:unit": "yarn run test:tooling && yarn run test:cc-widgets && yarn run test:meetings-widget", "test:e2e": "yarn playwright test", "test:styles": "yarn workspaces foreach --all --exclude webex-widgets run test:styles", "test:tooling": "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", + "test:meetings-widget": "yarn workspaces foreach --all --verbose --include @webex/widgets run test:unit", "build:dev": "NODE_ENV=development yarn build", "build:prod": "NODE_ENV=production yarn build:serial", "build": "NODE_OPTIONS=--max-old-space-size=4096 yarn workspaces foreach --all --parallel --topological --exclude samples-cc-react-app --exclude samples-cc-wc-app --exclude samples-meeting-app run build:src", diff --git a/packages/@webex/widgets/jest.config.js b/packages/@webex/widgets/jest.config.js new file mode 100644 index 000000000..385314bf2 --- /dev/null +++ b/packages/@webex/widgets/jest.config.js @@ -0,0 +1,14 @@ +const jestConfig = require('../../../jest.config.js'); + +jestConfig.rootDir = '../../../'; +jestConfig.testMatch = ['**/@webex/widgets/tests/**/*.test.{js,jsx}']; +jestConfig.globals = { + ...jestConfig.globals, + __appVersion__: '1.0.0-test', +}; +jestConfig.coveragePathIgnorePatterns = [ + ...(jestConfig.coveragePathIgnorePatterns || []), + 'WebexLogo\\.jsx$', +]; + +module.exports = jestConfig; diff --git a/packages/@webex/widgets/tests/WebexMeetings/WebexMeetings.test.jsx b/packages/@webex/widgets/tests/WebexMeetings/WebexMeetings.test.jsx new file mode 100644 index 000000000..4eb3f3e3e --- /dev/null +++ b/packages/@webex/widgets/tests/WebexMeetings/WebexMeetings.test.jsx @@ -0,0 +1,558 @@ +import React, {Component} from 'react'; +import {render, fireEvent, act} from '@testing-library/react'; +import '@testing-library/jest-dom'; + +let capturedAdapterFactory; + +jest.mock('@webex/components', () => ({ + WebexMediaAccess: (props) => ( +
+ ), + WebexMeeting: (props) => ( +
+ ), + withAdapter: (WrappedComponent, factory) => { + capturedAdapterFactory = factory; + return WrappedComponent; + }, + withMeeting: (WrappedComponent) => WrappedComponent, +})); + +jest.mock('@webex/components/dist/css/webex-components.css', () => {}); + +jest.mock('webex', () => jest.fn((config) => ({__mockWebex: true, ...config}))); +jest.mock('@webex/sdk-component-adapter', () => jest.fn((webex) => ({__mockAdapter: true, webex}))); + +const Webex = require('webex'); +const WebexSDKAdapter = require('@webex/sdk-component-adapter'); + +const WebexMeetingsWidget = require('../../src/widgets/WebexMeetings/WebexMeetings').default; + +class TestErrorBoundary extends Component { + constructor(props) { + super(props); + this.state = {hasError: false}; + } + + static getDerivedStateFromError() { + return {hasError: true}; + } + + componentDidCatch(error) { + if (this.props.onError) { + this.props.onError(error); + } + } + + render() { + if (this.state.hasError) { + return null; + } + return this.props.children; + } +} + +const baseMeeting = { + ID: 'meeting-123', + localAudio: {permission: 'GRANTED'}, + localVideo: {permission: 'GRANTED'}, +}; + +const baseProps = { + accessToken: 'test-token', + meetingDestination: 'test@webex.com', + meeting: baseMeeting, +}; + +describe('WebexMeetingsWidget', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.spyOn(console, 'error').mockImplementation(() => {}); + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + jest.useRealTimers(); + }); + + describe('Rendering', () => { + it('renders wrapper div with class "webex-meetings-widget" and tabIndex 0', () => { + const {container} = render(); + + const wrapper = container.firstChild; + expect(wrapper).toHaveClass('webex-meetings-widget'); + expect(wrapper).toHaveAttribute('tabindex', '0'); + }); + + it('renders WebexMediaAccess for microphone when audioPermission is ASKING', () => { + const meeting = {...baseMeeting, localAudio: {permission: 'ASKING'}}; + const {getByTestId, queryByTestId} = render( + + ); + + expect(getByTestId('webex-media-access')).toBeInTheDocument(); + expect(getByTestId('webex-media-access')).toHaveAttribute('data-media', 'microphone'); + expect(queryByTestId('webex-meeting')).not.toBeInTheDocument(); + }); + + it('renders WebexMediaAccess for camera when videoPermission is ASKING', () => { + const meeting = {...baseMeeting, localVideo: {permission: 'ASKING'}}; + const {getByTestId, queryByTestId} = render( + + ); + + expect(getByTestId('webex-media-access')).toBeInTheDocument(); + expect(getByTestId('webex-media-access')).toHaveAttribute('data-media', 'camera'); + expect(queryByTestId('webex-meeting')).not.toBeInTheDocument(); + }); + + it('audio ASKING takes priority over video ASKING', () => { + const meeting = { + ...baseMeeting, + localAudio: {permission: 'ASKING'}, + localVideo: {permission: 'ASKING'}, + }; + const {getByTestId} = render(); + + expect(getByTestId('webex-media-access')).toHaveAttribute('data-media', 'microphone'); + }); + + it('passes correct meetingID to WebexMediaAccess (microphone case)', () => { + const meeting = {...baseMeeting, localAudio: {permission: 'ASKING'}}; + const {getByTestId} = render(); + + expect(getByTestId('webex-media-access')).toHaveAttribute('data-meeting-id', 'meeting-123'); + }); + + it('passes correct meetingID to WebexMediaAccess (camera case)', () => { + const meeting = {...baseMeeting, localVideo: {permission: 'ASKING'}}; + const {getByTestId} = render(); + + expect(getByTestId('webex-media-access')).toHaveAttribute('data-meeting-id', 'meeting-123'); + }); + + it('renders WebexMeeting when no permission is ASKING', () => { + const {getByTestId, queryByTestId} = render(); + + expect(getByTestId('webex-meeting')).toBeInTheDocument(); + expect(queryByTestId('webex-media-access')).not.toBeInTheDocument(); + }); + + it('passes correct props to WebexMeeting', () => { + const controlsFn = jest.fn(); + const props = { + ...baseProps, + meetingPasswordOrPin: 'secret123', + participantName: 'Test User', + layout: 'Focus', + controls: controlsFn, + controlsCollapseRangeStart: 1, + controlsCollapseRangeEnd: -1, + }; + const {getByTestId} = render(); + + const meetingEl = getByTestId('webex-meeting'); + expect(meetingEl).toHaveAttribute('data-meeting-id', 'meeting-123'); + expect(meetingEl).toHaveAttribute('data-password', 'secret123'); + expect(meetingEl).toHaveAttribute('data-participant', 'Test User'); + expect(meetingEl).toHaveAttribute('data-layout', 'Focus'); + expect(meetingEl).toHaveAttribute('data-collapse-start', '1'); + expect(meetingEl).toHaveAttribute('data-collapse-end', '-1'); + }); + + it('applies custom className to wrapper', () => { + const {container} = render(); + + expect(container.firstChild).toHaveClass('webex-meetings-widget'); + expect(container.firstChild).toHaveClass('my-custom'); + }); + + it('applies custom style to wrapper', () => { + const customStyle = {backgroundColor: 'red', width: '500px'}; + const {container} = render(); + + expect(container.firstChild).toHaveStyle({backgroundColor: 'red', width: '500px'}); + }); + }); + + describe('Default Props', () => { + it('layout defaults to Grid', () => { + const {getByTestId} = render(); + + expect(getByTestId('webex-meeting')).toHaveAttribute('data-layout', 'Grid'); + }); + + it('className defaults to empty string', () => { + const {container} = render(); + + expect(container.firstChild.className).toBe('webex-meetings-widget '); + }); + + it('meetingPasswordOrPin defaults to empty string', () => { + const {getByTestId} = render(); + + expect(getByTestId('webex-meeting')).toHaveAttribute('data-password', ''); + }); + + it('participantName defaults to empty string', () => { + const {getByTestId} = render(); + + expect(getByTestId('webex-meeting')).toHaveAttribute('data-participant', ''); + }); + }); + + describe('Error Handling', () => { + it('should render null when the widget throws due to invalid meeting prop', () => { + const onError = jest.fn(); + const {container} = render( + + + + ); + + expect(container.firstChild).toBeNull(); + expect(onError).toHaveBeenCalledWith(expect.any(Error)); + }); + }); + + describe('Accessibility - Focus Management', () => { + it('on widget focus, sets tabIndex on media containers', () => { + const {container} = render(); + const wrapper = container.firstChild; + + const mediaContainer = document.createElement('div'); + mediaContainer.classList.add('wxc-interstitial-meeting__media-container'); + wrapper.querySelector('.webex-meetings-widget__content').appendChild(mediaContainer); + + act(() => { + fireEvent.focus(wrapper); + jest.advanceTimersByTime(0); + }); + + expect(mediaContainer.tabIndex).toBe(0); + }); + + it('on widget focus, falls back to focusing join button when no media containers exist', () => { + const {container} = render(); + const wrapper = container.firstChild; + + const joinButton = document.createElement('button'); + joinButton.setAttribute('aria-label', 'Join meeting'); + joinButton.focus = jest.fn(); + wrapper.appendChild(joinButton); + + act(() => { + fireEvent.focus(wrapper); + jest.advanceTimersByTime(0); + }); + + expect(joinButton.focus).toHaveBeenCalled(); + }); + + it('Tab on media container focuses join button', () => { + const {container} = render(); + const wrapper = container.firstChild; + + const mediaContainer = document.createElement('div'); + mediaContainer.classList.add('wxc-in-meeting__media-container'); + wrapper.querySelector('.webex-meetings-widget__content').appendChild(mediaContainer); + + act(() => { + fireEvent.focus(wrapper); + jest.advanceTimersByTime(0); + }); + + const joinButton = document.createElement('button'); + joinButton.setAttribute('aria-label', 'Join meeting'); + joinButton.focus = jest.fn(); + wrapper.appendChild(joinButton); + + Object.defineProperty(document, 'activeElement', { + value: mediaContainer, + writable: true, + configurable: true, + }); + + const tabEvent = new KeyboardEvent('keydown', { + key: 'Tab', + code: 'Tab', + bubbles: true, + cancelable: true, + }); + Object.defineProperty(tabEvent, 'currentTarget', {value: mediaContainer}); + mediaContainer.dispatchEvent(tabEvent); + + expect(joinButton.focus).toHaveBeenCalled(); + }); + + it('Shift+Tab on media container focuses widget container', () => { + const {container} = render(); + const wrapper = container.firstChild; + + const mediaContainer = document.createElement('div'); + mediaContainer.classList.add('wxc-interstitial-meeting__media-container'); + wrapper.querySelector('.webex-meetings-widget__content').appendChild(mediaContainer); + + act(() => { + fireEvent.focus(wrapper); + jest.advanceTimersByTime(0); + }); + + wrapper.focus = jest.fn(); + + Object.defineProperty(document, 'activeElement', { + value: mediaContainer, + writable: true, + configurable: true, + }); + + const shiftTabEvent = new KeyboardEvent('keydown', { + key: 'Tab', + code: 'Tab', + shiftKey: true, + bubbles: true, + cancelable: true, + }); + Object.defineProperty(shiftTabEvent, 'currentTarget', {value: mediaContainer}); + mediaContainer.dispatchEvent(shiftTabEvent); + + expect(wrapper.focus).toHaveBeenCalled(); + }); + + it('content div focus polls for inner meeting media container and focuses it', () => { + const {container} = render(); + const wrapper = container.firstChild; + const contentDiv = wrapper.querySelector('.webex-meetings-widget__content'); + + expect(contentDiv).toBeTruthy(); + + const innerMeeting = document.createElement('div'); + innerMeeting.classList.add('wxc-in-meeting__media-container'); + innerMeeting.focus = jest.fn(); + + contentDiv.dispatchEvent(new Event('focus')); + + contentDiv.appendChild(innerMeeting); + + act(() => { + jest.advanceTimersByTime(500); + }); + + expect(innerMeeting.focus).toHaveBeenCalled(); + expect(innerMeeting.tabIndex).toBe(0); + }); + + it('content div focus attaches one-time Tab handler to move focus to first interactive element', () => { + const {container} = render(); + const wrapper = container.firstChild; + const contentDiv = wrapper.querySelector('.webex-meetings-widget__content'); + + expect(contentDiv).toBeTruthy(); + + const innerMeeting = document.createElement('div'); + innerMeeting.classList.add('wxc-in-meeting__media-container'); + contentDiv.appendChild(innerMeeting); + + const interactiveBtn = document.createElement('button'); + interactiveBtn.focus = jest.fn(); + innerMeeting.appendChild(interactiveBtn); + + contentDiv.dispatchEvent(new Event('focus')); + + act(() => { + jest.advanceTimersByTime(0); + }); + + const tabEvent = new KeyboardEvent('keydown', { + key: 'Tab', + bubbles: true, + cancelable: true, + }); + innerMeeting.dispatchEvent(tabEvent); + + expect(interactiveBtn.focus).toHaveBeenCalled(); + }); + + it('arrow keys cycle through control buttons', () => { + const {container} = render(); + const wrapper = container.firstChild; + + const controlBar = document.createElement('div'); + controlBar.classList.add('wxc-meeting-control-bar__controls'); + + const btn1 = document.createElement('button'); + btn1.focus = jest.fn(); + const btn2 = document.createElement('button'); + btn2.focus = jest.fn(); + const btn3 = document.createElement('button'); + btn3.focus = jest.fn(); + + controlBar.appendChild(btn1); + controlBar.appendChild(btn2); + controlBar.appendChild(btn3); + wrapper.appendChild(controlBar); + + act(() => { + jest.advanceTimersByTime(700); + }); + + act(() => { + btn1.onkeydown({key: 'ArrowRight', preventDefault: jest.fn()}); + }); + expect(btn2.focus).toHaveBeenCalled(); + + act(() => { + btn1.onkeydown({key: 'ArrowLeft', preventDefault: jest.fn()}); + }); + expect(btn3.focus).toHaveBeenCalled(); + }); + + it('MutationObserver re-attaches listeners on DOM changes', () => { + let observerCallback; + const OriginalMutationObserver = window.MutationObserver; + + window.MutationObserver = class MockMutationObserver { + constructor(callback) { + observerCallback = callback; + } + observe() {} + disconnect() {} + }; + + const {container} = render(); + const wrapper = container.firstChild; + + const controlBar = document.createElement('div'); + controlBar.classList.add('wxc-meeting-control-bar__controls'); + + const btn1 = document.createElement('button'); + controlBar.appendChild(btn1); + wrapper.appendChild(controlBar); + + act(() => { + jest.advanceTimersByTime(700); + }); + + expect(btn1.onkeydown).toBeTruthy(); + + const newBtn = document.createElement('button'); + newBtn.focus = jest.fn(); + controlBar.appendChild(newBtn); + + act(() => { + observerCallback(); + }); + + expect(newBtn.onkeydown).toBeTruthy(); + + window.MutationObserver = OriginalMutationObserver; + }); + }); + + describe('Cleanup', () => { + it('disconnects MutationObserver on unmount', () => { + const disconnectSpy = jest.fn(); + const OriginalMutationObserver = window.MutationObserver; + + window.MutationObserver = class MockMutationObserver { + constructor(callback) { + this.callback = callback; + } + observe() {} + disconnect() { + disconnectSpy(); + } + }; + + const {unmount} = render(); + + unmount(); + + expect(disconnectSpy).toHaveBeenCalled(); + + window.MutationObserver = OriginalMutationObserver; + }); + }); + + describe('Adapter Factory', () => { + it('creates Webex with correct access_token', () => { + capturedAdapterFactory({accessToken: 'my-token', fedramp: false}); + + expect(Webex).toHaveBeenCalledWith( + expect.objectContaining({ + credentials: {access_token: 'my-token'}, + }) + ); + }); + + it('passes fedramp config', () => { + capturedAdapterFactory({accessToken: 'token', fedramp: true}); + + expect(Webex).toHaveBeenCalledWith( + expect.objectContaining({ + config: expect.objectContaining({fedramp: true}), + }) + ); + }); + + it('passes meeting experimental config', () => { + capturedAdapterFactory({accessToken: 'token', fedramp: false}); + + expect(Webex).toHaveBeenCalledWith( + expect.objectContaining({ + config: expect.objectContaining({ + meetings: { + experimental: { + enableUnifiedMeetings: true, + enableAdhocMeetings: true, + }, + }, + }), + }) + ); + }); + + it('passes appVersion from __appVersion__ global', () => { + capturedAdapterFactory({accessToken: 'token', fedramp: false}); + + expect(Webex).toHaveBeenCalledWith( + expect.objectContaining({ + config: expect.objectContaining({appVersion: '1.0.0-test'}), + }) + ); + }); + + it('creates WebexSDKAdapter from Webex instance', () => { + capturedAdapterFactory({accessToken: 'token', fedramp: false}); + + expect(WebexSDKAdapter).toHaveBeenCalledTimes(1); + const webexInstance = Webex.mock.results[Webex.mock.results.length - 1].value; + expect(WebexSDKAdapter).toHaveBeenCalledWith(webexInstance); + }); + + it('uses dev appName when NODE_ENV is not production', () => { + capturedAdapterFactory({accessToken: 'token', fedramp: false}); + + expect(Webex).toHaveBeenCalledWith( + expect.objectContaining({ + config: expect.objectContaining({appName: 'webex-widgets-meetings-dev'}), + }) + ); + }); + }); +}); diff --git a/yarn.lock b/yarn.lock index f6c2d52f0..cca76508c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12,10 +12,10 @@ __metadata: languageName: node linkType: hard -"@aml-org/amf-antlr-parsers@npm:0.8.28": - version: 0.8.28 - resolution: "@aml-org/amf-antlr-parsers@npm:0.8.28" - checksum: 10c0/ef31cfe06b35017d7855eb3eb3d9c64853e36ea7ad0398cb0754c70c48bfa6abd70d5b7906853877e1ab479c4b01fab3804526eebdfb6adf983ceda35426b16e +"@aml-org/amf-antlr-parsers@npm:0.8.34": + version: 0.8.34 + resolution: "@aml-org/amf-antlr-parsers@npm:0.8.34" + checksum: 10c0/0a8fa2f13df8dd027364e27a258ae23fe6592ea8c55cd898424132b663d3b39ab98d1f1e44f63d808b1c5b47f1e9ec55752ac495b9a56159944c506579eef778 languageName: node linkType: hard @@ -8085,9 +8085,9 @@ __metadata: linkType: hard "@types/lodash@npm:^4.14.182": - version: 4.17.16 - resolution: "@types/lodash@npm:4.17.16" - checksum: 10c0/cf017901b8ab1d7aabc86d5189d9288f4f99f19a75caf020c0e2c77b8d4cead4db0d0b842d009b029339f92399f49f34377dd7c2721053388f251778b4c23534 + version: 4.17.24 + resolution: "@types/lodash@npm:4.17.24" + checksum: 10c0/b72f60d4daacdad1fa643edb3faba204c02a01eb1ac00a83ff73496a6d236fc55e459c06106e8ced42277dba932d087d8fc090f8de4ef590d3f91e6d6f7ce85a languageName: node linkType: hard @@ -9934,15 +9934,15 @@ __metadata: linkType: hard "@webex/event-dictionary-ts@npm:^1.0.1930": - version: 1.0.1947 - resolution: "@webex/event-dictionary-ts@npm:1.0.1947" + version: 1.0.2091 + resolution: "@webex/event-dictionary-ts@npm:1.0.2091" dependencies: amf-client-js: "npm:^5.2.6" json-schema-to-typescript: "npm:^12.0.0" minimist: "npm:^1.2.8" shelljs: "npm:^0.8.5" webapi-parser: "npm:^0.5.0" - checksum: 10c0/3b563f15ca895134a7ed24707daf1a937d78e237fe272f5f77e937628b6063e273eb2888d4f14d6a0d8460546d94f9da42819e74142e3d4d931ce7186361fc6c + checksum: 10c0/20d0983cebc323593d7a15c8dd26cb7c9d373b0d5e810d6dd818cbb891d42fba844c45fe859ceda810c9daf283fffe939fafcfe0b0068563d34c9731bf8e183e languageName: node linkType: hard @@ -12556,7 +12556,7 @@ __metadata: languageName: node linkType: hard -"@webex/ts-sdp@npm:1.8.2": +"@webex/ts-sdp@npm:1.8.2, @webex/ts-sdp@npm:^1.8.1": version: 1.8.2 resolution: "@webex/ts-sdp@npm:1.8.2" checksum: 10c0/d336f6d3599cbee418de6f02621028266a53c07a0a965e82eb18c9e3993ab9d29d569f5398072de43d9448972776734ca76c1ae2f257859a38793e33f8a519aa @@ -12570,13 +12570,6 @@ __metadata: languageName: node linkType: hard -"@webex/ts-sdp@npm:^1.8.1": - version: 1.8.1 - resolution: "@webex/ts-sdp@npm:1.8.1" - checksum: 10c0/9dc7c63d3274cdbf1cf42c17a2d7bc5afef640bf8200e7c812732c9a19f97d3a84df5bfecba9abc349c19c199ede22c9b7d0db32c1cf802af3d5eb56fda3fefa - languageName: node - linkType: hard - "@webex/web-capabilities@npm:^1.6.1": version: 1.6.1 resolution: "@webex/web-capabilities@npm:1.6.1" @@ -12777,6 +12770,9 @@ __metadata: "@momentum-ui/react": "npm:^23.21.4" "@semantic-release/changelog": "npm:^6.0.0" "@semantic-release/git": "npm:^10.0.0" + "@testing-library/dom": "npm:10.4.0" + "@testing-library/jest-dom": "npm:6.6.2" + "@testing-library/react": "npm:16.0.1" "@wdio/cli": "npm:^7.3.1" "@wdio/jasmine-framework": "npm:^7.4.6" "@wdio/junit-reporter": "npm:^7.4.2" @@ -13207,15 +13203,15 @@ __metadata: linkType: hard "amf-client-js@npm:^5.2.6": - version: 5.7.0 - resolution: "amf-client-js@npm:5.7.0" + version: 5.10.0 + resolution: "amf-client-js@npm:5.10.0" dependencies: - "@aml-org/amf-antlr-parsers": "npm:0.8.28" + "@aml-org/amf-antlr-parsers": "npm:0.8.34" ajv: "npm:6.12.6" avro-js: "npm:1.11.3" bin: amf: bin/amf - checksum: 10c0/0bea2694b22de128d90696115ea2e716179841d5c076eef8e7ca4dba87f6a24090bac2cb8fb702641b3cc0ea445a000cf5f7260d7a3555ae6c6d3d2502f84909 + checksum: 10c0/50f7b9d546a719df4ddb2ae40dbf3721849c3be9a2544db3bf133c2336cb047a95057bbaf3c89df539d9c6ec0609a4f6ac934cb82e53ca404aa1407f3032a714 languageName: node linkType: hard @@ -34489,9 +34485,9 @@ __metadata: linkType: hard "underscore@npm:^1.13.2": - version: 1.13.7 - resolution: "underscore@npm:1.13.7" - checksum: 10c0/fad2b4aac48847674aaf3c30558f383399d4fdafad6dd02dd60e4e1b8103b52c5a9e5937e0cc05dacfd26d6a0132ed0410ab4258241240757e4a4424507471cd + version: 1.13.8 + resolution: "underscore@npm:1.13.8" + checksum: 10c0/6677688daeda30484823e77c0b89ce4dcf29964a77d5a06f37299c007ab4bb1c66a0ff75e0d274620b62a1fe2a6ba29879f8214533ca611d71a1ae504f2bfc9b languageName: node linkType: hard From 48bf3cda3f7337f74a5f9b7bd3a46690541d6678 Mon Sep 17 00:00:00 2001 From: Ritesh Singh Date: Mon, 9 Mar 2026 16:14:37 +0530 Subject: [PATCH 05/10] fix(meetings): remove ai-docs --- packages/@webex/widgets/ai-docs/AGENTS.md | 317 ------- .../@webex/widgets/ai-docs/ARCHITECTURE.md | 777 ------------------ 2 files changed, 1094 deletions(-) delete mode 100644 packages/@webex/widgets/ai-docs/AGENTS.md delete mode 100644 packages/@webex/widgets/ai-docs/ARCHITECTURE.md diff --git a/packages/@webex/widgets/ai-docs/AGENTS.md b/packages/@webex/widgets/ai-docs/AGENTS.md deleted file mode 100644 index 0f8606aa6..000000000 --- a/packages/@webex/widgets/ai-docs/AGENTS.md +++ /dev/null @@ -1,317 +0,0 @@ -# Meetings Widget - -## Overview - -The Meetings Widget provides a full-featured Webex meeting experience as an embeddable component. It orchestrates three external repositories — `webex-js-sdk` for backend communication, `sdk-component-adapter` for reactive data binding, and `components` for the React UI. - -**Widget:** Meetings - -**Package:** `@webex/widgets` - -**Location:** `packages/@webex/widgets` - ---- - -## Why and What is This Used For? - -### Purpose - -The Meetings Widget lets consuming applications embed a complete meeting experience without building any meeting logic themselves. It handles the entire lifecycle — from SDK initialization through meeting creation, joining, in-meeting controls, and leaving — by composing existing components and adapters together. - -### Key Capabilities - -- **Join Meetings** — Connect to a meeting via URL, SIP address, or Personal Meeting Room -- **Audio Controls** — Mute and unmute microphone with transitional states -- **Video Controls** — Start and stop camera with device switching -- **Screen Sharing** — Share screen, window, or tab with other participants -- **Member Roster** — View list of meeting participants -- **Device Settings** — Switch between cameras, microphones, and speakers -- **Guest/Host Authentication** — Password-protected meetings with host key support -- **Waiting for Host** — Automatic transition when host starts the meeting - ---- - -## Examples and Use Cases - -### Getting Started - -#### Basic Usage (React) - -The widget handles SDK initialization, adapter creation, meeting creation, and all internal wiring via the `withAdapter` and `withMeeting` HOCs. Consumers just import and render with props: - -```jsx -import {WebexMeetingsWidget} from '@webex/widgets'; - -function App() { - return ( - - ); -} -``` - -#### With All Optional Props - -```jsx - -``` - -#### What Happens Internally - -When `WebexMeetingsWidget` mounts, the `withAdapter` HOC: - -1. Creates a `Webex` instance using the `accessToken` prop -2. Wraps it in a `WebexSDKAdapter` -3. Calls `adapter.connect()` (registers device, opens WebSocket, syncs meetings) -4. Provides the adapter via `AdapterContext` - -The `withMeeting` HOC then creates a meeting from `meetingDestination` and passes the meeting object as a prop. The widget renders the appropriate view based on meeting state. - -### Common Use Cases - -#### 1. Password-Protected Meeting - -When a meeting requires a password, the `WebexMeeting` component detects `passwordRequired` from the adapter observable and renders the `WebexMeetingGuestAuthentication` modal. The user enters the password, and `JoinControl.action()` passes it to the SDK. - -**Key Points:** - -- `passwordRequired` is a boolean on the adapter meeting observable -- The component handles guest vs host authentication flows -- Wrong password triggers `invalidPassword` flag on the observable - -#### 2. Pre-Join Media Preview - -Before joining, the interstitial screen shows local media preview. The user can mute audio, stop video, or open settings before entering the meeting. - -**Key Points:** - -- `WebexInterstitialMeeting` renders when `state === 'NOT_JOINED'` -- Controls available pre-join: `mute-audio`, `mute-video`, `settings`, `join-meeting` -- `JoinControl.display()` shows a hint like "Unmuted, video on" based on current state - -#### 3. Device Switching Mid-Meeting - -During an active meeting, users can switch cameras, microphones, or speakers through the settings panel. - -**Key Points:** - -- `SettingsControl.action()` opens the `WebexSettings` modal -- `SwitchCameraControl.action({ meetingID, cameraId })` calls `switchCamera(meetingID, cameraId)` on the adapter -- The adapter acquires a new media stream with the selected device and emits an updated `localVideo.stream` - -#### 4. Screen Sharing - -The share button triggers the browser's native screen picker. The SDK handles `getDisplayMedia()` and negotiates the share stream with the backend. - -**Key Points:** - -- `ShareControl` checks `navigator.mediaDevices.getDisplayMedia` availability -- If unsupported, the control renders as DISABLED -- The adapter emits `localShare.stream` with the display stream when sharing starts - ---- - -## Three-Repository Architecture - -```mermaid -graph LR - subgraph "Widget" - W[Meetings Widget] - end - - subgraph "components" - C[WebexMeeting & UI] - end - - subgraph "sdk-component-adapter" - A[MeetingsSDKAdapter] - end - - subgraph "webex-js-sdk" - S[Webex Instance] - end - - W -->|renders| C - W -->|creates| A - W -->|initializes| S - C -->|uses via AdapterContext| A - A -->|wraps| S - - style W fill:#e1f5ff,color:#000 - style C fill:#d4edda,color:#000 - style A fill:#fff4e1,color:#000 - style S fill:#ffe1e1,color:#000 -``` - - - - -| Repository | Role | Key Exports Used | -| ----------------------- | ----------------------------------------- | ------------------------------------------------------------------- | -| `webex-js-sdk` | Core SDK for Webex backend communication | `Webex.init()`, `webex.meetings`, meeting methods | -| `sdk-component-adapter` | Reactive adapter layer (RxJS observables) | `WebexSDKAdapter`, `MeetingsSDKAdapter`, all Control classes | -| `components` | React UI components + hooks | `WebexMeeting`, `AdapterContext`, `useMeeting`, `useMeetingControl` | - - ---- - -## Dependencies - -**Note:** For exact versions, see [package.json](../package.json) - -### Runtime Dependencies - - -| Package | Purpose | -| ------------------------------ | ----------------------------------------------------- | -| `webex` | Core Webex JavaScript SDK for backend communication | -| `@webex/sdk-component-adapter` | Reactive adapter that wraps SDK into RxJS observables | -| `@webex/components` | React UI components for meeting views and controls | - - -### Peer Dependencies - - -| Package | Purpose | -| ----------- | ------------------- | -| `react` | React framework | -| `react-dom` | React DOM rendering | - - ---- - -## API Reference - -### WebexMeetingsWidget Props (Public API) - -These are the props consumers pass when using the widget. The widget handles SDK/adapter setup internally. - - -| Prop | Type | Required | Default | Description | -| --------------------------- | ---------- | -------- | ----------- | -------------------------------------------------------------- | -| `accessToken` | `string` | **Yes** | — | Webex access token for authentication | -| `meetingDestination` | `string` | **Yes** | — | Meeting URL, SIP address, email, or Personal Meeting Room link | -| `meetingPasswordOrPin` | `string` | No | `''` | Password or host pin for protected meetings | -| `participantName` | `string` | No | `''` | Display name for guest participants | -| `fedramp` | `bool` | No | `false` | Enable FedRAMP-compliant environment | -| `layout` | `string` | No | `'Grid'` | Remote video layout (`Grid`, `Stack`, `Overlay`, `Prominent`, `Focus`) | -| `controls` | `Function` | No | `undefined` | Function returning control IDs to render | -| `controlsCollapseRangeStart`| `number` | No | `undefined` | Zero-based index of the first collapsible control | -| `controlsCollapseRangeEnd` | `number` | No | `undefined` | Zero-based index before the last collapsible control | -| `className` | `string` | No | `''` | Custom CSS class for the root element | -| `style` | `object` | No | `{}` | Inline styles for the root element | - - -**Source:** `src/widgets/WebexMeetings/WebexMeetings.jsx` (see `WebexMeetingsWidget.propTypes` and `WebexMeetingsWidget.defaultProps`) - -### Internal Component Props (WebexMeeting from @webex/components) - -These are passed internally by `WebexMeetingsWidget` to the `WebexMeeting` component from `@webex/components`. Consumers do not interact with these directly. - - -| Prop | Type | Description | -| ---------------------- | ------------- | -------------------------------------------------------------- | -| `meetingID` | `string` | Injected by `withMeeting` HOC from `meetingDestination` | -| `meetingPasswordOrPin` | `string` | Forwarded from widget prop | -| `participantName` | `string` | Forwarded from widget prop | -| `controls` | `Function` | Forwarded from widget prop | -| `layout` | `string` | Forwarded from widget prop | -| `logo` | `JSX.Element` | Hard-coded `` SVG | -| `className` | `string` | Always `'webex-meetings-widget__content'` | - -The `WebexMeeting` component receives its adapter via `AdapterContext.Provider`, which is set up by the `withAdapter` HOC wrapping the widget. - -### Hooks (from `components`) - - -| Hook | Parameters | Returns | Description | -| ------------------------------------------- | --------------------------------- | ---------------------------------------------- | ------------------------------------------------------------------- | -| `useMeeting(meetingID)` | `meetingID: string` | Meeting object (see ARCHITECTURE.md for shape) | Subscribes to the adapter's meeting observable | -| `useMeetingControl(type, meetingID)` | `type: string, meetingID: string` | `[action, display]` (array) | Returns action function and display state for a control | -| `useMeetingDestination(meetingDestination)` | `meetingDestination: string` | Meeting object | Creates a meeting from destination and subscribes to its observable | - - -### WebexSDKAdapter Methods (top-level adapter) - - -| Method | Returns | Description | -| -------------- | --------------- | --------------------------------------------------------------------------------------------------------------- | -| `connect()` | `Promise` | Calls `sdk.internal.device.register()` → `sdk.internal.mercury.connect()` → `meetingsAdapter.connect()` | -| `disconnect()` | `Promise` | Calls `meetingsAdapter.disconnect()` → `sdk.internal.mercury.disconnect()` → `sdk.internal.device.unregister()` | - - -### MeetingsSDKAdapter Methods - - -| Method | Parameters | Returns | Description | -| ------------------------------------ | ------------------------------------------------------ | --------------------- | ------------------------------------------------------- | -| `connect()` | — | `Promise` | Calls `meetings.register()` + `meetings.syncMeetings()` | -| `disconnect()` | — | `Promise` | Calls `meetings.unregister()` | -| `createMeeting(destination)` | `destination: string` | `Observable` | Creates a meeting from URL, SIP, or PMR | -| `joinMeeting(ID, options)` | `ID: string, { password?, name?, hostKey?, captcha? }` | `Promise` | Joins the meeting | -| `leaveMeeting(ID)` | `ID: string` | `Promise` | Leaves and cleans up the meeting | -| `handleLocalAudio(ID)` | `ID: string` | `Promise` | Toggles audio mute/unmute | -| `handleLocalVideo(ID)` | `ID: string` | `Promise` | Toggles video on/off | -| `handleLocalShare(ID)` | `ID: string` | `Promise` | Toggles screen share on/off | -| `toggleRoster(ID)` | `ID: string` | `void` | Toggles member roster panel (client-side only) | -| `toggleSettings(ID)` | `ID: string` | `Promise` | Toggles settings modal; applies device changes on close | -| `switchCamera(ID, cameraID)` | `ID, cameraID: string` | `Promise` | Switches to a different camera device | -| `switchMicrophone(ID, microphoneID)` | `ID, microphoneID: string` | `Promise` | Switches to a different microphone | -| `switchSpeaker(ID, speakerID)` | `ID, speakerID: string` | `Promise` | Switches to a different speaker (client-side only) | - - -### Control Action Parameters - -All control `action()` methods take a **destructured object**, not a plain string. - - -| Control | `action()` Parameters | Adapter Method Called | -| ------------------------- | ------------------------------------------------------ | -------------------------------------------- | -| `AudioControl` | `{ meetingID }` | `handleLocalAudio(meetingID)` | -| `VideoControl` | `{ meetingID }` | `handleLocalVideo(meetingID)` | -| `ShareControl` | `{ meetingID }` | `handleLocalShare(meetingID)` | -| `JoinControl` | `{ meetingID, meetingPasswordOrPin, participantName }` | `joinMeeting(meetingID, { password, name })` | -| `ExitControl` | `{ meetingID }` | `leaveMeeting(meetingID)` | -| `RosterControl` | `{ meetingID }` | `toggleRoster(meetingID)` | -| `SettingsControl` | `{ meetingID }` | `toggleSettings(meetingID)` | -| `SwitchCameraControl` | `{ meetingID, cameraId }` | `switchCamera(meetingID, cameraId)` | -| `SwitchMicrophoneControl` | `{ meetingID, microphoneId }` | `switchMicrophone(meetingID, microphoneId)` | -| `SwitchSpeakerControl` | `{ meetingID, speakerId }` | `switchSpeaker(meetingID, speakerId)` | - - -### Control IDs for WebexMeetingControlBar - - -| Control ID | Class | Type | Available | -| ------------------- | ------------------------- | ----------- | --------------------- | -| `mute-audio` | `AudioControl` | TOGGLE | Pre-join + In-meeting | -| `mute-video` | `VideoControl` | TOGGLE | Pre-join + In-meeting | -| `share-screen` | `ShareControl` | TOGGLE | In-meeting only | -| `join-meeting` | `JoinControl` | JOIN | Pre-join only | -| `leave-meeting` | `ExitControl` | CANCEL | In-meeting only | -| `member-roster` | `RosterControl` | TOGGLE | In-meeting only | -| `settings` | `SettingsControl` | TOGGLE | Pre-join + In-meeting | -| `switch-camera` | `SwitchCameraControl` | MULTISELECT | Settings panel | -| `switch-microphone` | `SwitchMicrophoneControl` | MULTISELECT | Settings panel | -| `switch-speaker` | `SwitchSpeakerControl` | MULTISELECT | Settings panel | - - ---- - -## Additional Resources - -For detailed architecture, event flows, data structures, and troubleshooting, see [ARCHITECTURE.md](./ARCHITECTURE.md). - ---- - diff --git a/packages/@webex/widgets/ai-docs/ARCHITECTURE.md b/packages/@webex/widgets/ai-docs/ARCHITECTURE.md deleted file mode 100644 index f9f7c5f06..000000000 --- a/packages/@webex/widgets/ai-docs/ARCHITECTURE.md +++ /dev/null @@ -1,777 +0,0 @@ -# Meetings Widget - Architecture - -## Component Overview - -The Meetings Widget composes three external repositories into an embeddable meeting experience. The widget initializes `webex-js-sdk`, wraps it with `sdk-component-adapter`, and renders `components` repo UI via `AdapterContext`. - -### Layer Architecture - -```mermaid -graph TB - subgraph "Widget Layer" - W[Widget Entry Point] - end - - subgraph "UI Layer (components repo)" - WM[WebexMeeting] - WIM[WebexInterstitialMeeting] - WIN[WebexInMeeting] - MCB[WebexMeetingControlBar] - WLM[WebexLocalMedia] - WRM[WebexRemoteMedia] - end - - subgraph "Adapter Layer (sdk-component-adapter)" - ADAPT[MeetingsSDKAdapter] - AC[AudioControl] - VC[VideoControl] - SC[ShareControl] - JC[JoinControl] - EC[ExitControl] - end - - subgraph "SDK Layer (webex-js-sdk)" - SDK[Webex Instance] - end - - subgraph "Backend" - BE[Backend] - end - - W -->|creates| SDK - W -->|creates| ADAPT - W -->|AdapterContext| WM - WM --> WIM - WM --> WIN - WM --> MCB - WIN --> WLM - WIN --> WRM - MCB --> AC & VC & SC & JC & EC - AC & VC & SC & JC & EC --> ADAPT - ADAPT --> SDK - SDK --> BE - - style W fill:#e1f5ff,color:#000 - style ADAPT fill:#fff4e1,color:#000 - style SDK fill:#ffe1e1,color:#000 - style BE fill:#f0f0f0,color:#000 -``` - - - -### Component Table - - -| Component | Source | Purpose | Data Source | -| --------------------------------- | ------------------------------------------------------------ | ----------------------------------------------------------------- | ------------------------------------------ | -| `WebexMeeting` | `components/src/components/WebexMeeting/` | Master orchestrator — renders correct view based on meeting state | `useMeeting(meetingID)` | -| `WebexInterstitialMeeting` | `components/src/components/WebexInterstitialMeeting/` | Pre-join lobby with local media preview | state=NOT_JOINED | -| `WebexInMeeting` | `components/src/components/WebexInMeeting/` | Active meeting view with remote + local media | state=JOINED | -| `WebexWaitingForHost` | `components/src/components/WebexWaitingForHost/` | Waiting room when host hasn't started | state is else (not JOINED/NOT_JOINED/LEFT) | -| `WebexMeetingControlBar` | `components/src/components/WebexMeetingControlBar/` | Renders meeting control buttons | Maps control IDs to Control classes | -| `WebexMeetingControl` | `components/src/components/WebexMeetingControl/` | Individual control button | `useMeetingControl(controlID)` | -| `WebexLocalMedia` | `components/src/components/WebexLocalMedia/` | Local camera preview | `localVideo.stream` | -| `WebexRemoteMedia` | `components/src/components/WebexRemoteMedia/` | Remote participant video | `remoteVideo` / `remoteShare` | -| `WebexMemberRoster` | `components/src/components/WebexMemberRoster/` | Participant list panel | `showRoster` flag | -| `WebexSettings` | `components/src/components/WebexSettings/` | Audio/video device settings modal | `settings.visible` flag | -| `WebexMeetingGuestAuthentication` | `components/src/components/WebexMeetingGuestAuthentication/` | Guest password entry | `passwordRequired` flag | -| `WebexMeetingHostAuthentication` | `components/src/components/WebexMeetingHostAuthentication/` | Host pin entry | `passwordRequired` flag | - - ---- - -## SDK Integration - - -| Area | SDK Methods | Adapter Methods | Control Class | -| ----------------- | ---------------------------------------------------------------------------- | --------------------------------------------------------------------------------- | ------------------------- | -| Initialization | `Webex.init()`, `device.register()`, `mercury.connect()` | `sdkAdapter.connect()` → calls `meetings.register()` + `syncMeetings()` | — | -| Meeting creation | `webex.meetings.create(destination)` | `adapter.meetingsAdapter.createMeeting(dest)` | — | -| Join | `sdkMeeting.verifyPassword()`, `sdkMeeting.join({ pin, moderator, alias })` | `adapter.meetingsAdapter.joinMeeting(ID, options)` | `JoinControl` | -| Leave | `sdkMeeting.leave()` | `adapter.meetingsAdapter.leaveMeeting(ID)` (also calls `removeMedia`) | `ExitControl` | -| Mute/Unmute Audio | `sdkMeeting.muteAudio()`, `sdkMeeting.unmuteAudio()` | `adapter.meetingsAdapter.handleLocalAudio(ID)` | `AudioControl` | -| Mute/Unmute Video | `sdkMeeting.muteVideo()`, `sdkMeeting.unmuteVideo()` | `adapter.meetingsAdapter.handleLocalVideo(ID)` | `VideoControl` | -| Screen Share | `sdkMeeting.getMediaStreams()`, `sdkMeeting.updateShare()` * | `adapter.meetingsAdapter.handleLocalShare(ID)` | `ShareControl` | -| Toggle Roster | — (client-side) | `adapter.meetingsAdapter.toggleRoster(ID)` | `RosterControl` | -| Toggle Settings | `sdkMeeting.updateVideo()`, `sdkMeeting.updateAudio()` (on close, if joined) | `adapter.meetingsAdapter.toggleSettings(ID)` | `SettingsControl` | -| Switch Camera | `sdkMeeting.getMediaStreams()` * | `adapter.switchCamera(ID, cameraID)` | `SwitchCameraControl` | -| Switch Microphone | `sdkMeeting.getMediaStreams()` * | `adapter.switchMicrophone(ID, microphoneID)` | `SwitchMicrophoneControl` | -| Switch Speaker | — (client-side, updates meeting state only) | `adapter.switchSpeaker(ID, speakerID)` | `SwitchSpeakerControl` | -| Cleanup | `meetings.unregister()`, `mercury.disconnect()`, `device.unregister()` | `sdkAdapter.disconnect()` → calls `meetingsAdapter.disconnect()` then SDK cleanup | — | - - -** `getMediaStreams()` and `updateShare()` are the SDK methods invoked by the adapter source code. In newer SDK versions, equivalent functionality is provided by `media.getUserMedia()`, `addMedia()`, `publishStreams()`, and `updateMedia()`.* - ---- - -## Data Flow - -### Outbound (User Action → Backend) - -``` -User clicks control button - → Component (WebexMeetingControl) - → useMeetingControl hook - → Control.action({ meetingID }) - → sdk-component-adapter method - → webex-js-sdk meeting method - → Backend (REST/WebSocket) -``` - -### Inbound (Backend → UI Update) - -``` -Backend processes request - → WebSocket event delivered to webex-js-sdk - → sdk-component-adapter detects change - → RxJS BehaviorSubject emits new meeting state - → useMeeting hook receives update - → Component re-renders -``` - ---- - -## Adapter Meeting Object (from `createMeeting` + runtime updates) - -This is the real shape emitted by `adapter.meetingsAdapter.getMeeting(ID)`: - -``` -{ - ID: string - title: string - state: 'NOT_JOINED' | 'JOINED' | 'LEFT' - - localAudio: { - stream: MediaStream | null - permission: string | null // 'ALLOWED' | 'ERROR' | null - muting: boolean | undefined // true = muting in progress, false = unmuting, undefined = idle - } - localVideo: { - stream: MediaStream | null - permission: string | null - muting: boolean | undefined - error: string | null // e.g. 'Video not supported on iOS 15.1' - } - localShare: { - stream: MediaStream | null - } - - remoteAudio: MediaStream | null - remoteVideo: MediaStream | null - remoteShare: MediaStream | null - - disabledLocalAudio: MediaStream | null // stores the stream when audio is muted - disabledLocalVideo: MediaStream | null // stores the stream when video is muted - - showRoster: boolean | null - settings: { - visible: boolean - preview: { - audio: MediaStream | null - video: MediaStream | null - } - } - - passwordRequired: boolean - requiredCaptcha: object - cameraID: string | null - microphoneID: string | null - speakerID: string -} -``` - ---- - -## Event Flows - -### 1. SDK Initialization - -```mermaid -sequenceDiagram - participant User - participant Component as WebexMeeting - participant Adapter as sdk-component-adapter - participant SDK as webex-js-sdk - participant Backend - - User->>Component: Mount widget with accessToken - Component->>SDK: Webex.init({ credentials: { access_token } }) - Component->>Adapter: new WebexSDKAdapter(webex) - Adapter->>Adapter: Create MeetingsSDKAdapter(webex) with controls - - Component->>Adapter: sdkAdapter.connect() - Adapter->>SDK: sdk.internal.device.register() - SDK->>Backend: Register device - Backend-->>SDK: Device registered - Adapter->>SDK: sdk.internal.mercury.connect() - SDK->>Backend: Open WebSocket - Backend-->>SDK: WebSocket connected - Adapter->>SDK: webex.meetings.register() + syncMeetings() - SDK-->>Adapter: Meetings ready - - Component->>Component: Render with AdapterContext.Provider -``` - - - ---- - -### 2. Meeting Creation & Interstitial - -```mermaid -sequenceDiagram - participant User - participant Component as WebexMeeting - participant Adapter as sdk-component-adapter - participant SDK as webex-js-sdk - participant Backend - - User->>Component: Provide meeting destination (URL/SIP/PMR) - Component->>Adapter: createMeeting(destination) - Adapter->>SDK: webex.meetings.create(destination) - SDK->>Backend: Resolve meeting info, check active sessions, get user profile - Backend-->>SDK: Meeting info (title, sipUri), user profile - - Note over SDK: Meeting object created with state=NOT_JOINED - - SDK-->>Adapter: Meeting object - Adapter->>Adapter: Create meeting observable (RxJS) - Adapter-->>Component: meetingID - - Component->>Component: Render WebexInterstitialMeeting - Component->>Component: Show local media preview - Component->>Component: Show controls [mute-audio, mute-video, settings, join-meeting] -``` - - - ---- - -### 3. Join Meeting - -```mermaid -sequenceDiagram - participant User - participant Component as WebexMeetingControlBar - participant Adapter as JoinControl - participant SDK as webex-js-sdk - participant Backend - - User->>Component: Click "Join Meeting" button - Component->>Adapter: action({ meetingID, meetingPasswordOrPin, participantName }) - Adapter->>Adapter: joinMeeting(ID, { password, name }) - - alt Password Required - Adapter->>SDK: sdkMeeting.verifyPassword(password, captcha) - SDK->>Backend: Verify password - Backend-->>SDK: Verified - end - - Adapter->>SDK: sdkMeeting.join({ pin, moderator, alias }) - SDK->>Backend: Join meeting session - Backend-->>SDK: Session joined, media connections ready - - SDK->>SDK: Negotiate media (SDP offer/answer) - SDK->>Backend: Send local media description - Backend-->>SDK: Media established (audio + video active) - - SDK-->>Adapter: Meeting state updated - Adapter->>Adapter: Emit observable { state: JOINED } - Adapter-->>Component: Observable emits - - Component->>Component: Transition: WebexInterstitialMeeting → WebexInMeeting - Component->>Component: Update controls [mute-audio, mute-video, share-screen, member-roster, settings, leave-meeting] -``` - - - ---- - -### 4. Mute / Unmute Audio - -```mermaid -sequenceDiagram - participant User - participant Component as WebexMeetingControlBar - participant Adapter as AudioControl - participant SDK as webex-js-sdk - participant Backend - - Note over User: Audio is currently UNMUTED - - User->>Component: Click microphone button - Component->>Adapter: action({ meetingID }) - Adapter->>Adapter: handleLocalAudio(ID) - Adapter->>Adapter: Set localAudio.muting = true - Adapter->>SDK: sdkMeeting.muteAudio() - SDK->>Backend: Update media state (audio → receive-only) - Backend-->>SDK: Confirmed - - Adapter->>Adapter: Emit { disabledLocalAudio: stream, localAudio.stream: null } - Adapter-->>Component: display() emits { icon: microphone-muted, text: Unmute, state: ACTIVE } - Component->>Component: Re-render with muted icon - - Note over User: Audio is now MUTED — click again to unmute - - User->>Component: Click microphone button - Component->>Adapter: action({ meetingID }) - Adapter->>Adapter: handleLocalAudio(ID) - Adapter->>Adapter: Set localAudio.muting = false - Adapter->>SDK: sdkMeeting.unmuteAudio() - SDK->>Backend: Update media state (audio → send+receive) - Backend-->>SDK: Confirmed - - Adapter->>Adapter: Emit { disabledLocalAudio: null, localAudio.stream: stream } - Adapter-->>Component: display() emits { icon: microphone, text: Mute, state: INACTIVE } - Component->>Component: Re-render with unmuted icon -``` - - - ---- - -### 5. Start / Stop Video - -```mermaid -sequenceDiagram - participant User - participant Component as WebexMeetingControlBar - participant Adapter as VideoControl - participant SDK as webex-js-sdk - participant Backend - - Note over User: Video is currently ON - - User->>Component: Click camera button - Component->>Adapter: action({ meetingID }) - Adapter->>Adapter: handleLocalVideo(ID) - Adapter->>Adapter: Set localVideo.muting = true - Adapter->>SDK: sdkMeeting.muteVideo() - SDK->>Backend: Update media state (video → receive-only) - Backend-->>SDK: Confirmed - - Adapter->>Adapter: Emit { disabledLocalVideo: stream, localVideo.stream: null } - Adapter-->>Component: display() emits { icon: camera-muted, text: Start video, state: ACTIVE } - - Note over User: Video is now OFF — click again to start - - User->>Component: Click camera button - Component->>Adapter: action({ meetingID }) - Adapter->>Adapter: handleLocalVideo(ID) - Adapter->>Adapter: Set localVideo.muting = false - Adapter->>SDK: sdkMeeting.unmuteVideo() - SDK->>Backend: Update media state (video → send+receive) - Backend-->>SDK: Confirmed - - Adapter->>Adapter: Emit { disabledLocalVideo: null, localVideo.stream: stream } - Adapter-->>Component: display() emits { icon: camera, text: Stop video, state: INACTIVE } -``` - - - ---- - -### 6. Start / Stop Screen Share - -```mermaid -sequenceDiagram - participant User - participant Component as WebexMeetingControlBar - participant Adapter as ShareControl - participant SDK as webex-js-sdk - participant Backend - - User->>Component: Click share screen button - Component->>Adapter: action({ meetingID }) - Adapter->>Adapter: handleLocalShare(ID) - Adapter->>SDK: sdkMeeting.getMediaStreams({ sendShare: true }) - SDK->>User: Browser screen picker dialog (getDisplayMedia) - User->>SDK: Select screen/window/tab - SDK-->>Adapter: [, localShareStream] - Adapter->>SDK: sdkMeeting.updateShare({ stream, sendShare: true, receiveShare: true }) - SDK->>Backend: Update media state (share → send+receive) - Backend-->>SDK: Confirmed - - Adapter->>Adapter: Emit { localShare.stream: localShareStream } - Adapter-->>Component: display() emits { text: Stop sharing, state: ACTIVE } - - Note over User: Sharing active — click again to stop - - User->>Component: Click stop sharing - Component->>Adapter: action({ meetingID }) - Adapter->>Adapter: handleLocalShare(ID) - Adapter->>Adapter: stopStream(localShare.stream) - Adapter->>SDK: sdkMeeting.updateShare({ sendShare: false, receiveShare: true }) - SDK->>Backend: Update media state (share → receive-only) - Backend-->>SDK: Confirmed - - Adapter->>Adapter: Emit { localShare.stream: null } - Adapter-->>Component: display() emits { text: Start sharing, state: INACTIVE } -``` - - - ---- - -### 7. Toggle Member Roster - -```mermaid -sequenceDiagram - participant User - participant Component as WebexMeeting - participant Adapter as RosterControl - - Note over Adapter: Client-side only — no Backend call - - User->>Component: Click roster button - Component->>Adapter: action({ meetingID }) - Adapter->>Adapter: toggleRoster(ID) - Adapter->>Adapter: meeting.showRoster = !meeting.showRoster - Adapter->>Adapter: Emit observable { showRoster: true } - Adapter-->>Component: Observable emits - Component->>Component: Render WebexMemberRoster panel - - User->>Component: Click roster button (close) - Component->>Adapter: action({ meetingID }) - Adapter->>Adapter: toggleRoster(ID) - Adapter->>Adapter: Emit { showRoster: false } - Adapter-->>Component: Observable emits - Component->>Component: Remove WebexMemberRoster panel -``` - - - ---- - -### 8. Toggle Settings & Switch Camera - -```mermaid -sequenceDiagram - participant User - participant Component as WebexMeeting - participant Adapter as sdk-component-adapter - participant SDK as webex-js-sdk - - User->>Component: Click settings button - Component->>Adapter: SettingsControl.action({ meetingID }) - Adapter->>Adapter: toggleSettings(ID) - Adapter->>Adapter: Clone current streams to settings.preview - Adapter->>Adapter: Emit { settings.visible: true } - Adapter-->>Component: Observable emits - Component->>Component: Open WebexSettings modal - - Note over User: User selects a different camera - - User->>Component: Select new camera from dropdown - Component->>Adapter: SwitchCameraControl.action({ meetingID, cameraId }) - Adapter->>Adapter: switchCamera(ID, cameraId) - Adapter->>SDK: sdkMeeting.getMediaStreams({ sendVideo: true }, { video: { deviceId } }) - SDK->>SDK: getUserMedia with new deviceId - SDK-->>Adapter: New video MediaStream - Adapter->>Adapter: Emit { settings.preview.video: newStream, cameraID } - Adapter-->>Component: Settings preview re-renders with new camera - - User->>Component: Close settings modal - Component->>Adapter: SettingsControl.action({ meetingID }) - Adapter->>Adapter: toggleSettings(ID) - Adapter->>Adapter: Replace meeting streams with preview streams - - alt Meeting is joined - Adapter->>SDK: sdkMeeting.updateVideo({ stream, receiveVideo, sendVideo }) - Adapter->>SDK: sdkMeeting.updateAudio({ stream, receiveAudio, sendAudio }) - end - - Adapter->>Adapter: Emit { settings.visible: false } - Component->>Component: Close modal -``` - - - ---- - -### 9. Leave Meeting - -```mermaid -sequenceDiagram - participant User - participant Component as WebexMeetingControlBar - participant Adapter as ExitControl - participant SDK as webex-js-sdk - participant Backend - - User->>Component: Click leave meeting button - Component->>Adapter: action({ meetingID }) - Adapter->>Adapter: leaveMeeting(ID) - Adapter->>Adapter: removeMedia(ID) — stop all local streams - Adapter->>SDK: sdkMeeting.leave() - SDK->>Backend: Leave session - Backend-->>SDK: Confirmed - - SDK-->>Adapter: Meeting state updated - Adapter->>Adapter: Emit { state: LEFT } - Adapter-->>Component: Observable emits - - Component->>Component: Show "You've successfully left the meeting" -``` - - - ---- - -### 10. Guest/Host Authentication - -```mermaid -sequenceDiagram - participant User - participant Component as WebexMeeting - participant Adapter as JoinControl - participant SDK as webex-js-sdk - participant Backend - - Note over Component: Meeting has passwordRequired=true - - Component->>Component: Detect passwordRequired from observable - Component->>Component: Open WebexMeetingGuestAuthentication modal - - User->>Component: Enter password, click "Join as Guest" - Component->>Adapter: action({ meetingID, meetingPasswordOrPin: password }) - Adapter->>SDK: joinMeeting(ID, { password }) - SDK->>Backend: Verify password and join - Backend-->>SDK: Result - - alt Password Correct - SDK-->>Adapter: state → JOINED - Adapter-->>Component: Observable emits - Component->>Component: Close auth modal, show in-meeting view - else Password Incorrect - SDK-->>Adapter: Error / invalidPassword flag - Adapter-->>Component: Observable emits { invalidPassword: true } - Component->>Component: Show error in auth modal - end - - Note over User: Alternative: "I'm the host" - - User->>Component: Click "I'm the host" - Component->>Component: Switch to WebexMeetingHostAuthentication modal - User->>Component: Enter host pin, click "Start Meeting" - Component->>Adapter: action({ meetingID, meetingPasswordOrPin: hostPin }) - Adapter->>SDK: joinMeeting(ID, { hostKey: hostPin }) -``` - - - ---- - -### 11. Waiting for Host - -```mermaid -sequenceDiagram - participant User - participant Component as WebexMeeting - participant Adapter as sdk-component-adapter - participant SDK as webex-js-sdk - participant Backend - - Note over Component: Meeting joined but host not yet present - - Component->>Component: state is not JOINED, NOT_JOINED, or LEFT - Component->>Component: Render WebexWaitingForHost - Component->>User: Show "Waiting for the host to start the meeting" - - Note over Backend: Host joins the meeting - - Backend-->>SDK: WebSocket event — host joined, meeting started - SDK-->>Adapter: Meeting state updated - Adapter->>Adapter: Emit { state: JOINED } - Adapter-->>Component: Observable emits - - Component->>Component: Transition: WebexWaitingForHost → WebexInMeeting -``` - - - ---- - -## Meeting State Machine - -```mermaid -stateDiagram-v2 - [*] --> NOT_JOINED: SDK + Adapter ready, meeting created - - NOT_JOINED --> JOINED: User joins (JoinControl) - - JOINED --> LEFT: User leaves (ExitControl) - - LEFT --> [*]: Widget unmounts -``` - - - -*These are the three states emitted by the adapter's meeting observable. The `WebexMeeting` component also handles a falsy state (loading) and an else catch-all (WebexWaitingForHost).* - ---- - -## Control Display States (from actual source) - -### AudioControl - - -| State | Icon | Text | Tooltip | Control State | -| ------------ | ------------------ | ------------- | ----------------------- | ------------- | -| unmuted | `microphone` | Mute | Mute audio | INACTIVE | -| muted | `microphone-muted` | Unmute | Unmute audio | ACTIVE | -| muting | `microphone` | Muting... | Muting audio | DISABLED | -| unmuting | `microphone-muted` | Unmuting... | Unmuting audio | DISABLED | -| noMicrophone | `microphone-muted` | No microphone | No microphone available | DISABLED | - - -### VideoControl - - -| State | Icon | Text | Tooltip | Control State | -| -------- | -------------- | ----------- | ------------------- | ------------- | -| unmuted | `camera` | Stop video | Stop video | INACTIVE | -| muted | `camera-muted` | Start video | Start video | ACTIVE | -| muting | `camera` | Stopping... | Stopping video | DISABLED | -| unmuting | `camera-muted` | Starting... | Starting video | DISABLED | -| noCamera | `camera-muted` | No camera | No camera available | DISABLED | - - -### ShareControl - - -| State | Icon | Text | Tooltip | Control State | Type | -| ------------ | ------------------------------ | ------------- | -------------------------- | ------------- | ------ | -| inactive | `share-screen-presence-stroke` | Start sharing | Start sharing content | INACTIVE | TOGGLE | -| active | `share-screen-presence-stroke` | Stop sharing | Stop sharing content | ACTIVE | TOGGLE | -| notSupported | `share-screen-presence-stroke` | Start sharing | Share screen not supported | DISABLED | TOGGLE | - - -### JoinControl - - -| Text | Tooltip | Hint | Control State | Type | -| ------------ | ------------ | ------------------------------- | --------------------------------- | ---- | -| Join meeting | Join meeting | {Muted/Unmuted}, {video on/off} | ACTIVE (if NOT_JOINED) / DISABLED | JOIN | - - -### ExitControl - -Renders as a CANCEL type button. - ---- - -## Troubleshooting Guide - -### 1. Widget Stuck on Loading - -**Symptoms:** Loading state never resolves, no meeting UI appears - -**Possible Causes:** - -- Invalid or expired access token -- Network connectivity to backend -- Device registration failure - -**Solutions:** - -- Verify the access token is valid and not expired -- Check network connectivity (browser dev tools network tab) -- Check browser console for SDK error messages - ---- - -### 2. Audio/Video Not Working After Join - -**Symptoms:** Joined meeting but no audio/video, controls show "No camera" or "No microphone" - -**Possible Causes:** - -- Browser denied `getUserMedia` permissions -- Media negotiation (SDP/ROAP) failed -- Media server unreachable - -**Solutions:** - -- Check browser permission prompts for camera/microphone -- Verify `getUserMedia` works in browser console -- Check for errors in SDK logs - ---- - -### 3. Screen Share Not Available - -**Symptoms:** Share button disabled, shows "Share screen not supported" - -**Possible Causes:** - -- Browser doesn't support `getDisplayMedia` -- Running over HTTP instead of HTTPS -- `navigator.mediaDevices.getDisplayMedia` is undefined - -**Solutions:** - -- Verify HTTPS is being used -- Check browser compatibility -- `ShareControl` checks `navigator.mediaDevices.getDisplayMedia` availability before enabling - ---- - -### 4. Meeting State Not Updating - -**Symptoms:** UI doesn't change after control actions - -**Possible Causes:** - -- WebSocket connection dropped -- Observable subscription lost -- Adapter not emitting updates - -**Solutions:** - -- Check WebSocket status in network tab -- Verify the observable subscription is active -- Look for WebSocket events in the network inspector - ---- - -### 5. Multiple Meeting Instances Created - -**Symptoms:** Widget creates duplicate meetings - -**Possible Causes:** - -- React strict mode causing double initialization -- Missing cleanup on prop changes -- Missing dependency array in useEffect - -**Solutions:** - -- Use a ref to track initialization state -- Implement proper cleanup in useEffect return -- Guard against re-initialization - ---- - -### 6. AdapterContext Not Provided - -**Symptoms:** Components crash with "Cannot read property of undefined" - -**Possible Causes:** - -- `AdapterContext.Provider` not wrapping `WebexMeeting` -- Adapter not yet initialized when components render - -**Solutions:** - -- Ensure `` wraps all components -- Wait for adapter to be ready before rendering - ---- - -## Related Documentation - -- [Agent Documentation](./AGENTS.md) - Widget usage and API reference - ---- - From 48f41cc6131c0c9937ff5d5b8c4696ed3d2a9dd2 Mon Sep 17 00:00:00 2001 From: Ritesh Singh Date: Mon, 9 Mar 2026 16:18:31 +0530 Subject: [PATCH 06/10] fix(meetings): update package.json --- packages/@webex/widgets/package.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/@webex/widgets/package.json b/packages/@webex/widgets/package.json index cb908d17f..3dec22ace 100644 --- a/packages/@webex/widgets/package.json +++ b/packages/@webex/widgets/package.json @@ -17,6 +17,7 @@ "release:debug": "semantic-release --debug", "release:dry-run": "semantic-release --dry-run", "start": "npm run demo:serve", + "test:unit": "jest --config jest.config.js --coverage", "test:e2e": "npm run demo:build && wdio wdio.conf.js", "test:eslint": "echo 'Broken eslint tests'", "test:eslint:broken": "eslint src/" @@ -57,6 +58,9 @@ "@momentum-ui/react": "^23.21.4", "@semantic-release/changelog": "^6.0.0", "@semantic-release/git": "^10.0.0", + "@testing-library/dom": "10.4.0", + "@testing-library/jest-dom": "6.6.2", + "@testing-library/react": "16.0.1", "@wdio/cli": "^7.3.1", "@wdio/jasmine-framework": "^7.4.6", "@wdio/junit-reporter": "^7.4.2", From 0c87daf3c2a8e91b3be80896416406c347a11acb Mon Sep 17 00:00:00 2001 From: Ritesh Singh Date: Tue, 10 Mar 2026 09:46:45 +0530 Subject: [PATCH 07/10] fix(meetings): remove redundant tests --- .../WebexMeetings/WebexMeetings.test.jsx | 67 ------------------- 1 file changed, 67 deletions(-) diff --git a/packages/@webex/widgets/tests/WebexMeetings/WebexMeetings.test.jsx b/packages/@webex/widgets/tests/WebexMeetings/WebexMeetings.test.jsx index 4eb3f3e3e..7e0c2e8f2 100644 --- a/packages/@webex/widgets/tests/WebexMeetings/WebexMeetings.test.jsx +++ b/packages/@webex/widgets/tests/WebexMeetings/WebexMeetings.test.jsx @@ -488,71 +488,4 @@ describe('WebexMeetingsWidget', () => { window.MutationObserver = OriginalMutationObserver; }); }); - - describe('Adapter Factory', () => { - it('creates Webex with correct access_token', () => { - capturedAdapterFactory({accessToken: 'my-token', fedramp: false}); - - expect(Webex).toHaveBeenCalledWith( - expect.objectContaining({ - credentials: {access_token: 'my-token'}, - }) - ); - }); - - it('passes fedramp config', () => { - capturedAdapterFactory({accessToken: 'token', fedramp: true}); - - expect(Webex).toHaveBeenCalledWith( - expect.objectContaining({ - config: expect.objectContaining({fedramp: true}), - }) - ); - }); - - it('passes meeting experimental config', () => { - capturedAdapterFactory({accessToken: 'token', fedramp: false}); - - expect(Webex).toHaveBeenCalledWith( - expect.objectContaining({ - config: expect.objectContaining({ - meetings: { - experimental: { - enableUnifiedMeetings: true, - enableAdhocMeetings: true, - }, - }, - }), - }) - ); - }); - - it('passes appVersion from __appVersion__ global', () => { - capturedAdapterFactory({accessToken: 'token', fedramp: false}); - - expect(Webex).toHaveBeenCalledWith( - expect.objectContaining({ - config: expect.objectContaining({appVersion: '1.0.0-test'}), - }) - ); - }); - - it('creates WebexSDKAdapter from Webex instance', () => { - capturedAdapterFactory({accessToken: 'token', fedramp: false}); - - expect(WebexSDKAdapter).toHaveBeenCalledTimes(1); - const webexInstance = Webex.mock.results[Webex.mock.results.length - 1].value; - expect(WebexSDKAdapter).toHaveBeenCalledWith(webexInstance); - }); - - it('uses dev appName when NODE_ENV is not production', () => { - capturedAdapterFactory({accessToken: 'token', fedramp: false}); - - expect(Webex).toHaveBeenCalledWith( - expect.objectContaining({ - config: expect.objectContaining({appName: 'webex-widgets-meetings-dev'}), - }) - ); - }); - }); }); From 91690462480877475540c3b6572f8b33c548cac3 Mon Sep 17 00:00:00 2001 From: Ritesh Singh Date: Tue, 10 Mar 2026 10:13:25 +0530 Subject: [PATCH 08/10] fix(meetings): update UTs --- .../WebexMeetings/WebexMeetings.test.jsx | 491 ------------------ 1 file changed, 491 deletions(-) diff --git a/packages/@webex/widgets/tests/WebexMeetings/WebexMeetings.test.jsx b/packages/@webex/widgets/tests/WebexMeetings/WebexMeetings.test.jsx index 7e0c2e8f2..e69de29bb 100644 --- a/packages/@webex/widgets/tests/WebexMeetings/WebexMeetings.test.jsx +++ b/packages/@webex/widgets/tests/WebexMeetings/WebexMeetings.test.jsx @@ -1,491 +0,0 @@ -import React, {Component} from 'react'; -import {render, fireEvent, act} from '@testing-library/react'; -import '@testing-library/jest-dom'; - -let capturedAdapterFactory; - -jest.mock('@webex/components', () => ({ - WebexMediaAccess: (props) => ( -
- ), - WebexMeeting: (props) => ( -
- ), - withAdapter: (WrappedComponent, factory) => { - capturedAdapterFactory = factory; - return WrappedComponent; - }, - withMeeting: (WrappedComponent) => WrappedComponent, -})); - -jest.mock('@webex/components/dist/css/webex-components.css', () => {}); - -jest.mock('webex', () => jest.fn((config) => ({__mockWebex: true, ...config}))); -jest.mock('@webex/sdk-component-adapter', () => jest.fn((webex) => ({__mockAdapter: true, webex}))); - -const Webex = require('webex'); -const WebexSDKAdapter = require('@webex/sdk-component-adapter'); - -const WebexMeetingsWidget = require('../../src/widgets/WebexMeetings/WebexMeetings').default; - -class TestErrorBoundary extends Component { - constructor(props) { - super(props); - this.state = {hasError: false}; - } - - static getDerivedStateFromError() { - return {hasError: true}; - } - - componentDidCatch(error) { - if (this.props.onError) { - this.props.onError(error); - } - } - - render() { - if (this.state.hasError) { - return null; - } - return this.props.children; - } -} - -const baseMeeting = { - ID: 'meeting-123', - localAudio: {permission: 'GRANTED'}, - localVideo: {permission: 'GRANTED'}, -}; - -const baseProps = { - accessToken: 'test-token', - meetingDestination: 'test@webex.com', - meeting: baseMeeting, -}; - -describe('WebexMeetingsWidget', () => { - beforeEach(() => { - jest.clearAllMocks(); - jest.spyOn(console, 'error').mockImplementation(() => {}); - jest.useFakeTimers(); - }); - - afterEach(() => { - jest.restoreAllMocks(); - jest.useRealTimers(); - }); - - describe('Rendering', () => { - it('renders wrapper div with class "webex-meetings-widget" and tabIndex 0', () => { - const {container} = render(); - - const wrapper = container.firstChild; - expect(wrapper).toHaveClass('webex-meetings-widget'); - expect(wrapper).toHaveAttribute('tabindex', '0'); - }); - - it('renders WebexMediaAccess for microphone when audioPermission is ASKING', () => { - const meeting = {...baseMeeting, localAudio: {permission: 'ASKING'}}; - const {getByTestId, queryByTestId} = render( - - ); - - expect(getByTestId('webex-media-access')).toBeInTheDocument(); - expect(getByTestId('webex-media-access')).toHaveAttribute('data-media', 'microphone'); - expect(queryByTestId('webex-meeting')).not.toBeInTheDocument(); - }); - - it('renders WebexMediaAccess for camera when videoPermission is ASKING', () => { - const meeting = {...baseMeeting, localVideo: {permission: 'ASKING'}}; - const {getByTestId, queryByTestId} = render( - - ); - - expect(getByTestId('webex-media-access')).toBeInTheDocument(); - expect(getByTestId('webex-media-access')).toHaveAttribute('data-media', 'camera'); - expect(queryByTestId('webex-meeting')).not.toBeInTheDocument(); - }); - - it('audio ASKING takes priority over video ASKING', () => { - const meeting = { - ...baseMeeting, - localAudio: {permission: 'ASKING'}, - localVideo: {permission: 'ASKING'}, - }; - const {getByTestId} = render(); - - expect(getByTestId('webex-media-access')).toHaveAttribute('data-media', 'microphone'); - }); - - it('passes correct meetingID to WebexMediaAccess (microphone case)', () => { - const meeting = {...baseMeeting, localAudio: {permission: 'ASKING'}}; - const {getByTestId} = render(); - - expect(getByTestId('webex-media-access')).toHaveAttribute('data-meeting-id', 'meeting-123'); - }); - - it('passes correct meetingID to WebexMediaAccess (camera case)', () => { - const meeting = {...baseMeeting, localVideo: {permission: 'ASKING'}}; - const {getByTestId} = render(); - - expect(getByTestId('webex-media-access')).toHaveAttribute('data-meeting-id', 'meeting-123'); - }); - - it('renders WebexMeeting when no permission is ASKING', () => { - const {getByTestId, queryByTestId} = render(); - - expect(getByTestId('webex-meeting')).toBeInTheDocument(); - expect(queryByTestId('webex-media-access')).not.toBeInTheDocument(); - }); - - it('passes correct props to WebexMeeting', () => { - const controlsFn = jest.fn(); - const props = { - ...baseProps, - meetingPasswordOrPin: 'secret123', - participantName: 'Test User', - layout: 'Focus', - controls: controlsFn, - controlsCollapseRangeStart: 1, - controlsCollapseRangeEnd: -1, - }; - const {getByTestId} = render(); - - const meetingEl = getByTestId('webex-meeting'); - expect(meetingEl).toHaveAttribute('data-meeting-id', 'meeting-123'); - expect(meetingEl).toHaveAttribute('data-password', 'secret123'); - expect(meetingEl).toHaveAttribute('data-participant', 'Test User'); - expect(meetingEl).toHaveAttribute('data-layout', 'Focus'); - expect(meetingEl).toHaveAttribute('data-collapse-start', '1'); - expect(meetingEl).toHaveAttribute('data-collapse-end', '-1'); - }); - - it('applies custom className to wrapper', () => { - const {container} = render(); - - expect(container.firstChild).toHaveClass('webex-meetings-widget'); - expect(container.firstChild).toHaveClass('my-custom'); - }); - - it('applies custom style to wrapper', () => { - const customStyle = {backgroundColor: 'red', width: '500px'}; - const {container} = render(); - - expect(container.firstChild).toHaveStyle({backgroundColor: 'red', width: '500px'}); - }); - }); - - describe('Default Props', () => { - it('layout defaults to Grid', () => { - const {getByTestId} = render(); - - expect(getByTestId('webex-meeting')).toHaveAttribute('data-layout', 'Grid'); - }); - - it('className defaults to empty string', () => { - const {container} = render(); - - expect(container.firstChild.className).toBe('webex-meetings-widget '); - }); - - it('meetingPasswordOrPin defaults to empty string', () => { - const {getByTestId} = render(); - - expect(getByTestId('webex-meeting')).toHaveAttribute('data-password', ''); - }); - - it('participantName defaults to empty string', () => { - const {getByTestId} = render(); - - expect(getByTestId('webex-meeting')).toHaveAttribute('data-participant', ''); - }); - }); - - describe('Error Handling', () => { - it('should render null when the widget throws due to invalid meeting prop', () => { - const onError = jest.fn(); - const {container} = render( - - - - ); - - expect(container.firstChild).toBeNull(); - expect(onError).toHaveBeenCalledWith(expect.any(Error)); - }); - }); - - describe('Accessibility - Focus Management', () => { - it('on widget focus, sets tabIndex on media containers', () => { - const {container} = render(); - const wrapper = container.firstChild; - - const mediaContainer = document.createElement('div'); - mediaContainer.classList.add('wxc-interstitial-meeting__media-container'); - wrapper.querySelector('.webex-meetings-widget__content').appendChild(mediaContainer); - - act(() => { - fireEvent.focus(wrapper); - jest.advanceTimersByTime(0); - }); - - expect(mediaContainer.tabIndex).toBe(0); - }); - - it('on widget focus, falls back to focusing join button when no media containers exist', () => { - const {container} = render(); - const wrapper = container.firstChild; - - const joinButton = document.createElement('button'); - joinButton.setAttribute('aria-label', 'Join meeting'); - joinButton.focus = jest.fn(); - wrapper.appendChild(joinButton); - - act(() => { - fireEvent.focus(wrapper); - jest.advanceTimersByTime(0); - }); - - expect(joinButton.focus).toHaveBeenCalled(); - }); - - it('Tab on media container focuses join button', () => { - const {container} = render(); - const wrapper = container.firstChild; - - const mediaContainer = document.createElement('div'); - mediaContainer.classList.add('wxc-in-meeting__media-container'); - wrapper.querySelector('.webex-meetings-widget__content').appendChild(mediaContainer); - - act(() => { - fireEvent.focus(wrapper); - jest.advanceTimersByTime(0); - }); - - const joinButton = document.createElement('button'); - joinButton.setAttribute('aria-label', 'Join meeting'); - joinButton.focus = jest.fn(); - wrapper.appendChild(joinButton); - - Object.defineProperty(document, 'activeElement', { - value: mediaContainer, - writable: true, - configurable: true, - }); - - const tabEvent = new KeyboardEvent('keydown', { - key: 'Tab', - code: 'Tab', - bubbles: true, - cancelable: true, - }); - Object.defineProperty(tabEvent, 'currentTarget', {value: mediaContainer}); - mediaContainer.dispatchEvent(tabEvent); - - expect(joinButton.focus).toHaveBeenCalled(); - }); - - it('Shift+Tab on media container focuses widget container', () => { - const {container} = render(); - const wrapper = container.firstChild; - - const mediaContainer = document.createElement('div'); - mediaContainer.classList.add('wxc-interstitial-meeting__media-container'); - wrapper.querySelector('.webex-meetings-widget__content').appendChild(mediaContainer); - - act(() => { - fireEvent.focus(wrapper); - jest.advanceTimersByTime(0); - }); - - wrapper.focus = jest.fn(); - - Object.defineProperty(document, 'activeElement', { - value: mediaContainer, - writable: true, - configurable: true, - }); - - const shiftTabEvent = new KeyboardEvent('keydown', { - key: 'Tab', - code: 'Tab', - shiftKey: true, - bubbles: true, - cancelable: true, - }); - Object.defineProperty(shiftTabEvent, 'currentTarget', {value: mediaContainer}); - mediaContainer.dispatchEvent(shiftTabEvent); - - expect(wrapper.focus).toHaveBeenCalled(); - }); - - it('content div focus polls for inner meeting media container and focuses it', () => { - const {container} = render(); - const wrapper = container.firstChild; - const contentDiv = wrapper.querySelector('.webex-meetings-widget__content'); - - expect(contentDiv).toBeTruthy(); - - const innerMeeting = document.createElement('div'); - innerMeeting.classList.add('wxc-in-meeting__media-container'); - innerMeeting.focus = jest.fn(); - - contentDiv.dispatchEvent(new Event('focus')); - - contentDiv.appendChild(innerMeeting); - - act(() => { - jest.advanceTimersByTime(500); - }); - - expect(innerMeeting.focus).toHaveBeenCalled(); - expect(innerMeeting.tabIndex).toBe(0); - }); - - it('content div focus attaches one-time Tab handler to move focus to first interactive element', () => { - const {container} = render(); - const wrapper = container.firstChild; - const contentDiv = wrapper.querySelector('.webex-meetings-widget__content'); - - expect(contentDiv).toBeTruthy(); - - const innerMeeting = document.createElement('div'); - innerMeeting.classList.add('wxc-in-meeting__media-container'); - contentDiv.appendChild(innerMeeting); - - const interactiveBtn = document.createElement('button'); - interactiveBtn.focus = jest.fn(); - innerMeeting.appendChild(interactiveBtn); - - contentDiv.dispatchEvent(new Event('focus')); - - act(() => { - jest.advanceTimersByTime(0); - }); - - const tabEvent = new KeyboardEvent('keydown', { - key: 'Tab', - bubbles: true, - cancelable: true, - }); - innerMeeting.dispatchEvent(tabEvent); - - expect(interactiveBtn.focus).toHaveBeenCalled(); - }); - - it('arrow keys cycle through control buttons', () => { - const {container} = render(); - const wrapper = container.firstChild; - - const controlBar = document.createElement('div'); - controlBar.classList.add('wxc-meeting-control-bar__controls'); - - const btn1 = document.createElement('button'); - btn1.focus = jest.fn(); - const btn2 = document.createElement('button'); - btn2.focus = jest.fn(); - const btn3 = document.createElement('button'); - btn3.focus = jest.fn(); - - controlBar.appendChild(btn1); - controlBar.appendChild(btn2); - controlBar.appendChild(btn3); - wrapper.appendChild(controlBar); - - act(() => { - jest.advanceTimersByTime(700); - }); - - act(() => { - btn1.onkeydown({key: 'ArrowRight', preventDefault: jest.fn()}); - }); - expect(btn2.focus).toHaveBeenCalled(); - - act(() => { - btn1.onkeydown({key: 'ArrowLeft', preventDefault: jest.fn()}); - }); - expect(btn3.focus).toHaveBeenCalled(); - }); - - it('MutationObserver re-attaches listeners on DOM changes', () => { - let observerCallback; - const OriginalMutationObserver = window.MutationObserver; - - window.MutationObserver = class MockMutationObserver { - constructor(callback) { - observerCallback = callback; - } - observe() {} - disconnect() {} - }; - - const {container} = render(); - const wrapper = container.firstChild; - - const controlBar = document.createElement('div'); - controlBar.classList.add('wxc-meeting-control-bar__controls'); - - const btn1 = document.createElement('button'); - controlBar.appendChild(btn1); - wrapper.appendChild(controlBar); - - act(() => { - jest.advanceTimersByTime(700); - }); - - expect(btn1.onkeydown).toBeTruthy(); - - const newBtn = document.createElement('button'); - newBtn.focus = jest.fn(); - controlBar.appendChild(newBtn); - - act(() => { - observerCallback(); - }); - - expect(newBtn.onkeydown).toBeTruthy(); - - window.MutationObserver = OriginalMutationObserver; - }); - }); - - describe('Cleanup', () => { - it('disconnects MutationObserver on unmount', () => { - const disconnectSpy = jest.fn(); - const OriginalMutationObserver = window.MutationObserver; - - window.MutationObserver = class MockMutationObserver { - constructor(callback) { - this.callback = callback; - } - observe() {} - disconnect() { - disconnectSpy(); - } - }; - - const {unmount} = render(); - - unmount(); - - expect(disconnectSpy).toHaveBeenCalled(); - - window.MutationObserver = OriginalMutationObserver; - }); - }); -}); From a2127e17d96546f1425f8e79ea439b0b823cc50a Mon Sep 17 00:00:00 2001 From: Ritesh Singh Date: Tue, 10 Mar 2026 10:15:58 +0530 Subject: [PATCH 09/10] fix(meetings): update UTs --- .../WebexMeetings/WebexMeetings.test.jsx | 558 ++++++++++++++++++ 1 file changed, 558 insertions(+) diff --git a/packages/@webex/widgets/tests/WebexMeetings/WebexMeetings.test.jsx b/packages/@webex/widgets/tests/WebexMeetings/WebexMeetings.test.jsx index e69de29bb..750f1db85 100644 --- a/packages/@webex/widgets/tests/WebexMeetings/WebexMeetings.test.jsx +++ b/packages/@webex/widgets/tests/WebexMeetings/WebexMeetings.test.jsx @@ -0,0 +1,558 @@ +import React, {Component} from 'react'; +import {render, fireEvent, act} from '@testing-library/react'; +import '@testing-library/jest-dom'; + +let capturedAdapterFactory; + +jest.mock('@webex/components', () => ({ + WebexMediaAccess: (props) => ( +
+ ), + WebexMeeting: (props) => ( +
+ ), + withAdapter: (WrappedComponent, factory) => { + capturedAdapterFactory = factory; + return WrappedComponent; + }, + withMeeting: (WrappedComponent) => WrappedComponent, +})); + +jest.mock('@webex/components/dist/css/webex-components.css', () => {}); + +jest.mock('webex', () => jest.fn((config) => ({__mockWebex: true, ...config}))); +jest.mock('@webex/sdk-component-adapter', () => jest.fn((webex) => ({__mockAdapter: true, webex}))); + +const Webex = require('webex'); +const WebexSDKAdapter = require('@webex/sdk-component-adapter'); + +const WebexMeetingsWidget = require('../../src/widgets/WebexMeetings/WebexMeetings').default; + +class TestErrorBoundary extends Component { + constructor(props) { + super(props); + this.state = {hasError: false}; + } + + static getDerivedStateFromError() { + return {hasError: true}; + } + + componentDidCatch(error) { + if (this.props.onError) { + this.props.onError(error); + } + } + + render() { + if (this.state.hasError) { + return null; + } + return this.props.children; + } +} + +const baseMeeting = { + ID: 'meeting-123', + localAudio: {permission: 'GRANTED'}, + localVideo: {permission: 'GRANTED'}, +}; + +const baseProps = { + accessToken: 'test-token', + meetingDestination: 'test@webex.com', + meeting: baseMeeting, +}; + +describe('WebexMeetingsWidget', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.spyOn(console, 'error').mockImplementation(() => {}); + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + jest.useRealTimers(); + }); + + describe('Rendering', () => { + it('renders wrapper div with class "webex-meetings-widget" and tabIndex 0', () => { + const {container} = render(); + + const wrapper = container.firstChild; + expect(wrapper).toHaveClass('webex-meetings-widget'); + expect(wrapper).toHaveAttribute('tabindex', '0'); + }); + + it('renders WebexMediaAccess for microphone when audioPermission is ASKING', () => { + const meeting = {...baseMeeting, localAudio: {permission: 'ASKING'}}; + const {getByTestId, queryByTestId} = render( + + ); + + expect(getByTestId('webex-media-access')).toBeInTheDocument(); + expect(getByTestId('webex-media-access')).toHaveAttribute('data-media', 'microphone'); + expect(queryByTestId('webex-meeting')).not.toBeInTheDocument(); + }); + + it('renders WebexMediaAccess for camera when videoPermission is ASKING', () => { + const meeting = {...baseMeeting, localVideo: {permission: 'ASKING'}}; + const {getByTestId, queryByTestId} = render( + + ); + + expect(getByTestId('webex-media-access')).toBeInTheDocument(); + expect(getByTestId('webex-media-access')).toHaveAttribute('data-media', 'camera'); + expect(queryByTestId('webex-meeting')).not.toBeInTheDocument(); + }); + + it('audio ASKING takes priority over video ASKING', () => { + const meeting = { + ...baseMeeting, + localAudio: {permission: 'ASKING'}, + localVideo: {permission: 'ASKING'}, + }; + const {getByTestId} = render(); + + expect(getByTestId('webex-media-access')).toHaveAttribute('data-media', 'microphone'); + }); + + it('passes correct meetingID to WebexMediaAccess (microphone case)', () => { + const meeting = {...baseMeeting, localAudio: {permission: 'ASKING'}}; + const {getByTestId} = render(); + + expect(getByTestId('webex-media-access')).toHaveAttribute('data-meeting-id', 'meeting-123'); + }); + + it('passes correct meetingID to WebexMediaAccess (camera case)', () => { + const meeting = {...baseMeeting, localVideo: {permission: 'ASKING'}}; + const {getByTestId} = render(); + + expect(getByTestId('webex-media-access')).toHaveAttribute('data-meeting-id', 'meeting-123'); + }); + + it('renders WebexMeeting when no permission is ASKING', () => { + const {getByTestId, queryByTestId} = render(); + + expect(getByTestId('webex-meeting')).toBeInTheDocument(); + expect(queryByTestId('webex-media-access')).not.toBeInTheDocument(); + }); + + it('passes correct props to WebexMeeting', () => { + const controlsFn = jest.fn(); + const props = { + ...baseProps, + meetingPasswordOrPin: 'secret123', + participantName: 'Test User', + layout: 'Focus', + controls: controlsFn, + controlsCollapseRangeStart: 1, + controlsCollapseRangeEnd: -1, + }; + const {getByTestId} = render(); + + const meetingEl = getByTestId('webex-meeting'); + expect(meetingEl).toHaveAttribute('data-meeting-id', 'meeting-123'); + expect(meetingEl).toHaveAttribute('data-password', 'secret123'); + expect(meetingEl).toHaveAttribute('data-participant', 'Test User'); + expect(meetingEl).toHaveAttribute('data-layout', 'Focus'); + expect(meetingEl).toHaveAttribute('data-collapse-start', '1'); + expect(meetingEl).toHaveAttribute('data-collapse-end', '-1'); + }); + + it('applies custom className to wrapper', () => { + const {container} = render(); + + expect(container.firstChild).toHaveClass('webex-meetings-widget'); + expect(container.firstChild).toHaveClass('my-custom'); + }); + + it('applies custom style to wrapper', () => { + const customStyle = {backgroundColor: 'red', width: '500px'}; + const {container} = render(); + + expect(container.firstChild).toHaveStyle({backgroundColor: 'red', width: '500px'}); + }); + }); + + describe('Default Props', () => { + it('layout defaults to Grid', () => { + const {getByTestId} = render(); + + expect(getByTestId('webex-meeting')).toHaveAttribute('data-layout', 'Grid'); + }); + + it('className defaults to empty string', () => { + const {container} = render(); + + expect(container.firstChild.className).toBe('webex-meetings-widget '); + }); + + it('meetingPasswordOrPin defaults to empty string', () => { + const {getByTestId} = render(); + + expect(getByTestId('webex-meeting')).toHaveAttribute('data-password', ''); + }); + + it('participantName defaults to empty string', () => { + const {getByTestId} = render(); + + expect(getByTestId('webex-meeting')).toHaveAttribute('data-participant', ''); + }); + }); + + describe('Error Handling', () => { + it('should render null when the widget throws due to invalid meeting prop', () => { + const onError = jest.fn(); + const {container} = render( + + + + ); + + expect(container.firstChild).toBeNull(); + expect(onError).toHaveBeenCalledWith(expect.any(Error)); + }); + }); + + describe('Accessibility - Focus Management', () => { + it('on widget focus, sets tabIndex on media containers', () => { + const {container} = render(); + const wrapper = container.firstChild; + + const mediaContainer = document.createElement('div'); + mediaContainer.classList.add('wxc-interstitial-meeting__media-container'); + wrapper.querySelector('.webex-meetings-widget__content').appendChild(mediaContainer); + + act(() => { + fireEvent.focus(wrapper); + jest.advanceTimersByTime(0); + }); + + expect(mediaContainer.tabIndex).toBe(0); + }); + + it('on widget focus, falls back to focusing join button when no media containers exist', () => { + const {container} = render(); + const wrapper = container.firstChild; + + const joinButton = document.createElement('button'); + joinButton.setAttribute('aria-label', 'Join meeting'); + joinButton.focus = jest.fn(); + wrapper.appendChild(joinButton); + + act(() => { + fireEvent.focus(wrapper); + jest.advanceTimersByTime(0); + }); + + expect(joinButton.focus).toHaveBeenCalled(); + }); + + it('Tab on media container focuses join button', () => { + const {container} = render(); + const wrapper = container.firstChild; + + const mediaContainer = document.createElement('div'); + mediaContainer.classList.add('wxc-in-meeting__media-container'); + wrapper.querySelector('.webex-meetings-widget__content').appendChild(mediaContainer); + + act(() => { + fireEvent.focus(wrapper); + jest.advanceTimersByTime(0); + }); + + const joinButton = document.createElement('button'); + joinButton.setAttribute('aria-label', 'Join meeting'); + joinButton.focus = jest.fn(); + wrapper.appendChild(joinButton); + + Object.defineProperty(document, 'activeElement', { + value: mediaContainer, + writable: true, + configurable: true, + }); + + const tabEvent = new KeyboardEvent('keydown', { + key: 'Tab', + code: 'Tab', + bubbles: true, + cancelable: true, + }); + Object.defineProperty(tabEvent, 'currentTarget', {value: mediaContainer}); + mediaContainer.dispatchEvent(tabEvent); + + expect(joinButton.focus).toHaveBeenCalled(); + }); + + it('Shift+Tab on media container focuses widget container', () => { + const {container} = render(); + const wrapper = container.firstChild; + + const mediaContainer = document.createElement('div'); + mediaContainer.classList.add('wxc-interstitial-meeting__media-container'); + wrapper.querySelector('.webex-meetings-widget__content').appendChild(mediaContainer); + + act(() => { + fireEvent.focus(wrapper); + jest.advanceTimersByTime(0); + }); + + wrapper.focus = jest.fn(); + + Object.defineProperty(document, 'activeElement', { + value: mediaContainer, + writable: true, + configurable: true, + }); + + const shiftTabEvent = new KeyboardEvent('keydown', { + key: 'Tab', + code: 'Tab', + shiftKey: true, + bubbles: true, + cancelable: true, + }); + Object.defineProperty(shiftTabEvent, 'currentTarget', {value: mediaContainer}); + mediaContainer.dispatchEvent(shiftTabEvent); + + expect(wrapper.focus).toHaveBeenCalled(); + }); + + it('content div focus polls for inner meeting media container and focuses it', () => { + const {container} = render(); + const wrapper = container.firstChild; + const contentDiv = wrapper.querySelector('.webex-meetings-widget__content'); + + expect(contentDiv).toBeTruthy(); + + const innerMeeting = document.createElement('div'); + innerMeeting.classList.add('wxc-in-meeting__media-container'); + innerMeeting.focus = jest.fn(); + + contentDiv.dispatchEvent(new Event('focus')); + + contentDiv.appendChild(innerMeeting); + + act(() => { + jest.advanceTimersByTime(500); + }); + + expect(innerMeeting.focus).toHaveBeenCalled(); + expect(innerMeeting.tabIndex).toBe(0); + }); + + it('content div focus attaches one-time Tab handler to move focus to first interactive element', () => { + const {container} = render(); + const wrapper = container.firstChild; + const contentDiv = wrapper.querySelector('.webex-meetings-widget__content'); + + expect(contentDiv).toBeTruthy(); + + const innerMeeting = document.createElement('div'); + innerMeeting.classList.add('wxc-in-meeting__media-container'); + contentDiv.appendChild(innerMeeting); + + const interactiveBtn = document.createElement('button'); + interactiveBtn.focus = jest.fn(); + innerMeeting.appendChild(interactiveBtn); + + contentDiv.dispatchEvent(new Event('focus')); + + act(() => { + jest.advanceTimersByTime(0); + }); + + const tabEvent = new KeyboardEvent('keydown', { + key: 'Tab', + bubbles: true, + cancelable: true, + }); + innerMeeting.dispatchEvent(tabEvent); + + expect(interactiveBtn.focus).toHaveBeenCalled(); + }); + + it('arrow keys cycle through control buttons', () => { + const {container} = render(); + const wrapper = container.firstChild; + + const controlBar = document.createElement('div'); + controlBar.classList.add('wxc-meeting-control-bar__controls'); + + const btn1 = document.createElement('button'); + btn1.focus = jest.fn(); + const btn2 = document.createElement('button'); + btn2.focus = jest.fn(); + const btn3 = document.createElement('button'); + btn3.focus = jest.fn(); + + controlBar.appendChild(btn1); + controlBar.appendChild(btn2); + controlBar.appendChild(btn3); + wrapper.appendChild(controlBar); + + act(() => { + jest.advanceTimersByTime(700); + }); + + act(() => { + btn1.onkeydown({key: 'ArrowRight', preventDefault: jest.fn()}); + }); + expect(btn2.focus).toHaveBeenCalled(); + + act(() => { + btn1.onkeydown({key: 'ArrowLeft', preventDefault: jest.fn()}); + }); + expect(btn3.focus).toHaveBeenCalled(); + }); + + it('MutationObserver re-attaches listeners on DOM changes', () => { + let observerCallback; + const OriginalMutationObserver = window.MutationObserver; + + window.MutationObserver = class MockMutationObserver { + constructor(callback) { + observerCallback = callback; + } + observe() {} + disconnect() {} + }; + + const {container} = render(); + const wrapper = container.firstChild; + + const controlBar = document.createElement('div'); + controlBar.classList.add('wxc-meeting-control-bar__controls'); + + const btn1 = document.createElement('button'); + controlBar.appendChild(btn1); + wrapper.appendChild(controlBar); + + act(() => { + jest.advanceTimersByTime(700); + }); + + expect(btn1.onkeydown).toBeTruthy(); + + const newBtn = document.createElement('button'); + newBtn.focus = jest.fn(); + controlBar.appendChild(newBtn); + + act(() => { + observerCallback(); + }); + + expect(newBtn.onkeydown).toBeTruthy(); + + window.MutationObserver = OriginalMutationObserver; + }); + }); + + describe('Cleanup', () => { + it('disconnects MutationObserver on unmount', () => { + const disconnectSpy = jest.fn(); + const OriginalMutationObserver = window.MutationObserver; + + window.MutationObserver = class MockMutationObserver { + constructor(callback) { + this.callback = callback; + } + observe() {} + disconnect() { + disconnectSpy(); + } + }; + + const {unmount} = render(); + + unmount(); + + expect(disconnectSpy).toHaveBeenCalled(); + + window.MutationObserver = OriginalMutationObserver; + }); + }); + + describe('Adapter Factory', () => { + it('creates Webex with correct access_token', () => { + capturedAdapterFactory({accessToken: 'my-token', fedramp: false}); + + expect(Webex).toHaveBeenCalledWith( + expect.objectContaining({ + credentials: {access_token: 'my-token'}, + }) + ); + }); + + it('passes fedramp config', () => { + capturedAdapterFactory({accessToken: 'token', fedramp: true}); + + expect(Webex).toHaveBeenCalledWith( + expect.objectContaining({ + config: expect.objectContaining({fedramp: true}), + }) + ); + }); + + it('passes meeting experimental config', () => { + capturedAdapterFactory({accessToken: 'token', fedramp: false}); + + expect(Webex).toHaveBeenCalledWith( + expect.objectContaining({ + config: expect.objectContaining({ + meetings: { + experimental: { + enableUnifiedMeetings: true, + enableAdhocMeetings: true, + }, + }, + }), + }) + ); + }); + + it('passes appVersion from __appVersion__ global', () => { + capturedAdapterFactory({accessToken: 'token', fedramp: false}); + + expect(Webex).toHaveBeenCalledWith( + expect.objectContaining({ + config: expect.objectContaining({appVersion: '1.0.0-test'}), + }) + ); + }); + + it('creates WebexSDKAdapter from Webex instance', () => { + capturedAdapterFactory({accessToken: 'token', fedramp: false}); + + expect(WebexSDKAdapter).toHaveBeenCalledTimes(1); + const webexInstance = Webex.mock.results[Webex.mock.results.length - 1].value; + expect(WebexSDKAdapter).toHaveBeenCalledWith(webexInstance); + }); + + it('uses dev appName when NODE_ENV is not production', () => { + capturedAdapterFactory({accessToken: 'token', fedramp: false}); + + expect(Webex).toHaveBeenCalledWith( + expect.objectContaining({ + config: expect.objectContaining({appName: 'webex-widgets-meetings-dev'}), + }) + ); + }); + }); +}); \ No newline at end of file From f7049c10819e07938519f9da3021b55f072d6725 Mon Sep 17 00:00:00 2001 From: Ritesh Singh Date: Thu, 12 Mar 2026 21:44:25 +0530 Subject: [PATCH 10/10] fix(meetings): update UTs_ --- .../WebexMeetings/WebexMeetings.test.jsx | 128 +++++++++++------- 1 file changed, 76 insertions(+), 52 deletions(-) diff --git a/packages/@webex/widgets/tests/WebexMeetings/WebexMeetings.test.jsx b/packages/@webex/widgets/tests/WebexMeetings/WebexMeetings.test.jsx index 750f1db85..a592a9949 100644 --- a/packages/@webex/widgets/tests/WebexMeetings/WebexMeetings.test.jsx +++ b/packages/@webex/widgets/tests/WebexMeetings/WebexMeetings.test.jsx @@ -41,6 +41,7 @@ const Webex = require('webex'); const WebexSDKAdapter = require('@webex/sdk-component-adapter'); const WebexMeetingsWidget = require('../../src/widgets/WebexMeetings/WebexMeetings').default; +const adapterFactory = capturedAdapterFactory; class TestErrorBoundary extends Component { constructor(props) { @@ -80,6 +81,7 @@ const baseProps = { describe('WebexMeetingsWidget', () => { beforeEach(() => { + capturedAdapterFactory = undefined; jest.clearAllMocks(); jest.spyOn(console, 'error').mockImplementation(() => {}); jest.useFakeTimers(); @@ -282,22 +284,31 @@ describe('WebexMeetingsWidget', () => { joinButton.focus = jest.fn(); wrapper.appendChild(joinButton); + const originalActiveElement = Object.getOwnPropertyDescriptor(document, 'activeElement'); Object.defineProperty(document, 'activeElement', { value: mediaContainer, writable: true, configurable: true, }); - const tabEvent = new KeyboardEvent('keydown', { - key: 'Tab', - code: 'Tab', - bubbles: true, - cancelable: true, - }); - Object.defineProperty(tabEvent, 'currentTarget', {value: mediaContainer}); - mediaContainer.dispatchEvent(tabEvent); - - expect(joinButton.focus).toHaveBeenCalled(); + try { + const tabEvent = new KeyboardEvent('keydown', { + key: 'Tab', + code: 'Tab', + bubbles: true, + cancelable: true, + }); + Object.defineProperty(tabEvent, 'currentTarget', {value: mediaContainer}); + mediaContainer.dispatchEvent(tabEvent); + + expect(joinButton.focus).toHaveBeenCalled(); + } finally { + if (originalActiveElement) { + Object.defineProperty(document, 'activeElement', originalActiveElement); + } else { + delete document.activeElement; + } + } }); it('Shift+Tab on media container focuses widget container', () => { @@ -315,23 +326,32 @@ describe('WebexMeetingsWidget', () => { wrapper.focus = jest.fn(); + const originalActiveElement = Object.getOwnPropertyDescriptor(document, 'activeElement'); Object.defineProperty(document, 'activeElement', { value: mediaContainer, writable: true, configurable: true, }); - const shiftTabEvent = new KeyboardEvent('keydown', { - key: 'Tab', - code: 'Tab', - shiftKey: true, - bubbles: true, - cancelable: true, - }); - Object.defineProperty(shiftTabEvent, 'currentTarget', {value: mediaContainer}); - mediaContainer.dispatchEvent(shiftTabEvent); - - expect(wrapper.focus).toHaveBeenCalled(); + try { + const shiftTabEvent = new KeyboardEvent('keydown', { + key: 'Tab', + code: 'Tab', + shiftKey: true, + bubbles: true, + cancelable: true, + }); + Object.defineProperty(shiftTabEvent, 'currentTarget', {value: mediaContainer}); + mediaContainer.dispatchEvent(shiftTabEvent); + + expect(wrapper.focus).toHaveBeenCalled(); + } finally { + if (originalActiveElement) { + Object.defineProperty(document, 'activeElement', originalActiveElement); + } else { + delete document.activeElement; + } + } }); it('content div focus polls for inner meeting media container and focuses it', () => { @@ -434,33 +454,35 @@ describe('WebexMeetingsWidget', () => { disconnect() {} }; - const {container} = render(); - const wrapper = container.firstChild; + try { + const {container} = render(); + const wrapper = container.firstChild; - const controlBar = document.createElement('div'); - controlBar.classList.add('wxc-meeting-control-bar__controls'); - - const btn1 = document.createElement('button'); - controlBar.appendChild(btn1); - wrapper.appendChild(controlBar); + const controlBar = document.createElement('div'); + controlBar.classList.add('wxc-meeting-control-bar__controls'); - act(() => { - jest.advanceTimersByTime(700); - }); + const btn1 = document.createElement('button'); + controlBar.appendChild(btn1); + wrapper.appendChild(controlBar); - expect(btn1.onkeydown).toBeTruthy(); + act(() => { + jest.advanceTimersByTime(700); + }); - const newBtn = document.createElement('button'); - newBtn.focus = jest.fn(); - controlBar.appendChild(newBtn); + expect(btn1.onkeydown).toBeTruthy(); - act(() => { - observerCallback(); - }); + const newBtn = document.createElement('button'); + newBtn.focus = jest.fn(); + controlBar.appendChild(newBtn); - expect(newBtn.onkeydown).toBeTruthy(); + act(() => { + observerCallback(); + }); - window.MutationObserver = OriginalMutationObserver; + expect(newBtn.onkeydown).toBeTruthy(); + } finally { + window.MutationObserver = OriginalMutationObserver; + } }); }); @@ -479,19 +501,21 @@ describe('WebexMeetingsWidget', () => { } }; - const {unmount} = render(); - - unmount(); + try { + const {unmount} = render(); - expect(disconnectSpy).toHaveBeenCalled(); + unmount(); - window.MutationObserver = OriginalMutationObserver; + expect(disconnectSpy).toHaveBeenCalled(); + } finally { + window.MutationObserver = OriginalMutationObserver; + } }); }); describe('Adapter Factory', () => { it('creates Webex with correct access_token', () => { - capturedAdapterFactory({accessToken: 'my-token', fedramp: false}); + adapterFactory({accessToken: 'my-token', fedramp: false}); expect(Webex).toHaveBeenCalledWith( expect.objectContaining({ @@ -501,7 +525,7 @@ describe('WebexMeetingsWidget', () => { }); it('passes fedramp config', () => { - capturedAdapterFactory({accessToken: 'token', fedramp: true}); + adapterFactory({accessToken: 'token', fedramp: true}); expect(Webex).toHaveBeenCalledWith( expect.objectContaining({ @@ -511,7 +535,7 @@ describe('WebexMeetingsWidget', () => { }); it('passes meeting experimental config', () => { - capturedAdapterFactory({accessToken: 'token', fedramp: false}); + adapterFactory({accessToken: 'token', fedramp: false}); expect(Webex).toHaveBeenCalledWith( expect.objectContaining({ @@ -528,7 +552,7 @@ describe('WebexMeetingsWidget', () => { }); it('passes appVersion from __appVersion__ global', () => { - capturedAdapterFactory({accessToken: 'token', fedramp: false}); + adapterFactory({accessToken: 'token', fedramp: false}); expect(Webex).toHaveBeenCalledWith( expect.objectContaining({ @@ -538,7 +562,7 @@ describe('WebexMeetingsWidget', () => { }); it('creates WebexSDKAdapter from Webex instance', () => { - capturedAdapterFactory({accessToken: 'token', fedramp: false}); + adapterFactory({accessToken: 'token', fedramp: false}); expect(WebexSDKAdapter).toHaveBeenCalledTimes(1); const webexInstance = Webex.mock.results[Webex.mock.results.length - 1].value; @@ -546,7 +570,7 @@ describe('WebexMeetingsWidget', () => { }); it('uses dev appName when NODE_ENV is not production', () => { - capturedAdapterFactory({accessToken: 'token', fedramp: false}); + adapterFactory({accessToken: 'token', fedramp: false}); expect(Webex).toHaveBeenCalledWith( expect.objectContaining({