diff --git a/packages/calling/playwright/constants/selectors.ts b/packages/calling/playwright/constants/selectors.ts index 736d3716298..caf402b3f0b 100644 --- a/packages/calling/playwright/constants/selectors.ts +++ b/packages/calling/playwright/constants/selectors.ts @@ -6,6 +6,7 @@ export const CALLING_SELECTORS = { AUTH_STATUS: '#access-token-status', SERVICE_INDICATOR: '#ServiceIndicator', SERVICE_DOMAIN: '#ServiceDomain', + MOBIUS_WSS: '#mobius-wss', REGION_INPUT: '#region', COUNTRY_INPUT: '#country', FEDRAMP_CHECKBOX: '#fedramp', diff --git a/packages/calling/playwright/test-data.ts b/packages/calling/playwright/test-data.ts index 5cc3c68bbba..115910b7d00 100644 --- a/packages/calling/playwright/test-data.ts +++ b/packages/calling/playwright/test-data.ts @@ -20,6 +20,8 @@ export interface UserSet { testSuite: string; } +export type MobiusMode = 'http' | 'ws'; + /** * Roles that must have credentials/tokens available for the currently enabled * Playwright projects. @@ -36,6 +38,24 @@ export const REQUIRED_OAUTH_ROLES: AccountRole[] = [ /** Separator between set name and environment in project names (e.g. "SET_REGISTRATION_1 - PROD"). */ const ENV_SEPARATOR = ' - '; +/** + * Mobius transport mode for Playwright suites. + * + * MOBIUS=ws forces the sample app WebSocket override before SDK initialization. + * MOBIUS=http keeps the default HTTP transport. + */ +export const getMobiusMode = (): MobiusMode => { + const mode = process.env.MOBIUS?.toLowerCase(); + + if (mode === 'ws') { + return 'ws'; + } + + return 'http'; +}; + +export const isMobiusWsMode = (): boolean => getMobiusMode() === 'ws'; + /** * Whether a Playwright project targets the Integration environment. */ @@ -71,11 +91,16 @@ export const getToken = (role: AccountRole, isInt = false): string => { return token; }; +/** Env var for E.164 (or test) phone number, production vs integration Playwright projects. */ +export const phoneEnvVar = (role: AccountRole, isInt = false): string => + isInt ? `${role}_INT_PHONE_NUMBER` : `${role}_PHONE_NUMBER`; + /** * Read phone number for an account role. Throws if not set. + * Integration projects use `USER_N_INT_PHONE_NUMBER`; production uses `USER_N_PHONE_NUMBER`. */ -export const getPhoneNumber = (role: AccountRole): string => { - const envVar = `${role}_PHONE_NUMBER`; +export const getPhoneNumber = (role: AccountRole, isInt = false): string => { + const envVar = phoneEnvVar(role, isInt); const number = process.env[envVar]; if (!number) { throw new Error(`${envVar} not set.`); diff --git a/packages/calling/playwright/test-groups/call-controls.ts b/packages/calling/playwright/test-groups/call-controls.ts index b4ba83dc878..468ffc0dd76 100644 --- a/packages/calling/playwright/test-groups/call-controls.ts +++ b/packages/calling/playwright/test-groups/call-controls.ts @@ -1,6 +1,6 @@ import {test, expect} from '@playwright/test'; import {TestManager} from '../test-manager'; -import {getPhoneNumber} from '../test-data'; +import {getPhoneNumber, isMobiusWsMode} from '../test-data'; import { sendDTMF, holdCall, @@ -12,6 +12,7 @@ import { cleanupActiveCalls, } from '../utils/call'; import {CALLING_SELECTORS, AWAIT_TIMEOUT} from '../constants'; +import {MOBIUS_WS_MESSAGE, MobiusWsInterceptor} from '../utils/mobius-ws'; /** * Hold/resume tests: multiple cycles, callee-side hold, hold+disconnect combos. @@ -21,45 +22,45 @@ export function callHoldTests() { test.describe('Call Hold', () => { test.describe.configure({mode: 'serial', timeout: 180000}); - let tm: TestManager; + let testManager: TestManager; let calleeNumber: string; test.beforeAll(async ({browser}, testInfo) => { - tm = new TestManager(testInfo.project.name); + testManager = new TestManager(testInfo.project.name); await Promise.all([ - tm.setupContext(browser, 0, { + testManager.setupContext(browser, 0, { initSDK: true, service: 'calling', register: true, media: true, }), - tm.setupContext(browser, 1, { + testManager.setupContext(browser, 1, { initSDK: true, service: 'calling', register: true, media: true, }), ]); - calleeNumber = getPhoneNumber(tm.userSet.accounts[1]); + calleeNumber = getPhoneNumber(testManager.userSet.accounts[1], testManager.isInt); }); test.afterEach(async () => { await Promise.all([ - cleanupActiveCalls(tm.getPage(tm.userSet.accounts[0])), - cleanupActiveCalls(tm.getPage(tm.userSet.accounts[1])), + cleanupActiveCalls(testManager.getPage(testManager.userSet.accounts[0])), + cleanupActiveCalls(testManager.getPage(testManager.userSet.accounts[1])), ]); - if (!tm.page.isClosed()) { - await tm.page.waitForTimeout(3000); + if (!testManager.page.isClosed()) { + await testManager.page.waitForTimeout(3000); } }); test.afterAll(async () => { - await tm.cleanup(); + await testManager.cleanup(); }); test('CALL-021: Hold and resume - multiple cycles', async () => { - const callerPage = tm.getPage(tm.userSet.accounts[0]); - const calleePage = tm.getPage(tm.userSet.accounts[1]); + const callerPage = testManager.getPage(testManager.userSet.accounts[0]); + const calleePage = testManager.getPage(testManager.userSet.accounts[1]); await establishCall(callerPage, calleePage, calleeNumber); @@ -79,21 +80,21 @@ export function callHoldTests() { test.fixme('CALL-022: Callee-side hold and resume', async ({browser}) => { await Promise.all([ - tm.setupContext(browser, 0, { + testManager.setupContext(browser, 0, { initSDK: true, service: 'calling', register: true, media: true, }), - tm.setupContext(browser, 1, { + testManager.setupContext(browser, 1, { initSDK: true, service: 'calling', register: true, media: true, }), ]); - const callerPage = tm.getPage(tm.userSet.accounts[0]); - const calleePage = tm.getPage(tm.userSet.accounts[1]); + const callerPage = testManager.getPage(testManager.userSet.accounts[0]); + const calleePage = testManager.getPage(testManager.userSet.accounts[1]); // Let Mobius settle after fresh registration before placing a call await callerPage.waitForTimeout(5000); @@ -136,21 +137,21 @@ export function callHoldTests() { browser, }) => { await Promise.all([ - tm.setupContext(browser, 0, { + testManager.setupContext(browser, 0, { initSDK: true, service: 'calling', register: true, media: true, }), - tm.setupContext(browser, 1, { + testManager.setupContext(browser, 1, { initSDK: true, service: 'calling', register: true, media: true, }), ]); - const callerPage = tm.getPage(tm.userSet.accounts[0]); - const calleePage = tm.getPage(tm.userSet.accounts[1]); + const callerPage = testManager.getPage(testManager.userSet.accounts[0]); + const calleePage = testManager.getPage(testManager.userSet.accounts[1]); // Let Mobius settle after fresh registration before placing a call await callerPage.waitForTimeout(5000); @@ -174,21 +175,21 @@ export function callHoldTests() { browser, }) => { await Promise.all([ - tm.setupContext(browser, 0, { + testManager.setupContext(browser, 0, { initSDK: true, service: 'calling', register: true, media: true, }), - tm.setupContext(browser, 1, { + testManager.setupContext(browser, 1, { initSDK: true, service: 'calling', register: true, media: true, }), ]); - const callerPage = tm.getPage(tm.userSet.accounts[0]); - const calleePage = tm.getPage(tm.userSet.accounts[1]); + const callerPage = testManager.getPage(testManager.userSet.accounts[0]); + const calleePage = testManager.getPage(testManager.userSet.accounts[1]); // Let Mobius settle after fresh registration before placing a call await callerPage.waitForTimeout(5000); @@ -218,59 +219,79 @@ export function callHoldErrorTests() { test.describe('Call Hold Errors', () => { test.describe.configure({mode: 'serial', timeout: 180000}); - let tm: TestManager; + let testManager: TestManager; let calleeNumber: string; test.beforeAll(async ({browser}, testInfo) => { - tm = new TestManager(testInfo.project.name); + testManager = new TestManager(testInfo.project.name); await Promise.all([ - tm.setupContext(browser, 0, { + testManager.setupContext(browser, 0, { initSDK: true, service: 'calling', register: true, media: true, }), - tm.setupContext(browser, 1, { + testManager.setupContext(browser, 1, { initSDK: true, service: 'calling', register: true, media: true, }), ]); - calleeNumber = getPhoneNumber(tm.userSet.accounts[1]); + calleeNumber = getPhoneNumber(testManager.userSet.accounts[1], testManager.isInt); }); test.afterEach(async () => { await Promise.all([ - cleanupActiveCalls(tm.getPage(tm.userSet.accounts[0])), - cleanupActiveCalls(tm.getPage(tm.userSet.accounts[1])), + cleanupActiveCalls(testManager.getPage(testManager.userSet.accounts[0])), + cleanupActiveCalls(testManager.getPage(testManager.userSet.accounts[1])), ]); - if (!tm.page.isClosed()) { - await tm.page.waitForTimeout(3000); + if (!testManager.page.isClosed()) { + await testManager.page.waitForTimeout(3000); } }); test.afterAll(async () => { - await tm.cleanup(); + await testManager.cleanup(); }); test('CALL-025: Resume API failure - resume_error event emitted', async ({browser}) => { + const mobiusWsMode = isMobiusWsMode(); + let failResume = false; + let interceptor: MobiusWsInterceptor | undefined; + if (mobiusWsMode) { + interceptor = new MobiusWsInterceptor({ + onRequest: (frame) => { + if (failResume && frame.type === MOBIUS_WS_MESSAGE.CALL_RESUME) { + return { + statusCode: 500, + statusMessage: 'Internal Server Error', + data: {message: 'Resume failed'}, + }; + } + + return undefined; + }, + }); + } + await Promise.all([ - tm.setupContext(browser, 0, { + testManager.setupContext(browser, 0, { initSDK: true, service: 'calling', register: true, media: true, + beforeInit: interceptor ? (ctx) => interceptor!.install(ctx) : undefined, }), - tm.setupContext(browser, 1, { + testManager.setupContext(browser, 1, { initSDK: true, service: 'calling', register: true, media: true, }), ]); - const callerPage = tm.getPage(tm.userSet.accounts[0]); - const calleePage = tm.getPage(tm.userSet.accounts[1]); + const callerPage = testManager.getPage(testManager.userSet.accounts[0]); + const calleePage = testManager.getPage(testManager.userSet.accounts[1]); await establishCall(callerPage, calleePage, calleeNumber); await holdCall(callerPage); @@ -284,9 +305,13 @@ export function callHoldErrorTests() { }); }); - await callerPage.route('**/services/callhold/resume', (route) => { - route.fulfill({status: 500, body: 'Internal Server Error'}); - }); + if (mobiusWsMode) { + failResume = true; + } else { + await callerPage.route('**/services/callhold/resume', (route) => { + route.fulfill({status: 500, body: 'Internal Server Error'}); + }); + } await callerPage.locator(CALLING_SELECTORS.HOLD_BTN).click({timeout: AWAIT_TIMEOUT}); @@ -297,29 +322,51 @@ export function callHoldErrorTests() { expect(resumeError).toBeTruthy(); await expect(callerPage.locator(CALLING_SELECTORS.HOLD_BTN)).toHaveValue('Resume'); - await callerPage.unroute('**/services/callhold/resume'); + if (!mobiusWsMode) { + await callerPage.unroute('**/services/callhold/resume'); + } await endCall(callerPage); await Promise.all([waitForCallDisconnect(callerPage), waitForCallDisconnect(calleePage)]); }); test('CALL-026: Hold API failure - hold_error event emitted', async ({browser}) => { + const mobiusWsMode = isMobiusWsMode(); + let failHold = false; + let interceptor: MobiusWsInterceptor | undefined; + if (mobiusWsMode) { + interceptor = new MobiusWsInterceptor({ + onRequest: (frame) => { + if (failHold && frame.type === MOBIUS_WS_MESSAGE.CALL_HOLD) { + return { + statusCode: 500, + statusMessage: 'Internal Server Error', + data: {message: 'Hold failed'}, + }; + } + + return undefined; + }, + }); + } + await Promise.all([ - tm.setupContext(browser, 0, { + testManager.setupContext(browser, 0, { initSDK: true, service: 'calling', register: true, media: true, + beforeInit: interceptor ? (ctx) => interceptor!.install(ctx) : undefined, }), - tm.setupContext(browser, 1, { + testManager.setupContext(browser, 1, { initSDK: true, service: 'calling', register: true, media: true, }), ]); - const callerPage = tm.getPage(tm.userSet.accounts[0]); - const calleePage = tm.getPage(tm.userSet.accounts[1]); + const callerPage = testManager.getPage(testManager.userSet.accounts[0]); + const calleePage = testManager.getPage(testManager.userSet.accounts[1]); await establishCall(callerPage, calleePage, calleeNumber); @@ -332,9 +379,13 @@ export function callHoldErrorTests() { }); }); - await callerPage.route('**/services/callhold/hold', (route) => { - route.fulfill({status: 500, body: 'Internal Server Error'}); - }); + if (mobiusWsMode) { + failHold = true; + } else { + await callerPage.route('**/services/callhold/hold', (route) => { + route.fulfill({status: 500, body: 'Internal Server Error'}); + }); + } await callerPage.locator(CALLING_SELECTORS.HOLD_BTN).click({timeout: AWAIT_TIMEOUT}); @@ -345,7 +396,9 @@ export function callHoldErrorTests() { expect(holdError).toBeTruthy(); await expect(callerPage.locator(CALLING_SELECTORS.HOLD_BTN)).toHaveValue('Hold'); - await callerPage.unroute('**/services/callhold/hold'); + if (!mobiusWsMode) { + await callerPage.unroute('**/services/callhold/hold'); + } await endCall(callerPage); await Promise.all([waitForCallDisconnect(callerPage), waitForCallDisconnect(calleePage)]); @@ -361,45 +414,45 @@ export function callControlTests() { test.describe('Call Controls', () => { test.describe.configure({mode: 'serial', timeout: 180000}); - let tm: TestManager; + let testManager: TestManager; let calleeNumber: string; test.beforeAll(async ({browser}, testInfo) => { - tm = new TestManager(testInfo.project.name); + testManager = new TestManager(testInfo.project.name); await Promise.all([ - tm.setupContext(browser, 0, { + testManager.setupContext(browser, 0, { initSDK: true, service: 'calling', register: true, media: true, }), - tm.setupContext(browser, 1, { + testManager.setupContext(browser, 1, { initSDK: true, service: 'calling', register: true, media: true, }), ]); - calleeNumber = getPhoneNumber(tm.userSet.accounts[1]); + calleeNumber = getPhoneNumber(testManager.userSet.accounts[1], testManager.isInt); }); test.afterEach(async () => { await Promise.all([ - cleanupActiveCalls(tm.getPage(tm.userSet.accounts[0])), - cleanupActiveCalls(tm.getPage(tm.userSet.accounts[1])), + cleanupActiveCalls(testManager.getPage(testManager.userSet.accounts[0])), + cleanupActiveCalls(testManager.getPage(testManager.userSet.accounts[1])), ]); - if (!tm.page.isClosed()) { - await tm.page.waitForTimeout(3000); + if (!testManager.page.isClosed()) { + await testManager.page.waitForTimeout(3000); } }); test.afterAll(async () => { - await tm.cleanup(); + await testManager.cleanup(); }); test('CALL-027: Mute and unmute - toggle mute during active call', async () => { - const callerPage = tm.getPage(tm.userSet.accounts[0]); - const calleePage = tm.getPage(tm.userSet.accounts[1]); + const callerPage = testManager.getPage(testManager.userSet.accounts[0]); + const calleePage = testManager.getPage(testManager.userSet.accounts[1]); await establishCall(callerPage, calleePage, calleeNumber); @@ -435,8 +488,8 @@ export function callControlTests() { }); test('CALL-028: DTMF send - send digit sequence during call', async () => { - const callerPage = tm.getPage(tm.userSet.accounts[0]); - const calleePage = tm.getPage(tm.userSet.accounts[1]); + const callerPage = testManager.getPage(testManager.userSet.accounts[0]); + const calleePage = testManager.getPage(testManager.userSet.accounts[1]); await establishCall(callerPage, calleePage, calleeNumber); @@ -453,14 +506,14 @@ export function callControlTests() { }); test('CALL-029: Network flap with active call - call survives brief disruption', async () => { - const callerPage = tm.getPage(tm.userSet.accounts[0]); - const calleePage = tm.getPage(tm.userSet.accounts[1]); + const callerPage = testManager.getPage(testManager.userSet.accounts[0]); + const calleePage = testManager.getPage(testManager.userSet.accounts[1]); await establishCall(callerPage, calleePage, calleeNumber); - await tm.getContext(tm.userSet.accounts[0]).setOffline(true); + await testManager.getContext(testManager.userSet.accounts[0]).setOffline(true); await callerPage.waitForTimeout(3000); - await tm.getContext(tm.userSet.accounts[0]).setOffline(false); + await testManager.getContext(testManager.userSet.accounts[0]).setOffline(false); await callerPage.waitForTimeout(5000); const stillConnected = await callerPage.evaluate(() => { diff --git a/packages/calling/playwright/test-groups/call-errors.ts b/packages/calling/playwright/test-groups/call-errors.ts index dde4ad97b17..ebfa22c7f50 100644 --- a/packages/calling/playwright/test-groups/call-errors.ts +++ b/packages/calling/playwright/test-groups/call-errors.ts @@ -1,6 +1,6 @@ import {test, expect} from '@playwright/test'; import {TestManager} from '../test-manager'; -import {getUserSet, getToken, getPhoneNumber, isIntProject} from '../test-data'; +import {getUserSet, getToken, getPhoneNumber, isIntProject, isMobiusWsMode} from '../test-data'; import { navigateToCallingApp, initializeCallingSDK, @@ -19,6 +19,7 @@ import { cleanupActiveCalls, } from '../utils/call'; import {CALLING_SELECTORS, AWAIT_TIMEOUT} from '../constants'; +import {MOBIUS_WS_MESSAGE, MobiusWsInterceptor} from '../utils/mobius-ws'; /** * Call error tests that need route intercepts before the call is made. @@ -47,6 +48,20 @@ export function callErrorTests() { }, testInfo) => { const isInt = isIntProject(testInfo.project.name); const role = getUserSet(testInfo.project.name).accounts[0]; + const mobiusWsMode = isMobiusWsMode(); + + if (mobiusWsMode) { + await new MobiusWsInterceptor({ + onRequest: (frame) => + frame.type === MOBIUS_WS_MESSAGE.CALL_SETUP + ? { + statusCode: 500, + statusMessage: 'Internal Server Error', + data: {message: 'Call setup failed'}, + } + : undefined, + }).install(context); + } await navigateToCallingApp(page); if (isInt) await setEnvironmentToInt(page); @@ -56,9 +71,14 @@ export function callErrorTests() { await verifyLineRegistered(page); await getMediaStreams(page); - await context.route(/\/devices\/[^/]+\/call$/, (route) => { - route.abort('failed'); - }); + if (!mobiusWsMode) { + await context.route(/\/devices\/[^/]+\/call$/, (route) => { + route.fulfill({ + status: 500, + body: JSON.stringify({message: 'Call setup failed'}), + }); + }); + } await page .locator(CALLING_SELECTORS.DESTINATION_INPUT) @@ -90,7 +110,21 @@ export function callErrorTests() { }, testInfo) => { const isInt = isIntProject(testInfo.project.name); const accounts = getUserSet(testInfo.project.name).accounts; - const calleeNumber = getPhoneNumber(accounts[1]); + const calleeNumber = getPhoneNumber(accounts[1], isInt); + const mobiusWsMode = isMobiusWsMode(); + + if (mobiusWsMode) { + await new MobiusWsInterceptor({ + onRequest: (frame) => + frame.type === MOBIUS_WS_MESSAGE.CALL_MEDIA + ? { + statusCode: 500, + statusMessage: 'Internal Server Error', + data: {message: 'Media negotiation failed'}, + } + : undefined, + }).install(context); + } await navigateToCallingApp(page); if (isInt) await setEnvironmentToInt(page); @@ -100,9 +134,11 @@ export function callErrorTests() { await verifyLineRegistered(page); await getMediaStreams(page); - await context.route('**/calls/**/media', (route) => { - route.fulfill({status: 500, body: JSON.stringify({error: 'Media negotiation failed'})}); - }); + if (!mobiusWsMode) { + await context.route('**/calls/**/media', (route) => { + route.fulfill({status: 500, body: JSON.stringify({error: 'Media negotiation failed'})}); + }); + } await makeCall(page, calleeNumber); await page.waitForTimeout(3000); @@ -176,45 +212,45 @@ export function callEdgeCaseTests() { test.describe('Call Edge Cases', () => { test.describe.configure({mode: 'serial', timeout: 240000}); - let tm: TestManager; + let testManager: TestManager; let calleeNumber: string; test.beforeAll(async ({browser}, testInfo) => { - tm = new TestManager(testInfo.project.name); + testManager = new TestManager(testInfo.project.name); await Promise.all([ - tm.setupContext(browser, 0, { + testManager.setupContext(browser, 0, { initSDK: true, service: 'calling', register: true, media: true, }), - tm.setupContext(browser, 1, { + testManager.setupContext(browser, 1, { initSDK: true, service: 'calling', register: true, media: true, }), ]); - calleeNumber = getPhoneNumber(tm.userSet.accounts[1]); + calleeNumber = getPhoneNumber(testManager.userSet.accounts[1], testManager.isInt); }); test.afterEach(async () => { await Promise.all([ - cleanupActiveCalls(tm.getPage(tm.userSet.accounts[0])), - cleanupActiveCalls(tm.getPage(tm.userSet.accounts[1])), + cleanupActiveCalls(testManager.getPage(testManager.userSet.accounts[0])), + cleanupActiveCalls(testManager.getPage(testManager.userSet.accounts[1])), ]); - if (!tm.page.isClosed()) { - await tm.page.waitForTimeout(3000); + if (!testManager.page.isClosed()) { + await testManager.page.waitForTimeout(3000); } }); test.afterAll(async () => { - await tm.cleanup(); + await testManager.cleanup(); }); test('CALL-016: Resume and disconnect race - concurrent operations on 2 endpoints', async () => { - const callerPage = tm.getPage(tm.userSet.accounts[0]); - const calleePage = tm.getPage(tm.userSet.accounts[1]); + const callerPage = testManager.getPage(testManager.userSet.accounts[0]); + const calleePage = testManager.getPage(testManager.userSet.accounts[1]); await establishCall(callerPage, calleePage, calleeNumber); @@ -242,21 +278,21 @@ export function callEdgeCaseTests() { browser, }) => { await Promise.all([ - tm.setupContext(browser, 0, { + testManager.setupContext(browser, 0, { initSDK: true, service: 'calling', register: true, media: true, }), - tm.setupContext(browser, 1, { + testManager.setupContext(browser, 1, { initSDK: true, service: 'calling', register: true, media: true, }), ]); - const callerPage = tm.getPage(tm.userSet.accounts[0]); - const calleePage = tm.getPage(tm.userSet.accounts[1]); + const callerPage = testManager.getPage(testManager.userSet.accounts[0]); + const calleePage = testManager.getPage(testManager.userSet.accounts[1]); await establishCall(callerPage, calleePage, calleeNumber); @@ -294,21 +330,21 @@ export function callEdgeCaseTests() { }) => { // Fresh contexts — CALL-017 deregistered the callee await Promise.all([ - tm.setupContext(browser, 0, { + testManager.setupContext(browser, 0, { initSDK: true, service: 'calling', register: true, media: true, }), - tm.setupContext(browser, 1, { + testManager.setupContext(browser, 1, { initSDK: true, service: 'calling', register: true, media: true, }), ]); - const callerPage = tm.getPage(tm.userSet.accounts[0]); - const calleePage = tm.getPage(tm.userSet.accounts[1]); + const callerPage = testManager.getPage(testManager.userSet.accounts[0]); + const calleePage = testManager.getPage(testManager.userSet.accounts[1]); // Deregister callee so their device is offline await unregisterLine(calleePage); @@ -333,8 +369,8 @@ export function callEdgeCaseTests() { test.fixme( 'CALL-019: Page close during active call - callee browser closes mid-call', async () => { - const callerPage = tm.getPage(tm.userSet.accounts[0]); - const calleePage = tm.getPage(tm.userSet.accounts[1]); + const callerPage = testManager.getPage(testManager.userSet.accounts[0]); + const calleePage = testManager.getPage(testManager.userSet.accounts[1]); await establishCall(callerPage, calleePage, calleeNumber); @@ -367,20 +403,20 @@ export function callEdgeCaseTests() { // // Both contexts need fresh setup since CALL-019 closed the callee page // and the caller's UI state (hold button) may be stale - await tm.setupContext(browser, 0, { + await testManager.setupContext(browser, 0, { initSDK: true, service: 'calling', register: true, media: true, }); - await tm.setupContext(browser, 1, { + await testManager.setupContext(browser, 1, { initSDK: true, service: 'calling', register: true, media: true, }); - const callerPage = tm.getPage(tm.userSet.accounts[0]); - const calleePage = tm.getPage(tm.userSet.accounts[1]); + const callerPage = testManager.getPage(testManager.userSet.accounts[0]); + const calleePage = testManager.getPage(testManager.userSet.accounts[1]); await establishCall(callerPage, calleePage, calleeNumber); diff --git a/packages/calling/playwright/test-groups/call-keepalive.ts b/packages/calling/playwright/test-groups/call-keepalive.ts index dec466345d2..b24a67c6bc9 100644 --- a/packages/calling/playwright/test-groups/call-keepalive.ts +++ b/packages/calling/playwright/test-groups/call-keepalive.ts @@ -1,7 +1,8 @@ import {test, expect} from '@playwright/test'; import {TestManager} from '../test-manager'; -import {getPhoneNumber} from '../test-data'; +import {getPhoneNumber, isMobiusWsMode} from '../test-data'; import {endCall, waitForCallDisconnect, establishCall, cleanupActiveCalls} from '../utils/call'; +import {MOBIUS_WS_MESSAGE, MobiusWsInterceptor} from '../utils/mobius-ws'; /** * Helper: get the active call object and clear the SDK's 10-minute keepalive timer, @@ -56,47 +57,47 @@ export function callKeepaliveTests() { test.describe('Call Keepalive', () => { test.describe.configure({mode: 'serial', timeout: 180000}); - let tm: TestManager; + let testManager: TestManager; let calleeNumber: string; test.beforeAll(async ({browser}, testInfo) => { - tm = new TestManager(testInfo.project.name); + testManager = new TestManager(testInfo.project.name); await Promise.all([ - tm.setupContext(browser, 0, { + testManager.setupContext(browser, 0, { initSDK: true, service: 'calling', register: true, media: true, }), - tm.setupContext(browser, 1, { + testManager.setupContext(browser, 1, { initSDK: true, service: 'calling', register: true, media: true, }), ]); - calleeNumber = getPhoneNumber(tm.userSet.accounts[1]); + calleeNumber = getPhoneNumber(testManager.userSet.accounts[1], testManager.isInt); }); test.afterEach(async () => { - const callerPage = tm.getPage(tm.userSet.accounts[0]); - const calleePage = tm.getPage(tm.userSet.accounts[1]); + const callerPage = testManager.getPage(testManager.userSet.accounts[0]); + const calleePage = testManager.getPage(testManager.userSet.accounts[1]); // Unroute any intercepted requests from this test await callerPage.unrouteAll({behavior: 'ignoreErrors'}).catch(() => {}); await Promise.all([cleanupActiveCalls(callerPage), cleanupActiveCalls(calleePage)]); - if (!tm.page.isClosed()) { - await tm.page.waitForTimeout(3000); + if (!testManager.page.isClosed()) { + await testManager.page.waitForTimeout(3000); } }); test.afterAll(async () => { - await tm.cleanup(); + await testManager.cleanup(); }); test('CALL-009: Keepalive success - postStatus 200 keeps call alive', async () => { - const callerPage = tm.getPage(tm.userSet.accounts[0]); - const calleePage = tm.getPage(tm.userSet.accounts[1]); + const callerPage = testManager.getPage(testManager.userSet.accounts[0]); + const calleePage = testManager.getPage(testManager.userSet.accounts[1]); await establishCall(callerPage, calleePage, calleeNumber); await disableAutoKeepalive(callerPage); @@ -118,22 +119,42 @@ export function callKeepaliveTests() { }); test('CALL-010: Keepalive 401 - expired token tears down call', async ({browser}) => { + const mobiusWsMode = isMobiusWsMode(); + let failStatus = false; + let interceptor: MobiusWsInterceptor | undefined; + if (mobiusWsMode) { + interceptor = new MobiusWsInterceptor({ + onRequest: (frame) => { + if (failStatus && frame.type === MOBIUS_WS_MESSAGE.CALL_STATUS) { + return { + statusCode: 401, + statusMessage: 'Unauthorized', + data: {message: 'Token expired'}, + }; + } + + return undefined; + }, + }); + } + await Promise.all([ - tm.setupContext(browser, 0, { + testManager.setupContext(browser, 0, { initSDK: true, service: 'calling', register: true, media: true, + beforeInit: interceptor ? (ctx) => interceptor!.install(ctx) : undefined, }), - tm.setupContext(browser, 1, { + testManager.setupContext(browser, 1, { initSDK: true, service: 'calling', register: true, media: true, }), ]); - const callerPage = tm.getPage(tm.userSet.accounts[0]); - const calleePage = tm.getPage(tm.userSet.accounts[1]); + const callerPage = testManager.getPage(testManager.userSet.accounts[0]); + const calleePage = testManager.getPage(testManager.userSet.accounts[1]); await establishCall(callerPage, calleePage, calleeNumber); await disableAutoKeepalive(callerPage); @@ -149,13 +170,17 @@ export function callKeepaliveTests() { }); // Intercept the status POST and return 401 - await callerPage.route('**/calls/**/status', (route) => { - route.fulfill({ - status: 401, - contentType: 'application/json', - body: JSON.stringify({message: 'Token expired'}), + if (mobiusWsMode) { + failStatus = true; + } else { + await callerPage.route('**/calls/**/status', (route) => { + route.fulfill({ + status: 401, + contentType: 'application/json', + body: JSON.stringify({message: 'Token expired'}), + }); }); - }); + } // Trigger the keepalive error path via the SDK's internal handler. // We call handleCallKeepaliveError indirectly by invoking postStatus @@ -187,43 +212,71 @@ export function callKeepaliveTests() { test('CALL-011: Keepalive 500 with retry - transient failure then recovery', async ({ browser, }) => { + const mobiusWsMode = isMobiusWsMode(); + let statusRequestCount = 0; + let interceptStatus = false; + let interceptor: MobiusWsInterceptor | undefined; + if (mobiusWsMode) { + interceptor = new MobiusWsInterceptor({ + onRequest: (frame) => { + if (interceptStatus && frame.type === MOBIUS_WS_MESSAGE.CALL_STATUS) { + statusRequestCount += 1; + + if (statusRequestCount === 1) { + return { + statusCode: 500, + statusMessage: 'Internal Server Error', + metadata: {'retry-after': '2'}, + data: {error: 'Internal Server Error'}, + }; + } + } + + return undefined; + }, + }); + } + await Promise.all([ - tm.setupContext(browser, 0, { + testManager.setupContext(browser, 0, { initSDK: true, service: 'calling', register: true, media: true, + beforeInit: interceptor ? (ctx) => interceptor!.install(ctx) : undefined, }), - tm.setupContext(browser, 1, { + testManager.setupContext(browser, 1, { initSDK: true, service: 'calling', register: true, media: true, }), ]); - const callerPage = tm.getPage(tm.userSet.accounts[0]); - const calleePage = tm.getPage(tm.userSet.accounts[1]); + const callerPage = testManager.getPage(testManager.userSet.accounts[0]); + const calleePage = testManager.getPage(testManager.userSet.accounts[1]); await establishCall(callerPage, calleePage, calleeNumber); await disableAutoKeepalive(callerPage); - // Track status request count - let statusRequestCount = 0; - await callerPage.route('**/calls/**/status', (route) => { - statusRequestCount += 1; - if (statusRequestCount === 1) { - // First request: 500 with retry-after - route.fulfill({ - status: 500, - headers: {'retry-after': '2'}, - contentType: 'application/json', - body: JSON.stringify({error: 'Internal Server Error'}), - }); - } else { - // Subsequent requests: let through to real backend - route.continue(); - } - }); + if (mobiusWsMode) { + interceptStatus = true; + } else { + await callerPage.route('**/calls/**/status', (route) => { + statusRequestCount += 1; + if (statusRequestCount === 1) { + // First request: 500 with retry-after + route.fulfill({ + status: 500, + headers: {'retry-after': '2'}, + contentType: 'application/json', + body: JSON.stringify({error: 'Internal Server Error'}), + }); + } else { + // Subsequent requests: let through to real backend + route.continue(); + } + }); + } // Trigger the keepalive error flow await callerPage.evaluate(() => { @@ -260,37 +313,64 @@ export function callKeepaliveTests() { }); test('CALL-012: Keepalive max retries exhausted - all status POSTs fail', async ({browser}) => { + const mobiusWsMode = isMobiusWsMode(); + let statusRequestCount = 0; + let failStatus = false; + let interceptor: MobiusWsInterceptor | undefined; + if (mobiusWsMode) { + interceptor = new MobiusWsInterceptor({ + onRequest: (frame) => { + if (failStatus && frame.type === MOBIUS_WS_MESSAGE.CALL_STATUS) { + statusRequestCount += 1; + + return { + statusCode: 500, + statusMessage: 'Internal Server Error', + metadata: {'retry-after': '1'}, + data: {error: 'Internal Server Error'}, + }; + } + + return undefined; + }, + }); + } + await Promise.all([ - tm.setupContext(browser, 0, { + testManager.setupContext(browser, 0, { initSDK: true, service: 'calling', register: true, media: true, + beforeInit: interceptor ? (ctx) => interceptor!.install(ctx) : undefined, }), - tm.setupContext(browser, 1, { + testManager.setupContext(browser, 1, { initSDK: true, service: 'calling', register: true, media: true, }), ]); - const callerPage = tm.getPage(tm.userSet.accounts[0]); - const calleePage = tm.getPage(tm.userSet.accounts[1]); + const callerPage = testManager.getPage(testManager.userSet.accounts[0]); + const calleePage = testManager.getPage(testManager.userSet.accounts[1]); await establishCall(callerPage, calleePage, calleeNumber); await disableAutoKeepalive(callerPage); - // Track how many status requests are made - let statusRequestCount = 0; - await callerPage.route('**/calls/**/status', (route) => { - statusRequestCount += 1; - route.fulfill({ - status: 500, - headers: {'retry-after': '1'}, - contentType: 'application/json', - body: JSON.stringify({error: 'Internal Server Error'}), + if (mobiusWsMode) { + failStatus = true; + } else { + // Track how many status requests are made + await callerPage.route('**/calls/**/status', (route) => { + statusRequestCount += 1; + route.fulfill({ + status: 500, + headers: {'retry-after': '1'}, + contentType: 'application/json', + body: JSON.stringify({error: 'Internal Server Error'}), + }); }); - }); + } // Listen for call_error events await callerPage.evaluate(() => { diff --git a/packages/calling/playwright/test-groups/call-lifecycle.ts b/packages/calling/playwright/test-groups/call-lifecycle.ts index 894b432bf34..faf499d810a 100644 --- a/packages/calling/playwright/test-groups/call-lifecycle.ts +++ b/packages/calling/playwright/test-groups/call-lifecycle.ts @@ -21,46 +21,46 @@ export function callLifecycleTests() { test.describe('Call Lifecycle', () => { test.describe.configure({mode: 'serial', timeout: 180000}); - let tm: TestManager; + let testManager: TestManager; let calleeNumber: string; test.beforeAll(async ({browser}, testInfo) => { - tm = new TestManager(testInfo.project.name); + testManager = new TestManager(testInfo.project.name); await Promise.all([ - tm.setupContext(browser, 0, { + testManager.setupContext(browser, 0, { initSDK: true, service: 'calling', register: true, media: true, }), - tm.setupContext(browser, 1, { + testManager.setupContext(browser, 1, { initSDK: true, service: 'calling', register: true, media: true, }), ]); - calleeNumber = getPhoneNumber(tm.userSet.accounts[1]); + calleeNumber = getPhoneNumber(testManager.userSet.accounts[1], testManager.isInt); }); test.afterEach(async () => { await Promise.all([ - cleanupActiveCalls(tm.getPage(tm.userSet.accounts[0])), - cleanupActiveCalls(tm.getPage(tm.userSet.accounts[1])), + cleanupActiveCalls(testManager.getPage(testManager.userSet.accounts[0])), + cleanupActiveCalls(testManager.getPage(testManager.userSet.accounts[1])), ]); // Settle time for backend state between calls - if (!tm.page.isClosed()) { - await tm.page.waitForTimeout(3000); + if (!testManager.page.isClosed()) { + await testManager.page.waitForTimeout(3000); } }); test.afterAll(async () => { - await tm.cleanup(); + await testManager.cleanup(); }); test('CALL-001: Outgoing call happy path - full event sequence', async () => { - const callerPage = tm.getPage(tm.userSet.accounts[0]); - const calleePage = tm.getPage(tm.userSet.accounts[1]); + const callerPage = testManager.getPage(testManager.userSet.accounts[0]); + const calleePage = testManager.getPage(testManager.userSet.accounts[1]); await establishCall(callerPage, calleePage, calleeNumber); @@ -88,8 +88,8 @@ export function callLifecycleTests() { }); test('CALL-002: Incoming call answer flow - verify callee perspective', async () => { - const callerPage = tm.getPage(tm.userSet.accounts[0]); - const calleePage = tm.getPage(tm.userSet.accounts[1]); + const callerPage = testManager.getPage(testManager.userSet.accounts[0]); + const calleePage = testManager.getPage(testManager.userSet.accounts[1]); await establishCall(callerPage, calleePage, calleeNumber); @@ -98,8 +98,8 @@ export function callLifecycleTests() { }); test('CALL-003: Incoming call reject flow - callee rejects call', async () => { - const callerPage = tm.getPage(tm.userSet.accounts[0]); - const calleePage = tm.getPage(tm.userSet.accounts[1]); + const callerPage = testManager.getPage(testManager.userSet.accounts[0]); + const calleePage = testManager.getPage(testManager.userSet.accounts[1]); await makeCall(callerPage, calleeNumber); await waitForIncomingCall(calleePage); @@ -120,45 +120,45 @@ export function callLifecycleMediaTests() { test.describe('Call Lifecycle - Media & Disconnect', () => { test.describe.configure({mode: 'serial', timeout: 180000}); - let tm: TestManager; + let testManager: TestManager; let calleeNumber: string; test.beforeAll(async ({browser}, testInfo) => { - tm = new TestManager(testInfo.project.name); + testManager = new TestManager(testInfo.project.name); await Promise.all([ - tm.setupContext(browser, 0, { + testManager.setupContext(browser, 0, { initSDK: true, service: 'calling', register: true, media: true, }), - tm.setupContext(browser, 1, { + testManager.setupContext(browser, 1, { initSDK: true, service: 'calling', register: true, media: true, }), ]); - calleeNumber = getPhoneNumber(tm.userSet.accounts[1]); + calleeNumber = getPhoneNumber(testManager.userSet.accounts[1], testManager.isInt); }); test.afterEach(async () => { await Promise.all([ - cleanupActiveCalls(tm.getPage(tm.userSet.accounts[0])), - cleanupActiveCalls(tm.getPage(tm.userSet.accounts[1])), + cleanupActiveCalls(testManager.getPage(testManager.userSet.accounts[0])), + cleanupActiveCalls(testManager.getPage(testManager.userSet.accounts[1])), ]); - if (!tm.page.isClosed()) { - await tm.page.waitForTimeout(3000); + if (!testManager.page.isClosed()) { + await testManager.page.waitForTimeout(3000); } }); test.afterAll(async () => { - await tm.cleanup(); + await testManager.cleanup(); }); test('CALL-004: Local disconnect - establish call, caller hangs up', async () => { - const callerPage = tm.getPage(tm.userSet.accounts[0]); - const calleePage = tm.getPage(tm.userSet.accounts[1]); + const callerPage = testManager.getPage(testManager.userSet.accounts[0]); + const calleePage = testManager.getPage(testManager.userSet.accounts[1]); await establishCall(callerPage, calleePage, calleeNumber); @@ -167,8 +167,8 @@ export function callLifecycleMediaTests() { }); test('CALL-005: Remote disconnect - establish call, callee hangs up', async () => { - const callerPage = tm.getPage(tm.userSet.accounts[0]); - const calleePage = tm.getPage(tm.userSet.accounts[1]); + const callerPage = testManager.getPage(testManager.userSet.accounts[0]); + const calleePage = testManager.getPage(testManager.userSet.accounts[1]); await establishCall(callerPage, calleePage, calleeNumber); @@ -177,8 +177,8 @@ export function callLifecycleMediaTests() { }); test('CALL-006: Unanswered call - caller hangs up after no answer', async () => { - const callerPage = tm.getPage(tm.userSet.accounts[0]); - const calleePage = tm.getPage(tm.userSet.accounts[1]); + const callerPage = testManager.getPage(testManager.userSet.accounts[0]); + const calleePage = testManager.getPage(testManager.userSet.accounts[1]); await makeCall(callerPage, calleeNumber); await waitForIncomingCall(calleePage); @@ -200,8 +200,8 @@ export function callLifecycleMediaTests() { }); test('CALL-007: ROAP success - verify remote media after call established', async () => { - const callerPage = tm.getPage(tm.userSet.accounts[0]); - const calleePage = tm.getPage(tm.userSet.accounts[1]); + const callerPage = testManager.getPage(testManager.userSet.accounts[0]); + const calleePage = testManager.getPage(testManager.userSet.accounts[1]); await establishCall(callerPage, calleePage, calleeNumber); await callerPage.waitForTimeout(2000); @@ -226,8 +226,8 @@ export function callLifecycleMediaTests() { }); test('CALL-008: Call metrics - verify call quality data available during call', async () => { - const callerPage = tm.getPage(tm.userSet.accounts[0]); - const calleePage = tm.getPage(tm.userSet.accounts[1]); + const callerPage = testManager.getPage(testManager.userSet.accounts[0]); + const calleePage = testManager.getPage(testManager.userSet.accounts[1]); await establishCall(callerPage, calleePage, calleeNumber); await callerPage.waitForTimeout(5000); diff --git a/packages/calling/playwright/test-groups/registration-errors.ts b/packages/calling/playwright/test-groups/registration-errors.ts index f686c067d6c..673a336c253 100644 --- a/packages/calling/playwright/test-groups/registration-errors.ts +++ b/packages/calling/playwright/test-groups/registration-errors.ts @@ -1,7 +1,8 @@ import {test, expect} from '@playwright/test'; -import {navigateToCallingApp, setServiceIndicator} from '../utils/setup'; +import {navigateToCallingApp, setServiceIndicator, setMobiusWebSocket} from '../utils/setup'; import {isLineRegistered} from '../utils/registration'; import {CALLING_SELECTORS, AWAIT_TIMEOUT, SDK_INIT_TIMEOUT} from '../constants'; +import {isMobiusWsMode} from '../test-data'; /** * Registration error tests: REG-011. @@ -12,21 +13,31 @@ export function registrationErrorTests() { test('REG-011: Registration fails with invalid token', async ({page, context}) => { let registrationPosts = 0; let registrationStatus = 0; + const mobiusWsMode = isMobiusWsMode(); - await context.route(/\/calling\/web\/device$/, async (route) => { - if (route.request().method() === 'POST') { - registrationPosts += 1; - const response = await route.fetch(); - registrationStatus = response.status(); - await route.fulfill({response}); - } else { - await route.continue(); - } - }); + // In WS mode the SDK fails at HTTP service discovery (invalid token → 401 on + // region/server lookup) before ever opening a WebSocket. No WS interceptor is + // needed — the test still validates the end state: "not registered". + if (!mobiusWsMode) { + await context.route(/\/calling\/web\/device$/, async (route) => { + if (route.request().method() === 'POST') { + registrationPosts += 1; + const response = await route.fetch(); + registrationStatus = response.status(); + await route.fulfill({response}); + } else { + await route.continue(); + } + }); + } await navigateToCallingApp(page); await setServiceIndicator(page, 'calling'); + if (mobiusWsMode) { + await setMobiusWebSocket(page, true); + } + await page.locator(CALLING_SELECTORS.ACCESS_TOKEN_INPUT).fill('invalid-token-12345', { timeout: AWAIT_TIMEOUT, }); @@ -49,7 +60,7 @@ export function registrationErrorTests() { expect(await isLineRegistered(page)).toBe(false); } - if (registrationPosts > 0) { + if (!mobiusWsMode && registrationPosts > 0) { expect(registrationStatus).toBe(401); } diff --git a/packages/calling/playwright/test-groups/registration-failover.ts b/packages/calling/playwright/test-groups/registration-failover.ts index a0dd4a9e23b..29a6f993b2e 100644 --- a/packages/calling/playwright/test-groups/registration-failover.ts +++ b/packages/calling/playwright/test-groups/registration-failover.ts @@ -1,7 +1,7 @@ import {test, expect} from '@playwright/test'; import {TestManager} from '../test-manager'; import {isLineRegistered, getActiveMobiusUrl} from '../utils/registration'; -import {isIntProject} from '../test-data'; +import {isIntProject, isMobiusWsMode} from '../test-data'; import { CALLING_SELECTORS, AWAIT_TIMEOUT, @@ -9,6 +9,12 @@ import { PRIMARY_MOBIUS_URL, BACKUP_MOBIUS_URL, } from '../constants'; +import { + getDiscoveredMobiusWsUrls, + isKnownWsUrl, + MOBIUS_WS_MESSAGE, + MobiusWsInterceptor, +} from '../utils/mobius-ws'; /** * Failover & failback tests: REG-006, REG-017, REG-007. @@ -20,7 +26,7 @@ export function registrationFailoverTests() { test.describe('Failover & Failback', () => { test.describe.configure({mode: 'serial'}); - let tm: TestManager; + let testManager: TestManager; let registrationAttempts = 0; const attemptedUrls: string[] = []; let phase: 'failover' | 'failback' | 'failback-429' = 'failover'; @@ -30,69 +36,123 @@ export function registrationFailoverTests() { const MAX_FAILURES = 6; let expectedPrimaryUrl: string; let expectedBackupUrl: string; + let primaryWsUrls: string[] = []; + let backupWsUrls: string[] = []; + const mobiusWsMode = isMobiusWsMode(); test.beforeAll(async ({browser}, testInfo) => { const isInt = isIntProject(testInfo.project.name); expectedPrimaryUrl = isInt ? PRIMARY_MOBIUS_URL.INT : PRIMARY_MOBIUS_URL.PROD; expectedBackupUrl = isInt ? BACKUP_MOBIUS_URL.INT : BACKUP_MOBIUS_URL.PROD; - tm = new TestManager(testInfo.project.name); - const {context} = await tm.setupContext(browser, 0, { + testManager = new TestManager(testInfo.project.name); + let interceptor: MobiusWsInterceptor | undefined; + if (mobiusWsMode) { + interceptor = new MobiusWsInterceptor({ + onRequest: (frame, context) => { + if (frame.type !== MOBIUS_WS_MESSAGE.REGISTER) { + return undefined; + } + + registrationAttempts += 1; + attemptedUrls.push(context.url); + + if (phase === 'failover' && registrationAttempts <= MAX_FAILURES) { + return { + statusCode: 503, + statusMessage: 'Service Unavailable', + data: {message: 'Service Unavailable'}, + }; + } + + if (phase === 'failback-429') { + if (isKnownWsUrl(context.url, primaryWsUrls)) { + failback429Attempts += 1; + + return { + statusCode: 429, + statusMessage: 'Too Many Requests', + metadata: {'retry-after': String(FAILBACK_RETRY_AFTER_SECONDS)}, + data: {message: 'Too Many Requests'}, + }; + } + + return undefined; + } + + if (phase === 'failback') { + failbackRegistrationAttempts += 1; + } + + return undefined; + }, + }); + } + const {context} = await testManager.setupContext(browser, 0, { initSDK: true, service: 'calling', + beforeInit: interceptor + ? (browserContext) => interceptor!.install(browserContext) + : undefined, }); - // Intercept registration POST — behavior depends on current phase - await context.route(/\/calling\/web\/device$/, async (route) => { - if (route.request().method() === 'POST') { - registrationAttempts += 1; - attemptedUrls.push(route.request().url()); - - if (phase === 'failover' && registrationAttempts <= MAX_FAILURES) { - await route.fulfill({ - status: 503, - contentType: 'application/json', - body: JSON.stringify({message: 'Service Unavailable'}), - }); - } else if (phase === 'failback-429') { - const url = route.request().url(); - - if (url.startsWith(expectedPrimaryUrl)) { - // Primary attempts get 429 - failback429Attempts += 1; + if (mobiusWsMode) { + const discovered = await getDiscoveredMobiusWsUrls(testManager.page); + primaryWsUrls = discovered.primary; + backupWsUrls = discovered.backup; + } else { + // Intercept registration POST — behavior depends on current phase + await context.route(/\/calling\/web\/device$/, async (route) => { + if (route.request().method() === 'POST') { + registrationAttempts += 1; + attemptedUrls.push(route.request().url()); + + if (phase === 'failover' && registrationAttempts <= MAX_FAILURES) { await route.fulfill({ - status: 429, - headers: { - 'Retry-After': String(FAILBACK_RETRY_AFTER_SECONDS), - 'Content-Type': 'application/json', - }, - body: JSON.stringify({message: 'Too Many Requests'}), + status: 503, + contentType: 'application/json', + body: JSON.stringify({message: 'Service Unavailable'}), }); + } else if (phase === 'failback-429') { + const url = route.request().url(); + + if (url.startsWith(expectedPrimaryUrl)) { + // Primary attempts get 429 + failback429Attempts += 1; + await route.fulfill({ + status: 429, + headers: { + 'Retry-After': String(FAILBACK_RETRY_AFTER_SECONDS), + 'Content-Type': 'application/json', + }, + body: JSON.stringify({message: 'Too Many Requests'}), + }); + } else { + // Backup attempts pass through (restorePreviousRegistration) + await route.continue(); + } } else { - // Backup attempts pass through (restorePreviousRegistration) + if (phase === 'failback') { + failbackRegistrationAttempts += 1; + } await route.continue(); } } else { - if (phase === 'failback') { - failbackRegistrationAttempts += 1; - } await route.continue(); } - } else { - await route.continue(); - } - }); + }); + } }); test.afterAll(async () => { - await tm.context.unrouteAll({behavior: 'ignoreErrors'}); - await tm.cleanup(); + await testManager.context.unrouteAll({behavior: 'ignoreErrors'}); + await testManager.cleanup(); }); test('REG-006: Primary-to-backup failover on repeated failure', async () => { test.setTimeout(300000); - const page = tm.page; + const page = testManager.page; // Click register — will fail on primary, eventually succeed on backup await page.locator(CALLING_SELECTORS.REGISTER_BTN).click({timeout: AWAIT_TIMEOUT}); @@ -118,16 +178,24 @@ export function registrationFailoverTests() { // After failover, active Mobius should be the backup server const activeMobius = await getActiveMobiusUrl(page); - expect(activeMobius).toBe(expectedBackupUrl); + if (mobiusWsMode) { + expect(isKnownWsUrl(activeMobius, backupWsUrls)).toBe(true); + } else { + expect(activeMobius).toBe(expectedBackupUrl); + } }); test('REG-017: 429 during failback exhausts retry budget, stays on backup', async () => { test.setTimeout(300000); - const page = tm.page; + const page = testManager.page; // Device is on backup from REG-006 - expect(await getActiveMobiusUrl(page)).toBe(expectedBackupUrl); + if (mobiusWsMode) { + expect(isKnownWsUrl(await getActiveMobiusUrl(page), backupWsUrls)).toBe(true); + } else { + expect(await getActiveMobiusUrl(page)).toBe(expectedBackupUrl); + } // Switch to failback-429 phase — primary POSTs get 429, backup POSTs pass through phase = 'failback-429'; @@ -167,7 +235,11 @@ export function registrationFailoverTests() { expect(failback429Attempts).toBeGreaterThanOrEqual(5); // Device must still be on backup — failback should have given up - expect(await getActiveMobiusUrl(page)).toBe(expectedBackupUrl); + if (mobiusWsMode) { + expect(isKnownWsUrl(await getActiveMobiusUrl(page), backupWsUrls)).toBe(true); + } else { + expect(await getActiveMobiusUrl(page)).toBe(expectedBackupUrl); + } await expect .poll(() => isLineRegistered(page), { message: 'Line should remain registered on backup after failback 429 exhaustion', @@ -190,11 +262,15 @@ export function registrationFailoverTests() { test('REG-007: Fallback to primary from backup', async () => { test.setTimeout(300000); - const page = tm.page; + const page = testManager.page; // Record the backup URL from REG-006 const backupUrl = await getActiveMobiusUrl(page); - expect(backupUrl).toBe(expectedBackupUrl); + if (mobiusWsMode) { + expect(isKnownWsUrl(backupUrl, backupWsUrls)).toBe(true); + } else { + expect(backupUrl).toBe(expectedBackupUrl); + } // Switch to failback phase — all registration POSTs now succeed phase = 'failback'; @@ -234,7 +310,11 @@ export function registrationFailoverTests() { // Verify moved from backup to primary const newActiveMobiusUrl = await getActiveMobiusUrl(page); - expect(newActiveMobiusUrl).toBe(expectedPrimaryUrl); + if (mobiusWsMode) { + expect(isKnownWsUrl(newActiveMobiusUrl, primaryWsUrls)).toBe(true); + } else { + expect(newActiveMobiusUrl).toBe(expectedPrimaryUrl); + } }); }); } diff --git a/packages/calling/playwright/test-groups/registration-keepalive.ts b/packages/calling/playwright/test-groups/registration-keepalive.ts index 713f896acd7..12a66d62198 100644 --- a/packages/calling/playwright/test-groups/registration-keepalive.ts +++ b/packages/calling/playwright/test-groups/registration-keepalive.ts @@ -1,5 +1,5 @@ import {test, expect} from '@playwright/test'; -import {getToken, getUserSet, isIntProject} from '../test-data'; +import {getToken, getUserSet, isIntProject, isMobiusWsMode} from '../test-data'; import { navigateToCallingApp, initializeCallingSDK, @@ -20,6 +20,12 @@ import { PRIMARY_MOBIUS_URL, BACKUP_MOBIUS_URL, } from '../constants'; +import { + getDiscoveredMobiusWsUrls, + isKnownWsUrl, + MOBIUS_WS_MESSAGE, + MobiusWsInterceptor, +} from '../utils/mobius-ws'; /** * Keepalive & registration-retry tests: REG-004, REG-005, REG-015, REG-016. @@ -35,6 +41,7 @@ export function registrationKeepaliveTests() { test('REG-004: Keepalive 404 triggers re-registration', async ({page, context}, testInfo) => { const isInt = isIntProject(testInfo.project.name); const role = getUserSet(testInfo.project.name).accounts[0]; + const mobiusWsMode = isMobiusWsMode(); test.setTimeout(180000); let registrationCount = 0; @@ -42,36 +49,72 @@ export function registrationKeepaliveTests() { let postReRegKeepaliveCount = 0; let trackPostReRegKeepalive = false; - await context.route(/\/calling\/web\/device$/, async (route) => { - if (route.request().method() === 'POST') { - registrationCount += 1; - const response = await route.fetch(); - const body = await response.json(); - body.keepaliveInterval = 5; - await route.fulfill({response, body: JSON.stringify(body)}); - } else { - await route.continue(); - } - }); - - await context.route(/\/devices\/[^/]+\/status$/, async (route) => { - if (route.request().method() === 'POST') { - if (failKeepalive) { - await route.fulfill({ - status: 404, - contentType: 'application/json', - body: JSON.stringify({message: 'Device not found'}), - }); + if (mobiusWsMode) { + await new MobiusWsInterceptor({ + onRequest: (frame) => { + if (frame.type === MOBIUS_WS_MESSAGE.REGISTER) { + registrationCount += 1; + } + + if (frame.type === MOBIUS_WS_MESSAGE.DEVICE_STATUS) { + if (failKeepalive) { + return { + statusCode: 404, + statusMessage: 'Not Found', + data: {message: 'Device not found'}, + }; + } + + if (trackPostReRegKeepalive) { + postReRegKeepaliveCount += 1; + } + } + + return undefined; + }, + onResponse: (frame) => { + if (frame.subtype === MOBIUS_WS_MESSAGE.REGISTER && frame.statusCode === 200) { + return { + ...frame, + data: {...(frame.data || {}), keepaliveInterval: 5}, + }; + } + + return undefined; + }, + }).install(context); + } else { + await context.route(/\/calling\/web\/device$/, async (route) => { + if (route.request().method() === 'POST') { + registrationCount += 1; + const response = await route.fetch(); + const body = await response.json(); + body.keepaliveInterval = 5; + await route.fulfill({response, body: JSON.stringify(body)}); } else { - if (trackPostReRegKeepalive) { - postReRegKeepaliveCount += 1; + await route.continue(); + } + }); + + await context.route(/\/devices\/[^/]+\/status$/, async (route) => { + if (route.request().method() === 'POST') { + if (failKeepalive) { + await route.fulfill({ + status: 404, + contentType: 'application/json', + body: JSON.stringify({message: 'Device not found'}), + }); + } else { + if (trackPostReRegKeepalive) { + postReRegKeepaliveCount += 1; + } + await route.continue(); } + } else { await route.continue(); } - } else { - await route.continue(); - } - }); + }); + } await navigateToCallingApp(page); if (isInt) await setEnvironmentToInt(page); @@ -124,6 +167,7 @@ export function registrationKeepaliveTests() { test('REG-005: 429 Retry-After is honored on keepalive', async ({page, context}, testInfo) => { const isInt = isIntProject(testInfo.project.name); const role = getUserSet(testInfo.project.name).accounts[0]; + const mobiusWsMode = isMobiusWsMode(); test.setTimeout(180000); const RETRY_AFTER_SECONDS = 10; @@ -131,41 +175,78 @@ export function registrationKeepaliveTests() { let firstKeepaliveTime = 0; let resumedKeepaliveTime = 0; - await context.route(/\/calling\/web\/device$/, async (route) => { - if (route.request().method() === 'POST') { - const response = await route.fetch(); - const body = await response.json(); - body.keepaliveInterval = 5; - await route.fulfill({response, body: JSON.stringify(body)}); - } else { - await route.continue(); - } - }); - - await context.route(/\/devices\/[^/]+\/status$/, async (route) => { - if (route.request().method() === 'POST') { - keepaliveCount += 1; - - if (keepaliveCount === 1) { - firstKeepaliveTime = Date.now(); - await route.fulfill({ - status: 429, - headers: { - 'Retry-After': String(RETRY_AFTER_SECONDS), - 'Content-Type': 'application/json', - }, - body: JSON.stringify({message: 'Too Many Requests'}), - }); + if (mobiusWsMode) { + await new MobiusWsInterceptor({ + onRequest: (frame) => { + if (frame.type === MOBIUS_WS_MESSAGE.DEVICE_STATUS) { + keepaliveCount += 1; + + if (keepaliveCount === 1) { + firstKeepaliveTime = Date.now(); + + return { + statusCode: 429, + statusMessage: 'Too Many Requests', + metadata: {'retry-after': String(RETRY_AFTER_SECONDS)}, + data: {message: 'Too Many Requests'}, + }; + } + + if (resumedKeepaliveTime === 0) { + resumedKeepaliveTime = Date.now(); + } + } + + return undefined; + }, + onResponse: (frame) => { + if (frame.subtype === MOBIUS_WS_MESSAGE.REGISTER && frame.statusCode === 200) { + return { + ...frame, + data: {...(frame.data || {}), keepaliveInterval: 5}, + }; + } + + return undefined; + }, + }).install(context); + } else { + await context.route(/\/calling\/web\/device$/, async (route) => { + if (route.request().method() === 'POST') { + const response = await route.fetch(); + const body = await response.json(); + body.keepaliveInterval = 5; + await route.fulfill({response, body: JSON.stringify(body)}); } else { - if (resumedKeepaliveTime === 0) { - resumedKeepaliveTime = Date.now(); + await route.continue(); + } + }); + + await context.route(/\/devices\/[^/]+\/status$/, async (route) => { + if (route.request().method() === 'POST') { + keepaliveCount += 1; + + if (keepaliveCount === 1) { + firstKeepaliveTime = Date.now(); + await route.fulfill({ + status: 429, + headers: { + 'Retry-After': String(RETRY_AFTER_SECONDS), + 'Content-Type': 'application/json', + }, + body: JSON.stringify({message: 'Too Many Requests'}), + }); + } else { + if (resumedKeepaliveTime === 0) { + resumedKeepaliveTime = Date.now(); + } + await route.continue(); } + } else { await route.continue(); } - } else { - await route.continue(); - } - }); + }); + } await navigateToCallingApp(page); if (isInt) await setEnvironmentToInt(page); @@ -197,6 +278,7 @@ export function registrationKeepaliveTests() { }, testInfo) => { const isInt = isIntProject(testInfo.project.name); const role = getUserSet(testInfo.project.name).accounts[0]; + const mobiusWsMode = isMobiusWsMode(); test.setTimeout(300000); const RETRY_AFTER_SECONDS = 10; @@ -204,28 +286,50 @@ export function registrationKeepaliveTests() { let registrationAttempts = 0; const attemptTimestamps: number[] = []; - // Intercept registration POST — first N attempts return 429 with Retry-After - await context.route(/\/calling\/web\/device$/, async (route) => { - if (route.request().method() === 'POST') { - registrationAttempts += 1; - attemptTimestamps.push(Date.now()); - - if (registrationAttempts <= MAX_429_RESPONSES) { - await route.fulfill({ - status: 429, - headers: { - 'Retry-After': String(RETRY_AFTER_SECONDS), - 'Content-Type': 'application/json', - }, - body: JSON.stringify({message: 'Too Many Requests'}), - }); + if (mobiusWsMode) { + await new MobiusWsInterceptor({ + onRequest: (frame) => { + if (frame.type === MOBIUS_WS_MESSAGE.REGISTER) { + registrationAttempts += 1; + attemptTimestamps.push(Date.now()); + + if (registrationAttempts <= MAX_429_RESPONSES) { + return { + statusCode: 429, + statusMessage: 'Too Many Requests', + metadata: {'retry-after': String(RETRY_AFTER_SECONDS)}, + data: {message: 'Too Many Requests'}, + }; + } + } + + return undefined; + }, + }).install(context); + } else { + // Intercept registration POST — first N attempts return 429 with Retry-After + await context.route(/\/calling\/web\/device$/, async (route) => { + if (route.request().method() === 'POST') { + registrationAttempts += 1; + attemptTimestamps.push(Date.now()); + + if (registrationAttempts <= MAX_429_RESPONSES) { + await route.fulfill({ + status: 429, + headers: { + 'Retry-After': String(RETRY_AFTER_SECONDS), + 'Content-Type': 'application/json', + }, + body: JSON.stringify({message: 'Too Many Requests'}), + }); + } else { + await route.continue(); + } } else { await route.continue(); } - } else { - await route.continue(); - } - }); + }); + } await navigateToCallingApp(page); if (isInt) await setEnvironmentToInt(page); @@ -260,44 +364,77 @@ export function registrationKeepaliveTests() { }, testInfo) => { const isInt = isIntProject(testInfo.project.name); const role = getUserSet(testInfo.project.name).accounts[0]; + const mobiusWsMode = isMobiusWsMode(); test.setTimeout(300000); const expectedPrimaryUrl = isInt ? PRIMARY_MOBIUS_URL.INT : PRIMARY_MOBIUS_URL.PROD; const expectedBackupUrl = isInt ? BACKUP_MOBIUS_URL.INT : BACKUP_MOBIUS_URL.PROD; + let primaryWsUrls: string[] = []; + let backupWsUrls: string[] = []; const HIGH_RETRY_AFTER = 120; // Above RETRY_TIMER_UPPER_LIMIT (60s) let primaryAttempts = 0; let backupAttempts = 0; const testStartTime = Date.now(); - // Intercept registration POST — 429 on primary, pass-through on backup - await context.route(/\/calling\/web\/device$/, async (route) => { - if (route.request().method() === 'POST') { - const url = route.request().url(); - - if (url.startsWith(expectedPrimaryUrl)) { - primaryAttempts += 1; - await route.fulfill({ - status: 429, - headers: { - 'Retry-After': String(HIGH_RETRY_AFTER), - 'Content-Type': 'application/json', - }, - body: JSON.stringify({message: 'Too Many Requests'}), - }); - } else { + if (mobiusWsMode) { + await new MobiusWsInterceptor({ + onRequest: (frame, routeContext) => { + if (frame.type !== MOBIUS_WS_MESSAGE.REGISTER) { + return undefined; + } + + if (isKnownWsUrl(routeContext.url, primaryWsUrls)) { + primaryAttempts += 1; + + return { + statusCode: 429, + statusMessage: 'Too Many Requests', + metadata: {'retry-after': String(HIGH_RETRY_AFTER)}, + data: {message: 'Too Many Requests'}, + }; + } + backupAttempts += 1; + + return undefined; + }, + }).install(context); + } else { + // Intercept registration POST — 429 on primary, pass-through on backup + await context.route(/\/calling\/web\/device$/, async (route) => { + if (route.request().method() === 'POST') { + const url = route.request().url(); + + if (url.startsWith(expectedPrimaryUrl)) { + primaryAttempts += 1; + await route.fulfill({ + status: 429, + headers: { + 'Retry-After': String(HIGH_RETRY_AFTER), + 'Content-Type': 'application/json', + }, + body: JSON.stringify({message: 'Too Many Requests'}), + }); + } else { + backupAttempts += 1; + await route.continue(); + } + } else { await route.continue(); } - } else { - await route.continue(); - } - }); + }); + } await navigateToCallingApp(page); if (isInt) await setEnvironmentToInt(page); await setServiceIndicator(page, 'calling'); await initializeCallingSDK(page, getToken(role, isInt)); await verifySDKInitialized(page); + if (mobiusWsMode) { + const discovered = await getDiscoveredMobiusWsUrls(page); + primaryWsUrls = discovered.primary; + backupWsUrls = discovered.backup; + } await page.locator(CALLING_SELECTORS.REGISTER_BTN).click({timeout: AWAIT_TIMEOUT}); @@ -312,7 +449,11 @@ export function registrationKeepaliveTests() { // Verify registered on backup, not primary const activeMobius = await getActiveMobiusUrl(page); - expect(activeMobius).toBe(expectedBackupUrl); + if (mobiusWsMode) { + expect(isKnownWsUrl(activeMobius, backupWsUrls)).toBe(true); + } else { + expect(activeMobius).toBe(expectedBackupUrl); + } // Verify failover happened well before the 120s Retry-After would have elapsed const elapsed = Date.now() - testStartTime; diff --git a/packages/calling/playwright/test-groups/registration-lifecycle.ts b/packages/calling/playwright/test-groups/registration-lifecycle.ts index 81b95510410..d6af21be4f1 100644 --- a/packages/calling/playwright/test-groups/registration-lifecycle.ts +++ b/packages/calling/playwright/test-groups/registration-lifecycle.ts @@ -8,13 +8,20 @@ import { getActiveMobiusUrl, getDeviceInfo, } from '../utils/registration'; -import {isIntProject} from '../test-data'; +import {isIntProject, isMobiusWsMode} from '../test-data'; import { CALLING_SELECTORS, AWAIT_TIMEOUT, REGISTRATION_TIMEOUT, PRIMARY_MOBIUS_URL, } from '../constants'; +import { + getDiscoveredMobiusWsUrls, + isKnownWsUrl, + isMobiusWsActive, + MOBIUS_WS_MESSAGE, + MobiusWsInterceptor, +} from '../utils/mobius-ws'; /** * Registration lifecycle tests: REG-001, REG-003, REG-008, REG-010. @@ -27,62 +34,90 @@ export function registrationLifecycleTests() { test.describe('Registration Lifecycle', () => { test.describe.configure({mode: 'serial'}); - let tm: TestManager; + let testManager: TestManager; let registrationPosts = 0; let deletePosts = 0; let keepaliveCount = 0; let expectedPrimaryUrl: string; + let mobiusWsInterceptor: MobiusWsInterceptor | undefined; + const mobiusWsMode = isMobiusWsMode(); test.beforeAll(async ({browser}, testInfo) => { const isInt = isIntProject(testInfo.project.name); expectedPrimaryUrl = isInt ? PRIMARY_MOBIUS_URL.INT : PRIMARY_MOBIUS_URL.PROD; - tm = new TestManager(testInfo.project.name); - const {context, page} = await tm.setupContext(browser, 0, { + testManager = new TestManager(testInfo.project.name); + if (mobiusWsMode) { + mobiusWsInterceptor = new MobiusWsInterceptor({ + onResponse: (frame) => { + if (frame.subtype === MOBIUS_WS_MESSAGE.REGISTER && frame.statusCode === 200) { + return { + ...frame, + data: { + ...(frame.data || {}), + keepaliveInterval: 5, + }, + }; + } + + return undefined; + }, + }); + } + const {context, page} = await testManager.setupContext(browser, 0, { initSDK: true, service: 'calling', + beforeInit: mobiusWsInterceptor + ? (browserContext) => mobiusWsInterceptor!.install(browserContext) + : undefined, }); - // Track Mobius registration and delete requests across all tests, - // and shorten keepalive interval so REG-003 completes quickly. - await context.route(/\/calling\/web\/device$/, async (route) => { - if (route.request().method() === 'POST') { - registrationPosts += 1; - const response = await route.fetch(); - const body = await response.json(); - body.keepaliveInterval = 5; - await route.fulfill({response, body: JSON.stringify(body)}); - } else { + if (!mobiusWsMode) { + // Track Mobius registration and delete requests across all tests, + // and shorten keepalive interval so REG-003 completes quickly. + await context.route(/\/calling\/web\/device$/, async (route) => { + if (route.request().method() === 'POST') { + registrationPosts += 1; + const response = await route.fetch(); + const body = await response.json(); + body.keepaliveInterval = 5; + await route.fulfill({response, body: JSON.stringify(body)}); + } else { + await route.continue(); + } + }); + + // Track keepalive status requests for REG-003 + await context.route(/\/devices\/[^/]+\/status$/, async (route) => { + if (route.request().method() === 'POST') { + keepaliveCount += 1; + } await route.continue(); - } - }); + }); - // Track keepalive status requests for REG-003 - await context.route(/\/devices\/[^/]+\/status$/, async (route) => { - if (route.request().method() === 'POST') { - keepaliveCount += 1; - } - await route.continue(); - }); - - await context.route(/\/calling\/web\/devices\/[^/]+$/, async (route) => { - if (route.request().method() === 'DELETE') { - deletePosts += 1; - } - await route.continue(); - }); + await context.route(/\/calling\/web\/devices\/[^/]+$/, async (route) => { + if (route.request().method() === 'DELETE') { + deletePosts += 1; + } + await route.continue(); + }); + } await registerLine(page); await verifyLineRegistered(page); }); test.afterAll(async () => { - await tm.cleanup(); + await testManager.cleanup(); }); test('REG-001: Initial registration success', async () => { - const page = tm.page; + const page = testManager.page; - expect(registrationPosts).toBe(1); + if (mobiusWsMode) { + expect(mobiusWsInterceptor?.getRequestCount(MOBIUS_WS_MESSAGE.REGISTER)).toBe(1); + } else { + expect(registrationPosts).toBe(1); + } const statusText = await page.locator(CALLING_SELECTORS.REGISTRATION_STATUS).textContent(); expect(statusText).toMatch(/Registered, deviceId: .+/); @@ -90,7 +125,16 @@ export function registrationLifecycleTests() { expect(await isLineRegistered(page)).toBe(true); const activeMobiusUrl = await getActiveMobiusUrl(page); - expect(activeMobiusUrl).toBe(expectedPrimaryUrl); + if (mobiusWsMode) { + const discovered = await getDiscoveredMobiusWsUrls(page); + + expect(isMobiusWsActive(activeMobiusUrl)).toBe(true); + expect(isKnownWsUrl(activeMobiusUrl, [...discovered.primary, ...discovered.backup])).toBe( + true + ); + } else { + expect(activeMobiusUrl).toBe(expectedPrimaryUrl); + } const deviceInfo = await getDeviceInfo(page); expect(deviceInfo.device).toBeTruthy(); @@ -103,14 +147,20 @@ export function registrationLifecycleTests() { }); test('REG-003: Keepalive requests are sent after registration', async () => { - const page = tm.page; + const page = testManager.page; await expect - .poll(() => keepaliveCount, { - message: 'Expected at least one keepalive request within 20s', - timeout: 20000, - intervals: [1000], - }) + .poll( + () => + mobiusWsMode + ? mobiusWsInterceptor?.getRequestCount(MOBIUS_WS_MESSAGE.DEVICE_STATUS) || 0 + : keepaliveCount, + { + message: 'Expected at least one keepalive request within 20s', + timeout: 20000, + intervals: [1000], + } + ) .toBeGreaterThan(0); expect(await isLineRegistered(page)).toBe(true); @@ -119,10 +169,14 @@ export function registrationLifecycleTests() { test('REG-008: Connection restoration re-registers when no active calls', async () => { test.setTimeout(240000); - const page = tm.page; - const context = tm.context; - const initialRegCount = registrationPosts; - const initialDeleteCount = deletePosts; + const page = testManager.page; + const context = testManager.context; + const initialRegCount = mobiusWsMode + ? mobiusWsInterceptor?.getRequestCount(MOBIUS_WS_MESSAGE.REGISTER) || 0 + : registrationPosts; + const initialDeleteCount = mobiusWsMode + ? mobiusWsInterceptor?.getRequestCount(MOBIUS_WS_MESSAGE.UNREGISTER) || 0 + : deletePosts; const mobiusUrlBefore = await getActiveMobiusUrl(page); @@ -131,11 +185,17 @@ export function registrationLifecycleTests() { await context.setOffline(false); await expect - .poll(() => registrationPosts, { - message: 'Expected re-registration after network restoration', - timeout: 120000, - intervals: [2000], - }) + .poll( + () => + mobiusWsMode + ? mobiusWsInterceptor?.getRequestCount(MOBIUS_WS_MESSAGE.REGISTER) || 0 + : registrationPosts, + { + message: 'Expected re-registration after network restoration', + timeout: 120000, + intervals: [2000], + } + ) .toBeGreaterThan(initialRegCount); await expect @@ -151,14 +211,17 @@ export function registrationLifecycleTests() { {timeout: REGISTRATION_TIMEOUT} ); - expect(deletePosts).toBeGreaterThan(initialDeleteCount); + const deleteCount = mobiusWsMode + ? mobiusWsInterceptor?.getRequestCount(MOBIUS_WS_MESSAGE.UNREGISTER) || 0 + : deletePosts; + expect(deleteCount).toBeGreaterThan(initialDeleteCount); const mobiusUrlAfter = await getActiveMobiusUrl(page); expect(mobiusUrlAfter).toBe(mobiusUrlBefore); }); test('REG-010: Deregistration success and cleanup', async () => { - const page = tm.page; + const page = testManager.page; await unregisterLine(page); @@ -169,7 +232,10 @@ export function registrationLifecycleTests() { } ); - expect(deletePosts).toBeGreaterThanOrEqual(1); + const deleteCount = mobiusWsMode + ? mobiusWsInterceptor?.getRequestCount(MOBIUS_WS_MESSAGE.UNREGISTER) || 0 + : deletePosts; + expect(deleteCount).toBeGreaterThanOrEqual(1); await expect(async () => { expect(await isLineRegistered(page)).toBe(false); diff --git a/packages/calling/playwright/test-manager.ts b/packages/calling/playwright/test-manager.ts index 8a800573b40..36dd371c428 100644 --- a/packages/calling/playwright/test-manager.ts +++ b/packages/calling/playwright/test-manager.ts @@ -1,11 +1,20 @@ import {Page, BrowserContext, Browser} from '@playwright/test'; -import {AccountRole, UserSet, getToken, getUserSet, isIntProject} from './test-data'; +import { + AccountRole, + UserSet, + getToken, + getUserSet, + isIntProject, + isMobiusWsMode, +} from './test-data'; import { navigateToCallingApp, initializeCallingSDK, verifySDKInitialized, setServiceIndicator, setEnvironmentToInt, + setMobiusWebSocket, + verifyMobiusWebSocketEnabled, } from './utils/setup'; import {registerLine, verifyLineRegistered, unregisterLine} from './utils/registration'; import {cleanupActiveCalls, getMediaStreams} from './utils/call'; @@ -20,6 +29,10 @@ interface SetupConfig { media?: boolean; /** Service indicator to set before init (default: 'calling') */ service?: ServiceIndicator; + /** Force the sample app Mobius WebSocket override before init */ + mobiusWss?: boolean; + /** Configure context/page before navigation and SDK init, e.g. route setup */ + beforeInit?: (context: BrowserContext, page: Page, role: AccountRole) => Promise; } interface ManagedContext { @@ -125,6 +138,10 @@ export class TestManager { const mc: ManagedContext = {context, page, role}; this.contexts.set(role, mc); + if (config.beforeInit) { + await config.beforeInit(context, page, role); + } + if (config.initSDK) { await navigateToCallingApp(page); if (this.isInt) { @@ -133,8 +150,15 @@ export class TestManager { if (config.service) { await setServiceIndicator(page, config.service); } + const mobiusWss = config.mobiusWss ?? isMobiusWsMode(); + if (mobiusWss) { + await setMobiusWebSocket(page, true); + } await initializeCallingSDK(page, getToken(role, this.isInt)); await verifySDKInitialized(page); + if (mobiusWss) { + await verifyMobiusWebSocketEnabled(page); + } if (config.register) { await registerLine(page); diff --git a/packages/calling/playwright/utils/mobius-ws.ts b/packages/calling/playwright/utils/mobius-ws.ts new file mode 100644 index 00000000000..24e506379b4 --- /dev/null +++ b/packages/calling/playwright/utils/mobius-ws.ts @@ -0,0 +1,221 @@ +import {BrowserContext, Page, WebSocketRoute} from '@playwright/test'; + +export const MOBIUS_WS_MESSAGE = { + AUTH: 'auth', + REGISTER: 'register', + UNREGISTER: 'unregister', + DEVICE_STATUS: 'device_status', + CALL_SETUP: 'call_setup', + CALL_STATUS: 'call_status', + CALL_MEDIA: 'call_media', + CALL_HOLD: 'call_hold', + CALL_RESUME: 'call_resume', +} as const; + +type MobiusWsFrame = { + type?: string; + subtype?: string; + trackingId?: string; + statusCode?: number; + statusMessage?: string; + metadata?: Record; + data?: any; + [key: string]: unknown; +}; + +type MockResponse = { + statusCode: number; + statusMessage?: string; + metadata?: Record; + data?: any; +}; + +type RouteContext = { + url: string; + requestCount: number; + responseCount: number; +}; + +type MobiusWsInterceptorOptions = { + onRequest?: ( + frame: MobiusWsFrame, + context: RouteContext + ) => MockResponse | void | Promise; + onResponse?: ( + frame: MobiusWsFrame, + context: RouteContext + ) => MobiusWsFrame | void | Promise; +}; + +const MOBIUS_WS_ROUTE = '**/calling/web**'; + +const parseFrame = (message: string | Buffer): MobiusWsFrame | undefined => { + const text = typeof message === 'string' ? message : message.toString(); + + try { + return JSON.parse(text) as MobiusWsFrame; + } catch { + return undefined; + } +}; + +const stringifyFrame = (frame: MobiusWsFrame): string => JSON.stringify(frame); + +const buildResponseFrame = (request: MobiusWsFrame, response: MockResponse): MobiusWsFrame => ({ + type: 'response_event', + subtype: request.type, + trackingId: request.trackingId, + statusCode: response.statusCode, + statusMessage: response.statusMessage ?? (response.statusCode >= 400 ? 'Error' : 'OK'), + metadata: response.metadata, + data: response.data, +}); + +const redactFrame = (frame: MobiusWsFrame): MobiusWsFrame => { + if (!frame?.metadata?.authorization) return frame; + + return {...frame, metadata: {...frame.metadata, authorization: '[REDACTED]'}}; +}; + +export class MobiusWsInterceptor { + private readonly options: MobiusWsInterceptorOptions; + + private readonly requestCounts = new Map(); + + private readonly responseCounts = new Map(); + + public readonly requests: Array = []; + + public readonly responses: Array = []; + + constructor(options: MobiusWsInterceptorOptions = {}) { + this.options = options; + } + + async install(context: BrowserContext): Promise { + await context.routeWebSocket(MOBIUS_WS_ROUTE, async (route: WebSocketRoute) => { + const server = route.connectToServer(); + const url = route.url(); + + route.onMessage(async (message) => { + const frame = parseFrame(message); + + if (!frame?.type) { + server.send(message); + + return; + } + + const requestCount = MobiusWsInterceptor.increment(this.requestCounts, frame.type); + this.requests.push({...redactFrame(frame), url}); + + let mockedResponse: MockResponse | void; + try { + mockedResponse = await this.options.onRequest?.(frame, { + url, + requestCount, + responseCount: this.getResponseCount(frame.type), + }); + } catch (err) { + console.error('MobiusWsInterceptor onRequest threw, passing frame through', err); + server.send(message); + + return; + } + + if (mockedResponse) { + const responseFrame = buildResponseFrame(frame, mockedResponse); + const responseType = responseFrame.subtype || responseFrame.type || 'unknown'; + + MobiusWsInterceptor.increment(this.responseCounts, responseType); + this.responses.push({...redactFrame(responseFrame), url}); + route.send(stringifyFrame(responseFrame)); + + return; + } + + server.send(message); + }); + + server.onMessage(async (message) => { + const frame = parseFrame(message); + + if (!frame) { + route.send(message); + + return; + } + + const responseType = frame.subtype || frame.type || 'unknown'; + const responseCount = MobiusWsInterceptor.increment(this.responseCounts, responseType); + this.responses.push({...redactFrame(frame), url}); + + let transformedFrame: MobiusWsFrame | void; + try { + transformedFrame = await this.options.onResponse?.(frame, { + url, + requestCount: this.getRequestCount(responseType), + responseCount, + }); + } catch (err) { + console.error('MobiusWsInterceptor onResponse threw, passing frame through', err); + route.send(message); + + return; + } + + route.send(stringifyFrame(transformedFrame || frame)); + }); + + route.onClose((code, reason) => { + server.close({code, reason}).catch(() => {}); + }); + + server.onClose((code, reason) => { + route.close({code, reason}).catch(() => {}); + }); + }); + } + + getRequestCount(type: string): number { + return this.requestCounts.get(type) || 0; + } + + getResponseCount(type: string): number { + return this.responseCounts.get(type) || 0; + } + + private static increment(map: Map, key: string): number { + const nextValue = (map.get(key) || 0) + 1; + map.set(key, nextValue); + + return nextValue; + } +} + +export const normalizeWsUrl = (url: string): string => (url.endsWith('/') ? url : `${url}/`); + +export const isKnownWsUrl = (url: string | undefined, urls: string[]): boolean => { + if (!url) { + return false; + } + + const normalized = normalizeWsUrl(url); + + return urls.map(normalizeWsUrl).some((knownUrl) => normalized.startsWith(knownUrl)); +}; + +export const getDiscoveredMobiusWsUrls = ( + page: Page +): Promise<{primary: string[]; backup: string[]}> => + page.evaluate(() => { + const client = (window as any).callingClient; + + return { + primary: client?.primaryWssMobiusUris ?? [], + backup: client?.backupWssMobiusUris ?? [], + }; + }); + +export const isMobiusWsActive = (url: string | undefined): boolean => + url?.startsWith('wss://') ?? false; diff --git a/packages/calling/playwright/utils/oauth.setup.ts b/packages/calling/playwright/utils/oauth.setup.ts index 17f9f7cf0d9..0f52c7d63c3 100644 --- a/packages/calling/playwright/utils/oauth.setup.ts +++ b/packages/calling/playwright/utils/oauth.setup.ts @@ -75,7 +75,9 @@ const fetchAccessToken = async ( await page.getByRole('textbox', {name: 'name@example.com'}).press('Enter'); // 4. Enter password and click Sign In - await page.getByPlaceholder('Password').fill(password, {timeout: 15000}); + await page + .locator('input#IDToken2[name="IDToken2"][type="password"]') + .fill(password, {timeout: 15000}); await page.getByRole('button', {name: 'Sign In'}).click(); // 5. Wait for redirect back to getting-started page diff --git a/packages/calling/playwright/utils/setup.ts b/packages/calling/playwright/utils/setup.ts index 2a229ffebd6..8ea7bcc15bf 100644 --- a/packages/calling/playwright/utils/setup.ts +++ b/packages/calling/playwright/utils/setup.ts @@ -6,6 +6,7 @@ import { SDK_INIT_TIMEOUT, ServiceIndicator, } from '../constants'; +import {isMobiusWsMode} from '../test-data'; import {registerLine, verifyLineRegistered} from './registration'; type DiscoveryLocation = { @@ -41,6 +42,10 @@ export const initializeCallingSDK = async (page: Page, accessToken: string): Pro throw new Error('Access token is required to initialize Calling SDK'); } + if (isMobiusWsMode()) { + await setMobiusWebSocket(page, true); + } + // Fill in the access token await page .locator(CALLING_SELECTORS.ACCESS_TOKEN_INPUT) @@ -113,6 +118,50 @@ export const setServiceIndicator = async (page: Page, service: ServiceIndicator) .selectOption(service, {timeout: AWAIT_TIMEOUT}); }; +/** + * Force the calling sample app to enable or disable Mobius WebSocket transport. + * Must be called before SDK initialization. + */ +export const setMobiusWebSocket = async (page: Page, enabled: boolean): Promise => { + const value = enabled ? 'true' : 'false'; + + await page.locator(CALLING_SELECTORS.MOBIUS_WSS).selectOption(value, {timeout: AWAIT_TIMEOUT}); + + await page.evaluate((selectedValue) => { + localStorage.setItem('mobius-wss-enabled', selectedValue); + }, value); + + await expect(page.locator(CALLING_SELECTORS.MOBIUS_WSS)).toHaveValue(value, { + timeout: AWAIT_TIMEOUT, + }); + await expect + .poll(() => page.evaluate(() => localStorage.getItem('mobius-wss-enabled')), { + message: 'Expected Mobius WebSocket sample override to be persisted', + timeout: AWAIT_TIMEOUT, + intervals: [500], + }) + .toBe(value); +}; + +/** + * Verify that the initialized CallingClient is using Mobius WebSocket transport. + */ +export const verifyMobiusWebSocketEnabled = async (page: Page): Promise => { + await expect + .poll( + () => + page.evaluate( + () => (window as any).callingClient?.apiRequest?.isSocketEnabled?.() === true + ), + { + message: 'Expected Mobius WebSocket transport to be enabled', + timeout: SDK_INIT_TIMEOUT, + intervals: [1000], + } + ) + .toBe(true); +}; + /** * Set service domain before initialization (needed for contactcenter) */ @@ -178,17 +227,19 @@ export const captureMobiusDiscoveryResponse = (page: Page): Promise => { const mobiusServers = await page.evaluate(() => { const client = (window as any).callingClient; + const useWss = client?.apiRequest?.isSocketEnabled?.() === true; return { - primary: client?.primaryMobiusUris ?? [], - backup: client?.backupMobiusUris ?? [], + primary: useWss ? client?.primaryWssMobiusUris ?? [] : client?.primaryMobiusUris ?? [], + backup: useWss ? client?.backupWssMobiusUris ?? [] : client?.backupMobiusUris ?? [], + protocol: useWss ? 'wss://' : '/calling/web/', }; }); expect(mobiusServers.primary.length + mobiusServers.backup.length).toBeGreaterThan(0); expect( [...mobiusServers.primary, ...mobiusServers.backup].every((uri: string) => - uri.includes('/calling/web/') + uri.includes(mobiusServers.protocol) ) ).toBe(true); };