From 83f5bb3c88207d08c404f0b1cf14e26afa7f0db6 Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Wed, 8 Oct 2025 13:21:28 +0200 Subject: [PATCH 1/8] wip: Allow mocking JSON-RPC implementations --- packages/snaps-simulation/src/helpers.ts | 21 ++++++++++--- .../snaps-simulation/src/middleware/mock.ts | 24 ++++++++------ packages/snaps-simulation/src/store/mocks.ts | 23 ++++---------- packages/snaps-simulation/src/types.ts | 31 ++++++++++++------- 4 files changed, 57 insertions(+), 42 deletions(-) diff --git a/packages/snaps-simulation/src/helpers.ts b/packages/snaps-simulation/src/helpers.ts index d0c8ba0ecc..9f2dba8d33 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'; @@ -557,14 +558,26 @@ export function getHelpers({ mockJsonRpc(mock: JsonRpcMockOptions) { log('Mocking JSON-RPC request %o.', mock); - const { method, result } = create(mock, JsonRpcMockOptionsStruct); - store.dispatch(addJsonRpcMock({ method, result })); + const id = nanoid(); + + if (typeof mock === 'function') { + store.dispatch(addJsonRpcMock({ id, implementation: mock })); + } else { + const { method, result } = create(mock, JsonRpcMockOptionsStruct); + const implementation = (request: JsonRpcRequest) => { + if (request.method === method) { + return result; + } + return undefined; + }; + store.dispatch(addJsonRpcMock({ id, implementation })); + } return { unmock() { log('Unmocking JSON-RPC request %o.', mock); - store.dispatch(removeJsonRpcMock(method)); + store.dispatch(removeJsonRpcMock(id)); }, }; }, diff --git a/packages/snaps-simulation/src/middleware/mock.ts b/packages/snaps-simulation/src/middleware/mock.ts index b1cb1ce32f..d4cda29cda 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 } from '../store/mocks'; /** * Create a middleware for handling JSON-RPC methods that have been mocked. @@ -13,13 +16,16 @@ 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 = Object.values(getJsonRpcMocks(store.getState())); + for (const mock of mocks) { + const result = await mock(request); + if (result) { + response.result = result; + return; + } } - return next(); - }; + await next(); + }); } diff --git a/packages/snaps-simulation/src/store/mocks.ts b/packages/snaps-simulation/src/store/mocks.ts index 9c037a1e5f..33e1a280ad 100644 --- a/packages/snaps-simulation/src/store/mocks.ts +++ b/packages/snaps-simulation/src/store/mocks.ts @@ -1,16 +1,16 @@ -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; }; export type MocksState = { - jsonRpc: Record; + jsonRpc: Record; }; /** @@ -25,9 +25,7 @@ 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] = action.payload.implementation; }, removeJsonRpcMock: (state, action: PayloadAction) => { delete state.jsonRpc[action.payload]; @@ -44,12 +42,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/types.ts b/packages/snaps-simulation/src/types.ts index 141757460f..fe235a1c78 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,24 @@ 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; - - /** - * The JSON-RPC response, which will be returned when a request with the - * specified method is sent. - */ - result: Json; -}; +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; + +export type JsonRpcMockImplementation = ( + request: JsonRpcRequest, +) => Promise | Json | undefined; /** * This is the main entry point to interact with the snap. It is returned by From 5bcbd3ad1bc1a4b9530edb236dad1b66cf677a34 Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Wed, 8 Oct 2025 14:15:17 +0200 Subject: [PATCH 2/8] Add mockJsonRpcOnce --- .../snaps-simulation/src/helpers.test.tsx | 60 +++++++++++++ packages/snaps-simulation/src/helpers.ts | 85 +++++++++++++------ .../src/middleware/mock.test.ts | 5 +- .../snaps-simulation/src/middleware/mock.ts | 15 +++- .../snaps-simulation/src/store/mocks.test.ts | 44 +++++----- packages/snaps-simulation/src/store/mocks.ts | 8 +- 6 files changed, 160 insertions(+), 57 deletions(-) diff --git a/packages/snaps-simulation/src/helpers.test.tsx b/packages/snaps-simulation/src/helpers.test.tsx index 7a88596093..49ed1465a8 100644 --- a/packages/snaps-simulation/src/helpers.test.tsx +++ b/packages/snaps-simulation/src/helpers.test.tsx @@ -876,4 +876,64 @@ describe('helpers', () => { 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', + 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-simulation/src/helpers.ts b/packages/snaps-simulation/src/helpers.ts index 9f2dba8d33..abc43ec329 100644 --- a/packages/snaps-simulation/src/helpers.ts +++ b/packages/snaps-simulation/src/helpers.ts @@ -222,6 +222,35 @@ 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 + */ + 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. @@ -319,6 +348,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 @@ -556,30 +612,11 @@ export function getHelpers({ }, mockJsonRpc(mock: JsonRpcMockOptions) { - log('Mocking JSON-RPC request %o.', mock); - - const id = nanoid(); - - if (typeof mock === 'function') { - store.dispatch(addJsonRpcMock({ id, implementation: mock })); - } else { - const { method, result } = create(mock, JsonRpcMockOptionsStruct); - const implementation = (request: JsonRpcRequest) => { - if (request.method === method) { - return result; - } - return undefined; - }; - store.dispatch(addJsonRpcMock({ id, implementation })); - } - - return { - unmock() { - log('Unmocking JSON-RPC request %o.', mock); - - store.dispatch(removeJsonRpcMock(id)); - }, - }; + return mockJsonRpc(mock, false); + }, + + 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 d4cda29cda..8984121fa9 100644 --- a/packages/snaps-simulation/src/middleware/mock.ts +++ b/packages/snaps-simulation/src/middleware/mock.ts @@ -5,7 +5,7 @@ import { import type { Json, JsonRpcParams } from '@metamask/utils'; import type { Store } from '../store'; -import { getJsonRpcMocks } from '../store/mocks'; +import { getJsonRpcMocks, removeJsonRpcMock } from '../store/mocks'; /** * Create a middleware for handling JSON-RPC methods that have been mocked. @@ -17,9 +17,16 @@ export function createMockMiddleware( store: Store, ): JsonRpcMiddleware { return createAsyncMiddleware(async (request, response, next) => { - const mocks = Object.values(getJsonRpcMocks(store.getState())); - for (const mock of mocks) { - const result = await mock(request); + 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 (once) { + store.dispatch(removeJsonRpcMock(key)); + } + if (result) { response.result = result; return; 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 33e1a280ad..8260121070 100644 --- a/packages/snaps-simulation/src/store/mocks.ts +++ b/packages/snaps-simulation/src/store/mocks.ts @@ -7,10 +7,11 @@ import type { JsonRpcMockImplementation } from '../types'; export type JsonRpcMock = { id: string; implementation: JsonRpcMockImplementation; + once?: boolean; }; export type MocksState = { - jsonRpc: Record; + jsonRpc: Record>; }; /** @@ -25,7 +26,10 @@ export const mocksSlice = createSlice({ initialState: INITIAL_STATE, reducers: { addJsonRpcMock: (state, action: PayloadAction) => { - state.jsonRpc[action.payload.id] = action.payload.implementation; + state.jsonRpc[action.payload.id] = { + implementation: action.payload.implementation, + once: action.payload.once, + }; }, removeJsonRpcMock: (state, action: PayloadAction) => { delete state.jsonRpc[action.payload]; From 4920338fc39918b5da938c567e3e3c03b7b83873 Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Wed, 8 Oct 2025 14:59:36 +0200 Subject: [PATCH 3/8] Expose in snaps-jest --- packages/snaps-jest/src/helpers.test.tsx | 119 ++++++++++++++++++ packages/snaps-jest/src/helpers.ts | 2 + .../snaps-simulation/src/helpers.test.tsx | 59 +++++++++ packages/snaps-simulation/src/store/store.ts | 4 +- packages/snaps-simulation/src/types.ts | 29 +++++ 5 files changed, 212 insertions(+), 1 deletion(-) 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 49ed1465a8..007f1c5348 100644 --- a/packages/snaps-simulation/src/helpers.test.tsx +++ b/packages/snaps-simulation/src/helpers.test.tsx @@ -875,6 +875,65 @@ 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', () => { 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 fe235a1c78..8bb7f36bba 100644 --- a/packages/snaps-simulation/src/types.ts +++ b/packages/snaps-simulation/src/types.ts @@ -567,6 +567,35 @@ 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 + */ + 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. From 1c788956f0f8387b6f9d87e7f5a885490633cbee Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Wed, 8 Oct 2025 15:05:05 +0200 Subject: [PATCH 4/8] Test mock ordering --- packages/snaps-simulation/src/helpers.test.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/snaps-simulation/src/helpers.test.tsx b/packages/snaps-simulation/src/helpers.test.tsx index 007f1c5348..184c24bbc9 100644 --- a/packages/snaps-simulation/src/helpers.test.tsx +++ b/packages/snaps-simulation/src/helpers.test.tsx @@ -956,6 +956,11 @@ describe('helpers', () => { }); const { request, close, mockJsonRpcOnce } = await installSnap(snapId); + mockJsonRpcOnce({ + method: 'foo_2', + result: 'invalid_mock', + }); + mockJsonRpcOnce({ method: 'foo', result: 'mock', From 9c448dcb94a1839039f0911149dc88501ff339e4 Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Wed, 8 Oct 2025 15:11:19 +0200 Subject: [PATCH 5/8] Only remove mock if returned --- packages/snaps-simulation/src/middleware/mock.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/snaps-simulation/src/middleware/mock.ts b/packages/snaps-simulation/src/middleware/mock.ts index 8984121fa9..61b170ab0e 100644 --- a/packages/snaps-simulation/src/middleware/mock.ts +++ b/packages/snaps-simulation/src/middleware/mock.ts @@ -23,7 +23,7 @@ export function createMockMiddleware( const { implementation, once } = mocks[key]; const result = await implementation(request); - if (once) { + if (result !== undefined && once) { store.dispatch(removeJsonRpcMock(key)); } From 754170a9ae462972bf8fd7067194289a05ae45fd Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Wed, 8 Oct 2025 15:20:38 +0200 Subject: [PATCH 6/8] Update packages/snaps-simulation/src/middleware/mock.ts Co-authored-by: Maarten Zuidhoorn --- packages/snaps-simulation/src/middleware/mock.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/snaps-simulation/src/middleware/mock.ts b/packages/snaps-simulation/src/middleware/mock.ts index 61b170ab0e..ea6b9ff43f 100644 --- a/packages/snaps-simulation/src/middleware/mock.ts +++ b/packages/snaps-simulation/src/middleware/mock.ts @@ -23,11 +23,11 @@ export function createMockMiddleware( const { implementation, once } = mocks[key]; const result = await implementation(request); - if (result !== undefined && once) { - store.dispatch(removeJsonRpcMock(key)); - } - - if (result) { + if (result !== undefined) { + if (once) { + store.dispatch(removeJsonRpcMock(key)); + } + response.result = result; return; } From 10cc42f7dff6326979714d0de53cefb155c29dc0 Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Wed, 8 Oct 2025 15:32:18 +0200 Subject: [PATCH 7/8] Address PR comments --- .../snaps-simulation/src/helpers.test.tsx | 76 +++++++++++++++++++ packages/snaps-simulation/src/helpers.ts | 33 ++++++++ packages/snaps-simulation/src/types.ts | 39 ++++++++++ 3 files changed, 148 insertions(+) diff --git a/packages/snaps-simulation/src/helpers.test.tsx b/packages/snaps-simulation/src/helpers.test.tsx index 184c24bbc9..ffd8f259cf 100644 --- a/packages/snaps-simulation/src/helpers.test.tsx +++ b/packages/snaps-simulation/src/helpers.test.tsx @@ -999,5 +999,81 @@ describe('helpers', () => { 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 abc43ec329..cd8eeff229 100644 --- a/packages/snaps-simulation/src/helpers.ts +++ b/packages/snaps-simulation/src/helpers.ts @@ -214,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): { /** @@ -243,6 +258,24 @@ export type SnapHelpers = { * * 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): { /** diff --git a/packages/snaps-simulation/src/types.ts b/packages/snaps-simulation/src/types.ts index 8bb7f36bba..701821f758 100644 --- a/packages/snaps-simulation/src/types.ts +++ b/packages/snaps-simulation/src/types.ts @@ -372,6 +372,12 @@ export type JsonRpcMockOptions = } | JsonRpcMockImplementation; +/** + * 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; @@ -559,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): { /** @@ -588,6 +609,24 @@ export type Snap = { * * 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): { /** From 5c765f14f8f5a293b15e7a2e2aa7285c9504bcbe Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Wed, 8 Oct 2025 15:34:05 +0200 Subject: [PATCH 8/8] fix lint --- packages/snaps-simulation/src/middleware/mock.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/snaps-simulation/src/middleware/mock.ts b/packages/snaps-simulation/src/middleware/mock.ts index ea6b9ff43f..6bddaed4dd 100644 --- a/packages/snaps-simulation/src/middleware/mock.ts +++ b/packages/snaps-simulation/src/middleware/mock.ts @@ -27,7 +27,7 @@ export function createMockMiddleware( if (once) { store.dispatch(removeJsonRpcMock(key)); } - + response.result = result; return; }