Skip to content

test(calling): add mobius socket WebSocket E2E test infrastructure#4995

Open
eigengravy wants to merge 4 commits into
webex:nextfrom
eigengravy:mobius-ws
Open

test(calling): add mobius socket WebSocket E2E test infrastructure#4995
eigengravy wants to merge 4 commits into
webex:nextfrom
eigengravy:mobius-ws

Conversation

@eigengravy
Copy link
Copy Markdown
Member

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

  • Added 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.
  • Extended playwright/utils/setup.ts and test-manager.ts to support running suites in mobius WS mode via env, including conditional WS route installation per worker.
  • Updated all calling test groups (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.
  • Added the necessary selectors / test-data plumbing for the WS-mode runs and tweaked oauth.setup.ts to be transport-agnostic.

Change Type

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update
  • Tooling change
  • Internal code refactor

The following scenarios were tested

  • Ran the calling Playwright suites locally in mobius WS mode (MOBIUS_TRANSPORT=ws via env) covering:
    • SDK init project (CALLER + CALLEE + TRANSFER) in parallel.
    • SET_CALLER lifecycle (REG-001/002/008/010/014) and error flows (REG-011/012/013).
    • SET_CALLEE failover chain (REG-006 → REG-007).
    • SET_TRANSFER keepalive (REG-003/004/005).
    • Call lifecycle, controls, keepalive, and error flows.
  • Re-ran the same suites in HTTP mode to confirm no regression on the existing transport path.
  • Manually exercised the mobius WS switch in the calling sample app to validate the interceptor's frame shapes against real traffic.

The GAI Coding Policy And Copyright Annotation Best Practices

  • GAI was not used (or, no additional notation is required)
  • Code was generated entirely by GAI
  • GAI was used to create a draft that was subsequently customized or modified
  • Coder created a draft manually that was non-substantively modified by GAI (e.g., refactoring was performed by GAI on manually written code)
  • Tool used for AI assistance (GitHub Copilot / Other - specify)
    • Github Copilot
    • Other - Please Specify
      • Claude Code (Anthropic)
  • This PR is related to
    • Feature
    • Defect fix
    • Tech Debt
    • Automation

I certified that

  • I have read and followed contributing guidelines
  • I discussed changes with code owners prior to submitting this pull request
  • I have not skipped any automated checks
  • All existing and new tests passed
  • I have updated the documentation accordingly

Make sure to have followed the contributing guidelines before submitting.

@eigengravy eigengravy requested a review from a team as a code owner May 20, 2026 08:33
@aws-amplify-us-east-2
Copy link
Copy Markdown

This pull request is automatically being deployed by Amplify Hosting (learn more).

Access this pull request here: https://pr-4995.d3m3l2kee0btzx.amplifyapp.com

@Shreyas281299 Shreyas281299 added the validated If the pull request is validated for automation. label May 29, 2026
Comment on lines +262 to +272
? new MobiusWsInterceptor({
onRequest: (frame) =>
failResume && frame.type === MOBIUS_WS_MESSAGE.CALL_RESUME
? {
statusCode: 500,
statusMessage: 'Internal Server Error',
data: {message: 'Resume failed'},
}
: undefined,
})
: undefined;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Q. How are we using this value ?

}),
]);
calleeNumber = getPhoneNumber(tm.userSet.accounts[1]);
calleeNumber = getPhoneNumber(tm.userSet.accounts[1], tm.isInt);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

Comment on lines +20 to +39
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);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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({
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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({
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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) => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Q. What is the difference between route.onMessage() and server.onMessage() ?

Comment on lines 31 to 36
parse(event: SocketCloseEvent = {}) {
this.code = event.code;
this.reason = event.reason;

return event.reason;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This change was already done earlier I think. @rsarika Please verify if this is alright

Copy link
Copy Markdown
Contributor

@Shreyas281299 Shreyas281299 left a comment

Choose a reason for hiding this comment

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

Submitting existing pending review to allow fresh inline comments.

await tm.cleanup();
});

test('CALL-030: Mute and unmute - toggle mute during active call', async () => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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() {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[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);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[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);
}

Comment on lines +106 to +139
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, {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[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.

Comment on lines 53 to +76
@@ -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');
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[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.

Comment on lines +104 to +137
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});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[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 =>
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[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:116SET_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;
};

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

validated If the pull request is validated for automation.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants