From 967360cd8943f87887d70221491afe3799364eb5 Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Tue, 17 Dec 2024 10:50:38 +0100 Subject: [PATCH] feat: Add `waitForUpdate` interface action (#2960) Adds `waitForUpdate` as an interface action, which lets developers wait for the Snap content to be updated by the Snap. This is useful when waiting for the Snap to populate the UI with information gathered asynchronously. Fixes https://github.com/MetaMask/snaps/issues/2958 --- packages/snaps-jest/src/helpers.test.tsx | 3 + packages/snaps-simulation/package.json | 1 + packages/snaps-simulation/src/controllers.ts | 6 +- .../snaps-simulation/src/helpers.test.tsx | 3 + .../snaps-simulation/src/interface.test.tsx | 85 +++++++++++++++++++ packages/snaps-simulation/src/interface.ts | 54 +++++++++++- .../snaps-simulation/src/request.test.tsx | 3 + packages/snaps-simulation/src/types.ts | 5 ++ yarn.lock | 1 + 9 files changed, 159 insertions(+), 2 deletions(-) diff --git a/packages/snaps-jest/src/helpers.test.tsx b/packages/snaps-jest/src/helpers.test.tsx index 6f7ccf69ba..998325c349 100644 --- a/packages/snaps-jest/src/helpers.test.tsx +++ b/packages/snaps-jest/src/helpers.test.tsx @@ -411,6 +411,7 @@ describe('installSnap', () => { selectFromRadioGroup: expect.any(Function), selectFromSelector: expect.any(Function), uploadFile: expect.any(Function), + waitForUpdate: expect.any(Function), ok: expect.any(Function), cancel: expect.any(Function), }); @@ -473,6 +474,7 @@ describe('installSnap', () => { selectFromRadioGroup: expect.any(Function), selectFromSelector: expect.any(Function), uploadFile: expect.any(Function), + waitForUpdate: expect.any(Function), ok: expect.any(Function), cancel: expect.any(Function), }); @@ -535,6 +537,7 @@ describe('installSnap', () => { selectFromRadioGroup: expect.any(Function), selectFromSelector: expect.any(Function), uploadFile: expect.any(Function), + waitForUpdate: expect.any(Function), ok: expect.any(Function), }); diff --git a/packages/snaps-simulation/package.json b/packages/snaps-simulation/package.json index f9948c9f39..8c573f5276 100644 --- a/packages/snaps-simulation/package.json +++ b/packages/snaps-simulation/package.json @@ -70,6 +70,7 @@ "@metamask/superstruct": "^3.1.0", "@metamask/utils": "^10.0.0", "@reduxjs/toolkit": "^1.9.5", + "fast-deep-equal": "^3.1.3", "mime": "^3.0.0", "readable-stream": "^3.6.2", "redux-saga": "^1.2.3" diff --git a/packages/snaps-simulation/src/controllers.ts b/packages/snaps-simulation/src/controllers.ts index 0877480b7d..b67989d539 100644 --- a/packages/snaps-simulation/src/controllers.ts +++ b/packages/snaps-simulation/src/controllers.ts @@ -15,6 +15,7 @@ import type { ExecutionServiceActions, SnapInterfaceControllerActions, SnapInterfaceControllerAllowedActions, + SnapInterfaceControllerStateChangeEvent, } from '@metamask/snaps-controllers'; import { caveatSpecifications as snapsCaveatsSpecifications, @@ -38,9 +39,12 @@ export type RootControllerAllowedActions = | ExecutionServiceActions | SubjectMetadataControllerActions; +export type RootControllerAllowedEvents = + SnapInterfaceControllerStateChangeEvent; + export type RootControllerMessenger = ControllerMessenger< RootControllerAllowedActions, - any + RootControllerAllowedEvents >; export type GetControllersOptions = { diff --git a/packages/snaps-simulation/src/helpers.test.tsx b/packages/snaps-simulation/src/helpers.test.tsx index 53368ef92a..7dc369ef8e 100644 --- a/packages/snaps-simulation/src/helpers.test.tsx +++ b/packages/snaps-simulation/src/helpers.test.tsx @@ -145,6 +145,7 @@ describe('helpers', () => { selectFromRadioGroup: expect.any(Function), selectFromSelector: expect.any(Function), uploadFile: expect.any(Function), + waitForUpdate: expect.any(Function), ok: expect.any(Function), cancel: expect.any(Function), }); @@ -207,6 +208,7 @@ describe('helpers', () => { selectFromRadioGroup: expect.any(Function), selectFromSelector: expect.any(Function), uploadFile: expect.any(Function), + waitForUpdate: expect.any(Function), ok: expect.any(Function), cancel: expect.any(Function), }); @@ -269,6 +271,7 @@ describe('helpers', () => { selectFromRadioGroup: expect.any(Function), selectFromSelector: expect.any(Function), uploadFile: expect.any(Function), + waitForUpdate: expect.any(Function), ok: expect.any(Function), }); diff --git a/packages/snaps-simulation/src/interface.test.tsx b/packages/snaps-simulation/src/interface.test.tsx index 0ed7bc866e..02368bfaed 100644 --- a/packages/snaps-simulation/src/interface.test.tsx +++ b/packages/snaps-simulation/src/interface.test.tsx @@ -50,6 +50,7 @@ import { typeInField, uploadFile, selectFromSelector, + waitForUpdate, } from './interface'; import type { RunSagaFunction } from './store'; import { createStore, resolveInterface, setInterface } from './store'; @@ -90,6 +91,7 @@ describe('getInterfaceResponse', () => { selectFromRadioGroup: jest.fn(), selectFromSelector: jest.fn(), uploadFile: jest.fn(), + waitForUpdate: jest.fn(), }; it('returns an `ok` function that resolves the user interface with `null` for alert dialogs', async () => { @@ -111,6 +113,7 @@ describe('getInterfaceResponse', () => { selectFromRadioGroup: expect.any(Function), selectFromSelector: expect.any(Function), uploadFile: expect.any(Function), + waitForUpdate: expect.any(Function), ok: expect.any(Function), }); @@ -138,6 +141,7 @@ describe('getInterfaceResponse', () => { selectFromRadioGroup: expect.any(Function), selectFromSelector: expect.any(Function), uploadFile: expect.any(Function), + waitForUpdate: expect.any(Function), ok: expect.any(Function), cancel: expect.any(Function), }); @@ -166,6 +170,7 @@ describe('getInterfaceResponse', () => { selectFromRadioGroup: expect.any(Function), selectFromSelector: expect.any(Function), uploadFile: expect.any(Function), + waitForUpdate: expect.any(Function), ok: expect.any(Function), cancel: expect.any(Function), }); @@ -194,6 +199,7 @@ describe('getInterfaceResponse', () => { selectFromRadioGroup: expect.any(Function), selectFromSelector: expect.any(Function), uploadFile: expect.any(Function), + waitForUpdate: expect.any(Function), ok: expect.any(Function), cancel: expect.any(Function), }); @@ -222,6 +228,7 @@ describe('getInterfaceResponse', () => { selectFromRadioGroup: expect.any(Function), selectFromSelector: expect.any(Function), uploadFile: expect.any(Function), + waitForUpdate: expect.any(Function), ok: expect.any(Function), cancel: expect.any(Function), }); @@ -250,6 +257,7 @@ describe('getInterfaceResponse', () => { selectFromRadioGroup: expect.any(Function), selectFromSelector: expect.any(Function), uploadFile: expect.any(Function), + waitForUpdate: expect.any(Function), ok: expect.any(Function), cancel: expect.any(Function), }); @@ -296,6 +304,7 @@ describe('getInterfaceResponse', () => { selectInDropdown: expect.any(Function), selectFromRadioGroup: expect.any(Function), selectFromSelector: expect.any(Function), + waitForUpdate: expect.any(Function), uploadFile: expect.any(Function), }); }); @@ -336,6 +345,7 @@ describe('getInterfaceResponse', () => { selectFromRadioGroup: expect.any(Function), selectFromSelector: expect.any(Function), uploadFile: expect.any(Function), + waitForUpdate: expect.any(Function), cancel: expect.any(Function), }); }); @@ -370,6 +380,7 @@ describe('getInterfaceResponse', () => { selectFromRadioGroup: expect.any(Function), selectFromSelector: expect.any(Function), uploadFile: expect.any(Function), + waitForUpdate: expect.any(Function), cancel: expect.any(Function), ok: expect.any(Function), }); @@ -1250,6 +1261,7 @@ describe('getInterface', () => { selectFromRadioGroup: expect.any(Function), selectFromSelector: expect.any(Function), uploadFile: expect.any(Function), + waitForUpdate: expect.any(Function), ok: expect.any(Function), }); }); @@ -1280,6 +1292,7 @@ describe('getInterface', () => { selectFromRadioGroup: expect.any(Function), selectFromSelector: expect.any(Function), uploadFile: expect.any(Function), + waitForUpdate: expect.any(Function), ok: expect.any(Function), }); }); @@ -1468,6 +1481,41 @@ describe('getInterface', () => { }, ); }); + + it('waits for the interface content to update when `waitForUpdate` is called', async () => { + jest.spyOn(rootControllerMessenger, 'call'); + const { store, runSaga } = createStore(getMockOptions()); + + const content = ( + + + + ); + const id = await interfaceController.createInterface(MOCK_SNAP_ID, content); + const type = DialogType.Alert; + const ui = { type: DIALOG_APPROVAL_TYPES[type], id }; + + store.dispatch(setInterface(ui)); + + const result = await runSaga( + getInterface, + runSaga, + MOCK_SNAP_ID, + rootControllerMessenger, + ).toPromise(); + + const promise = result.waitForUpdate(); + + await interfaceController.updateInterface( + MOCK_SNAP_ID, + id, + Hello world!, + ); + + const newInterface = await promise; + + expect(newInterface.content.type).toBe('Text'); + }); }); describe('selectFromRadioGroup', () => { @@ -1761,3 +1809,40 @@ describe('selectFromSelector', () => { ); }); }); + +describe('waitForUpdate', () => { + const rootControllerMessenger = getRootControllerMessenger(); + const controllerMessenger = getRestrictedSnapInterfaceControllerMessenger( + rootControllerMessenger, + ); + + const interfaceController = new SnapInterfaceController({ + messenger: controllerMessenger, + }); + + it('waits for the interface content to update', async () => { + const content = ; + + const interfaceId = await interfaceController.createInterface( + MOCK_SNAP_ID, + content, + ); + + const promise = waitForUpdate( + rootControllerMessenger, + MOCK_SNAP_ID, + interfaceId, + content, + ); + + await interfaceController.updateInterface( + MOCK_SNAP_ID, + interfaceId, + Hello world!, + ); + + const newInterface = await promise; + + expect(newInterface.content.type).toBe('Text'); + }); +}); diff --git a/packages/snaps-simulation/src/interface.ts b/packages/snaps-simulation/src/interface.ts index 6933857f83..1c9aee7c28 100644 --- a/packages/snaps-simulation/src/interface.ts +++ b/packages/snaps-simulation/src/interface.ts @@ -1,3 +1,4 @@ +import type { SnapInterfaceControllerState } from '@metamask/snaps-controllers'; import type { DialogApprovalTypes } from '@metamask/snaps-rpc-methods'; import { DIALOG_APPROVAL_TYPES } from '@metamask/snaps-rpc-methods'; import type { @@ -19,6 +20,7 @@ import { } from '@metamask/snaps-utils'; import { assertExhaustive, hasProperty } from '@metamask/utils'; import type { PayloadAction } from '@reduxjs/toolkit'; +import deepEqual from 'fast-deep-equal'; import { type SagaIterator } from 'redux-saga'; import { call, put, select, take } from 'redux-saga/effects'; @@ -26,7 +28,12 @@ import type { RootControllerMessenger } from './controllers'; import { getFileSize, getFileToUpload } from './files'; import type { Interface, RunSagaFunction } from './store'; import { getCurrentInterface, resolveInterface, setInterface } from './store'; -import type { FileOptions, SnapInterface, SnapInterfaceActions } from './types'; +import type { + FileOptions, + SnapHandlerInterface, + SnapInterface, + SnapInterfaceActions, +} from './types'; /** * The maximum file size that can be uploaded. @@ -752,6 +759,48 @@ export async function selectFromSelector( }); } +/** + * Wait for an interface to be updated. + * + * @param controllerMessenger - The controller messenger used to call actions. + * @param snapId - The Snap ID. + * @param id - The interface ID. + * @param originalContent - The original interface content. + * @returns A promise that resolves to the updated interface. + */ +export async function waitForUpdate( + controllerMessenger: RootControllerMessenger, + snapId: SnapId, + id: string, + originalContent: JSXElement, +) { + return new Promise((resolve) => { + const listener = (state: SnapInterfaceControllerState) => { + const currentInterface = state.interfaces[id]; + const newContent = currentInterface?.content; + + if (!deepEqual(originalContent, newContent)) { + controllerMessenger.unsubscribe( + 'SnapInterfaceController:stateChange', + listener, + ); + + const actions = getInterfaceActions(snapId, controllerMessenger, { + content: newContent, + id, + }); + + resolve({ ...actions, content: newContent }); + } + }; + + controllerMessenger.subscribe( + 'SnapInterfaceController:stateChange', + listener, + ); + }); +} + /** * Get a formatted file size. * @@ -923,6 +972,9 @@ export function getInterfaceActions( options, ); }, + + waitForUpdate: async () => + waitForUpdate(controllerMessenger, snapId, id, content), }; } diff --git a/packages/snaps-simulation/src/request.test.tsx b/packages/snaps-simulation/src/request.test.tsx index 60e3025c4e..67adb77b60 100644 --- a/packages/snaps-simulation/src/request.test.tsx +++ b/packages/snaps-simulation/src/request.test.tsx @@ -115,6 +115,7 @@ describe('handleRequest', () => { selectInDropdown: expect.any(Function), typeInField: expect.any(Function), uploadFile: expect.any(Function), + waitForUpdate: expect.any(Function), }); await closeServer(); @@ -346,6 +347,7 @@ describe('getInterfaceApi', () => { selectFromRadioGroup: expect.any(Function), selectFromSelector: expect.any(Function), uploadFile: expect.any(Function), + waitForUpdate: expect.any(Function), }); }); @@ -379,6 +381,7 @@ describe('getInterfaceApi', () => { selectFromRadioGroup: expect.any(Function), selectFromSelector: expect.any(Function), uploadFile: expect.any(Function), + waitForUpdate: expect.any(Function), }); }); diff --git a/packages/snaps-simulation/src/types.ts b/packages/snaps-simulation/src/types.ts index 5bb52f557c..0c1c119c34 100644 --- a/packages/snaps-simulation/src/types.ts +++ b/packages/snaps-simulation/src/types.ts @@ -173,6 +173,11 @@ export type SnapInterfaceActions = { file: string | Uint8Array, options?: FileOptions, ): Promise; + + /** + * Wait for the interface to be updated. + */ + waitForUpdate: () => Promise; }; /** diff --git a/yarn.lock b/yarn.lock index 5eb2d26aed..e956661fc8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6145,6 +6145,7 @@ __metadata: eslint-plugin-prettier: "npm:^4.2.1" eslint-plugin-promise: "npm:^6.1.1" express: "npm:^4.18.2" + fast-deep-equal: "npm:^3.1.3" jest: "npm:^29.0.2" jest-it-up: "npm:^2.0.0" jest-silent-reporter: "npm:^0.6.0"