1
0
mirror of https://github.com/kremalicious/metamask-extension.git synced 2024-12-23 09:52:26 +01:00
metamask-extension/app/scripts/controllers/network/provider-api-tests/helpers.ts
Elliot Winkler 26db0aee46
Convert NetworkController net client tests to TS (#18490)
In order to be able to better compare differences between the version of
NetworkController in this repo and the version in the `core` repo before
we replace this version with the `core` version, this commit converts
the NetworkController network client tests to TypeScript.

The added types here are copied from the `core` repo. We plan on
making more improvements on the `core` side at some point to polish the
tests and types and reduce some of the duplication, but for now we're
just trying to keep things as similar as possible.
2023-04-14 10:21:29 -06:00

544 lines
17 KiB
TypeScript

import nock, { Scope as NockScope } from 'nock';
import sinon from 'sinon';
import type { JSONRPCResponse } from '@json-rpc-specification/meta-schema';
import EthQuery from 'eth-query';
import { Hex } from '@metamask/utils';
import { BuiltInInfuraNetwork } from '../../../../../shared/constants/network';
import {
createNetworkClient,
NetworkClientType,
} from '../create-network-client';
/**
* A dummy value for the `infuraProjectId` option that `createInfuraClient`
* needs. (Infura should not be hit during tests, but just in case, this should
* not refer to a real project ID.)
*/
const MOCK_INFURA_PROJECT_ID = 'abc123';
/**
* A dummy value for the `rpcUrl` option that `createJsonRpcClient` needs. (This
* should not be hit during tests, but just in case, this should also not refer
* to a real Infura URL.)
*/
const MOCK_RPC_URL = 'http://foo.com';
/**
* A default value for the `eth_blockNumber` request that the block tracker
* makes.
*/
const DEFAULT_LATEST_BLOCK_NUMBER = '0x42';
/**
* A reference to the original `setTimeout` function so that we can use it even
* when using fake timers.
*/
const originalSetTimeout = setTimeout;
/**
* If you're having trouble writing a test and you're wondering why the test
* keeps failing, you can set `process.env.DEBUG_PROVIDER_TESTS` to `1`. This
* will turn on some extra logging.
*
* @param args - The arguments that `console.log` takes.
*/
function debug(...args: any) {
if (process.env.DEBUG_PROVIDER_TESTS === '1') {
console.log(...args);
}
}
/**
* Builds a Nock scope object for mocking provider requests.
*
* @param rpcUrl - The URL of the RPC endpoint.
* @returns The nock scope.
*/
function buildScopeForMockingRequests(rpcUrl: string): NockScope {
return nock(rpcUrl).filteringRequestBody((body) => {
debug('Nock Received Request: ', body);
return body;
});
}
type Request = { method: string; params?: any[] };
type Response = {
id?: number | string;
jsonrpc?: '2.0';
error?: any;
result?: any;
httpStatus?: number;
};
type ResponseBody = { body: JSONRPCResponse };
type BodyOrResponse = ResponseBody | Response;
type CurriedMockRpcCallOptions = {
request: Request;
// The response data.
response?: BodyOrResponse;
/**
* An error to throw while making the request.
* Takes precedence over `response`.
*/
error?: Error | string;
/**
* The amount of time that should pass before the
* request resolves with the response.
*/
delay?: number;
/**
* The number of times that the request is
* expected to be made.
*/
times?: number;
};
type MockRpcCallOptions = {
// A nock scope (a set of mocked requests scoped to a certain base URL).
nockScope: nock.Scope;
} & CurriedMockRpcCallOptions;
type MockRpcCallResult = nock.Interceptor | nock.Scope;
/**
* Mocks a JSON-RPC request sent to the provider with the given response.
* Provider type is inferred from the base url set on the nockScope.
*
* @param args - The arguments.
* @param args.nockScope - A nock scope (a set of mocked requests scoped to a
* certain base URL).
* @param args.request - The request data.
* @param args.response - Information concerning the response that the request
* should have. If a `body` property is present, this is taken as the complete
* response body. If an `httpStatus` property is present, then it is taken as
* the HTTP status code to respond with. Properties other than these two are
* used to build a complete response body (including `id` and `jsonrpc`
* properties).
* @param args.error - An error to throw while making the request. Takes
* precedence over `response`.
* @param args.delay - The amount of time that should pass before the request
* resolves with the response.
* @param args.times - The number of times that the request is expected to be
* made.
* @returns The nock scope.
*/
function mockRpcCall({
nockScope,
request,
response,
error,
delay,
times,
}: MockRpcCallOptions): MockRpcCallResult {
// eth-query always passes `params`, so even if we don't supply this property,
// for consistency with makeRpcCall, assume that the `body` contains it
const { method, params = [], ...rest } = request;
let httpStatus = 200;
let completeResponse: JSONRPCResponse = { id: 2, jsonrpc: '2.0' };
if (response !== undefined) {
if ('body' in response) {
completeResponse = response.body;
} else {
if (response.error) {
completeResponse.error = response.error;
} else {
completeResponse.result = response.result;
}
if (response.httpStatus) {
httpStatus = response.httpStatus;
}
}
}
/* @ts-expect-error The types for Nock do not include `basePath` in the interface for Nock.Scope. */
const url = nockScope.basePath.includes('infura.io')
? `/v3/${MOCK_INFURA_PROJECT_ID}`
: '/';
debug('Mocking request:', {
url,
method,
params,
response,
error,
...rest,
times,
});
let nockRequest = nockScope.post(url, {
id: /\d*/u,
jsonrpc: '2.0',
method,
params,
...rest,
});
if (delay !== undefined) {
nockRequest = nockRequest.delay(delay);
}
if (times !== undefined) {
nockRequest = nockRequest.times(times);
}
if (error !== undefined) {
return nockRequest.replyWithError(error);
} else if (completeResponse !== undefined) {
return nockRequest.reply(httpStatus, (_, requestBody: any) => {
if (response !== undefined && !('body' in response)) {
if (response.id === undefined) {
completeResponse.id = requestBody.id;
} else {
completeResponse.id = response.id;
}
}
debug('Nock returning Response', completeResponse);
return completeResponse;
});
}
return nockRequest;
}
type MockBlockTrackerRequestOptions = {
/**
* A nock scope (a set of mocked requests scoped to a certain base url).
*/
nockScope: NockScope;
/**
* The block number that the block tracker should report, as a 0x-prefixed hex
* string.
*/
blockNumber: string;
};
/**
* Mocks the next request for the latest block that the block tracker will make.
*
* @param args - The arguments.
* @param args.nockScope - A nock scope (a set of mocked requests scoped to a
* certain base URL).
* @param args.blockNumber - The block number that the block tracker should
* report, as a 0x-prefixed hex string.
*/
function mockNextBlockTrackerRequest({
nockScope,
blockNumber = DEFAULT_LATEST_BLOCK_NUMBER,
}: MockBlockTrackerRequestOptions) {
mockRpcCall({
nockScope,
request: { method: 'eth_blockNumber', params: [] },
response: { result: blockNumber },
});
}
/**
* Mocks all requests for the latest block that the block tracker will make.
*
* @param args - The arguments.
* @param args.nockScope - A nock scope (a set of mocked requests scoped to a
* certain base URL).
* @param args.blockNumber - The block number that the block tracker should
* report, as a 0x-prefixed hex string.
*/
async function mockAllBlockTrackerRequests({
nockScope,
blockNumber = DEFAULT_LATEST_BLOCK_NUMBER,
}: MockBlockTrackerRequestOptions) {
const result = await mockRpcCall({
nockScope,
request: { method: 'eth_blockNumber', params: [] },
response: { result: blockNumber },
});
if ('persist' in result) {
result.persist();
}
}
/**
* Makes a JSON-RPC call through the given eth-query object.
*
* @param ethQuery - The eth-query object.
* @param request - The request data.
* @returns A promise that either resolves with the result from the JSON-RPC
* response if it is successful or rejects with the error from the JSON-RPC
* response otherwise.
*/
function makeRpcCall(ethQuery: EthQuery, request: Request) {
return new Promise((resolve, reject) => {
debug('[makeRpcCall] making request', request);
ethQuery.sendAsync(request, (error, result) => {
debug('[makeRpcCall > ethQuery handler] error', error, 'result', result);
if (error) {
reject(error);
} else {
resolve(result);
}
});
});
}
export type ProviderType = 'infura' | 'custom';
export type MockOptions = {
infuraNetwork?: BuiltInInfuraNetwork;
providerType: ProviderType;
customRpcUrl?: string;
customChainId?: Hex;
};
export type MockCommunications = {
mockNextBlockTrackerRequest: (options?: any) => void;
mockAllBlockTrackerRequests: (options?: any) => void;
mockRpcCall: (options: CurriedMockRpcCallOptions) => MockRpcCallResult;
rpcUrl: string;
infuraNetwork: BuiltInInfuraNetwork;
};
/**
* Sets up request mocks for requests to the provider.
*
* @param options - An options bag.
* @param options.providerType - The type of network client being tested.
* @param options.infuraNetwork - The name of the Infura network being tested,
* assuming that `providerType` is "infura" (default: "mainnet").
* @param options.customRpcUrl - The URL of the custom RPC endpoint, assuming
* that `providerType` is "custom".
* @param fn - A function which will be called with an object that allows
* interaction with the network client.
* @returns The return value of the given function.
*/
export async function withMockedCommunications(
{
providerType,
infuraNetwork = 'mainnet',
customRpcUrl = MOCK_RPC_URL,
}: MockOptions,
fn: (comms: MockCommunications) => Promise<void>,
) {
if (providerType !== 'infura' && providerType !== 'custom') {
throw new Error(
`providerType must be either "infura" or "custom", was "${providerType}" instead`,
);
}
const rpcUrl =
providerType === 'infura'
? `https://${infuraNetwork}.infura.io`
: customRpcUrl;
const nockScope = buildScopeForMockingRequests(rpcUrl);
const curriedMockNextBlockTrackerRequest = (localOptions: any) =>
mockNextBlockTrackerRequest({ nockScope, ...localOptions });
const curriedMockAllBlockTrackerRequests = (localOptions: any) =>
mockAllBlockTrackerRequests({ nockScope, ...localOptions });
const curriedMockRpcCall = (localOptions: any) =>
mockRpcCall({ nockScope, ...localOptions });
const comms = {
mockNextBlockTrackerRequest: curriedMockNextBlockTrackerRequest,
mockAllBlockTrackerRequests: curriedMockAllBlockTrackerRequests,
mockRpcCall: curriedMockRpcCall,
rpcUrl,
infuraNetwork,
};
try {
return await fn(comms);
} finally {
nock.isDone();
nock.cleanAll();
}
}
type MockNetworkClient = {
blockTracker: any;
clock: sinon.SinonFakeTimers;
makeRpcCall: (request: Request) => Promise<any>;
makeRpcCallsInSeries: (requests: Request[]) => Promise<any[]>;
};
/**
* Some middleware contain logic which retries the request if some condition
* applies. This retrying always happens out of band via `setTimeout`, and
* because we are stubbing time via Jest's fake timers, we have to manually
* advance the clock so that the `setTimeout` handlers get fired. We don't know
* when these timers will get created, however, so we have to keep advancing
* timers until the request has been made an appropriate number of times.
* Unfortunately we don't have a good way to know how many times a request has
* been retried, but the good news is that the middleware won't end, and thus
* the promise which the RPC call returns won't get fulfilled, until all retries
* have been made.
*
* @param promise - The promise which is returned by the RPC call.
* @param clock - A Sinon clock object which can be used to advance to the next
* `setTimeout` handler.
*/
export async function waitForPromiseToBeFulfilledAfterRunningAllTimers(
promise: any,
clock: any,
) {
let hasPromiseBeenFulfilled = false;
let numTimesClockHasBeenAdvanced = 0;
promise
.catch((error: any) => {
// This is used to silence Node.js warnings about the rejection
// being handled asynchronously. The error is handled later when
// `promise` is awaited, but we log it here anyway in case it gets
// swallowed.
debug(error);
})
.finally(() => {
hasPromiseBeenFulfilled = true;
});
// `hasPromiseBeenFulfilled` is modified asynchronously.
/* eslint-disable-next-line no-unmodified-loop-condition */
while (!hasPromiseBeenFulfilled && numTimesClockHasBeenAdvanced < 15) {
clock.runAll();
await new Promise((resolve) => originalSetTimeout(resolve, 10));
numTimesClockHasBeenAdvanced += 1;
}
return promise;
}
/**
* Builds a provider from the middleware (for the provider type) along with a
* block tracker, runs the given function with those two things, and then
* ensures the block tracker is stopped at the end.
*
* @param options - An options bag.
* @param options.providerType - The type of network client being tested.
* @param options.infuraNetwork - The name of the Infura network being tested,
* assuming that `providerType` is "infura" (default: "mainnet").
* @param options.customRpcUrl - The URL of the custom RPC endpoint, assuming
* that `providerType` is "custom".
* @param options.customChainId - The chain id belonging to the custom RPC
* endpoint, assuming that `providerType` is "custom" (default: "0x1").
* @param fn - A function which will be called with an object that allows
* interaction with the network client.
* @returns The return value of the given function.
*/
export async function withNetworkClient(
{
providerType,
infuraNetwork = 'mainnet',
customRpcUrl = MOCK_RPC_URL,
customChainId = '0x1',
}: MockOptions,
fn: (client: MockNetworkClient) => Promise<any>,
) {
if (providerType !== 'infura' && providerType !== 'custom') {
throw new Error(
`providerType must be either "infura" or "custom", was "${providerType}" instead`,
);
}
// Faking timers ends up doing two things:
// 1. Halting the block tracker (which depends on `setTimeout` to periodically
// request the latest block) set up in `eth-json-rpc-middleware`
// 2. Halting the retry logic in `@metamask/eth-json-rpc-infura` (which also
// depends on `setTimeout`)
const clock = sinon.useFakeTimers();
// The JSON-RPC client wraps `eth_estimateGas` so that it takes 2 seconds longer
// than it usually would to complete. Or at least it should — this doesn't
// appear to be working correctly. Unset `IN_TEST` on `process.env` to prevent
// this behavior.
const inTest = process.env.IN_TEST;
delete process.env.IN_TEST;
const clientUnderTest =
providerType === 'infura'
? createNetworkClient({
network: infuraNetwork,
infuraProjectId: MOCK_INFURA_PROJECT_ID,
type: NetworkClientType.Infura,
})
: createNetworkClient({
chainId: customChainId,
rpcUrl: customRpcUrl,
type: NetworkClientType.Custom,
});
process.env.IN_TEST = inTest;
const { provider, blockTracker } = clientUnderTest;
const ethQuery = new EthQuery(provider);
const curriedMakeRpcCall = (request: Request) =>
makeRpcCall(ethQuery, request);
const makeRpcCallsInSeries = async (requests: Request[]) => {
const responses = [];
for (const request of requests) {
responses.push(await curriedMakeRpcCall(request));
}
return responses;
};
const client = {
blockTracker,
clock,
makeRpcCall: curriedMakeRpcCall,
makeRpcCallsInSeries,
};
try {
return await fn(client);
} finally {
await blockTracker.destroy();
clock.restore();
}
}
type BuildMockParamsOptions = {
// The block parameter value to set.
blockParam: any;
// The index of the block parameter.
blockParamIndex: number;
};
/**
* Build mock parameters for a JSON-RPC call.
*
* The string 'some value' is used as the default value for each entry. The
* block parameter index determines the number of parameters to generate.
*
* The block parameter can be set to a custom value. If no value is given, it
* is set as undefined.
*
* @param args - Arguments.
* @param args.blockParamIndex - The index of the block parameter.
* @param args.blockParam - The block parameter value to set.
* @returns The mock params.
*/
export function buildMockParams({
blockParam,
blockParamIndex,
}: BuildMockParamsOptions) {
const params = new Array(blockParamIndex).fill('some value');
params[blockParamIndex] = blockParam;
return params;
}
/**
* Returns a partial JSON-RPC request object, with the "block" param replaced
* with the given value.
*
* @param request - The request object.
* @param request.method - The request method.
* @param request.params - The request params.
* @param blockParamIndex - The index within the `params` array of the block
* param.
* @param blockParam - The desired block param value.
* @returns The updated request object.
*/
export function buildRequestWithReplacedBlockParam(
{ method, params = [] }: Request,
blockParamIndex: number,
blockParam: any,
) {
const updatedParams = params.slice();
updatedParams[blockParamIndex] = blockParam;
return { method, params: updatedParams };
}