Skip to content

Commit d015f34

Browse files
test: removes llamarpc.com as an allowed rpc url (#27595)
<!-- Please submit this PR as a draft initially. Do not mark it as "Ready for review" until the template has been completely filled out, and PR status checks have passed at least once. --> ## **Description** Filter mockttp "Aborted" errors on both unhandledRejection and uncaughtException. When the app drops connections mid-request (e.g. UI navigation, AbortController), mockttp's streamToBuffer rejects with Error('Aborted'). Depending on how Node.js surfaces the error, it can appear as either event type — both must be intercepted to prevent Jest from recording false test failures. Also increased the post-stop drain period from 150ms to 500ms for CI reliability. This PR also migrates permission system tests to ts and adds mocks for custom RPC to reduce the allow list. <!-- Write a short description of the changes included in this pull request, also include relevant motivation and context. Have in mind the following questions: 1. What is the reason for the change? 2. What is the improvement/solution? --> ## **Changelog** <!-- If this PR is not End-User-Facing and should not show up in the CHANGELOG, you can choose to either: 1. Write `CHANGELOG entry: null` 2. Label with `no-changelog` If this PR is End-User-Facing, please write a short User-Facing description in the past tense like: `CHANGELOG entry: Added a new tab for users to see their NFTs` `CHANGELOG entry: Fixed a bug that was causing some NFTs to flicker` (This helps the Release Engineer do their job more quickly and accurately) --> CHANGELOG entry: ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** <!-- If applicable, add screenshots and/or recordings to visualize the before and after of your change. --> ### **Before** <!-- [screenshots/recordings] --> ### **After** <!-- [screenshots/recordings] --> ## **Pre-merge author checklist** - [ ] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I've included tests if applicable - [ ] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Touches the E2E mock server’s global `process` error handlers and shutdown timing, which can affect how Jest reports failures across the whole test run. Changes are test-only but could mask real errors if the abort detection is too broad or handler restoration is incorrect. > > **Overview** > Improves E2E reliability by suppressing mockttp `Error('Aborted')` failures across the full server lifecycle: `MockServerE2E` now filters these errors from both `unhandledRejection` and `uncaughtException`, preserves/restores any handlers added during runtime, and increases the post-stop drain wait from 150ms to 500ms. > > Reduces reliance on live allowlisted endpoints by removing `https://eth.llamarpc.com/` and adding `CUSTOM_RPC_PROVIDER_MOCKS` to intercept `eth.llamarpc.com` JSON-RPC calls via `/proxy`. Updates Hyperliquid mocks to cover additional `POST /info` request bodies (`allMids`, `perpDexs`, `frontendOpenOrders`) and adjusts permission-system smoke tests to use the new fixture setup (dropping explicit `.withPermissionController()` and adding the custom RPC test-specific mock where needed). > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit c4abd2a. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 6dc66fb commit d015f34

6 files changed

Lines changed: 207 additions & 35 deletions

File tree

tests/api-mocking/MockServerE2E.ts

Lines changed: 104 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -194,8 +194,10 @@ export default class MockServerE2E implements Resource {
194194
private _activeRequests = 0;
195195
private _shuttingDown = false;
196196
private _abortFilterHandler: ((...args: unknown[]) => void) | null = null;
197+
private _abortExceptionHandler: ((...args: unknown[]) => void) | null = null;
197198
private _abortSuppressCount = 0;
198199
private _originalRejectionHandlers: ((...args: unknown[]) => void)[] = [];
200+
private _originalExceptionHandlers: ((...args: unknown[]) => void)[] = [];
199201

200202
constructor(params: {
201203
events: MockEventsObject;
@@ -562,7 +564,8 @@ export default class MockServerE2E implements Resource {
562564
}
563565
// Brief drain period: 'aborted' events from destroyed sockets may fire
564566
// asynchronously on the next event-loop tick after `stop()` resolves.
565-
await new Promise((resolve) => setTimeout(resolve, 150));
567+
// 500ms is generous for CI environments where event-loop ticks may be delayed.
568+
await new Promise((resolve) => setTimeout(resolve, 500));
566569
} catch (error) {
567570
logger.error('Error stopping mock server:', error);
568571
} finally {
@@ -577,19 +580,31 @@ export default class MockServerE2E implements Resource {
577580
}
578581

579582
/**
580-
* Installs a lifecycle-wide filter for mockttp "Aborted" unhandled promise rejections.
583+
* Checks whether an error is a mockttp "Aborted" error from streamToBuffer.
584+
*/
585+
private static _isMockttpAbortError(error: unknown): boolean {
586+
return (
587+
error instanceof Error &&
588+
error.message === 'Aborted' &&
589+
(error.stack?.includes('mockttp') ?? false)
590+
);
591+
}
592+
593+
/**
594+
* Installs lifecycle-wide filters for mockttp "Aborted" errors on BOTH
595+
* `unhandledRejection` AND `uncaughtException`.
581596
*
582597
* When the app unexpectedly drops connections during a test (e.g., UI transitions,
583598
* RN bridge interruptions), mockttp's internal `streamToBuffer` rejects with
584-
* `Error('Aborted')` from `buffer-utils.ts`. Since this happens in mockttp's pipeline
585-
* before reaching our handler callback, the rejection is unhandled and Jest catches it
586-
* as a test failure.
599+
* `Error('Aborted')` from `buffer-utils.ts`. Depending on the Node.js runtime
600+
* behaviour and how the error surfaces through mockttp's internals, this can appear
601+
* as either an unhandled promise rejection OR an uncaught exception (e.g. from a
602+
* stream 'error' event with no listener, or a throw inside a setImmediate callback).
603+
*
604+
* jest-circus installs the same `uncaught` handler on both event types, so we must
605+
* intercept both to prevent the error from being recorded as a test failure.
587606
*
588-
* This filter is active for the entire server lifecycle (start → stop), not just
589-
* during shutdown. It removes all existing `unhandledRejection` handlers (e.g. Jest's)
590-
* and replaces them with a single filter that suppresses mockttp Aborted errors and
591-
* forwards everything else to the original handlers. This ensures Jest never sees
592-
* the Aborted rejections.
607+
* The filters are active for the entire server lifecycle (start → stop).
593608
*/
594609
private _installAbortFilter(): void {
595610
if (this._abortFilterHandler) {
@@ -598,23 +613,22 @@ export default class MockServerE2E implements Resource {
598613

599614
this._abortSuppressCount = 0;
600615

601-
// Snapshot and remove all existing handlers so Jest never sees Aborted errors.
616+
// --- unhandledRejection filter ---
602617
this._originalRejectionHandlers = process
603618
.rawListeners('unhandledRejection')
604619
.slice() as ((...args: unknown[]) => void)[];
605620
process.removeAllListeners('unhandledRejection');
606621

607622
this._abortFilterHandler = (reason: unknown, promise: unknown) => {
608623
const rejectedPromise = promise as Promise<unknown>;
609-
if (
610-
reason instanceof Error &&
611-
reason.message === 'Aborted' &&
612-
reason.stack?.includes('mockttp')
613-
) {
624+
if (MockServerE2E._isMockttpAbortError(reason)) {
614625
// Mark the promise as handled so Node.js does not consider it unhandled.
615626
// eslint-disable-next-line no-empty-function
616627
rejectedPromise.catch(() => {});
617628
this._abortSuppressCount++;
629+
logger.debug(
630+
`Suppressed mockttp Aborted rejection (#${this._abortSuppressCount})`,
631+
);
618632
return;
619633
}
620634

@@ -638,46 +652,107 @@ export default class MockServerE2E implements Resource {
638652
};
639653

640654
process.on('unhandledRejection', this._abortFilterHandler);
641-
logger.debug('Installed lifecycle-wide Aborted error filter');
655+
656+
// --- uncaughtException filter ---
657+
this._originalExceptionHandlers = process
658+
.rawListeners('uncaughtException')
659+
.slice() as ((...args: unknown[]) => void)[];
660+
process.removeAllListeners('uncaughtException');
661+
662+
this._abortExceptionHandler = (error: unknown, origin: unknown) => {
663+
if (MockServerE2E._isMockttpAbortError(error)) {
664+
this._abortSuppressCount++;
665+
logger.debug(
666+
`Suppressed mockttp Aborted exception (#${this._abortSuppressCount})`,
667+
);
668+
return;
669+
}
670+
671+
// Forward any other exception to the original handlers (e.g. Jest).
672+
if (this._originalExceptionHandlers.length === 0) {
673+
throw error;
674+
}
675+
let firstError: unknown;
676+
for (const handler of this._originalExceptionHandlers) {
677+
try {
678+
handler(error, origin);
679+
} catch (err) {
680+
if (firstError === undefined) {
681+
firstError = err;
682+
}
683+
}
684+
}
685+
if (firstError !== undefined) {
686+
throw firstError;
687+
}
688+
};
689+
690+
process.on('uncaughtException', this._abortExceptionHandler);
691+
692+
logger.info(
693+
`Abort filter installed — listeners: unhandledRejection=${process.listenerCount('unhandledRejection')}, uncaughtException=${process.listenerCount('uncaughtException')}`,
694+
);
642695
}
643696

644697
/**
645-
* Removes the lifecycle-wide Aborted error filter and restores original handlers.
698+
* Removes the lifecycle-wide Aborted error filters and restores original handlers.
646699
* Any handlers added by other code during the filter's lifetime are preserved.
647700
*/
648701
private _removeAbortFilter(): void {
649702
if (!this._abortFilterHandler) {
650703
return;
651704
}
652705

653-
// Preserve any handlers that were added by other code during the
654-
// filter's lifetime so they are not permanently lost.
655-
const currentHandlers = process.rawListeners('unhandledRejection').slice();
656-
const addedDuringLifecycle = currentHandlers.filter(
706+
// --- Restore unhandledRejection handlers ---
707+
const currentRejectionHandlers = process
708+
.rawListeners('unhandledRejection')
709+
.slice();
710+
const addedRejectionDuringLifecycle = currentRejectionHandlers.filter(
657711
(h) =>
658712
h !== this._abortFilterHandler &&
659713
!this._originalRejectionHandlers.includes(
660714
h as (...args: unknown[]) => void,
661715
),
662716
);
663717

664-
// Remove all and restore: originals first, then any newly added handlers.
665718
process.removeAllListeners('unhandledRejection');
666719
for (const handler of [
667720
...this._originalRejectionHandlers,
668-
...(addedDuringLifecycle as ((...args: unknown[]) => void)[]),
721+
...(addedRejectionDuringLifecycle as ((...args: unknown[]) => void)[]),
669722
]) {
670723
process.on('unhandledRejection', handler);
671724
}
672725

726+
// --- Restore uncaughtException handlers ---
727+
if (this._abortExceptionHandler) {
728+
const currentExceptionHandlers = process
729+
.rawListeners('uncaughtException')
730+
.slice();
731+
const addedExceptionDuringLifecycle = currentExceptionHandlers.filter(
732+
(h) =>
733+
h !== this._abortExceptionHandler &&
734+
!this._originalExceptionHandlers.includes(
735+
h as (...args: unknown[]) => void,
736+
),
737+
);
738+
739+
process.removeAllListeners('uncaughtException');
740+
for (const handler of [
741+
...this._originalExceptionHandlers,
742+
...(addedExceptionDuringLifecycle as ((...args: unknown[]) => void)[]),
743+
]) {
744+
process.on('uncaughtException', handler);
745+
}
746+
}
747+
673748
this._abortFilterHandler = null;
749+
this._abortExceptionHandler = null;
674750
this._originalRejectionHandlers = [];
751+
this._originalExceptionHandlers = [];
675752

676-
if (this._abortSuppressCount > 0) {
677-
logger.info(
678-
`Suppressed ${this._abortSuppressCount} mockttp "Aborted" rejection(s) during server lifecycle`,
679-
);
680-
}
753+
logger.info(
754+
`Abort filter removed — suppressed ${this._abortSuppressCount} mockttp "Aborted" error(s) during server lifecycle`,
755+
);
681756
this._abortSuppressCount = 0;
682757
}
683758

tests/api-mocking/mock-e2e-allowlist.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,6 @@ export const ALLOWLISTED_URLS = [
4747
'https://min-api.cryptocompare.com/data/price?fsym=ETH&tsyms=LINEAETH',
4848
'https://signature-insights.api.cx.metamask.io/v1/signature?chainId=0x539',
4949
'https://mainnet.era.zksync.io/',
50-
'https://eth.llamarpc.com/',
5150
'https://rpc.atlantischain.network/',
5251
'https://nft.api.cx.metamask.io/collections?chainId=0x539&contract=0xb2552e4f4bc23e1572041677234d192774558bf0',
5352
'https://metamask.github.io/test-dapp/metamask-fox.svg',
@@ -59,5 +58,4 @@ export const ALLOWLISTED_URLS = [
5958
'https://signature-insights.api.cx.metamask.io/v1/signature?chainId=0xaa36a7',
6059
'https://price.api.cx.metamask.io/v1/exchange-rates?baseCurrency=usd',
6160
'https://api.hyperliquid.xyz/exchange',
62-
'https://api.hyperliquid.xyz/info',
6361
];
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
/**
2+
* Mock responses for custom RPC provider endpoints (e.g. eth.llamarpc.com)
3+
* that are not covered by Infura mocks.
4+
*/
5+
6+
import type { Mockttp } from 'mockttp';
7+
import type { TestSpecificMock } from '../../framework';
8+
9+
// Ethereum mainnet block-like response
10+
const MOCK_BLOCK = {
11+
number: '0x178a60b',
12+
hash: '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef',
13+
timestamp: '0x' + Math.floor(Date.now() / 1000).toString(16),
14+
gasLimit: '0x1c9c380',
15+
gasUsed: '0x5208',
16+
transactions: [],
17+
};
18+
19+
// Method-specific mock responses for Ethereum mainnet RPC calls
20+
const MOCK_RESPONSES: Record<string, unknown> = {
21+
eth_blockNumber: '0x178a60b',
22+
net_version: '1',
23+
eth_chainId: '0x1',
24+
eth_getBlockByNumber: MOCK_BLOCK,
25+
eth_call:
26+
'0x0000000000000000000000000000000000000000000000000000000000000000',
27+
eth_getBalance: '0x0',
28+
eth_getTransactionCount: '0x0',
29+
eth_gasPrice: '0x3b9aca00',
30+
eth_estimateGas: '0x5208',
31+
};
32+
33+
const LLAMARPC_URL = 'https://eth.llamarpc.com';
34+
35+
/**
36+
* TestSpecificMock that intercepts eth.llamarpc.com RPC calls
37+
* through the mobile proxy, returning static responses per JSON-RPC method.
38+
*/
39+
export const CUSTOM_RPC_PROVIDER_MOCKS: TestSpecificMock = async (
40+
mockServer: Mockttp,
41+
) => {
42+
await mockServer
43+
.forPost('/proxy')
44+
.matching((request) => {
45+
const urlParam = new URL(request.url).searchParams.get('url');
46+
return Boolean(urlParam?.startsWith(LLAMARPC_URL));
47+
})
48+
.asPriority(1000)
49+
.thenCallback(async (request) => {
50+
try {
51+
const bodyText = await request.body.getText();
52+
const body = bodyText ? JSON.parse(bodyText) : undefined;
53+
const method = body?.method as string | undefined;
54+
const result = method ? (MOCK_RESPONSES[method] ?? '0x') : '0x';
55+
56+
return {
57+
statusCode: 200,
58+
body: JSON.stringify({
59+
id: body?.id ?? 1,
60+
jsonrpc: '2.0',
61+
result,
62+
}),
63+
};
64+
} catch {
65+
return {
66+
statusCode: 200,
67+
body: JSON.stringify({
68+
id: 1,
69+
jsonrpc: '2.0',
70+
result: '0x',
71+
}),
72+
};
73+
}
74+
});
75+
};
Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,35 @@
11
import { MockEventsObject } from '../../../framework';
22

3+
const hyperliquidInfoEndpoint = 'https://api.hyperliquid.xyz/info';
4+
35
export const PERPS_HYPERLIQUID_MOCKS: MockEventsObject = {
46
POST: [
57
{
6-
urlEndpoint: 'https://api.hyperliquid.xyz/info',
8+
urlEndpoint: hyperliquidInfoEndpoint,
9+
requestBody: { type: 'allMids' },
10+
responseCode: 200,
11+
response: {},
12+
},
13+
{
14+
urlEndpoint: hyperliquidInfoEndpoint,
715
requestBody: { type: 'meta' },
816
responseCode: 200,
917
response: {},
1018
},
19+
{
20+
urlEndpoint: hyperliquidInfoEndpoint,
21+
requestBody: { type: 'perpDexs' },
22+
responseCode: 200,
23+
response: {},
24+
},
25+
{
26+
urlEndpoint: hyperliquidInfoEndpoint,
27+
requestBody: {
28+
type: 'frontendOpenOrders',
29+
},
30+
ignoreFields: ['user'],
31+
responseCode: 200,
32+
response: {},
33+
},
1134
],
1235
};

tests/smoke/multichain/permissions/chains/permission-system-dapp-chain-switch-grant.spec.js renamed to tests/smoke/multichain/permissions/chains/permission-system-dapp-chain-switch-grant.spec.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import ConnectedAccountsModal from '../../../../page-objects/Browser/ConnectedAc
1212
import NetworkConnectMultiSelector from '../../../../page-objects/Browser/NetworkConnectMultiSelector';
1313
import NetworkNonPemittedBottomSheet from '../../../../page-objects/Network/NetworkNonPemittedBottomSheet';
1414
import { DappVariants } from '../../../../framework/Constants';
15+
import { CUSTOM_RPC_PROVIDER_MOCKS } from '../../../../api-mocking/mock-responses/custom-rpc-provider-mocks';
1516

1617
describe(SmokeNetworkAbstractions('Chain Permission System'), () => {
1718
beforeAll(async () => {
@@ -32,9 +33,9 @@ describe(SmokeNetworkAbstractions('Chain Permission System'), () => {
3233
.withNetworkController(
3334
CustomNetworks.EthereumMainCustom.providerConfig,
3435
)
35-
.withPermissionController()
3636
.build(),
3737
restartDevice: true,
38+
testSpecificMock: CUSTOM_RPC_PROVIDER_MOCKS,
3839
},
3940
async () => {
4041
// Setup: Login and navigate to browser

tests/smoke/multichain/permissions/chains/permission-system-initial-connection.spec.js renamed to tests/smoke/multichain/permissions/chains/permission-system-initial-connection.spec.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ describe(SmokeNetworkExpansion('Chain Permission Management'), () => {
2525
dappVariant: DappVariants.TEST_DAPP,
2626
},
2727
],
28-
fixture: new FixtureBuilder().withPermissionController().build(),
28+
fixture: new FixtureBuilder().build(),
2929
restartDevice: true,
3030
},
3131
async () => {
@@ -53,7 +53,7 @@ describe(SmokeNetworkExpansion('Chain Permission Management'), () => {
5353
dappVariant: DappVariants.TEST_DAPP,
5454
},
5555
],
56-
fixture: new FixtureBuilder().withPermissionController().build(),
56+
fixture: new FixtureBuilder().build(),
5757
restartDevice: true,
5858
},
5959
async () => {

0 commit comments

Comments
 (0)