From 51a1d04ea50c5c286262df1959ef0b1ced84b6e2 Mon Sep 17 00:00:00 2001 From: Guillaume Roux Date: Wed, 27 Mar 2024 13:20:44 +0100 Subject: [PATCH] [BREAKING] Support Interactive UI in `snaps-jest` (#2286) This adds new methods to support user interactions in `snaps-jest`. - Add `clickElement` method to allow a click simulation on an element. - Add `typeInField` method to allow field typing simulation. - Add those new methods to `getInteface` result for `snap_dialog`. - [BREAKING] Refactor the snap handler result object to remove the static `content` field and replace it with `getInterface`, this allows to get the interface after an update due to a user interaction. --- .../packages/home-page/src/index.test.ts | 4 +- .../packages/interactive-ui/src/index.test.ts | 107 ++++- .../signature-insights/src/index.test.ts | 4 +- .../transaction-insights/src/index.test.ts | 20 +- packages/snaps-jest/README.md | 107 ++++- packages/snaps-jest/src/helpers.test.ts | 8 +- packages/snaps-jest/src/helpers.ts | 41 +- .../snaps-jest/src/internals/request.test.ts | 197 ++++++++- packages/snaps-jest/src/internals/request.ts | 95 ++++- .../internals/simulation/interface.test.ts | 383 +++++++++++++++++- .../src/internals/simulation/interface.ts | 280 ++++++++++++- .../methods/hooks/interface.test.ts | 18 +- .../snaps-jest/src/internals/structs.test.ts | 121 +++++- packages/snaps-jest/src/internals/structs.ts | 60 +-- packages/snaps-jest/src/matchers.test.ts | 32 +- .../snaps-jest/src/test-utils/controller.ts | 6 + .../snaps-jest/src/test-utils/response.ts | 21 +- packages/snaps-jest/src/types.ts | 69 +++- packages/snaps-sdk/src/ui/index.ts | 1 + packages/snaps-sdk/src/ui/nodes.ts | 7 + packages/snaps-utils/coverage.json | 4 +- packages/snaps-utils/src/ui.test.ts | 24 +- packages/snaps-utils/src/ui.ts | 16 +- 23 files changed, 1464 insertions(+), 161 deletions(-) diff --git a/packages/examples/packages/home-page/src/index.test.ts b/packages/examples/packages/home-page/src/index.test.ts index 4c494a84d5..10872266fb 100644 --- a/packages/examples/packages/home-page/src/index.test.ts +++ b/packages/examples/packages/home-page/src/index.test.ts @@ -8,7 +8,9 @@ describe('onHomePage', () => { const response = await onHomePage(); - expect(response).toRender( + const screen = response.getInterface(); + + expect(screen).toRender( panel([heading('Hello world!'), text('Welcome to my Snap home page!')]), ); }); diff --git a/packages/examples/packages/interactive-ui/src/index.test.ts b/packages/examples/packages/interactive-ui/src/index.test.ts index 8c8a507a27..1e497d33b9 100644 --- a/packages/examples/packages/interactive-ui/src/index.test.ts +++ b/packages/examples/packages/interactive-ui/src/index.test.ts @@ -1,6 +1,17 @@ import { expect } from '@jest/globals'; import { installSnap } from '@metamask/snaps-jest'; -import { address, button, heading, panel, row } from '@metamask/snaps-sdk'; +import { + ButtonType, + address, + button, + copyable, + form, + heading, + input, + panel, + row, + text, +} from '@metamask/snaps-sdk'; import { assert } from '@metamask/utils'; describe('onRpcRequest', () => { @@ -30,17 +41,50 @@ describe('onRpcRequest', () => { method: 'dialog', }); - const ui = await response.getInterface(); - assert(ui.type === 'confirmation'); + const startScreen = await response.getInterface(); + assert(startScreen.type === 'confirmation'); - expect(ui).toRender( + expect(startScreen).toRender( panel([ heading('Interactive UI Example Snap'), button({ value: 'Update UI', name: 'update' }), ]), ); - await ui.ok(); + await startScreen.clickElement('update'); + + const formScreen = await response.getInterface(); + + expect(formScreen).toRender( + panel([ + heading('Interactive UI Example Snap'), + form({ + name: 'example-form', + children: [ + input({ + name: 'example-input', + placeholder: 'Enter something...', + }), + button('Submit', ButtonType.Submit, 'submit'), + ], + }), + ]), + ); + + await formScreen.typeInField('example-input', 'foobar'); + + await formScreen.clickElement('submit'); + + const resultScreen = await response.getInterface(); + + expect(resultScreen).toRender( + panel([ + heading('Interactive UI Example Snap'), + text('The submitted value is:'), + copyable('foobar'), + ]), + ); + await resultScreen.ok(); expect(await response).toRespondWith(true); }); @@ -73,12 +117,48 @@ describe('onHomePage', () => { const response = await onHomePage(); - expect(response).toRender( + const startScreen = response.getInterface(); + + expect(startScreen).toRender( panel([ heading('Interactive UI Example Snap'), button({ value: 'Update UI', name: 'update' }), ]), ); + + await startScreen.clickElement('update'); + + const formScreen = response.getInterface(); + + expect(formScreen).toRender( + panel([ + heading('Interactive UI Example Snap'), + form({ + name: 'example-form', + children: [ + input({ + name: 'example-input', + placeholder: 'Enter something...', + }), + button('Submit', ButtonType.Submit, 'submit'), + ], + }), + ]), + ); + + await formScreen.typeInField('example-input', 'foobar'); + + await formScreen.clickElement('submit'); + + const resultScreen = response.getInterface(); + + expect(resultScreen).toRender( + panel([ + heading('Interactive UI Example Snap'), + text('The submitted value is:'), + copyable('foobar'), + ]), + ); }); }); @@ -96,12 +176,25 @@ describe('onTransaction', () => { data: '0xa9059cbb00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000', }); - expect(response).toRender( + const startScreen = response.getInterface(); + + expect(startScreen).toRender( panel([ row('From', address(FROM_ADDRESS)), row('To', address(TO_ADDRESS)), button({ value: 'See transaction type', name: 'transaction-type' }), ]), ); + + await startScreen.clickElement('transaction-type'); + + const txTypeScreen = response.getInterface(); + + expect(txTypeScreen).toRender( + panel([ + row('Transaction type', text('ERC-20')), + button({ value: 'Go back', name: 'go-back' }), + ]), + ); }); }); diff --git a/packages/examples/packages/signature-insights/src/index.test.ts b/packages/examples/packages/signature-insights/src/index.test.ts index a616456486..0feddc4433 100644 --- a/packages/examples/packages/signature-insights/src/index.test.ts +++ b/packages/examples/packages/signature-insights/src/index.test.ts @@ -13,7 +13,9 @@ describe('onSignature', () => { data: '0x879a053d4800c6354e76c7985a865d2922c82fb5b3f4577b2fe08b998954f2e0', }); - expect(response).toRender( + const screen = response.getInterface(); + + expect(screen).toRender( panel([ row('From:', text('0xd8da6bf26964af9d7eed9e03e53415d37aa96045')), row( diff --git a/packages/examples/packages/transaction-insights/src/index.test.ts b/packages/examples/packages/transaction-insights/src/index.test.ts index f931497091..cb26ae56d2 100644 --- a/packages/examples/packages/transaction-insights/src/index.test.ts +++ b/packages/examples/packages/transaction-insights/src/index.test.ts @@ -17,7 +17,9 @@ describe('onTransaction', () => { data: '0xa9059cbb00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000', }); - expect(response).toRender( + const screen = response.getInterface(); + + expect(screen).toRender( panel([ row('From', address(FROM_ADDRESS)), row('To', address(TO_ADDRESS)), @@ -37,7 +39,9 @@ describe('onTransaction', () => { data: '0x23b872dd00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000', }); - expect(response).toRender( + const screen = response.getInterface(); + + expect(screen).toRender( panel([ row('From', address(FROM_ADDRESS)), row('To', address(TO_ADDRESS)), @@ -57,7 +61,9 @@ describe('onTransaction', () => { data: '0xf242432a00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000', }); - expect(response).toRender( + const screen = response.getInterface(); + + expect(screen).toRender( panel([ row('From', address(FROM_ADDRESS)), row('To', address(TO_ADDRESS)), @@ -75,7 +81,9 @@ describe('onTransaction', () => { data: '0xabcdef1200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000', }); - expect(response).toRender( + const screen = response.getInterface(); + + expect(screen).toRender( panel([ row('From', address(FROM_ADDRESS)), row('To', address(TO_ADDRESS)), @@ -93,7 +101,9 @@ describe('onTransaction', () => { data: '0x', }); - expect(response).toRender( + const screen = response.getInterface(); + + expect(screen).toRender( panel([ row('From', address(FROM_ADDRESS)), row('To', address(TO_ADDRESS)), diff --git a/packages/snaps-jest/README.md b/packages/snaps-jest/README.md index 08d4cbdd03..21b96188c5 100644 --- a/packages/snaps-jest/README.md +++ b/packages/snaps-jest/README.md @@ -194,7 +194,7 @@ All properties are optional, and have sensible defaults. The addresses are randomly generated by default. Most values can be specified as a hex string, or a decimal number. -It returns an object with the user interface that was shown by the snap, in the +It returns a `getInterface` function that gets the user interface that was shown by the snap, in the [onTransaction](https://docs.metamask.io/snaps/reference/exports/#ontransaction) function. @@ -214,7 +214,9 @@ describe('MySnap', () => { nonce: '0x0', }); - expect(response).toRender(panel([text('Hello, world!')])); + const screen = response.getInterface(); + + expect(screen).toRender(panel([text('Hello, world!')])); }); }); ``` @@ -233,7 +235,7 @@ All properties are optional, and have sensible defaults. The addresses are randomly generated by default. Most values can be specified as a hex string, or a decimal number. -It returns an object with the user interface that was shown by the snap, in the +It returns a `getInterface` function that gets the user interface that was shown by the snap, in the [onSignature](https://docs.metamask.io/snaps/reference/exports/#onsignature) function. @@ -246,7 +248,9 @@ describe('MySnap', () => { const { onSignature } = await installSnap(/* optional snap ID */); const response = await onSignature(); - expect(response).toRender( + const screen = response.getInterface(); + + expect(screen).toRender( panel([text('You are using the personal_sign method')]), ); }); @@ -303,7 +307,7 @@ describe('MySnap', () => { ### `snap.onHomePage` The `onHomePage` function can be used to request the home page of the snap. It -takes no arguments, and returns a promise that resolves to the response from the +takes no arguments, and returns a promise that contains a `getInterface` function to get the response from the [onHomePage](https://docs.metamask.io/snaps/reference/entry-points/#onhomepage) function. @@ -318,7 +322,9 @@ describe('MySnap', () => { params: [], }); - expect(response).toRender(/* ... */); + const screen = response.getInterface(); + + expect(screen).toRender(/* ... */); }); }); ``` @@ -344,6 +350,8 @@ assert that a response from a snap matches an expected value: ### Interacting with user interfaces +#### `snap_dialog` + If your snap uses `snap_dialog` to show user interfaces, you can use the `request.getInterface` function to interact with them. This method is present on the return value of the `snap.request` function. @@ -351,7 +359,7 @@ the return value of the `snap.request` function. It waits for the user interface to be shown, and returns an object with functions that can be used to interact with the user interface. -#### Example +##### Example ```js import { installSnap } from '@metamask/snaps-jest'; @@ -384,6 +392,91 @@ describe('MySnap', () => { }); ``` +#### handlers + +If your snap uses handlers that shows user interfaces (`onTransaction`, `onSignature`, `onHomePage`), you can use the +`response.getInterface` function to interact with them. This method is present on +the return value of the `snap.request` function. + +It returns an object with functions that can be used to interact with the user interface. + +##### Example + +```js +import { installSnap } from '@metamask/snaps-jest'; + +describe('MySnap', () => { + it('should do something', async () => { + const { onHomePage } = await installSnap(/* optional snap ID */); + const response = await onHomePage({ + method: 'foo', + params: [], + }); + + const screen = response.getInterface(); + + expect(screen).toRender(/* ... */); + }); +}); +``` + +### User interactions in user interfaces + +The object returned by the `getInterface` function exposes other functions to trigger user interactions in the user interface. + +- `clickElement(elementName)`: Click on a button inside the user interface. If the button with the given name does not exist in the interface this method will throw. +- `typeInField(elementName, valueToType)`: Enter a value in a field inside the user interface. If the input field with the given name des not exist in the interface this method will throw. + +#### Example + +```js +import { installSnap } from '@metamask/snaps-jest'; + +describe('MySnap', () => { + it('should do something', async () => { + const { onHomePage } = await installSnap(/* optional snap ID */); + const response = await onHomePage({ + method: 'foo', + params: [], + }); + + const screen = response.getInterface(); + + expect(screen).toRender(/* ... */); + + await screen.clickElement('myButton'); + + const screen = response.getInterface(); + + expect(screen).toRender(/* ... */); + }); +}); +``` + +```js +import { installSnap } from '@metamask/snaps-jest'; + +describe('MySnap', () => { + it('should do something', async () => { + const { onHomePage } = await installSnap(/* optional snap ID */); + const response = await onHomePage({ + method: 'foo', + params: [], + }); + + const screen = response.getInterface(); + + expect(screen).toRender(/* ... */); + + await screen.typeInField('myField', 'the value to type'); + + const screen = response.getInterface(); + + expect(screen).toRender(/* ... */); + }); +}); +``` + ## Options You can pass options to the test environment by adding a diff --git a/packages/snaps-jest/src/helpers.test.ts b/packages/snaps-jest/src/helpers.test.ts index eedf390009..2956a44553 100644 --- a/packages/snaps-jest/src/helpers.test.ts +++ b/packages/snaps-jest/src/helpers.test.ts @@ -403,6 +403,8 @@ describe('installSnap', () => { type: 'text', value: 'Hello, world!', }, + clickElement: expect.any(Function), + typeInField: expect.any(Function), ok: expect.any(Function), cancel: expect.any(Function), }); @@ -462,6 +464,8 @@ describe('installSnap', () => { type: 'text', value: 'Hello, world!', }, + clickElement: expect.any(Function), + typeInField: expect.any(Function), ok: expect.any(Function), cancel: expect.any(Function), }); @@ -520,6 +524,8 @@ describe('installSnap', () => { type: 'text', value: 'Hello, world!', }, + clickElement: expect.any(Function), + typeInField: expect.any(Function), ok: expect.any(Function), }); @@ -707,7 +713,7 @@ describe('installSnap', () => { expect(response).toStrictEqual( expect.objectContaining({ - content: { type: 'text', value: 'Hello, world!' }, + getInterface: expect.any(Function), }), ); diff --git a/packages/snaps-jest/src/helpers.ts b/packages/snaps-jest/src/helpers.ts index 0895a1b9a1..438c892dac 100644 --- a/packages/snaps-jest/src/helpers.ts +++ b/packages/snaps-jest/src/helpers.ts @@ -1,7 +1,7 @@ import type { AbstractExecutionService } from '@metamask/snaps-controllers'; import type { SnapId } from '@metamask/snaps-sdk'; import { HandlerType, logInfo } from '@metamask/snaps-utils'; -import { createModuleLogger } from '@metamask/utils'; +import { assertStruct, createModuleLogger } from '@metamask/utils'; import { create } from 'superstruct'; import { @@ -11,6 +11,7 @@ import { getEnvironment, JsonRpcMockOptionsStruct, SignatureOptionsStruct, + SnapResponseWithInterfaceStruct, } from './internals'; import type { InstallSnapOptions } from './internals'; import { @@ -18,6 +19,7 @@ import { removeJsonRpcMock, } from './internals/simulation/store/mocks'; import type { + SnapResponseWithInterface, CronjobOptions, JsonRpcMockOptions, Snap, @@ -49,6 +51,17 @@ 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 @@ -199,7 +212,7 @@ export async function installSnap< const onTransaction = async ( request: TransactionOptions, - ): Promise => { + ): Promise => { log('Sending transaction %o.', request); const { @@ -208,7 +221,7 @@ export async function installSnap< ...transaction } = create(request, TransactionOptionsStruct); - return handleRequest({ + const response = await handleRequest({ snapId: installedSnapId, store, executionService, @@ -224,6 +237,10 @@ export async function installSnap< }, }, }); + + assertIsResponseWithInterface(response); + + return response; }; const onCronjob = (request: CronjobOptions) => { @@ -258,7 +275,9 @@ export async function installSnap< onTransaction, sendTransaction: onTransaction, - onSignature: async (request: unknown): Promise => { + onSignature: async ( + request: unknown, + ): Promise => { log('Requesting signature %o.', request); const { origin: signatureOrigin, ...signature } = create( @@ -266,7 +285,7 @@ export async function installSnap< SignatureOptionsStruct, ); - return handleRequest({ + const response = await handleRequest({ snapId: installedSnapId, store, executionService, @@ -281,15 +300,19 @@ export async function installSnap< }, }, }); + + assertIsResponseWithInterface(response); + + return response; }, onCronjob, runCronjob: onCronjob, - onHomePage: async (): Promise => { + onHomePage: async (): Promise => { log('Rendering home page.'); - return handleRequest({ + const response = await handleRequest({ snapId: installedSnapId, store, executionService, @@ -300,6 +323,10 @@ export async function installSnap< method: '', }, }); + + assertIsResponseWithInterface(response); + + return response; }, mockJsonRpc(mock: JsonRpcMockOptions) { diff --git a/packages/snaps-jest/src/internals/request.test.ts b/packages/snaps-jest/src/internals/request.test.ts index ab4e67c587..8017baae81 100644 --- a/packages/snaps-jest/src/internals/request.test.ts +++ b/packages/snaps-jest/src/internals/request.test.ts @@ -1,14 +1,19 @@ import { SnapInterfaceController } from '@metamask/snaps-controllers'; import type { SnapId } from '@metamask/snaps-sdk'; -import { text } from '@metamask/snaps-sdk'; +import { UserInputEventType, button, input, text } from '@metamask/snaps-sdk'; import { HandlerType } from '@metamask/snaps-utils'; +import { MOCK_SNAP_ID } from '@metamask/snaps-utils/test-utils'; import { getMockServer, getRestrictedSnapInterfaceControllerMessenger, getRootControllerMessenger, } from '../test-utils'; -import { getContentFromResult, handleRequest } from './request'; +import { + getInterfaceApi, + getInterfaceFromResult, + handleRequest, +} from './request'; import { handleInstallSnap } from './simulation'; describe('handleRequest', () => { @@ -32,7 +37,6 @@ describe('handleRequest', () => { }); expect(response).toStrictEqual({ - content: undefined, id: expect.any(String), response: { result: 'Hello, world!', @@ -85,7 +89,7 @@ describe('handleRequest', () => { }, }, notifications: [], - content, + getInterface: expect.any(Function), }); await closeServer(); @@ -127,46 +131,199 @@ describe('handleRequest', () => { }); }); -describe('getContentFromResult', () => { - it('gets the content from the SnapInterfaceController if the result contains an ID', async () => { +describe('getInterfaceFromResult', () => { + const controllerMessenger = getRootControllerMessenger(); + // eslint-disable-next-line no-new + new SnapInterfaceController({ + messenger: + getRestrictedSnapInterfaceControllerMessenger(controllerMessenger), + }); + + it('returns the interface ID if the result includes it', async () => { + const result = await getInterfaceFromResult( + { id: 'foo' }, + MOCK_SNAP_ID, + controllerMessenger, + ); + + expect(result).toBe('foo'); + }); + + it('creates a new interface and returns its ID if the result contains content', async () => { + jest.spyOn(controllerMessenger, 'call'); + + const result = await getInterfaceFromResult( + { content: text('foo') }, + MOCK_SNAP_ID, + controllerMessenger, + ); + + expect(result).toStrictEqual(expect.any(String)); + + expect(controllerMessenger.call).toHaveBeenCalledWith( + 'SnapInterfaceController:createInterface', + MOCK_SNAP_ID, + text('foo'), + ); + }); +}); + +describe('getInterfaceApi', () => { + it('gets the content from the SnapInterfaceController if the result contains an interface ID', async () => { const controllerMessenger = getRootControllerMessenger(); const interfaceController = new SnapInterfaceController({ messenger: getRestrictedSnapInterfaceControllerMessenger(controllerMessenger), }); - - const snapId = 'foo' as SnapId; const content = text('foo'); - const id = await interfaceController.createInterface(snapId, content); + const id = await interfaceController.createInterface(MOCK_SNAP_ID, content); + + const getInterface = await getInterfaceApi( + { id }, + MOCK_SNAP_ID, + controllerMessenger, + ); + + expect(getInterface).toStrictEqual(expect.any(Function)); - const result = getContentFromResult({ id }, snapId, controllerMessenger); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const result = getInterface!(); - expect(result).toStrictEqual(content); + expect(result).toStrictEqual({ + content, + clickElement: expect.any(Function), + typeInField: expect.any(Function), + }); }); - it('gets the content from the result if the result contains the content', () => { + it('gets the content from the SnapInterfaceController if the result contains content', async () => { const controllerMessenger = getRootControllerMessenger(); - const snapId = 'foo' as SnapId; + // eslint-disable-next-line no-new + new SnapInterfaceController({ + messenger: + getRestrictedSnapInterfaceControllerMessenger(controllerMessenger), + }); + const content = text('foo'); - const result = getContentFromResult( + const getInterface = await getInterfaceApi( { content }, - snapId, + MOCK_SNAP_ID, controllerMessenger, ); - expect(result).toStrictEqual(content); + expect(getInterface).toStrictEqual(expect.any(Function)); + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const result = getInterface!(); + + expect(result).toStrictEqual({ + content, + clickElement: expect.any(Function), + typeInField: expect.any(Function), + }); }); - it('returns undefined if there is no content associated with the result', () => { + it('returns undefined if there is no interface ID associated with the result', async () => { const controllerMessenger = getRootControllerMessenger(); - const snapId = 'foo' as SnapId; - - const result = getContentFromResult({}, snapId, controllerMessenger); + const result = await getInterfaceApi({}, MOCK_SNAP_ID, controllerMessenger); expect(result).toBeUndefined(); }); + + it('sends the request to the snap when using `clickElement`', async () => { + const controllerMessenger = getRootControllerMessenger(); + + jest.spyOn(controllerMessenger, 'call'); + + // eslint-disable-next-line no-new + new SnapInterfaceController({ + messenger: + getRestrictedSnapInterfaceControllerMessenger(controllerMessenger), + }); + + const content = button({ value: 'foo', name: 'foo' }); + + const getInterface = await getInterfaceApi( + { content }, + MOCK_SNAP_ID, + controllerMessenger, + ); + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const snapInterface = getInterface!(); + + await snapInterface.clickElement('foo'); + + expect(controllerMessenger.call).toHaveBeenNthCalledWith( + 4, + 'ExecutionService:handleRpcRequest', + MOCK_SNAP_ID, + { + origin: '', + handler: HandlerType.OnUserInput, + request: { + jsonrpc: '2.0', + method: ' ', + params: { + event: { + type: UserInputEventType.ButtonClickEvent, + name: 'foo', + }, + id: expect.any(String), + }, + }, + }, + ); + }); + + it('sends the request to the snap when using `typeInField`', async () => { + const controllerMessenger = getRootControllerMessenger(); + + jest.spyOn(controllerMessenger, 'call'); + + // eslint-disable-next-line no-new + new SnapInterfaceController({ + messenger: + getRestrictedSnapInterfaceControllerMessenger(controllerMessenger), + }); + + const content = input('foo'); + + const getInterface = await getInterfaceApi( + { content }, + MOCK_SNAP_ID, + controllerMessenger, + ); + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const snapInterface = getInterface!(); + + await snapInterface.typeInField('foo', 'bar'); + + expect(controllerMessenger.call).toHaveBeenNthCalledWith( + 6, + 'ExecutionService:handleRpcRequest', + MOCK_SNAP_ID, + { + origin: '', + handler: HandlerType.OnUserInput, + request: { + jsonrpc: '2.0', + method: ' ', + params: { + event: { + type: UserInputEventType.InputChangeEvent, + name: 'foo', + value: 'bar', + }, + id: expect.any(String), + }, + }, + }, + ); + }); }); diff --git a/packages/snaps-jest/src/internals/request.ts b/packages/snaps-jest/src/internals/request.ts index 4fc3c8b812..c34222fbf8 100644 --- a/packages/snaps-jest/src/internals/request.ts +++ b/packages/snaps-jest/src/internals/request.ts @@ -5,11 +5,17 @@ import { unwrapError } from '@metamask/snaps-utils'; import { getSafeJson, hasProperty, isPlainObject } from '@metamask/utils'; import { nanoid } from '@reduxjs/toolkit'; -import type { RequestOptions, SnapRequest } from '../types'; +import type { + RequestOptions, + SnapHandlerInterface, + SnapRequest, +} from '../types'; import { clearNotifications, + clickElement, getInterface, getNotifications, + typeInField, } from './simulation'; import type { RunSagaFunction, Store } from './simulation'; import type { RootControllerMessenger } from './simulation/controllers'; @@ -63,11 +69,15 @@ export function handleRequest({ ...options, }, }) - .then((result) => { + .then(async (result) => { const notifications = getNotifications(store.getState()); store.dispatch(clearNotifications()); - const content = getContentFromResult(result, snapId, controllerMessenger); + const getInterfaceFn = await getInterfaceApi( + result, + snapId, + controllerMessenger, + ); return { id: String(id), @@ -75,7 +85,7 @@ export function handleRequest({ result: getSafeJson(result), }, notifications, - content, + ...(getInterfaceFn ? { getInterface: getInterfaceFn } : {}), }; }) .catch((error) => { @@ -103,28 +113,85 @@ export function handleRequest({ } /** - * Get the response content either from the SnapInterfaceController or the response object if there is one. + * Get the interface ID from the result if it's available or create a new interface if the result contains static components. * * @param result - The handler result object. * @param snapId - The Snap ID. * @param controllerMessenger - The controller messenger. - * @returns The content components if any. + * @returns The interface ID or undefined if the result doesn't include content. */ -export function getContentFromResult( +export async function getInterfaceFromResult( result: unknown, snapId: SnapId, controllerMessenger: RootControllerMessenger, -): Component | undefined { +) { if (isPlainObject(result) && hasProperty(result, 'id')) { - return controllerMessenger.call( - 'SnapInterfaceController:getInterface', - snapId, - result.id as string, - ).content; + return result.id as string; } if (isPlainObject(result) && hasProperty(result, 'content')) { - return result.content as Component; + const id = await controllerMessenger.call( + 'SnapInterfaceController:createInterface', + snapId, + result.content as Component, + ); + + return id; + } + + return undefined; +} + +/** + * Get the response content from the SnapInterfaceController and include the interaction methods. + * + * @param result - The handler result object. + * @param snapId - The Snap ID. + * @param controllerMessenger - The controller messenger. + * @returns The content components if any. + */ +export async function getInterfaceApi( + result: unknown, + snapId: SnapId, + controllerMessenger: RootControllerMessenger, +): Promise<(() => SnapHandlerInterface) | undefined> { + const interfaceId = await getInterfaceFromResult( + result, + snapId, + controllerMessenger, + ); + + if (interfaceId) { + return () => { + const { content } = controllerMessenger.call( + 'SnapInterfaceController:getInterface', + snapId, + interfaceId, + ); + + return { + content, + clickElement: async (name) => { + await clickElement( + controllerMessenger, + interfaceId, + content, + snapId, + name, + ); + }, + typeInField: async (name, value) => { + await typeInField( + controllerMessenger, + interfaceId, + content, + snapId, + name, + value, + ); + }, + }; + }; } return undefined; diff --git a/packages/snaps-jest/src/internals/simulation/interface.test.ts b/packages/snaps-jest/src/internals/simulation/interface.test.ts index 872bed543d..236bc953d8 100644 --- a/packages/snaps-jest/src/internals/simulation/interface.test.ts +++ b/packages/snaps-jest/src/internals/simulation/interface.test.ts @@ -1,6 +1,16 @@ import { SnapInterfaceController } from '@metamask/snaps-controllers'; -import type { SnapId } from '@metamask/snaps-sdk'; -import { DialogType, text } from '@metamask/snaps-sdk'; +import { + ButtonType, + DialogType, + UserInputEventType, + button, + form, + input, + panel, + text, +} from '@metamask/snaps-sdk'; +import { HandlerType } from '@metamask/snaps-utils'; +import { MOCK_SNAP_ID } from '@metamask/snaps-utils/test-utils'; import { assert } from '@metamask/utils'; import type { SagaIterator } from 'redux-saga'; import { take } from 'redux-saga/effects'; @@ -10,7 +20,14 @@ import { getRestrictedSnapInterfaceControllerMessenger, getRootControllerMessenger, } from '../../test-utils'; -import { getInterface, getInterfaceResponse } from './interface'; +import { + clickElement, + getElement, + getInterface, + getInterfaceResponse, + mergeValue, + typeInField, +} from './interface'; import type { RunSagaFunction } from './store'; import { createStore, resolveInterface, setInterface } from './store'; @@ -29,17 +46,21 @@ async function getResolve(runSaga: RunSagaFunction) { } describe('getInterfaceResponse', () => { + const interfaceActions = { clickElement: jest.fn(), typeInField: jest.fn() }; it('returns an `ok` function that resolves the user interface with `null` for alert dialogs', async () => { const { runSaga } = createStore('password', getMockOptions()); const response = getInterfaceResponse( runSaga, DialogType.Alert, text('foo'), + interfaceActions, ); expect(response).toStrictEqual({ type: DialogType.Alert, content: text('foo'), + clickElement: expect.any(Function), + typeInField: expect.any(Function), ok: expect.any(Function), }); @@ -54,11 +75,14 @@ describe('getInterfaceResponse', () => { runSaga, DialogType.Confirmation, text('foo'), + interfaceActions, ); expect(response).toStrictEqual({ type: DialogType.Confirmation, content: text('foo'), + clickElement: expect.any(Function), + typeInField: expect.any(Function), ok: expect.any(Function), cancel: expect.any(Function), }); @@ -74,12 +98,15 @@ describe('getInterfaceResponse', () => { runSaga, DialogType.Confirmation, text('foo'), + interfaceActions, ); assert(response.type === DialogType.Confirmation); expect(response).toStrictEqual({ type: DialogType.Confirmation, content: text('foo'), + clickElement: expect.any(Function), + typeInField: expect.any(Function), ok: expect.any(Function), cancel: expect.any(Function), }); @@ -95,11 +122,14 @@ describe('getInterfaceResponse', () => { runSaga, DialogType.Prompt, text('foo'), + interfaceActions, ); expect(response).toStrictEqual({ type: DialogType.Prompt, content: text('foo'), + clickElement: expect.any(Function), + typeInField: expect.any(Function), ok: expect.any(Function), cancel: expect.any(Function), }); @@ -115,11 +145,14 @@ describe('getInterfaceResponse', () => { runSaga, DialogType.Prompt, text('foo'), + interfaceActions, ); expect(response).toStrictEqual({ type: DialogType.Prompt, content: text('foo'), + clickElement: expect.any(Function), + typeInField: expect.any(Function), ok: expect.any(Function), cancel: expect.any(Function), }); @@ -135,12 +168,15 @@ describe('getInterfaceResponse', () => { runSaga, DialogType.Prompt, text('foo'), + interfaceActions, ); assert(response.type === DialogType.Prompt); expect(response).toStrictEqual({ type: DialogType.Prompt, content: text('foo'), + clickElement: expect.any(Function), + typeInField: expect.any(Function), ok: expect.any(Function), cancel: expect.any(Function), }); @@ -160,6 +196,249 @@ describe('getInterfaceResponse', () => { }); }); +describe('getElement', () => { + it('gets an element at the root', () => { + const content = button({ value: 'foo', name: 'bar' }); + + const result = getElement(content, 'bar'); + + expect(result).toStrictEqual({ + element: button({ value: 'foo', name: 'bar' }), + }); + }); + + it('gets an element with a given name inside a panel', () => { + const content = panel([button({ value: 'foo', name: 'bar' })]); + + const result = getElement(content, 'bar'); + + expect(result).toStrictEqual({ + element: button({ value: 'foo', name: 'bar' }), + form: undefined, + }); + }); + + it('gets an element in a form', () => { + const content = form('foo', [button({ value: 'foo', name: 'bar' })]); + + const result = getElement(content, 'bar'); + + expect(result).toStrictEqual({ + element: button({ value: 'foo', name: 'bar' }), + form: 'foo', + }); + }); +}); + +describe('clickElement', () => { + const rootControllerMessenger = getRootControllerMessenger(); + const controllerMessenger = getRestrictedSnapInterfaceControllerMessenger( + rootControllerMessenger, + ); + + const interfaceController = new SnapInterfaceController({ + messenger: controllerMessenger, + }); + + const handleRpcRequestMock = jest.fn(); + + rootControllerMessenger.registerActionHandler( + 'ExecutionService:handleRpcRequest', + handleRpcRequestMock, + ); + + it('sends a ButtonClickEvent to the snap', async () => { + const content = button({ value: 'foo', name: 'bar' }); + + const interfaceId = await interfaceController.createInterface( + MOCK_SNAP_ID, + content, + ); + + await clickElement( + rootControllerMessenger, + interfaceId, + content, + MOCK_SNAP_ID, + 'bar', + ); + + expect(handleRpcRequestMock).toHaveBeenCalledWith(MOCK_SNAP_ID, { + origin: '', + handler: HandlerType.OnUserInput, + request: { + jsonrpc: '2.0', + method: ' ', + params: { + event: { + type: UserInputEventType.ButtonClickEvent, + name: 'bar', + }, + id: interfaceId, + }, + }, + }); + }); + + it('sends a FormSubmitEvent to the snap', async () => { + const content = form('bar', [ + input({ value: 'foo', name: 'foo' }), + button({ value: 'baz', name: 'baz', buttonType: ButtonType.Submit }), + ]); + + const interfaceId = await interfaceController.createInterface( + MOCK_SNAP_ID, + content, + ); + + await clickElement( + rootControllerMessenger, + interfaceId, + content, + MOCK_SNAP_ID, + 'baz', + ); + + expect(handleRpcRequestMock).toHaveBeenCalledWith(MOCK_SNAP_ID, { + origin: '', + handler: HandlerType.OnUserInput, + request: { + jsonrpc: '2.0', + method: ' ', + params: { + event: { + type: UserInputEventType.FormSubmitEvent, + name: 'bar', + value: { + foo: 'foo', + }, + }, + id: interfaceId, + }, + }, + }); + }); + + it('throws if there is no button with the given name in the interface', async () => { + const content = button({ value: 'foo', name: 'foo' }); + + const interfaceId = await interfaceController.createInterface( + MOCK_SNAP_ID, + content, + ); + + await expect( + clickElement( + rootControllerMessenger, + interfaceId, + content, + MOCK_SNAP_ID, + 'baz', + ), + ).rejects.toThrow('No button found in the interface.'); + + expect(handleRpcRequestMock).not.toHaveBeenCalled(); + }); +}); + +describe('mergeValue', () => { + it('merges a value outside of a form', () => { + const state = { foo: 'bar' }; + + const result = mergeValue(state, 'foo', 'baz'); + + expect(result).toStrictEqual({ foo: 'baz' }); + }); + + it('merges a value inside of a form', () => { + const state = { foo: { bar: 'baz' } }; + + const result = mergeValue(state, 'bar', 'test', 'foo'); + + expect(result).toStrictEqual({ foo: { bar: 'test' } }); + }); +}); + +describe('typeInField', () => { + const rootControllerMessenger = getRootControllerMessenger(); + const controllerMessenger = getRestrictedSnapInterfaceControllerMessenger( + rootControllerMessenger, + ); + + const interfaceController = new SnapInterfaceController({ + messenger: controllerMessenger, + }); + + const handleRpcRequestMock = jest.fn(); + + rootControllerMessenger.registerActionHandler( + 'ExecutionService:handleRpcRequest', + handleRpcRequestMock, + ); + it('updates the interface state and sends an InputChangeEvent', async () => { + jest.spyOn(rootControllerMessenger, 'call'); + + const content = input('bar'); + + const interfaceId = await interfaceController.createInterface( + MOCK_SNAP_ID, + content, + ); + + await typeInField( + rootControllerMessenger, + interfaceId, + content, + MOCK_SNAP_ID, + 'bar', + 'baz', + ); + + expect(rootControllerMessenger.call).toHaveBeenCalledWith( + 'SnapInterfaceController:updateInterfaceState', + interfaceId, + { bar: 'baz' }, + ); + + expect(handleRpcRequestMock).toHaveBeenCalledWith(MOCK_SNAP_ID, { + origin: '', + handler: HandlerType.OnUserInput, + request: { + jsonrpc: '2.0', + method: ' ', + params: { + event: { + type: UserInputEventType.InputChangeEvent, + name: 'bar', + value: 'baz', + }, + id: interfaceId, + }, + }, + }); + }); + + it('throws if there is no inputs in the interface', async () => { + const content = text('bar'); + + const interfaceId = await interfaceController.createInterface( + MOCK_SNAP_ID, + content, + ); + + await expect( + typeInField( + rootControllerMessenger, + interfaceId, + content, + MOCK_SNAP_ID, + 'bar', + 'baz', + ), + ).rejects.toThrow('No input found in the interface.'); + }); +}); + describe('getInterface', () => { const rootControllerMessenger = getRootControllerMessenger(); const controllerMessenger = getRestrictedSnapInterfaceControllerMessenger( @@ -172,9 +451,8 @@ describe('getInterface', () => { it('returns the current user interface, if any', async () => { const { store, runSaga } = createStore('password', getMockOptions()); - const snapId = 'foo' as SnapId; const content = text('foo'); - const id = await interfaceController.createInterface(snapId, content); + const id = await interfaceController.createInterface(MOCK_SNAP_ID, content); const type = DialogType.Alert; const ui = { type, id }; @@ -183,12 +461,14 @@ describe('getInterface', () => { const result = await runSaga( getInterface, runSaga, - snapId, + MOCK_SNAP_ID, rootControllerMessenger, ).toPromise(); expect(result).toStrictEqual({ type, content, + clickElement: expect.any(Function), + typeInField: expect.any(Function), ok: expect.any(Function), }); }); @@ -196,17 +476,15 @@ describe('getInterface', () => { it('waits for a user interface to be set if none is currently set', async () => { const { store, runSaga } = createStore('password', getMockOptions()); - const snapId = 'foo' as SnapId; - const promise = runSaga( getInterface, runSaga, - snapId, + MOCK_SNAP_ID, rootControllerMessenger, ).toPromise(); const content = text('foo'); - const id = await interfaceController.createInterface(snapId, content); + const id = await interfaceController.createInterface(MOCK_SNAP_ID, content); const type = DialogType.Alert; const ui = { type, id }; store.dispatch(setInterface(ui)); @@ -215,7 +493,92 @@ describe('getInterface', () => { expect(result).toStrictEqual({ type, content, + clickElement: expect.any(Function), + typeInField: expect.any(Function), ok: expect.any(Function), }); }); + + it('sends a request to the snap when `clickElement` is called', async () => { + jest.spyOn(rootControllerMessenger, 'call'); + const { store, runSaga } = createStore('password', getMockOptions()); + + const content = button({ value: 'foo', name: 'foo' }); + const id = await interfaceController.createInterface(MOCK_SNAP_ID, content); + const type = DialogType.Alert; + const ui = { type, id }; + + store.dispatch(setInterface(ui)); + + const result = await runSaga( + getInterface, + runSaga, + MOCK_SNAP_ID, + rootControllerMessenger, + ).toPromise(); + + await result.clickElement('foo'); + + expect(rootControllerMessenger.call).toHaveBeenCalledWith( + 'ExecutionService:handleRpcRequest', + MOCK_SNAP_ID, + { + origin: '', + handler: HandlerType.OnUserInput, + request: { + jsonrpc: '2.0', + method: ' ', + params: { + event: { + type: UserInputEventType.ButtonClickEvent, + name: 'foo', + }, + id, + }, + }, + }, + ); + }); + + it('sends a request to the snap when `typeInField` is called', async () => { + jest.spyOn(rootControllerMessenger, 'call'); + const { store, runSaga } = createStore('password', getMockOptions()); + + const content = input('foo'); + const id = await interfaceController.createInterface(MOCK_SNAP_ID, content); + const type = DialogType.Alert; + const ui = { type, id }; + + store.dispatch(setInterface(ui)); + + const result = await runSaga( + getInterface, + runSaga, + MOCK_SNAP_ID, + rootControllerMessenger, + ).toPromise(); + + await result.typeInField('foo', 'bar'); + + expect(rootControllerMessenger.call).toHaveBeenCalledWith( + 'ExecutionService:handleRpcRequest', + MOCK_SNAP_ID, + { + origin: '', + handler: HandlerType.OnUserInput, + request: { + jsonrpc: '2.0', + method: ' ', + params: { + event: { + type: UserInputEventType.InputChangeEvent, + name: 'foo', + value: 'bar', + }, + id, + }, + }, + }, + ); + }); }); diff --git a/packages/snaps-jest/src/internals/simulation/interface.ts b/packages/snaps-jest/src/internals/simulation/interface.ts index aa49baf38d..edbef3b28b 100644 --- a/packages/snaps-jest/src/internals/simulation/interface.ts +++ b/packages/snaps-jest/src/internals/simulation/interface.ts @@ -1,10 +1,24 @@ -import type { Component, SnapId } from '@metamask/snaps-sdk'; -import { DialogType } from '@metamask/snaps-sdk'; +import type { + Button, + Component, + FormState, + Input, + InterfaceState, + SnapId, +} from '@metamask/snaps-sdk'; +import { + ButtonType, + DialogType, + NodeType, + UserInputEventType, + assert, +} from '@metamask/snaps-sdk'; +import { HandlerType, hasChildren } from '@metamask/snaps-utils'; import type { PayloadAction } from '@reduxjs/toolkit'; -import type { SagaIterator } from 'redux-saga'; -import { put, select, take } from 'redux-saga/effects'; +import { type SagaIterator } from 'redux-saga'; +import { call, put, select, take } from 'redux-saga/effects'; -import type { SnapInterface } from '../../types'; +import type { SnapInterface, SnapInterfaceActions } from '../../types'; import type { RootControllerMessenger } from './controllers'; import type { Interface, RunSagaFunction } from './store'; import { getCurrentInterface, resolveInterface, setInterface } from './store'; @@ -15,16 +29,19 @@ import { getCurrentInterface, resolveInterface, setInterface } from './store'; * @param runSaga - A function to run a saga outside the usual Redux flow. * @param type - The type of the interface. * @param content - The content to show in the interface. + * @param interfaceActions - The actions to interact with the interface. * @returns The user interface object. */ export function getInterfaceResponse( runSaga: RunSagaFunction, type: DialogType, content: Component, + interfaceActions: SnapInterfaceActions, ): SnapInterface { switch (type) { case DialogType.Alert: return { + ...interfaceActions, type, content, ok: resolveWith(runSaga, null), @@ -32,6 +49,7 @@ export function getInterfaceResponse( case DialogType.Confirmation: return { + ...interfaceActions, type, content, @@ -41,6 +59,7 @@ export function getInterfaceResponse( case DialogType.Prompt: return { + ...interfaceActions, type, content, @@ -100,37 +119,268 @@ function resolveWithInput(runSaga: RunSagaFunction) { } /** - * Get a user interface object from a Snap. + * Get the stored user interface from the store. * - * @param runSaga - A function to run a saga outside the usual Redux flow. - * @param snapId - The Snap ID. * @param controllerMessenger - The controller messenger used to call actions. + * @param snapId - The Snap ID. * @yields Takes the set interface action. * @returns The user interface object. */ -export function* getInterface( - runSaga: RunSagaFunction, - snapId: SnapId, +function* getStoredInterface( controllerMessenger: RootControllerMessenger, -): SagaIterator { + snapId: SnapId, +): SagaIterator { const currentInterface: Interface | null = yield select(getCurrentInterface); + if (currentInterface) { const { content } = controllerMessenger.call( 'SnapInterfaceController:getInterface', snapId, currentInterface.id, ); - return getInterfaceResponse(runSaga, currentInterface.type, content); + + return { ...currentInterface, content }; } const { payload }: PayloadAction = yield take(setInterface.type); - const { type, id } = payload; const { content } = controllerMessenger.call( 'SnapInterfaceController:getInterface', snapId, + payload.id, + ); + + return { ...payload, content }; +} + +/** + * Get a Button or an Input from an interface. + * + * @param content - The interface content. + * @param name - The element name. + * @returns An object containing the element and the form name if it's contained in a form, otherwise undefined. + */ +export function getElement( + content: Component, + name: string, +): + | { + element: Button | Input; + form?: string; + } + | undefined { + const { type } = content; + + if ( + (type === NodeType.Button || type === NodeType.Input) && + content.name === name + ) { + return { element: content }; + } + + if (hasChildren(content)) { + for (const element of content.children) { + const result = getElement(element, name); + const form = type === NodeType.Form ? content.name : result?.form; + + if (result) { + return { element: result.element, form }; + } + } + } + + return undefined; +} + +/** + * Click on an element of the Snap interface. + * + * @param controllerMessenger - The controller messenger used to call actions. + * @param id - The interface ID. + * @param content - The interface content. + * @param snapId - The Snap ID. + * @param name - The element name. + */ +export async function clickElement( + controllerMessenger: RootControllerMessenger, + id: string, + content: Component, + snapId: SnapId, + name: string, +): Promise { + const result = getElement(content, name); + assert( + result !== undefined && result.element.type === NodeType.Button, + 'No button found in the interface.', + ); + + if (result.form && result.element.buttonType === ButtonType.Submit) { + const { state } = controllerMessenger.call( + 'SnapInterfaceController:getInterface', + snapId, + id, + ); + + await controllerMessenger.call( + 'ExecutionService:handleRpcRequest', + snapId, + { + origin: '', + handler: HandlerType.OnUserInput, + request: { + jsonrpc: '2.0', + method: ' ', + params: { + event: { + type: UserInputEventType.FormSubmitEvent, + name: result.form, + value: state[result.form], + }, + id, + }, + }, + }, + ); + + return; + } + + if (result.element.buttonType !== ButtonType.Submit) { + await controllerMessenger.call( + 'ExecutionService:handleRpcRequest', + snapId, + { + origin: '', + handler: HandlerType.OnUserInput, + request: { + jsonrpc: '2.0', + method: ' ', + params: { + event: { + type: UserInputEventType.ButtonClickEvent, + name: result.element.name, + }, + id, + }, + }, + }, + ); + } +} + +/** + * Merge a value in the interface state. + * + * @param state - The actual interface state. + * @param name - The component name that changed value. + * @param value - The new value. + * @param form - The form name if the element is in one. + * @returns The state with the merged value. + */ +export function mergeValue( + state: InterfaceState, + name: string, + value: string | null, + form?: string, +): InterfaceState { + if (form) { + return { + ...state, + [form]: { + ...(state[form] as FormState), + [name]: value, + }, + }; + } + + return { ...state, [name]: value }; +} + +/** + * Type a value in an interface element. + * + * @param controllerMessenger - The controller messenger used to call actions. + * @param id - The interface ID. + * @param content - The interface Components. + * @param snapId - The Snap ID. + * @param name - The element name. + * @param value - The value to type in the element. + */ +export async function typeInField( + controllerMessenger: RootControllerMessenger, + id: string, + content: Component, + snapId: SnapId, + name: string, + value: string, +) { + const result = getElement(content, name); + + assert( + result !== undefined && result.element.type === NodeType.Input, + 'No input found in the interface.', + ); + + const { state } = controllerMessenger.call( + 'SnapInterfaceController:getInterface', + snapId, + id, + ); + + const newState = mergeValue(state, name, value, result.form); + + controllerMessenger.call( + 'SnapInterfaceController:updateInterfaceState', id, + newState, + ); + + await controllerMessenger.call('ExecutionService:handleRpcRequest', snapId, { + origin: '', + handler: HandlerType.OnUserInput, + request: { + jsonrpc: '2.0', + method: ' ', + params: { + event: { + type: UserInputEventType.InputChangeEvent, + name: result.element.name, + value, + }, + id, + }, + }, + }); +} + +/** + * Get a user interface object from a Snap. + * + * @param runSaga - A function to run a saga outside the usual Redux flow. + * @param snapId - The Snap ID. + * @param controllerMessenger - The controller messenger used to call actions. + * @yields Takes the set interface action. + * @returns The user interface object. + */ +export function* getInterface( + runSaga: RunSagaFunction, + snapId: SnapId, + controllerMessenger: RootControllerMessenger, +): SagaIterator { + const { type, id, content } = yield call( + getStoredInterface, + controllerMessenger, + snapId, ); - return getInterfaceResponse(runSaga, type, content); + const interfaceActions = { + clickElement: async (name: string) => { + await clickElement(controllerMessenger, id, content, snapId, name); + }, + typeInField: async (name: string, value: string) => { + await typeInField(controllerMessenger, id, content, snapId, name, value); + }, + }; + + return getInterfaceResponse(runSaga, type, content, interfaceActions); } diff --git a/packages/snaps-jest/src/internals/simulation/methods/hooks/interface.test.ts b/packages/snaps-jest/src/internals/simulation/methods/hooks/interface.test.ts index e794a7e857..81ed8d2c55 100644 --- a/packages/snaps-jest/src/internals/simulation/methods/hooks/interface.test.ts +++ b/packages/snaps-jest/src/internals/simulation/methods/hooks/interface.test.ts @@ -1,6 +1,6 @@ import { SnapInterfaceController } from '@metamask/snaps-controllers'; -import type { SnapId } from '@metamask/snaps-sdk'; import { text } from '@metamask/snaps-sdk'; +import { MOCK_SNAP_ID } from '@metamask/snaps-utils/test-utils'; import { getRestrictedSnapInterfaceControllerMessenger, @@ -24,16 +24,15 @@ describe('getCreateInterfaceImplementation', () => { const fn = getCreateInterfaceImplementation(controllerMessenger); - const snapId = 'foo' as SnapId; const content = text('bar'); - const id = await fn(snapId, content); + const id = await fn(MOCK_SNAP_ID, content); - const result = interfaceController.getInterface(snapId, id); + const result = interfaceController.getInterface(MOCK_SNAP_ID, id); expect(controllerMessenger.call).toHaveBeenCalledWith( 'SnapInterfaceController:createInterface', - snapId, + MOCK_SNAP_ID, content, ); expect(result.content).toStrictEqual(content); @@ -53,18 +52,17 @@ describe('getGetInterfaceImplementation', () => { const fn = getGetInterfaceImplementation(controllerMessenger); - const snapId = 'foo' as SnapId; const content = text('bar'); - const id = await interfaceController.createInterface(snapId, content); + const id = await interfaceController.createInterface(MOCK_SNAP_ID, content); - const result = fn(snapId, id); + const result = fn(MOCK_SNAP_ID, id); expect(controllerMessenger.call).toHaveBeenCalledWith( 'SnapInterfaceController:getInterface', - snapId, + MOCK_SNAP_ID, id, ); - expect(result).toStrictEqual({ content, state: {}, snapId }); + expect(result).toStrictEqual({ content, state: {}, snapId: MOCK_SNAP_ID }); }); }); diff --git a/packages/snaps-jest/src/internals/structs.test.ts b/packages/snaps-jest/src/internals/structs.test.ts index 700d0736a2..f27d6720ea 100644 --- a/packages/snaps-jest/src/internals/structs.test.ts +++ b/packages/snaps-jest/src/internals/structs.test.ts @@ -7,6 +7,8 @@ import { SignatureOptionsStruct, SnapOptionsStruct, SnapResponseStruct, + SnapResponseWithInterfaceStruct, + SnapResponseWithoutInterfaceStruct, TransactionOptionsStruct, } from './structs'; @@ -202,7 +204,7 @@ describe('InterfaceStruct', () => { }); }); -describe('SnapResponseStruct', () => { +describe('SnapResponseWithInterfaceStruct', () => { it('accepts a valid object', () => { const options = create( { @@ -217,8 +219,9 @@ describe('SnapResponseStruct', () => { message: 'Hello, world!', }, ], + getInterface: () => undefined, }, - SnapResponseStruct, + SnapResponseWithInterfaceStruct, ); expect(options).toStrictEqual({ @@ -233,6 +236,120 @@ describe('SnapResponseStruct', () => { message: 'Hello, world!', }, ], + getInterface: expect.any(Function), + }); + }); + + it.each(INVALID_VALUES)('throws for invalid value: %p', (value) => { + // eslint-disable-next-line jest/require-to-throw-message + expect(() => create(value, SnapResponseWithInterfaceStruct)).toThrow(); + }); +}); + +describe('SnapResponseWithoutInterfaceStruct', () => { + it('accepts a valid object', () => { + const options = create( + { + id: '1', + response: { + result: '0x1', + }, + notifications: [ + { + id: '1', + type: 'native', + message: 'Hello, world!', + }, + ], + }, + SnapResponseWithoutInterfaceStruct, + ); + + expect(options).toStrictEqual({ + id: '1', + response: { + result: '0x1', + }, + notifications: [ + { + id: '1', + type: 'native', + message: 'Hello, world!', + }, + ], + }); + }); + + it.each(INVALID_VALUES)('throws for invalid value: %p', (value) => { + // eslint-disable-next-line jest/require-to-throw-message + expect(() => create(value, SnapResponseWithoutInterfaceStruct)).toThrow(); + }); +}); + +describe('SnapResponseStruct', () => { + it('accepts a valid object', () => { + const optionsWithInterface = create( + { + id: '1', + response: { + result: '0x1', + }, + notifications: [ + { + id: '1', + type: 'native', + message: 'Hello, world!', + }, + ], + getInterface: () => undefined, + }, + SnapResponseStruct, + ); + + expect(optionsWithInterface).toStrictEqual({ + id: '1', + response: { + result: '0x1', + }, + notifications: [ + { + id: '1', + type: 'native', + message: 'Hello, world!', + }, + ], + getInterface: expect.any(Function), + }); + + const optionsWithoutInterface = create( + { + id: '1', + response: { + result: '0x1', + }, + notifications: [ + { + id: '1', + type: 'native', + message: 'Hello, world!', + }, + ], + }, + SnapResponseStruct, + ); + + expect(optionsWithoutInterface).toStrictEqual({ + id: '1', + response: { + result: '0x1', + }, + notifications: [ + { + id: '1', + type: 'native', + message: 'Hello, world!', + }, + ], }); }); diff --git a/packages/snaps-jest/src/internals/structs.ts b/packages/snaps-jest/src/internals/structs.ts index 61a0fa37df..a1dacb2754 100644 --- a/packages/snaps-jest/src/internals/structs.ts +++ b/packages/snaps-jest/src/internals/structs.ts @@ -1,6 +1,6 @@ import { - NotificationType, ComponentStruct, + NotificationType, enumValue, } from '@metamask/snaps-sdk'; import { @@ -22,10 +22,11 @@ import { object, optional, string, - type, union, record, any, + func, + type, } from 'superstruct'; // TODO: Export this from `@metamask/utils` instead. @@ -207,29 +208,38 @@ export const InterfaceStruct = type({ content: optional(ComponentStruct), }); -export const SnapResponseStruct = assign( - InterfaceStruct, - object({ - id: string(), - - response: union([ - object({ - result: JsonStruct, - }), - object({ - error: JsonStruct, - }), - ]), +export const SnapResponseWithoutInterfaceStruct = object({ + id: string(), + + response: union([ + object({ + result: JsonStruct, + }), + object({ + error: JsonStruct, + }), + ]), + + notifications: array( + object({ + id: string(), + message: string(), + type: union([ + enumValue(NotificationType.InApp), + enumValue(NotificationType.Native), + ]), + }), + ), +}); - notifications: array( - object({ - id: string(), - message: string(), - type: union([ - enumValue(NotificationType.InApp), - enumValue(NotificationType.Native), - ]), - }), - ), +export const SnapResponseWithInterfaceStruct = assign( + SnapResponseWithoutInterfaceStruct, + object({ + getInterface: func(), }), ); + +export const SnapResponseStruct = union([ + SnapResponseWithoutInterfaceStruct, + SnapResponseWithInterfaceStruct, +]); diff --git a/packages/snaps-jest/src/matchers.test.ts b/packages/snaps-jest/src/matchers.test.ts index 9fa035bbb9..618ba158a7 100644 --- a/packages/snaps-jest/src/matchers.test.ts +++ b/packages/snaps-jest/src/matchers.test.ts @@ -7,7 +7,7 @@ import { toRespondWithError, toSendNotification, } from './matchers'; -import { getMockResponse } from './test-utils'; +import { getMockInterfaceResponse, getMockResponse } from './test-utils'; expect.extend({ toRespondWith, @@ -284,30 +284,26 @@ describe('toSendNotification', () => { describe('toRender', () => { it('passes when the component is correct', () => { - expect( - getMockResponse({ - content: panel([text('Hello, world!')]), - }), - ).toRender(panel([text('Hello, world!')])); + expect(getMockInterfaceResponse(panel([text('Hello, world!')]))).toRender( + panel([text('Hello, world!')]), + ); }); it('fails when the component is incorrect', () => { expect(() => - expect( - getMockResponse({ - content: panel([text('Hello, world!')]), - }), - ).toRender(panel([text('Hello, world?')])), + expect(getMockInterfaceResponse(panel([text('Hello, world!')]))).toRender( + panel([text('Hello, world?')]), + ), ).toThrow('Received:'); }); it('fails when the component is missing', () => { expect(() => expect( - getMockResponse({ + getMockInterfaceResponse( // @ts-expect-error - Invalid response. - content: null, - }), + null, + ), ).not.toRender(panel([text('Hello, world!')])), ).toThrow('Received has type:'); }); @@ -315,18 +311,14 @@ describe('toRender', () => { describe('not', () => { it('passes when the component is correct', () => { expect( - getMockResponse({ - content: panel([text('Hello, world!')]), - }), + getMockInterfaceResponse(panel([text('Hello, world!')])), ).not.toRender(panel([text('Hello, world?')])); }); it('fails when the component is incorrect', () => { expect(() => expect( - getMockResponse({ - content: panel([text('Hello, world!')]), - }), + getMockInterfaceResponse(panel([text('Hello, world!')])), ).not.toRender(panel([text('Hello, world!')])), ).toThrow('Received:'); }); diff --git a/packages/snaps-jest/src/test-utils/controller.ts b/packages/snaps-jest/src/test-utils/controller.ts index c4661f0324..8fe8d0fbe8 100644 --- a/packages/snaps-jest/src/test-utils/controller.ts +++ b/packages/snaps-jest/src/test-utils/controller.ts @@ -19,6 +19,11 @@ export const getRootControllerMessenger = (mocked = true) => { result: false, type: 'all', })); + + messenger.registerActionHandler( + 'ExecutionService:handleRpcRequest', + jest.fn(), + ); } return messenger; @@ -39,6 +44,7 @@ export const getRestrictedSnapInterfaceControllerMessenger = ( 'PhishingController:testOrigin', 'PhishingController:maybeUpdateState', ], + allowedEvents: [], }); return snapInterfaceControllerMessenger; diff --git a/packages/snaps-jest/src/test-utils/response.ts b/packages/snaps-jest/src/test-utils/response.ts index 9c79ef50d2..4f63bdff96 100644 --- a/packages/snaps-jest/src/test-utils/response.ts +++ b/packages/snaps-jest/src/test-utils/response.ts @@ -1,4 +1,6 @@ -import type { SnapResponse } from '../types'; +import type { Component } from '@metamask/snaps-sdk'; + +import type { SnapHandlerInterface, SnapResponse } from '../types'; /** * Get a mock response. @@ -7,7 +9,6 @@ import type { SnapResponse } from '../types'; * @param options.id - The ID to use. * @param options.response - The response to use. * @param options.notifications - The notifications to use. - * @param options.content - The content to use. * @returns The mock response. */ export function getMockResponse({ @@ -16,12 +17,26 @@ export function getMockResponse({ result: 'foo', }, notifications = [], - content = undefined, }: Partial): SnapResponse { return { id, response, notifications, + }; +} + +/** + * Get a mock handler interface. + * + * @param content - The content to use. + * @returns The mock handler interface. + */ +export function getMockInterfaceResponse( + content: Component, +): SnapHandlerInterface { + return { content, + clickElement: jest.fn(), + typeInField: jest.fn(), }; } diff --git a/packages/snaps-jest/src/types.ts b/packages/snaps-jest/src/types.ts index a40c7e19ff..b769772121 100644 --- a/packages/snaps-jest/src/types.ts +++ b/packages/snaps-jest/src/types.ts @@ -1,4 +1,9 @@ -import type { Component } from '@metamask/snaps-sdk'; +import type { + Component, + NotificationType, + EnumToUnion, +} from '@metamask/snaps-sdk'; +import type { InferMatching } from '@metamask/snaps-utils'; import type { Json, JsonRpcId, JsonRpcParams } from '@metamask/utils'; import type { Infer } from 'superstruct'; @@ -79,6 +84,23 @@ export type SignatureOptions = Infer; */ export type SnapOptions = Infer; +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; +}; + /** * A `snap_dialog` alert interface. */ @@ -151,10 +173,12 @@ export type SnapPromptInterface = { cancel(): Promise; }; -export type SnapInterface = +export type SnapInterface = ( | SnapAlertInterface | SnapConfirmationInterface - | SnapPromptInterface; + | SnapPromptInterface +) & + SnapInterfaceActions; export type SnapRequestObject = { /** @@ -223,7 +247,7 @@ export type Snap = { */ onTransaction( transaction?: Partial, - ): Promise; + ): Promise; /** * Send a transaction to the snap. @@ -236,7 +260,7 @@ export type Snap = { */ sendTransaction( transaction?: Partial, - ): Promise; + ): Promise; /** * Send a signature request to the snap. @@ -246,7 +270,9 @@ export type Snap = { * Any missing fields will be filled in with default values. * @returns The response. */ - onSignature(signature?: Partial): Promise; + onSignature( + signature?: Partial, + ): Promise; /** * Run a cronjob in the snap. This is similar to {@link request}, but the @@ -276,7 +302,7 @@ export type Snap = { * * @returns The response. */ - onHomePage(): Promise; + onHomePage(): Promise; /** * Mock a JSON-RPC request. This will cause the snap to respond with the @@ -315,4 +341,31 @@ export type Snap = { close(): Promise; }; -export type SnapResponse = Infer; +export type SnapHandlerInterface = { + content: Component; +} & 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-sdk/src/ui/index.ts b/packages/snaps-sdk/src/ui/index.ts index 722e59d03c..e3b417437f 100644 --- a/packages/snaps-sdk/src/ui/index.ts +++ b/packages/snaps-sdk/src/ui/index.ts @@ -1,3 +1,4 @@ export * from './components'; export * from './component'; +export type { NodeWithChildren } from './nodes'; export { NodeType } from './nodes'; diff --git a/packages/snaps-sdk/src/ui/nodes.ts b/packages/snaps-sdk/src/ui/nodes.ts index 6daa430153..ca299e835e 100644 --- a/packages/snaps-sdk/src/ui/nodes.ts +++ b/packages/snaps-sdk/src/ui/nodes.ts @@ -1,6 +1,8 @@ import type { Infer } from 'superstruct'; import { assign, object, string, unknown } from 'superstruct'; +import type { Form, Panel } from './components'; + /** * The supported node types. This is based on SIP-7. * @@ -22,6 +24,11 @@ export enum NodeType { Form = 'form', } +/** + * The nodes with a children. + */ +export type NodeWithChildren = Panel | Form; + /** * @internal */ diff --git a/packages/snaps-utils/coverage.json b/packages/snaps-utils/coverage.json index aabd9ab11b..e1083d7c23 100644 --- a/packages/snaps-utils/coverage.json +++ b/packages/snaps-utils/coverage.json @@ -1,6 +1,6 @@ { "branches": 96.48, "functions": 98.64, - "lines": 98.74, - "statements": 94.52 + "lines": 98.75, + "statements": 94.53 } diff --git a/packages/snaps-utils/src/ui.test.ts b/packages/snaps-utils/src/ui.test.ts index 5402601db3..140e892bde 100644 --- a/packages/snaps-utils/src/ui.test.ts +++ b/packages/snaps-utils/src/ui.test.ts @@ -1,9 +1,18 @@ -import { panel, text, row, address, image } from '@metamask/snaps-sdk'; +import { + panel, + text, + row, + address, + image, + form, + button, +} from '@metamask/snaps-sdk'; import { validateTextLinks, validateComponentLinks, getTotalTextLength, + hasChildren, } from './ui'; describe('validateTextLinks', () => { @@ -173,3 +182,16 @@ describe('getTotalTextLength', () => { expect(getTotalTextLength(panel([text('foo'), image('')]))).toBe(3); }); }); + +describe('hasChildren', () => { + it.each([panel([text('foo')]), form('bar', [button('foo')])])( + 'returns true if the node has children', + (value) => { + expect(hasChildren(value)).toBe(true); + }, + ); + + it('returns false if the node does not have children', () => { + expect(hasChildren(text('foo'))).toBe(false); + }); +}); diff --git a/packages/snaps-utils/src/ui.ts b/packages/snaps-utils/src/ui.ts index 0b757a0959..ca76576ea7 100644 --- a/packages/snaps-utils/src/ui.ts +++ b/packages/snaps-utils/src/ui.ts @@ -1,6 +1,6 @@ -import type { Component } from '@metamask/snaps-sdk'; +import type { Component, NodeWithChildren } from '@metamask/snaps-sdk'; import { NodeType } from '@metamask/snaps-sdk'; -import { assert, AssertionError } from '@metamask/utils'; +import { assert, AssertionError, hasProperty } from '@metamask/utils'; import type { Tokens } from 'marked'; import { lexer, walkTokens } from 'marked'; @@ -120,3 +120,15 @@ export function getTotalTextLength(component: Component): number { return 0; } } + +/** + * Check if a component has children. + * + * @param component - A custom UI component. + * @returns `true` if the component has children, `false` otherwise. + */ +export function hasChildren( + component: Component, +): component is NodeWithChildren { + return hasProperty(component, 'children'); +}