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"