diff --git a/packages/snaps-jest/src/helpers.test.tsx b/packages/snaps-jest/src/helpers.test.tsx index a129f6d3e0..5c4a81739b 100644 --- a/packages/snaps-jest/src/helpers.test.tsx +++ b/packages/snaps-jest/src/helpers.test.tsx @@ -993,6 +993,125 @@ describe('installSnap', () => { await close(); await closeServer(); }); + + it('mocks a JSON-RPC implementation', async () => { + jest.spyOn(console, 'log').mockImplementation(); + + const { snapId, close: closeServer } = await getMockServer({ + sourceCode: ` + module.exports.onRpcRequest = async () => { + return await ethereum.request({ + method: 'foo', + }); + }; + `, + manifest: getSnapManifest({ + initialPermissions: { + 'endowment:ethereum-provider': {}, + }, + }), + }); + + const { request, close, mockJsonRpc } = await installSnap(snapId); + const { unmock } = mockJsonRpc(({ method }) => { + return `${method}_mocked`; + }); + + const response = await request({ + method: 'foo', + }); + + expect(response).toStrictEqual( + expect.objectContaining({ + response: { + result: 'foo_mocked', + }, + }), + ); + + unmock(); + + const unmockedResponse = await request({ + method: 'foo', + }); + + expect(unmockedResponse).toStrictEqual( + expect.objectContaining({ + response: { + error: expect.objectContaining({ + code: -32601, + message: 'The method "foo" does not exist / is not available.', + }), + }, + }), + ); + + // `close` is deprecated because the Jest environment will automatically + // close the Snap when the test finishes. However, we still need to close + // the Snap in this test because it's run outside the Jest environment. + await close(); + await closeServer(); + }); + }); + + describe('mockJsonRpcOnce', () => { + it('mocks a JSON-RPC method', async () => { + jest.spyOn(console, 'log').mockImplementation(); + + const { snapId, close: closeServer } = await getMockServer({ + sourceCode: ` + module.exports.onRpcRequest = async () => { + return await ethereum.request({ + method: 'foo', + }); + }; + `, + manifest: getSnapManifest({ + initialPermissions: { + 'endowment:ethereum-provider': {}, + }, + }), + }); + + const { request, close, mockJsonRpcOnce } = await installSnap(snapId); + mockJsonRpcOnce({ + method: 'foo', + result: 'mock', + }); + + const response = await request({ + method: 'foo', + }); + + expect(response).toStrictEqual( + expect.objectContaining({ + response: { + result: 'mock', + }, + }), + ); + + const unmockedResponse = await request({ + method: 'foo', + }); + + expect(unmockedResponse).toStrictEqual( + expect.objectContaining({ + response: { + error: expect.objectContaining({ + code: -32601, + message: 'The method "foo" does not exist / is not available.', + }), + }, + }), + ); + + // `close` is deprecated because the Jest environment will automatically + // close the Snap when the test finishes. However, we still need to close + // the Snap in this test because it's run outside the Jest environment. + await close(); + await closeServer(); + }); }); }); diff --git a/packages/snaps-jest/src/helpers.ts b/packages/snaps-jest/src/helpers.ts index 59e06a7510..1508cfa2c0 100644 --- a/packages/snaps-jest/src/helpers.ts +++ b/packages/snaps-jest/src/helpers.ts @@ -211,6 +211,7 @@ export async function installSnap< onProtocolRequest, onClientRequest, mockJsonRpc, + mockJsonRpcOnce, close, } = await getEnvironment().installSnap(...resolvedOptions); /* eslint-enable @typescript-eslint/unbound-method */ @@ -233,6 +234,7 @@ export async function installSnap< onProtocolRequest, onClientRequest, mockJsonRpc, + mockJsonRpcOnce, close: async () => { log('Closing execution service.'); logInfo( diff --git a/packages/snaps-simulation/src/helpers.test.tsx b/packages/snaps-simulation/src/helpers.test.tsx index 7a88596093..ffd8f259cf 100644 --- a/packages/snaps-simulation/src/helpers.test.tsx +++ b/packages/snaps-simulation/src/helpers.test.tsx @@ -875,5 +875,205 @@ describe('helpers', () => { await close(); await closeServer(); }); + + it('mocks a JSON-RPC implementation', async () => { + jest.spyOn(console, 'log').mockImplementation(); + + const { snapId, close: closeServer } = await getMockServer({ + sourceCode: ` + module.exports.onRpcRequest = async () => { + return await ethereum.request({ + method: 'foo', + }); + }; + `, + manifest: getSnapManifest({ + initialPermissions: { + 'endowment:ethereum-provider': {}, + }, + }), + }); + + const { request, close, mockJsonRpc } = await installSnap(snapId); + const { unmock } = mockJsonRpc(({ method }) => { + return `${method}_mocked`; + }); + + const response = await request({ + method: 'foo', + }); + + expect(response).toStrictEqual( + expect.objectContaining({ + response: { + result: 'foo_mocked', + }, + }), + ); + + unmock(); + + const unmockedResponse = await request({ + method: 'foo', + }); + + expect(unmockedResponse).toStrictEqual( + expect.objectContaining({ + response: { + error: expect.objectContaining({ + code: -32601, + message: 'The method "foo" does not exist / is not available.', + }), + }, + }), + ); + + // `close` is deprecated because the Jest environment will automatically + // close the Snap when the test finishes. However, we still need to close + // the Snap in this test because it's run outside the Jest environment. + await close(); + await closeServer(); + }); + }); + + describe('mockJsonRpcOnce', () => { + it('mocks a JSON-RPC method once', async () => { + jest.spyOn(console, 'log').mockImplementation(); + + const { snapId, close: closeServer } = await getMockServer({ + sourceCode: ` + module.exports.onRpcRequest = async () => { + return await ethereum.request({ + method: 'foo', + }); + }; + `, + manifest: getSnapManifest({ + initialPermissions: { + 'endowment:ethereum-provider': {}, + }, + }), + }); + + const { request, close, mockJsonRpcOnce } = await installSnap(snapId); + mockJsonRpcOnce({ + method: 'foo_2', + result: 'invalid_mock', + }); + + mockJsonRpcOnce({ + method: 'foo', + result: 'mock', + }); + + const response = await request({ + method: 'foo', + }); + + expect(response).toStrictEqual( + expect.objectContaining({ + response: { + result: 'mock', + }, + }), + ); + + const unmockedResponse = await request({ + method: 'foo', + }); + + expect(unmockedResponse).toStrictEqual( + expect.objectContaining({ + response: { + error: expect.objectContaining({ + code: -32601, + message: 'The method "foo" does not exist / is not available.', + }), + }, + }), + ); + + // `close` is deprecated because the Jest environment will automatically + // close the Snap when the test finishes. However, we still need to close + // the Snap in this test because it's run outside the Jest environment. + await close(); + await closeServer(); + }); + + it('supports queueing JSON-RPC mocks', async () => { + jest.spyOn(console, 'log').mockImplementation(); + + const { snapId, close: closeServer } = await getMockServer({ + sourceCode: ` + module.exports.onRpcRequest = async () => { + return await ethereum.request({ + method: 'foo', + }); + }; + `, + manifest: getSnapManifest({ + initialPermissions: { + 'endowment:ethereum-provider': {}, + }, + }), + }); + + const { request, close, mockJsonRpcOnce } = await installSnap(snapId); + + mockJsonRpcOnce({ + method: 'foo', + result: 'mock', + }); + + mockJsonRpcOnce({ + method: 'foo', + result: 'mock2', + }); + + const response1 = await request({ + method: 'foo', + }); + + expect(response1).toStrictEqual( + expect.objectContaining({ + response: { + result: 'mock', + }, + }), + ); + + const response2 = await request({ + method: 'foo', + }); + + expect(response2).toStrictEqual( + expect.objectContaining({ + response: { + result: 'mock2', + }, + }), + ); + + const unmockedResponse = await request({ + method: 'foo', + }); + + expect(unmockedResponse).toStrictEqual( + expect.objectContaining({ + response: { + error: expect.objectContaining({ + code: -32601, + message: 'The method "foo" does not exist / is not available.', + }), + }, + }), + ); + + // `close` is deprecated because the Jest environment will automatically + // close the Snap when the test finishes. However, we still need to close + // the Snap in this test because it's run outside the Jest environment. + await close(); + await closeServer(); + }); }); }); diff --git a/packages/snaps-simulation/src/helpers.ts b/packages/snaps-simulation/src/helpers.ts index d0c8ba0ecc..cd8eeff229 100644 --- a/packages/snaps-simulation/src/helpers.ts +++ b/packages/snaps-simulation/src/helpers.ts @@ -1,7 +1,8 @@ import { HandlerType } from '@metamask/snaps-utils'; import { create } from '@metamask/superstruct'; -import type { CaipChainId } from '@metamask/utils'; +import type { CaipChainId, JsonRpcRequest } from '@metamask/utils'; import { createModuleLogger } from '@metamask/utils'; +import { nanoid } from '@reduxjs/toolkit'; import { rootLogger } from './logger'; import type { SimulationOptions } from './options'; @@ -213,6 +214,21 @@ export type SnapHelpers = { * // In the Snap * const response = * await ethereum.request({ method: 'eth_accounts' }); // ['0x1234'] + * + * @example + * import { installSnap } from '@metamask/snaps-jest'; + * + * // In the test + * const snap = await installSnap(); + * snap.mockJsonRpc((request) => { + * if (request.method === 'eth_accounts') { + * return ['0x1234']; + * } + * }); + * + * // In the Snap + * const response = + * await ethereum.request({ method: 'eth_accounts' }); // ['0x1234'] */ mockJsonRpc(mock: JsonRpcMockOptions): { /** @@ -221,6 +237,53 @@ export type SnapHelpers = { unmock(): void; }; + /** + * Mock a JSON-RPC request once. This will cause the snap to respond with the + * specified response when a request with the specified method is sent. + * + * @param mock - The mock options. + * @param mock.method - The JSON-RPC request method. + * @param mock.result - The JSON-RPC response, which will be returned when a + * request with the specified method is sent. + * @example + * import { installSnap } from '@metamask/snaps-jest'; + * + * // In the test + * const snap = await installSnap(); + * snap.mockJsonRpcOnce({ method: 'eth_accounts', result: ['0x1234'] }); + * + * // In the Snap + * const response = + * await ethereum.request({ method: 'eth_accounts' }); // ['0x1234'] + * + * const response2 = + * await ethereum.request({ method: 'eth_accounts' }); // Default behavior + * + * @example + * import { installSnap } from '@metamask/snaps-jest'; + * + * // In the test + * const snap = await installSnap(); + * snap.mockJsonRpcOnce((request) => { + * if (request.method === 'eth_accounts') { + * return ['0x1234']; + * } + * }); + * + * // In the Snap + * const response = + * await ethereum.request({ method: 'eth_accounts' }); // ['0x1234'] + * + * const response2 = + * await ethereum.request({ method: 'eth_accounts' }); // Default behavior + */ + mockJsonRpcOnce(mock: JsonRpcMockOptions): { + /** + * Remove the mock. + */ + unmock(): void; + }; + /** * Close the page running the snap. This is mainly useful for cleaning up * the test environment, and calling it is not strictly necessary. @@ -318,6 +381,33 @@ export function getHelpers({ }); }; + const mockJsonRpc = (mock: JsonRpcMockOptions, once: boolean) => { + log('Mocking JSON-RPC request %o.', mock); + + const id = nanoid(); + + if (typeof mock === 'function') { + store.dispatch(addJsonRpcMock({ id, implementation: mock, once })); + } else { + const { method, result } = create(mock, JsonRpcMockOptionsStruct); + const implementation = (request: JsonRpcRequest) => { + if (request.method === method) { + return result; + } + return undefined; + }; + store.dispatch(addJsonRpcMock({ id, implementation, once })); + } + + return { + unmock() { + log('Unmocking JSON-RPC request %o.', mock); + + store.dispatch(removeJsonRpcMock(id)); + }, + }; + }; + return { // This can't be async because it returns a `SnapRequest`. // eslint-disable-next-line @typescript-eslint/promise-function-async @@ -555,18 +645,11 @@ export function getHelpers({ }, mockJsonRpc(mock: JsonRpcMockOptions) { - log('Mocking JSON-RPC request %o.', mock); - - const { method, result } = create(mock, JsonRpcMockOptionsStruct); - store.dispatch(addJsonRpcMock({ method, result })); - - return { - unmock() { - log('Unmocking JSON-RPC request %o.', mock); + return mockJsonRpc(mock, false); + }, - store.dispatch(removeJsonRpcMock(method)); - }, - }; + mockJsonRpcOnce(mock: JsonRpcMockOptions) { + return mockJsonRpc(mock, true); }, close: async () => { diff --git a/packages/snaps-simulation/src/middleware/mock.test.ts b/packages/snaps-simulation/src/middleware/mock.test.ts index 6c5c37f1af..72f1458293 100644 --- a/packages/snaps-simulation/src/middleware/mock.test.ts +++ b/packages/snaps-simulation/src/middleware/mock.test.ts @@ -10,8 +10,9 @@ describe('createMockMiddleware', () => { const { store } = createStore(getMockOptions()); store.dispatch( addJsonRpcMock({ - method: 'foo', - result: 'bar', + id: 'foo', + implementation: () => 'bar', + once: false, }), ); diff --git a/packages/snaps-simulation/src/middleware/mock.ts b/packages/snaps-simulation/src/middleware/mock.ts index b1cb1ce32f..6bddaed4dd 100644 --- a/packages/snaps-simulation/src/middleware/mock.ts +++ b/packages/snaps-simulation/src/middleware/mock.ts @@ -1,8 +1,11 @@ -import type { JsonRpcMiddleware } from '@metamask/json-rpc-engine'; +import { + createAsyncMiddleware, + type JsonRpcMiddleware, +} from '@metamask/json-rpc-engine'; import type { Json, JsonRpcParams } from '@metamask/utils'; import type { Store } from '../store'; -import { getJsonRpcMock } from '../store/mocks'; +import { getJsonRpcMocks, removeJsonRpcMock } from '../store/mocks'; /** * Create a middleware for handling JSON-RPC methods that have been mocked. @@ -13,13 +16,23 @@ import { getJsonRpcMock } from '../store/mocks'; export function createMockMiddleware( store: Store, ): JsonRpcMiddleware { - return function mockMiddleware(request, response, next, end) { - const result = getJsonRpcMock(store.getState(), request.method); - if (result) { - response.result = result; - return end(); + return createAsyncMiddleware(async (request, response, next) => { + const mocks = getJsonRpcMocks(store.getState()); + const keys = Object.keys(mocks); + for (const key of keys) { + const { implementation, once } = mocks[key]; + const result = await implementation(request); + + if (result !== undefined) { + if (once) { + store.dispatch(removeJsonRpcMock(key)); + } + + response.result = result; + return; + } } - return next(); - }; + await next(); + }); } diff --git a/packages/snaps-simulation/src/store/mocks.test.ts b/packages/snaps-simulation/src/store/mocks.test.ts index cacd516cb3..46ab139e40 100644 --- a/packages/snaps-simulation/src/store/mocks.test.ts +++ b/packages/snaps-simulation/src/store/mocks.test.ts @@ -1,6 +1,5 @@ import { addJsonRpcMock, - getJsonRpcMock, getJsonRpcMocks, mocksSlice, removeJsonRpcMock, @@ -14,14 +13,18 @@ describe('mocksSlice', () => { jsonRpc: {}, }, addJsonRpcMock({ - method: 'foo', - result: 'bar', + id: 'foo', + implementation: () => 'bar', + once: false, }), ); expect(state).toStrictEqual({ jsonRpc: { - foo: 'bar', + foo: { + implementation: expect.any(Function), + once: false, + }, }, }); }); @@ -32,7 +35,10 @@ describe('mocksSlice', () => { const state = mocksSlice.reducer( { jsonRpc: { - foo: 'bar', + foo: { + implementation: () => 'bar', + once: false, + }, }, }, removeJsonRpcMock('foo'), @@ -52,30 +58,18 @@ describe('getJsonRpcMocks', () => { getJsonRpcMocks({ mocks: { jsonRpc: { - foo: 'bar', + foo: { + implementation: () => 'bar', + once: false, + }, }, }, }), ).toStrictEqual({ - foo: 'bar', + foo: { + implementation: expect.any(Function), + once: false, + }, }); }); }); - -describe('getJsonRpcMock', () => { - it('gets a JSON-RPC mock from the state', () => { - expect( - getJsonRpcMock( - // @ts-expect-error - Partially defined state. - { - mocks: { - jsonRpc: { - foo: 'bar', - }, - }, - }, - 'foo', - ), - ).toBe('bar'); - }); -}); diff --git a/packages/snaps-simulation/src/store/mocks.ts b/packages/snaps-simulation/src/store/mocks.ts index 9c037a1e5f..8260121070 100644 --- a/packages/snaps-simulation/src/store/mocks.ts +++ b/packages/snaps-simulation/src/store/mocks.ts @@ -1,16 +1,17 @@ -import type { Json } from '@metamask/utils'; import type { PayloadAction } from '@reduxjs/toolkit'; -import { createSelector, createSlice } from '@reduxjs/toolkit'; +import { createSlice } from '@reduxjs/toolkit'; import type { ApplicationState } from './store'; +import type { JsonRpcMockImplementation } from '../types'; export type JsonRpcMock = { - method: string; - result: Json; + id: string; + implementation: JsonRpcMockImplementation; + once?: boolean; }; export type MocksState = { - jsonRpc: Record; + jsonRpc: Record>; }; /** @@ -25,9 +26,10 @@ export const mocksSlice = createSlice({ initialState: INITIAL_STATE, reducers: { addJsonRpcMock: (state, action: PayloadAction) => { - // @ts-expect-error - TS2589: Type instantiation is excessively deep and - // possibly infinite. - state.jsonRpc[action.payload.method] = action.payload.result; + state.jsonRpc[action.payload.id] = { + implementation: action.payload.implementation, + once: action.payload.once, + }; }, removeJsonRpcMock: (state, action: PayloadAction) => { delete state.jsonRpc[action.payload]; @@ -44,12 +46,3 @@ export const { addJsonRpcMock, removeJsonRpcMock } = mocksSlice.actions; * @returns The JSON-RPC mocks. */ export const getJsonRpcMocks = (state: ApplicationState) => state.mocks.jsonRpc; - -/** - * Get the JSON-RPC mock for a given method from the state. - */ -export const getJsonRpcMock = createSelector( - getJsonRpcMocks, - (_: unknown, method: string) => method, - (jsonRpcMocks, method) => jsonRpcMocks[method], -); diff --git a/packages/snaps-simulation/src/store/store.ts b/packages/snaps-simulation/src/store/store.ts index 9553c78074..4d0070a180 100644 --- a/packages/snaps-simulation/src/store/store.ts +++ b/packages/snaps-simulation/src/store/store.ts @@ -27,7 +27,9 @@ export function createStore({ state, unencryptedState }: SimulationOptions) { ui: uiSlice.reducer, }, middleware: (getDefaultMiddleware) => - getDefaultMiddleware({ thunk: false }).concat(sagaMiddleware), + getDefaultMiddleware({ thunk: false, serializableCheck: false }).concat( + sagaMiddleware, + ), }); // Set initial state for the Snap. diff --git a/packages/snaps-simulation/src/types.ts b/packages/snaps-simulation/src/types.ts index 141757460f..701821f758 100644 --- a/packages/snaps-simulation/src/types.ts +++ b/packages/snaps-simulation/src/types.ts @@ -12,6 +12,7 @@ import type { Json, JsonRpcId, JsonRpcParams, + JsonRpcRequest, } from '@metamask/utils'; import type { @@ -356,18 +357,30 @@ export type SnapRequest = Promise & SnapRequestObject; /** * The options to use for mocking a JSON-RPC request. */ -export type JsonRpcMockOptions = { - /** - * The JSON-RPC request method. - */ - method: string; +export type JsonRpcMockOptions = + | { + /** + * The JSON-RPC request method. + */ + method: string; + + /** + * The JSON-RPC response, which will be returned when a request with the + * specified method is sent. + */ + result: Json; + } + | JsonRpcMockImplementation; - /** - * The JSON-RPC response, which will be returned when a request with the - * specified method is sent. - */ - result: Json; -}; +/** + * A function that can be used to mock a JSON-RPC implementation. + * + * @param request - The JSON-RPC request. + * @returns A valid JSON value, optionally as a promise or undefined. + */ +export type JsonRpcMockImplementation = ( + request: JsonRpcRequest, +) => Promise | Json | undefined; /** * This is the main entry point to interact with the snap. It is returned by @@ -552,6 +565,21 @@ export type Snap = { * // In the Snap * const response = * await ethereum.request({ method: 'eth_accounts' }); // ['0x1234'] + * + * @example + * import { installSnap } from '@metamask/snaps-jest'; + * + * // In the test + * const snap = await installSnap(); + * snap.mockJsonRpc((request) => { + * if (request.method === 'eth_accounts') { + * return ['0x1234']; + * } + * }); + * + * // In the Snap + * const response = + * await ethereum.request({ method: 'eth_accounts' }); // ['0x1234'] */ mockJsonRpc(mock: JsonRpcMockOptions): { /** @@ -560,6 +588,53 @@ export type Snap = { unmock(): void; }; + /** + * Mock a JSON-RPC request once. This will cause the snap to respond with the + * specified response when a request with the specified method is sent. + * + * @param mock - The mock options. + * @param mock.method - The JSON-RPC request method. + * @param mock.result - The JSON-RPC response, which will be returned when a + * request with the specified method is sent. + * @example + * import { installSnap } from '@metamask/snaps-jest'; + * + * // In the test + * const snap = await installSnap(); + * snap.mockJsonRpcOnce({ method: 'eth_accounts', result: ['0x1234'] }); + * + * // In the Snap + * const response = + * await ethereum.request({ method: 'eth_accounts' }); // ['0x1234'] + * + * const response2 = + * await ethereum.request({ method: 'eth_accounts' }); // Default behavior + * + * @example + * import { installSnap } from '@metamask/snaps-jest'; + * + * // In the test + * const snap = await installSnap(); + * snap.mockJsonRpcOnce((request) => { + * if (request.method === 'eth_accounts') { + * return ['0x1234']; + * } + * }); + * + * // In the Snap + * const response = + * await ethereum.request({ method: 'eth_accounts' }); // ['0x1234'] + * + * const response2 = + * await ethereum.request({ method: 'eth_accounts' }); // Default behavior + */ + mockJsonRpcOnce(mock: JsonRpcMockOptions): { + /** + * Remove the mock. + */ + unmock(): void; + }; + /** * Close the page running the snap. This is mainly useful for cleaning up * the test environment, and calling it is not strictly necessary.