test(calling): add mobius socket WebSocket E2E test infrastructure#4995
test(calling): add mobius socket WebSocket E2E test infrastructure#4995eigengravy wants to merge 4 commits into
Conversation
|
This pull request is automatically being deployed by Amplify Hosting (learn more). |
| ? new MobiusWsInterceptor({ | ||
| onRequest: (frame) => | ||
| failResume && frame.type === MOBIUS_WS_MESSAGE.CALL_RESUME | ||
| ? { | ||
| statusCode: 500, | ||
| statusMessage: 'Internal Server Error', | ||
| data: {message: 'Resume failed'}, | ||
| } | ||
| : undefined, | ||
| }) | ||
| : undefined; |
There was a problem hiding this comment.
Let's avoid using nested ternary operator. Applicable to other places as well
| contentType: 'application/json', | ||
| body: JSON.stringify({error: 'Internal Server Error'}), | ||
| if (mobiusWsMode) { | ||
| failStatus = true; |
There was a problem hiding this comment.
Q. How are we using this value ?
| }), | ||
| ]); | ||
| calleeNumber = getPhoneNumber(tm.userSet.accounts[1]); | ||
| calleeNumber = getPhoneNumber(tm.userSet.accounts[1], tm.isInt); |
There was a problem hiding this comment.
nitpick: Instead of using tm, will be better to use timeManager or taskManager --- you can see how tm gives two different meaning in our context
| await new MobiusWsInterceptor({ | ||
| onRequest: (frame) => { | ||
| if (frame.type === MOBIUS_WS_MESSAGE.AUTH) { | ||
| return {statusCode: 200, statusMessage: 'OK'}; | ||
| } | ||
|
|
||
| if (frame.type === MOBIUS_WS_MESSAGE.REGISTER) { | ||
| registrationPosts += 1; | ||
| registrationStatus = 401; | ||
|
|
||
| return { | ||
| statusCode: 401, | ||
| statusMessage: 'Unauthorized', | ||
| data: {message: 'Unauthorized'}, | ||
| }; | ||
| } | ||
|
|
||
| return undefined; | ||
| }, | ||
| }).install(context); |
There was a problem hiding this comment.
The callback onRequest can be defined outside. Defining this inside makes it less readable
please see if this pattern is repeated.
|
|
||
| tm = new TestManager(testInfo.project.name); | ||
| const interceptor = mobiusWsMode | ||
| ? new MobiusWsInterceptor({ |
There was a problem hiding this comment.
Let's not use ternary operator for this type of code, makes difficult to read
| expectedPrimaryUrl = isInt ? PRIMARY_MOBIUS_URL.INT : PRIMARY_MOBIUS_URL.PROD; | ||
| tm = new TestManager(testInfo.project.name); | ||
| if (mobiusWsMode) { | ||
| mobiusWsInterceptor = new MobiusWsInterceptor({ |
There was a problem hiding this comment.
Do you think making this interceptor a singleton will make it easy and performant to use ?
We can create the interceptor once and then we can add the onResponse() through a setter based on the test. After each test we can reset the interceptor (clear the existing onResponse() )
| return; | ||
| } | ||
|
|
||
| const requestCount = MobiusWsInterceptor.increment(this.requestCounts, frame.type); |
There was a problem hiding this comment.
Q. What is the purpose of increment and count? How are we utilizing this ? Just trying to understand the logic behind request/response
| const server = route.connectToServer(); | ||
| const url = route.url(); | ||
|
|
||
| route.onMessage(async (message) => { |
There was a problem hiding this comment.
Q. What is the difference between route.onMessage() and server.onMessage() ?
| parse(event: SocketCloseEvent = {}) { | ||
| this.code = event.code; | ||
| this.reason = event.reason; | ||
|
|
||
| return event.reason; | ||
| } |
There was a problem hiding this comment.
This change was already done earlier I think. @rsarika Please verify if this is alright
Shreyas281299
left a comment
There was a problem hiding this comment.
Submitting existing pending review to allow fresh inline comments.
| await tm.cleanup(); | ||
| }); | ||
|
|
||
| test('CALL-030: Mute and unmute - toggle mute during active call', async () => { |
There was a problem hiding this comment.
What this CALL-030? I see each test has some CALL-XXX.
| * Call control tests: mute/unmute, DTMF, network flap. | ||
| * Fresh contexts — isolated from hold and error groups. | ||
| */ | ||
| export function callControlTests() { |
There was a problem hiding this comment.
[Critical / Bug] export function callControlTests() is declared twice in this file — here at line 21 and again at line 528. TypeScript rejects duplicate function implementations (TS2393), and at runtime the second declaration overwrites the first, so the three new tests added here (CALL-030 mute, CALL-008 DTMF, CALL-016 network-flap) at lines 21–141 never register with the runner.
Either delete the second callControlTests block (lines 528–end) or merge the two into one (and pick a single set of test IDs). Run npx tsc --noEmit against the calling playwright tsconfig to confirm no duplicate-symbol errors remain.
| }); | ||
| } | ||
|
|
||
| await navigateToCallingApp(page); |
There was a problem hiding this comment.
[High / Test correctness] REG-011 in WS mode passes vacuously. The interceptor is installed above (lines 19–39), but setMobiusWebSocket(page, true) is never called and this test deliberately bypasses initializeCallingSDK. The sample-app's localStorage flag stays unset, the SDK falls back to HTTP, the interceptor sees zero traffic, and registrationPosts stays at 0. The only WS-validating assertion (if (registrationPosts > 0) expect(registrationStatus).toBe(401)) is therefore skipped, and the remaining assertions ("not registered") pass regardless of transport with an invalid token — so MOBIUS=ws runs give zero coverage of the WS 401 path.
Suggestion: enable WS explicitly in the WS branch, then make the assertion unconditional:
await navigateToCallingApp(page);
await setServiceIndicator(page, 'calling');
if (mobiusWsMode) {
await setMobiusWebSocket(page, true);
}
// ...
if (mobiusWsMode) {
expect(registrationPosts).toBeGreaterThan(0);
expect(registrationStatus).toBe(401);
} else if (registrationPosts > 0) {
expect(registrationStatus).toBe(401);
}| const mockedResponse = await this.options.onRequest?.(frame, { | ||
| url, | ||
| requestCount, | ||
| responseCount: this.getResponseCount(frame.type), | ||
| }); | ||
|
|
||
| if (mockedResponse) { | ||
| const responseFrame = buildResponseFrame(frame, mockedResponse); | ||
| const responseType = responseFrame.subtype || responseFrame.type || 'unknown'; | ||
|
|
||
| MobiusWsInterceptor.increment(this.responseCounts, responseType); | ||
| this.responses.push({...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({...frame, url}); | ||
|
|
||
| const transformedFrame = await this.options.onResponse?.(frame, { |
There was a problem hiding this comment.
[Medium / Reliability] await this.options.onRequest?.(...) (line 106) and await this.options.onResponse?.(...) (line 139) have no try/catch. If a test's callback throws — e.g., a buggy expectation in a closure or an unexpected frame shape — the WebSocketRoute handler rejects, the proxy stops forwarding, and the SDK's pending-response timer eventually fires with an opaque "Mobius socket request timeout" instead of the original assertion error. Tests become flaky and very hard to debug.
Wrap both callbacks and fall through to passthrough on throw:
let mockedResponse;
try {
mockedResponse = await this.options.onRequest?.(frame, ctx);
} catch (err) {
console.error('MobiusWsInterceptor onRequest threw, passing frame through', err);
server.send(message);
return;
}Same pattern for onResponse, with route.send(message) as the passthrough.
| @@ -56,9 +71,11 @@ 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.abort('failed'); | |||
There was a problem hiding this comment.
[Medium / Test correctness] CALL-013 exercises different failure modes in the two transports under one ID. The WS branch returns a structured {statusCode: 500, ...} response for CALL_SETUP — the SDK gets a server-side error. The HTTP branch uses route.abort('failed') — a transport-level network error (no response, fetch rejection). These hit different code paths in the SDK (response error vs transport error). The "no stuck call" assertion is true in both cases, but the test isn't validating equivalent behavior across transports.
Pick one failure mode and mirror it: either drop the WS connection / never reply on the WS side, or use route.fulfill({status: 500, ...}) on the HTTP side.
| this.requests.push({...frame, url}); | ||
|
|
||
| const mockedResponse = await this.options.onRequest?.(frame, { | ||
| url, | ||
| requestCount, | ||
| responseCount: this.getResponseCount(frame.type), | ||
| }); | ||
|
|
||
| if (mockedResponse) { | ||
| const responseFrame = buildResponseFrame(frame, mockedResponse); | ||
| const responseType = responseFrame.subtype || responseFrame.type || 'unknown'; | ||
|
|
||
| MobiusWsInterceptor.increment(this.responseCounts, responseType); | ||
| this.responses.push({...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({...frame, url}); |
There was a problem hiding this comment.
[Medium / Security] this.requests.push({...frame, url}) (line 104) and this.responses.push({...frame, url}) (line 137) capture the entire frame, including metadata.authorization — Mobius supplementary-service WS messages put the user's access token there (see packages/calling/src/CallingClient/registration/request.ts). The Playwright config uses trace: 'retain-on-failure', so failed-test traces would persist live tokens, and CI typically uploads those artifacts.
Redact authorization before pushing:
const redact = (frame) => {
if (!frame?.metadata?.authorization) return frame;
return {...frame, metadata: {...frame.metadata, authorization: '[REDACTED]'}};
};
this.requests.push({...redact(frame), url});| }; | ||
|
|
||
| /** Env var for E.164 (or test) phone number, production vs integration Playwright projects. */ | ||
| export const phoneEnvVar = (role: AccountRole, isInt = false): string => |
There was a problem hiding this comment.
[Medium / Breaking change] phoneEnvVar(role, true) resolves to USER_N_INT_PHONE_NUMBER — a brand-new env var name. The old code used only ${role}_PHONE_NUMBER. INT projects are still active (per playwright.config.ts:116 — SET_CALL - INT), so existing CI envs and developer .env files that only set USER_N_PHONE_NUMBER will hit Error: USER_2_INT_PHONE_NUMBER not set. immediately on the first INT run after this lands. The new var is also undocumented in any README or .env.example.
Suggestion: fall back to the prod var when the INT-specific one isn't set, and add the new vars to the package README / .env.example:
export const getPhoneNumber = (role: AccountRole, isInt = false): string => {
const intVar = `${role}_INT_PHONE_NUMBER`;
const prodVar = `${role}_PHONE_NUMBER`;
const number = (isInt && process.env[intVar]) || process.env[prodVar];
if (!number) {
throw new Error(`${isInt ? intVar : prodVar} not set.`);
}
return number;
};
COMPLETES CAI-7883
This pull request addresses
The Calling SDK Playwright E2E suite needs to exercise flows over the new mobius WebSocket transport (introduced in #4906 / #4982) in addition to the existing HTTP transport. Without a way to intercept and mock mobius WS frames, the existing registration, keepalive, call lifecycle, and error-flow tests cannot be re-run against the WS transport, leaving the new socket path uncovered by E2E.
by making the following changes
packages/calling/playwright/utils/mobius-ws.ts— a reusable WebSocket route interceptor for mobius frames (auth/register/unregister/device_status/call_*) with hooks for request/response mocking, plus tracking-id correlation.playwright/utils/setup.tsandtest-manager.tsto support running suites in mobius WS mode via env, including conditional WS route installation per worker.registration-lifecycle,registration-failover,registration-keepalive,registration-errors,call-lifecycle,call-controls,call-keepalive,call-errors) to drive the same scenarios over the WS transport using the new interceptor.oauth.setup.tsto be transport-agnostic.Change Type
The following scenarios were tested
MOBIUS_TRANSPORT=wsvia env) covering:The GAI Coding Policy And Copyright Annotation Best Practices
I certified that
Make sure to have followed the contributing guidelines before submitting.