diff --git a/packages/snaps-jest/src/environment.ts b/packages/snaps-jest/src/environment.ts index cd3f01f606..4a9bcda8ec 100644 --- a/packages/snaps-jest/src/environment.ts +++ b/packages/snaps-jest/src/environment.ts @@ -4,11 +4,12 @@ import type { } from '@jest/environment'; import type { AbstractExecutionService } from '@metamask/snaps-controllers'; import type { SnapId } from '@metamask/snaps-sdk'; +import { installSnap } from '@metamask/snaps-simulation'; import type { InstalledSnap, InstallSnapOptions, + SnapHelpers, } from '@metamask/snaps-simulation'; -import { installSnap } from '@metamask/snaps-simulation'; import { assert, createModuleLogger } from '@metamask/utils'; import type { Server } from 'http'; import NodeEnvironment from 'jest-environment-node'; @@ -31,7 +32,7 @@ export class SnapsEnvironment extends NodeEnvironment { #server: Server | undefined; - #instance: InstalledSnap | undefined; + #instance: (InstalledSnap & SnapHelpers) | undefined; /** * Constructor. diff --git a/packages/snaps-jest/src/helpers.ts b/packages/snaps-jest/src/helpers.ts index dbfbeb247b..09002ae37a 100644 --- a/packages/snaps-jest/src/helpers.ts +++ b/packages/snaps-jest/src/helpers.ts @@ -1,28 +1,10 @@ import type { AbstractExecutionService } from '@metamask/snaps-controllers'; import type { SnapId } from '@metamask/snaps-sdk'; -import type { InstallSnapOptions } from '@metamask/snaps-simulation'; -import { - JsonRpcMockOptionsStruct, - SignatureOptionsStruct, - handleRequest, - TransactionOptionsStruct, - addJsonRpcMock, - removeJsonRpcMock, - SnapResponseWithInterfaceStruct, -} from '@metamask/snaps-simulation'; -import { HandlerType, logInfo } from '@metamask/snaps-utils'; -import { create } from '@metamask/superstruct'; -import { assertStruct, createModuleLogger } from '@metamask/utils'; +import type { InstallSnapOptions, Snap } from '@metamask/snaps-simulation'; +import { logInfo } from '@metamask/snaps-utils'; +import { createModuleLogger } from '@metamask/utils'; import { rootLogger, getEnvironment } from './internals'; -import type { - SnapResponseWithInterface, - CronjobOptions, - JsonRpcMockOptions, - Snap, - SnapResponse, - TransactionOptions, -} from './types'; const log = createModuleLogger(rootLogger, 'helpers'); @@ -48,17 +30,6 @@ function getOptions< return [snapId, options]; } -/** - * Ensure that the actual response contains `getInterface`. - * - * @param response - The response of the handler. - */ -function assertIsResponseWithInterface( - response: SnapResponse, -): asserts response is SnapResponseWithInterface { - assertStruct(response, SnapResponseWithInterfaceStruct); -} - /** * Load a snap into the environment. This is the main entry point for testing * snaps: It returns a {@link Snap} object that can be used to interact with the @@ -200,154 +171,33 @@ export async function installSnap< ): Promise { const resolvedOptions = getOptions(snapId, options); const { - snapId: installedSnapId, - store, - executionService, - runSaga, - controllerMessenger, + request, + onTransaction, + sendTransaction, + onSignature, + onCronjob, + runCronjob, + onHomePage, + mockJsonRpc, + close, } = await getEnvironment().installSnap(...resolvedOptions); - const onTransaction = async ( - request: TransactionOptions, - ): Promise => { - log('Sending transaction %o.', request); - - const { - origin: transactionOrigin, - chainId, - ...transaction - } = create(request, TransactionOptionsStruct); - - const response = await handleRequest({ - snapId: installedSnapId, - store, - executionService, - runSaga, - controllerMessenger, - handler: HandlerType.OnTransaction, - request: { - method: '', - params: { - chainId, - transaction, - transactionOrigin, - }, - }, - }); - - assertIsResponseWithInterface(response); - - return response; - }; - - const onCronjob = (request: CronjobOptions) => { - log('Running cronjob %o.', options); - - return handleRequest({ - snapId: installedSnapId, - store, - executionService, - controllerMessenger, - runSaga, - handler: HandlerType.OnCronjob, - request, - }); - }; - return { - request: (request) => { - log('Sending request %o.', request); - - return handleRequest({ - snapId: installedSnapId, - store, - executionService, - controllerMessenger, - runSaga, - handler: HandlerType.OnRpcRequest, - request, - }); - }, - + request, onTransaction, - sendTransaction: onTransaction, - - onSignature: async ( - request: unknown, - ): Promise => { - log('Requesting signature %o.', request); - - const { origin: signatureOrigin, ...signature } = create( - request, - SignatureOptionsStruct, - ); - - const response = await handleRequest({ - snapId: installedSnapId, - store, - executionService, - controllerMessenger, - runSaga, - handler: HandlerType.OnSignature, - request: { - method: '', - params: { - signature, - signatureOrigin, - }, - }, - }); - - assertIsResponseWithInterface(response); - - return response; - }, - + sendTransaction, + onSignature, onCronjob, - runCronjob: onCronjob, - - onHomePage: async (): Promise => { - log('Rendering home page.'); - - const response = await handleRequest({ - snapId: installedSnapId, - store, - executionService, - controllerMessenger, - runSaga, - handler: HandlerType.OnHomePage, - request: { - method: '', - }, - }); - - assertIsResponseWithInterface(response); - - return response; - }, - - 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); - - store.dispatch(removeJsonRpcMock(method)); - }, - }; - }, - + runCronjob, + onHomePage, + mockJsonRpc, close: async () => { log('Closing execution service.'); logInfo( 'Calling `snap.close()` is deprecated, and will be removed in a future release. Snaps are now automatically closed when the test ends.', ); - await executionService.terminateAllSnaps(); + await close(); }, }; } diff --git a/packages/snaps-jest/src/index.ts b/packages/snaps-jest/src/index.ts index f97525de0f..d3f9d13f6e 100644 --- a/packages/snaps-jest/src/index.ts +++ b/packages/snaps-jest/src/index.ts @@ -4,7 +4,6 @@ import './global'; export { default, default as TestEnvironment } from './environment'; export * from './helpers'; export * from './options'; -export * from './types'; export { assertCustomDialogHasNoFooter, @@ -14,3 +13,29 @@ export { assertIsCustomDialog, assertIsPromptDialog, } from '@metamask/snaps-simulation'; + +export type { + CronjobOptions, + DefaultSnapInterface, + DefaultSnapInterfaceWithFooter, + DefaultSnapInterfaceWithPartialFooter, + DefaultSnapInterfaceWithoutFooter, + FileOptions, + RequestOptions, + SignatureOptions, + Snap, + SnapAlertInterface, + SnapConfirmationInterface, + SnapHandlerInterface, + SnapInterface, + SnapInterfaceActions, + SnapOptions, + SnapPromptInterface, + SnapResponse, + SnapResponseType, + SnapResponseWithInterface, + SnapResponseWithoutInterface, + SnapRequest, + SnapRequestObject, + TransactionOptions, +} from '@metamask/snaps-simulation'; diff --git a/packages/snaps-jest/src/matchers.ts b/packages/snaps-jest/src/matchers.ts index 471d5cf25b..b4d8898430 100644 --- a/packages/snaps-jest/src/matchers.ts +++ b/packages/snaps-jest/src/matchers.ts @@ -13,6 +13,7 @@ import type { } from '@metamask/snaps-sdk'; import type { JSXElement } from '@metamask/snaps-sdk/jsx'; import { isJSXElementUnsafe } from '@metamask/snaps-sdk/jsx'; +import type { SnapResponse } from '@metamask/snaps-simulation'; import { InterfaceStruct, SnapResponseStruct, @@ -35,8 +36,6 @@ import { RECEIVED_COLOR, } from 'jest-matcher-utils'; -import type { SnapResponse } from './types'; - /** * Ensure that the actual value is a response from the `request` function. * diff --git a/packages/snaps-jest/src/test-utils/response.ts b/packages/snaps-jest/src/test-utils/response.ts index 1babfeed76..cf71d8bacf 100644 --- a/packages/snaps-jest/src/test-utils/response.ts +++ b/packages/snaps-jest/src/test-utils/response.ts @@ -1,6 +1,8 @@ import type { JSXElement } from '@metamask/snaps-sdk/jsx'; - -import type { SnapHandlerInterface, SnapResponse } from '../types'; +import type { + SnapHandlerInterface, + SnapResponse, +} from '@metamask/snaps-simulation'; /** * Get a mock response. @@ -40,6 +42,7 @@ export function getMockInterfaceResponse( typeInField: jest.fn(), selectInDropdown: jest.fn(), selectFromRadioGroup: jest.fn(), + selectFromSelector: jest.fn(), uploadFile: jest.fn(), }; } diff --git a/packages/snaps-jest/src/types/index.ts b/packages/snaps-jest/src/types/index.ts deleted file mode 100644 index fcb073fefc..0000000000 --- a/packages/snaps-jest/src/types/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './types'; diff --git a/packages/snaps-jest/src/types/types.ts b/packages/snaps-jest/src/types/types.ts deleted file mode 100644 index a96b4ae1e4..0000000000 --- a/packages/snaps-jest/src/types/types.ts +++ /dev/null @@ -1,471 +0,0 @@ -import type { NotificationType, EnumToUnion } from '@metamask/snaps-sdk'; -import type { JSXElement } from '@metamask/snaps-sdk/jsx'; -import type { - SignatureOptionsStruct, - SnapOptionsStruct, - SnapResponseStruct, - TransactionOptionsStruct, -} from '@metamask/snaps-simulation'; -import type { InferMatching } from '@metamask/snaps-utils'; -import type { Infer } from '@metamask/superstruct'; -import type { Json, JsonRpcId, JsonRpcParams } from '@metamask/utils'; - -export type RequestOptions = { - /** - * The JSON-RPC request ID. - */ - id?: JsonRpcId; - - /** - * The JSON-RPC method. - */ - method: string; - - /** - * The JSON-RPC params. - */ - params?: JsonRpcParams; - - /** - * The origin to send the request from. - */ - origin?: string; -}; - -/** - * The `runCronjob` options. This is the same as {@link RequestOptions}, except - * that it does not have an `origin` property. - */ -export type CronjobOptions = Omit; - -/** - * The options to use for transaction requests. - * - * @property chainId - The CAIP-2 chain ID to send the transaction on. Defaults - * to `eip155:1`. - * @property origin - The origin to send the transaction from. Defaults to - * `metamask.io`. - * @property from - The address to send the transaction from. Defaults to a - * randomly generated address. - * @property to - The address to send the transaction to. Defaults to a randomly - * generated address. - * @property value - The value to send with the transaction. Defaults to `0`. - * @property data - The data to send with the transaction. Defaults to `0x`. - * @property gasLimit - The gas limit to use for the transaction. Defaults to - * `21_000`. - * @property maxFeePerGas - The maximum fee per gas to use for the transaction. - * Defaults to `1`. - * @property maxPriorityFeePerGas - The maximum priority fee per gas to use for - * the transaction. Defaults to `1`. - * @property nonce - The nonce to use for the transaction. Defaults to `0`. - */ -export type TransactionOptions = Infer; - -/** - * The options to use for signature requests. - * - * @property origin - The origin to send the signature request from. Defaults to - * `metamask.io`. - * @property from - The address to send the signature from. Defaults to a - * randomly generated address. - * @property data - The data to sign. Defaults to `0x`. - * @property signatureMethod - The signature method. - */ -export type SignatureOptions = Infer; - -/** - * The options to use for requests to the snap. - * - * @property timeout - The timeout in milliseconds to use. Defaults to `1000`. - */ -export type SnapOptions = Infer; - -/** - * Options for uploading a file. - * - * @property fileName - The name of the file. By default, this is inferred from - * the file path if it's a path, and defaults to an empty string if it's a - * `Uint8Array`. - * @property contentType - The content type of the file. By default, this is - * inferred from the file name if it's a path, and defaults to - * `application/octet-stream` if it's a `Uint8Array` or the content type cannot - * be inferred from the file name. - */ -export type FileOptions = { - fileName?: string; - contentType?: string; -}; - -export type SnapInterfaceActions = { - /** - * Click on an interface element. - * - * @param name - The element name to click. - */ - clickElement(name: string): Promise; - - /** - * Type a value in a interface field. - * - * @param name - The element name to type in. - * @param value - The value to type. - */ - typeInField(name: string, value: string): Promise; - - /** - * Select an option with a value in a dropdown. - * - * @param name - The element name to type in. - * @param value - The value to type. - */ - selectInDropdown(name: string, value: string): Promise; - - /** - * Choose an option with a value from radio group. - * - * @param name - The element name to type in. - * @param value - The value to type. - */ - selectFromRadioGroup(name: string, value: string): Promise; - - /** - * Choose an option with a value from Selector component. - * - * @param name - The element name to type in. - * @param value - The value to type. - */ - selectFromSelector(name: string, value: string): Promise; - - /** - * Upload a file. - * - * @param name - The element name to upload the file to. - * @param file - The file to upload. This can be a path to a file or a - * `Uint8Array` containing the file contents. If this is a path, the file is - * resolved relative to the current working directory. - * @param options - The file options. - * @param options.fileName - The name of the file. By default, this is - * inferred from the file path if it's a path, and defaults to an empty string - * if it's a `Uint8Array`. - * @param options.contentType - The content type of the file. By default, this - * is inferred from the file name if it's a path, and defaults to - * `application/octet-stream` if it's a `Uint8Array` or the content type - * cannot be inferred from the file name. - */ - uploadFile( - name: string, - file: string | Uint8Array, - options?: FileOptions, - ): Promise; -}; - -/** - * A `snap_dialog` alert interface. - */ -export type SnapAlertInterface = { - /** - * The type of the interface. This is always `alert`. - */ - type: 'alert'; - - /** - * The content to show in the alert. - */ - content: JSXElement; - - /** - * Close the alert. - */ - ok(): Promise; -}; - -/** - * A `snap_dialog` confirmation interface. - */ -export type SnapConfirmationInterface = { - /** - * The type of the interface. This is always `confirmation`. - */ - type: 'confirmation'; - - /** - * The content to show in the confirmation. - */ - content: JSXElement; - - /** - * Close the confirmation. - */ - ok(): Promise; - - /** - * Cancel the confirmation. - */ - cancel(): Promise; -}; - -/** - * A `snap_dialog` prompt interface. - */ -export type SnapPromptInterface = { - /** - * The type of the interface. This is always `prompt`. - */ - type: 'prompt'; - - /** - * The content to show in the prompt. - */ - content: JSXElement; - - /** - * Close the prompt. - * - * @param value - The value to close the prompt with. - */ - ok(value?: string): Promise; - - /** - * Cancel the prompt. - */ - cancel(): Promise; -}; - -/** - * A `snap_dialog` default interface that has a Footer with two buttons defined. - * The approval of this confirmation is handled by the snap. - */ -export type DefaultSnapInterfaceWithFooter = { - /** - * The content to show in the interface. - */ - content: JSXElement; -}; - -/** - * A `snap_dialog` default interface that has a Footer with one button defined. - * A cancel button is automatically applied to the interface in this case. - */ -export type DefaultSnapInterfaceWithPartialFooter = - DefaultSnapInterfaceWithFooter & { - /** - * Cancel the dialog. - */ - cancel(): Promise; - }; - -/** - * A `snap_dialog` default interface that has no Footer defined. - * A cancel and ok button is automatically applied to the interface in this case. - */ -export type DefaultSnapInterfaceWithoutFooter = - DefaultSnapInterfaceWithPartialFooter & { - /** - * Close the dialog. - * - */ - ok(): Promise; - }; - -export type DefaultSnapInterface = - | DefaultSnapInterfaceWithFooter - | DefaultSnapInterfaceWithPartialFooter - | DefaultSnapInterfaceWithoutFooter; - -export type SnapInterface = ( - | SnapAlertInterface - | SnapConfirmationInterface - | SnapPromptInterface - | DefaultSnapInterface -) & - SnapInterfaceActions; - -export type SnapRequestObject = { - /** - * Get a user interface object from a snap. This will throw an error if the - * snap does not show a user interface within the timeout. - * - * @param options - The options to use. - * @param options.timeout - The timeout in milliseconds to use. Defaults to - * `1000`. - * @returns The user interface object. - */ - getInterface(options?: SnapOptions): Promise; -}; - -/** - * A pending request object. This is a promise with extra - * {@link SnapRequestObject} fields. - */ -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; -}; - -/** - * This is the main entry point to interact with the snap. It is returned by - * {@link installSnap}, and has methods to send requests to the snap. - * - * @example - * import { installSnap } from '@metamask/snaps-jest'; - * - * const snap = await installSnap(); - * const response = await snap.request({ method: 'hello' }); - * - * expect(response).toRespondWith('Hello, world!'); - */ -export type Snap = { - /** - * Send a JSON-RPC request to the snap. - * - * @param request - The request. This is similar to a JSON-RPC request, but - * has an extra `origin` field. - * @returns The response promise, with extra {@link SnapRequestObject} fields. - */ - request(request: RequestOptions): SnapRequest; - - /** - * Send a transaction to the snap. - * - * @param transaction - The transaction. This is similar to an Ethereum - * transaction object, but has an extra `origin` field. Any missing fields - * will be filled in with default values. - * @returns The response. - */ - onTransaction( - transaction?: Partial, - ): Promise; - - /** - * Send a transaction to the snap. - * - * @param transaction - The transaction. This is similar to an Ethereum - * transaction object, but has an extra `origin` field. Any missing fields - * will be filled in with default values. - * @returns The response. - * @deprecated Use {@link onTransaction} instead. - */ - sendTransaction( - transaction?: Partial, - ): Promise; - - /** - * Send a signature request to the snap. - * - * @param signature - The signature request object. Contains the params from - * the various signature methods, but has an extra `origin` and `signatureMethod` field. - * Any missing fields will be filled in with default values. - * @returns The response. - */ - onSignature( - signature?: Partial, - ): Promise; - - /** - * Run a cronjob in the snap. This is similar to {@link request}, but the - * request will be sent to the `onCronjob` method of the snap. - * - * @param cronjob - The cronjob request. This is similar to a JSON-RPC - * request, and is normally specified in the snap manifest, under the - * `endowment:cronjob` permission. - * @returns The response promise, with extra {@link SnapRequestObject} fields. - */ - onCronjob(cronjob?: Partial): SnapRequest; - - /** - * Run a cronjob in the snap. This is similar to {@link request}, but the - * request will be sent to the `onCronjob` method of the snap. - * - * @param cronjob - The cronjob request. This is similar to a JSON-RPC - * request, and is normally specified in the snap manifest, under the - * `endowment:cronjob` permission. - * @returns The response promise, with extra {@link SnapRequestObject} fields. - * @deprecated Use {@link onCronjob} instead. - */ - runCronjob(cronjob: CronjobOptions): SnapRequest; - - /** - * Get the response from the snap's `onHomePage` method. - * - * @returns The response. - */ - onHomePage(): Promise; - - /** - * Mock a JSON-RPC request. 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.mockJsonRpc({ method: 'eth_accounts', result: ['0x1234'] }); - * - * // In the Snap - * const response = - * await ethereum.request({ method: 'eth_accounts' }); // ['0x1234'] - */ - mockJsonRpc(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. - * - * @returns A promise that resolves when the page is closed. - * @deprecated Snaps are now automatically closed when the test ends. This - * method will be removed in a future release. - */ - close(): Promise; -}; - -export type SnapHandlerInterface = { - content: JSXElement; -} & SnapInterfaceActions; - -export type SnapResponseWithInterface = { - id: string; - response: { result: Json } | { error: Json }; - notifications: { - id: string; - message: string; - type: EnumToUnion; - }[]; - getInterface(): SnapHandlerInterface; -}; - -export type SnapResponseWithoutInterface = Omit< - SnapResponseWithInterface, - 'getInterface' ->; - -export type SnapResponseType = - | SnapResponseWithoutInterface - | SnapResponseWithInterface; - -export type SnapResponse = InferMatching< - typeof SnapResponseStruct, - SnapResponseType ->; diff --git a/packages/snaps-simulation/src/helpers.test.tsx b/packages/snaps-simulation/src/helpers.test.tsx new file mode 100644 index 0000000000..1c04e36b19 --- /dev/null +++ b/packages/snaps-simulation/src/helpers.test.tsx @@ -0,0 +1,530 @@ +import { DialogType } from '@metamask/snaps-sdk'; +import { Text } from '@metamask/snaps-sdk/jsx'; +import { getSnapManifest } from '@metamask/snaps-utils/test-utils'; + +import { installSnap } from './simulation'; +import { getMockServer } from './test-utils'; +import { + assertIsAlertDialog, + assertIsConfirmationDialog, + assertIsPromptDialog, +} from './validation'; + +describe('helpers', () => { + beforeEach(() => { + Object.defineProperty(global, 'snapsEnvironment', { + writable: true, + value: { + installSnap, + }, + }); + }); + + describe('request', () => { + it('sends a JSON-RPC request to the Snap and returns the result', async () => { + jest.spyOn(console, 'log').mockImplementation(); + + const { snapId, close: closeServer } = await getMockServer({ + sourceCode: ` + module.exports.onRpcRequest = (request) => { + return { + request + }; + }; + `, + }); + + const { request, close } = await installSnap(snapId); + const response = await request({ + method: 'hello', + params: { + foo: 'bar', + }, + }); + + expect(response).toStrictEqual( + expect.objectContaining({ + response: { + result: { + request: { + origin: 'https://metamask.io', + request: { + id: 1, + jsonrpc: '2.0', + method: 'hello', + params: { + foo: 'bar', + }, + }, + }, + }, + }, + }), + ); + + // `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('sends a JSON-RPC request to the Snap and returns the error', async () => { + jest.spyOn(console, 'log').mockImplementation(); + + const { snapId, close: closeServer } = await getMockServer({ + sourceCode: ` + module.exports.onRpcRequest = ({ origin }) => { + throw new Error('Something went wrong!'); + }; + `, + }); + + const { request, close } = await installSnap(snapId); + const response = await request({ + method: 'foo', + }); + + expect(response).toStrictEqual( + expect.objectContaining({ + response: { + error: expect.objectContaining({ + code: -32603, + message: 'Something went wrong!', + }), + }, + }), + ); + + // `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('handles alert dialogs', async () => { + jest.spyOn(console, 'log').mockImplementation(); + + const { snapId, close: closeServer } = await getMockServer({ + sourceCode: ` + module.exports.onRpcRequest = async () => { + return await snap.request({ + method: 'snap_dialog', + params: { + type: 'prompt', + content: { + type: 'text', + value: 'Hello, world!', + }, + }, + }); + }; + `, + manifest: getSnapManifest({ + initialPermissions: { + // eslint-disable-next-line @typescript-eslint/naming-convention + snap_dialog: {}, + }, + }), + }); + + const { request, close } = await installSnap(snapId); + const response = request({ + method: 'foo', + }); + + const ui = await response.getInterface(); + assertIsPromptDialog(ui); + expect(ui).toStrictEqual({ + type: DialogType.Prompt, + content: Hello, world!, + clickElement: expect.any(Function), + typeInField: expect.any(Function), + selectInDropdown: expect.any(Function), + selectFromRadioGroup: expect.any(Function), + selectFromSelector: expect.any(Function), + uploadFile: expect.any(Function), + ok: expect.any(Function), + cancel: expect.any(Function), + }); + + await ui.ok('foo'); + expect(await response).toStrictEqual( + expect.objectContaining({ + response: { + result: 'foo', + }, + }), + ); + + // `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('handles confirmation dialogs', async () => { + jest.spyOn(console, 'log').mockImplementation(); + + const { snapId, close: closeServer } = await getMockServer({ + sourceCode: ` + module.exports.onRpcRequest = async () => { + return await snap.request({ + method: 'snap_dialog', + params: { + type: 'confirmation', + content: { + type: 'text', + value: 'Hello, world!', + }, + }, + }); + }; + `, + manifest: getSnapManifest({ + initialPermissions: { + // eslint-disable-next-line @typescript-eslint/naming-convention + snap_dialog: {}, + }, + }), + }); + + const { request, close } = await installSnap(snapId); + const response = request({ + method: 'foo', + }); + + const ui = await response.getInterface(); + assertIsConfirmationDialog(ui); + expect(ui).toStrictEqual({ + type: DialogType.Confirmation, + content: Hello, world!, + clickElement: expect.any(Function), + typeInField: expect.any(Function), + selectInDropdown: expect.any(Function), + selectFromRadioGroup: expect.any(Function), + selectFromSelector: expect.any(Function), + uploadFile: expect.any(Function), + ok: expect.any(Function), + cancel: expect.any(Function), + }); + + await ui.cancel(); + expect(await response).toStrictEqual( + expect.objectContaining({ + response: { + result: false, + }, + }), + ); + + // `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('handles prompt dialogs', async () => { + jest.spyOn(console, 'log').mockImplementation(); + + const { snapId, close: closeServer } = await getMockServer({ + sourceCode: ` + module.exports.onRpcRequest = async () => { + return await snap.request({ + method: 'snap_dialog', + params: { + type: 'alert', + content: { + type: 'text', + value: 'Hello, world!', + }, + }, + }); + }; + `, + manifest: getSnapManifest({ + initialPermissions: { + // eslint-disable-next-line @typescript-eslint/naming-convention + snap_dialog: {}, + }, + }), + }); + + const { request, close } = await installSnap(snapId); + const response = request({ + method: 'foo', + }); + + const ui = await response.getInterface(); + assertIsAlertDialog(ui); + expect(ui).toStrictEqual({ + type: DialogType.Alert, + content: Hello, world!, + clickElement: expect.any(Function), + typeInField: expect.any(Function), + selectInDropdown: expect.any(Function), + selectFromRadioGroup: expect.any(Function), + selectFromSelector: expect.any(Function), + uploadFile: expect.any(Function), + ok: expect.any(Function), + }); + + await ui.ok(); + expect(await response).toStrictEqual( + expect.objectContaining({ + response: { + result: null, + }, + }), + ); + + // `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('allows specifying the origin', async () => { + jest.spyOn(console, 'log').mockImplementation(); + + const { snapId, close: closeServer } = await getMockServer({ + sourceCode: ` + module.exports.onRpcRequest = ({ origin }) => { + return origin; + }; + `, + }); + + const { request, close } = await installSnap(snapId); + const response = await request({ + method: 'hello', + origin: 'https://example.com', + }); + + expect(response).toStrictEqual( + expect.objectContaining({ + response: { + result: 'https://example.com', + }, + }), + ); + + // `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('sendTransaction', () => { + it('sends a transaction and returns the result', async () => { + jest.spyOn(console, 'log').mockImplementation(); + + const { snapId, close: closeServer } = await getMockServer({ + sourceCode: ` + module.exports.onTransaction = async ({ transaction }) => { + return { + content: { + type: 'text', + value: 'Hello, world! (value: ' + transaction.value + ')', + }, + }; + }; + `, + }); + + const { sendTransaction, close } = await installSnap(snapId); + const response = await sendTransaction({ + value: '0x1', + }); + + expect(response).toStrictEqual( + expect.objectContaining({ + response: { + result: { + content: { + type: 'text', + value: 'Hello, world! (value: 0x01)', + }, + }, + }, + }), + ); + + // `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('onSignature', () => { + it('sends a signature request and returns the result', async () => { + jest.spyOn(console, 'log').mockImplementation(); + + const { snapId, close: closeServer } = await getMockServer({ + sourceCode: ` + module.exports.onSignature = async ({ signature }) => { + return { + content: { + type: 'text', + value: 'You are using the ' + signature.signatureMethod + ' method.', + }, + severity: 'critical', + }; + }; + `, + }); + + const { onSignature, close } = await installSnap(snapId); + const response = await onSignature({ + signatureMethod: 'personal_sign', + }); + + expect(response).toStrictEqual( + expect.objectContaining({ + response: { + result: { + content: { + type: 'text', + value: 'You are using the personal_sign method.', + }, + severity: 'critical', + }, + }, + }), + ); + + // `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('runCronjob', () => { + it('runs a cronjob and returns the result', async () => { + jest.spyOn(console, 'log').mockImplementation(); + + const { snapId, close: closeServer } = await getMockServer({ + sourceCode: ` + module.exports.onCronjob = async ({ request }) => { + return request.method; + }; + `, + }); + + const { runCronjob, close } = await installSnap(snapId); + const response = await runCronjob({ + method: 'foo', + }); + + expect(response).toStrictEqual( + expect.objectContaining({ + response: { + result: 'foo', + }, + }), + ); + + await close(); + await closeServer(); + }); + }); + + describe('getHomePage', () => { + it('sends a OnHomePage request and returns the result', async () => { + jest.spyOn(console, 'log').mockImplementation(); + + const { snapId, close: closeServer } = await getMockServer({ + sourceCode: ` + module.exports.onHomePage = async () => { + return { content: { type: 'text', value: 'Hello, world!' } }; + }; + `, + }); + + const { onHomePage, close } = await installSnap(snapId); + const response = await onHomePage(); + + expect(response).toStrictEqual( + expect.objectContaining({ + getInterface: expect.any(Function), + }), + ); + + await close(); + await closeServer(); + }); + }); + + describe('mockJsonRpc', () => { + 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: { + // eslint-disable-next-line @typescript-eslint/naming-convention + 'endowment:ethereum-provider': {}, + }, + }), + }); + + const { request, close, mockJsonRpc } = await installSnap(snapId); + const { unmock } = mockJsonRpc({ + method: 'foo', + result: 'mock', + }); + + const response = await request({ + method: 'foo', + }); + + expect(response).toStrictEqual( + expect.objectContaining({ + response: { + result: 'mock', + }, + }), + ); + + 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(); + }); + }); +}); diff --git a/packages/snaps-simulation/src/helpers.ts b/packages/snaps-simulation/src/helpers.ts new file mode 100644 index 0000000000..66c653bd29 --- /dev/null +++ b/packages/snaps-simulation/src/helpers.ts @@ -0,0 +1,311 @@ +import { HandlerType } from '@metamask/snaps-utils'; +import { create } from '@metamask/superstruct'; +import { createModuleLogger } from '@metamask/utils'; + +import { rootLogger } from './logger'; +import type { SimulationOptions } from './options'; +import { handleRequest } from './request'; +import type { InstalledSnap } from './simulation'; +import { addJsonRpcMock, removeJsonRpcMock } from './store'; +import { + assertIsResponseWithInterface, + JsonRpcMockOptionsStruct, + SignatureOptionsStruct, + TransactionOptionsStruct, +} from './structs'; +import type { + CronjobOptions, + JsonRpcMockOptions, + RequestOptions, + SignatureOptions, + SnapRequest, + SnapResponseWithInterface, + TransactionOptions, +} from './types'; + +const log = createModuleLogger(rootLogger, 'helpers'); + +/** + * This is the main entry point to interact with the snap. It is returned by + * {@link installSnap}, and has methods to send requests to the snap. + * + * @example + * import { installSnap } from '@metamask/snaps-jest'; + * + * const snap = await installSnap(); + * const response = await snap.request({ method: 'hello' }); + * + * expect(response).toRespondWith('Hello, world!'); + */ +export type SnapHelpers = { + /** + * Send a JSON-RPC request to the snap. + * + * @param request - The request. This is similar to a JSON-RPC request, but + * has an extra `origin` field. + * @returns The response promise, with extra {@link SnapRequestObject} fields. + */ + request(request: RequestOptions): SnapRequest; + + /** + * Send a transaction to the snap. + * + * @param transaction - The transaction. This is similar to an Ethereum + * transaction object, but has an extra `origin` field. Any missing fields + * will be filled in with default values. + * @returns The response. + */ + onTransaction( + transaction?: Partial, + ): Promise; + + /** + * Send a transaction to the snap. + * + * @param transaction - The transaction. This is similar to an Ethereum + * transaction object, but has an extra `origin` field. Any missing fields + * will be filled in with default values. + * @returns The response. + * @deprecated Use {@link onTransaction} instead. + */ + sendTransaction( + transaction?: Partial, + ): Promise; + + /** + * Send a signature request to the snap. + * + * @param signature - The signature request object. Contains the params from + * the various signature methods, but has an extra `origin` and `signatureMethod` field. + * Any missing fields will be filled in with default values. + * @returns The response. + */ + onSignature( + signature?: Partial, + ): Promise; + + /** + * Run a cronjob in the snap. This is similar to {@link request}, but the + * request will be sent to the `onCronjob` method of the snap. + * + * @param cronjob - The cronjob request. This is similar to a JSON-RPC + * request, and is normally specified in the snap manifest, under the + * `endowment:cronjob` permission. + * @returns The response promise, with extra {@link SnapRequestObject} fields. + */ + onCronjob(cronjob?: Partial): SnapRequest; + + /** + * Run a cronjob in the snap. This is similar to {@link request}, but the + * request will be sent to the `onCronjob` method of the snap. + * + * @param cronjob - The cronjob request. This is similar to a JSON-RPC + * request, and is normally specified in the snap manifest, under the + * `endowment:cronjob` permission. + * @returns The response promise, with extra {@link SnapRequestObject} fields. + * @deprecated Use {@link onCronjob} instead. + */ + runCronjob(cronjob: CronjobOptions): SnapRequest; + + /** + * Get the response from the snap's `onHomePage` method. + * + * @returns The response. + */ + onHomePage(): Promise; + + /** + * Mock a JSON-RPC request. 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.mockJsonRpc({ method: 'eth_accounts', result: ['0x1234'] }); + * + * // In the Snap + * const response = + * await ethereum.request({ method: 'eth_accounts' }); // ['0x1234'] + */ + mockJsonRpc(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. + * + * @returns A promise that resolves when the page is closed. + */ + close(): Promise; +}; + +/** + * Get the helper functions for the Snap. + * + * @param snap - The installed Snap. + * @param snap.snapId - The ID of the Snap. + * @param snap.store - The Redux store. + * @param snap.executionService - The execution service. + * @param snap.runSaga - The `runSaga` function. + * @param snap.controllerMessenger - The controller messenger. + * @param snap.options - The simulation options. + * @returns The Snap helpers. + */ +export function getHelpers({ + snapId, + store, + executionService, + runSaga, + controllerMessenger, + options, +}: InstalledSnap & { options: SimulationOptions }): SnapHelpers { + const onTransaction = async ( + request: TransactionOptions, + ): Promise => { + log('Sending transaction %o.', request); + + const { + origin: transactionOrigin, + chainId, + ...transaction + } = create(request, TransactionOptionsStruct); + + const response = await handleRequest({ + snapId, + store, + executionService, + runSaga, + controllerMessenger, + handler: HandlerType.OnTransaction, + request: { + method: '', + params: { + chainId, + transaction, + transactionOrigin, + }, + }, + }); + + assertIsResponseWithInterface(response); + + return response; + }; + + const onCronjob = (request: CronjobOptions) => { + log('Running cronjob %o.', options); + + return handleRequest({ + snapId, + store, + executionService, + controllerMessenger, + runSaga, + handler: HandlerType.OnCronjob, + request, + }); + }; + + return { + request: (request) => { + log('Sending request %o.', request); + + return handleRequest({ + snapId, + store, + executionService, + controllerMessenger, + runSaga, + handler: HandlerType.OnRpcRequest, + request, + }); + }, + + onTransaction, + sendTransaction: onTransaction, + + onSignature: async ( + request: unknown, + ): Promise => { + log('Requesting signature %o.', request); + + const { origin: signatureOrigin, ...signature } = create( + request, + SignatureOptionsStruct, + ); + + const response = await handleRequest({ + snapId, + store, + executionService, + controllerMessenger, + runSaga, + handler: HandlerType.OnSignature, + request: { + method: '', + params: { + signature, + signatureOrigin, + }, + }, + }); + + assertIsResponseWithInterface(response); + + return response; + }, + + onCronjob, + runCronjob: onCronjob, + + onHomePage: async (): Promise => { + log('Rendering home page.'); + + const response = await handleRequest({ + snapId, + store, + executionService, + controllerMessenger, + runSaga, + handler: HandlerType.OnHomePage, + request: { + method: '', + }, + }); + + assertIsResponseWithInterface(response); + + return response; + }, + + 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); + + store.dispatch(removeJsonRpcMock(method)); + }, + }; + }, + + close: async () => { + log('Closing execution service.'); + await executionService.terminateAllSnaps(); + }, + }; +} diff --git a/packages/snaps-simulation/src/index.ts b/packages/snaps-simulation/src/index.ts index 7ff912556d..f30f971e70 100644 --- a/packages/snaps-simulation/src/index.ts +++ b/packages/snaps-simulation/src/index.ts @@ -1,5 +1,6 @@ export * from './constants'; export * from './controllers'; +export * from './helpers'; export * from './interface'; export * from './options'; export * from './request'; diff --git a/packages/snaps-simulation/src/logger.test.ts b/packages/snaps-simulation/src/logger.test.ts new file mode 100644 index 0000000000..7d8fcb1b1f --- /dev/null +++ b/packages/snaps-simulation/src/logger.test.ts @@ -0,0 +1,7 @@ +import { rootLogger } from './logger'; + +describe('rootLogger', () => { + it('is a function', () => { + expect(rootLogger).toBeInstanceOf(Function); + }); +}); diff --git a/packages/snaps-simulation/src/logger.ts b/packages/snaps-simulation/src/logger.ts new file mode 100644 index 0000000000..179a10aa64 --- /dev/null +++ b/packages/snaps-simulation/src/logger.ts @@ -0,0 +1,3 @@ +import { createProjectLogger } from '@metamask/utils'; + +export const rootLogger = createProjectLogger('snaps-simulation'); diff --git a/packages/snaps-simulation/src/simulation.test.ts b/packages/snaps-simulation/src/simulation.test.ts index f8187bf894..7df4010d5f 100644 --- a/packages/snaps-simulation/src/simulation.test.ts +++ b/packages/snaps-simulation/src/simulation.test.ts @@ -2,6 +2,7 @@ import { mnemonicPhraseToBytes } from '@metamask/key-tree'; import { detectSnapLocation, fetchSnap, + NodeProcessExecutionService, NodeThreadExecutionService, SnapInterfaceController, } from '@metamask/snaps-controllers/node'; @@ -20,7 +21,7 @@ import { getRootControllerMessenger, } from './test-utils'; -describe('handleInstallSnap', () => { +describe('installSnap', () => { it('installs a Snap and returns the execution service', async () => { const { snapId, close } = await getMockServer(); const installedSnap = await installSnap(snapId); @@ -31,6 +32,176 @@ describe('handleInstallSnap', () => { await close(); }); + + it('installs a Snap into a custom execution environment', async () => { + jest.spyOn(console, 'log').mockImplementation(); + + const { snapId, close: closeServer } = await getMockServer({ + sourceCode: ` + module.exports.onRpcRequest = async (request) => { + return 'Hello, world!'; + }; + `, + }); + + const { request, close } = await installSnap(snapId, { + executionService: NodeProcessExecutionService, + options: { + locale: 'nl', + }, + }); + + const response = await request({ + method: 'hello', + params: { + foo: 'bar', + }, + }); + + expect(response).toStrictEqual( + expect.objectContaining({ + response: { + result: 'Hello, world!', + }, + }), + ); + + // `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('allows specifying the locale', async () => { + jest.spyOn(console, 'log').mockImplementation(); + + const { snapId, close: closeServer } = await getMockServer({ + sourceCode: ` + module.exports.onRpcRequest = async (request) => { + return await snap.request({ + method: 'snap_getLocale', + }); + }; + `, + manifest: getSnapManifest({ + initialPermissions: { + // eslint-disable-next-line @typescript-eslint/naming-convention + snap_getLocale: {}, + }, + }), + }); + + const { request, close } = await installSnap(snapId, { + options: { + locale: 'nl', + }, + }); + + const response = await request({ + method: 'hello', + params: { + foo: 'bar', + }, + }); + + expect(response).toStrictEqual( + expect.objectContaining({ + response: { + result: 'nl', + }, + }), + ); + + // `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('allows specifying initial state', async () => { + jest.spyOn(console, 'log').mockImplementation(); + + const { snapId, close: closeServer } = await getMockServer({ + sourceCode: ` + module.exports.onRpcRequest = async (request) => { + return await snap.request({ + method: 'snap_manageState', + params: { + operation: 'get', + }, + }); + }; + `, + manifest: getSnapManifest({ + initialPermissions: { + // eslint-disable-next-line @typescript-eslint/naming-convention + snap_manageState: {}, + }, + }), + }); + + const { request, close } = await installSnap(snapId, { + options: { + state: { + foo: 'bar', + }, + }, + }); + + const response = await request({ + method: 'hello', + }); + + expect(response).toStrictEqual( + expect.objectContaining({ + response: { + result: { + foo: 'bar', + }, + }, + }), + ); + + // `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('works without options', async () => { + jest.spyOn(console, 'log').mockImplementation(); + + const { snapId, close: closeServer } = await getMockServer({ + sourceCode: ` + module.exports.onRpcRequest = async (request) => { + return 'Hello, world!'; + }; + `, + }); + + const { request, close } = await installSnap(snapId); + + const response = await request({ + method: 'hello', + }); + + expect(response).toStrictEqual( + expect.objectContaining({ + response: { + result: 'Hello, world!', + }, + }), + ); + + // `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('getHooks', () => { diff --git a/packages/snaps-simulation/src/simulation.ts b/packages/snaps-simulation/src/simulation.ts index 6d83ac424e..2559784d9d 100644 --- a/packages/snaps-simulation/src/simulation.ts +++ b/packages/snaps-simulation/src/simulation.ts @@ -31,6 +31,8 @@ import { select } from 'redux-saga/effects'; import type { RootControllerMessenger } from './controllers'; import { getControllers, registerSnap } from './controllers'; import { getSnapFile } from './files'; +import type { SnapHelpers } from './helpers'; +import { getHelpers } from './helpers'; import { resolveWithSaga } from './interface'; import { getEndowments } from './methods'; import { createJsonRpcEngine } from './middleware'; @@ -145,7 +147,7 @@ export async function installSnap< executionServiceOptions, options: rawOptions = {}, }: Partial> = {}, -): Promise { +): Promise { const options = getOptions(rawOptions); // Fetch Snap files. @@ -218,12 +220,22 @@ export async function installSnap< endowments: await getEndowments(permissionController, snapId), }); + const helpers = getHelpers({ + snapId, + store, + controllerMessenger, + runSaga, + executionService: service, + options, + }); + return { snapId, store, executionService: service, controllerMessenger, runSaga, + ...helpers, }; } diff --git a/packages/snaps-simulation/src/structs.ts b/packages/snaps-simulation/src/structs.ts index 1d19d6a67a..224d6c8c21 100644 --- a/packages/snaps-simulation/src/structs.ts +++ b/packages/snaps-simulation/src/structs.ts @@ -19,6 +19,7 @@ import { type, } from '@metamask/superstruct'; import { + assertStruct, bytesToHex, JsonStruct, StrictHexStruct, @@ -26,6 +27,8 @@ import { } from '@metamask/utils'; import { randomBytes } from 'crypto'; +import type { SnapResponse, SnapResponseWithInterface } from './types'; + // TODO: Export this from `@metamask/utils` instead. const BytesLikeStruct = union([ bigint(), @@ -240,3 +243,14 @@ export const SnapResponseStruct = union([ SnapResponseWithoutInterfaceStruct, SnapResponseWithInterfaceStruct, ]); + +/** + * Ensure that the actual response contains `getInterface`. + * + * @param response - The response of the handler. + */ +export function assertIsResponseWithInterface( + response: SnapResponse, +): asserts response is SnapResponseWithInterface { + assertStruct(response, SnapResponseWithInterfaceStruct); +}