From 141685da676dd6a5ba8a23a6c6e07d1007c0d301 Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Tue, 4 Feb 2025 14:23:52 +0100 Subject: [PATCH 01/12] test: Add basic tests for SnapUIRenderer --- .../snap-ui-renderer.test.js.snap | 243 ++++++++++++++++++ .../snap-ui-renderer/snap-ui-renderer.test.js | 183 ++++++++++++- .../app/snaps/snap-ui-renderer/utils.test.ts | 15 ++ 3 files changed, 431 insertions(+), 10 deletions(-) create mode 100644 ui/components/app/snaps/snap-ui-renderer/__snapshots__/snap-ui-renderer.test.js.snap create mode 100644 ui/components/app/snaps/snap-ui-renderer/utils.test.ts diff --git a/ui/components/app/snaps/snap-ui-renderer/__snapshots__/snap-ui-renderer.test.js.snap b/ui/components/app/snaps/snap-ui-renderer/__snapshots__/snap-ui-renderer.test.js.snap new file mode 100644 index 000000000000..f2d49704a43f --- /dev/null +++ b/ui/components/app/snaps/snap-ui-renderer/__snapshots__/snap-ui-renderer.test.js.snap @@ -0,0 +1,243 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`SnapUIRenderer renders basic UI 1`] = ` +
+
+
+
+

+ Hello world! +

+
+
+
+
+`; + +exports[`SnapUIRenderer renders footers 1`] = ` +
+
+
+
+

+ Hello world! +

+
+ +
+
+
+`; + +exports[`SnapUIRenderer renders loading state 1`] = ` +
+
+
+
+
+
+
+
+
+`; + +exports[`SnapUIRenderer supports interactive inputs 1`] = ` +
+
+
+
+
+
+ +
+
+
+ +
+
+
+`; + +exports[`SnapUIRenderer supports the contentBackgroundColor prop 1`] = ` +
+
+
+
+

+ Hello world! +

+
+ +
+
+
+`; + +exports[`SnapUIRenderer supports the onCancel prop 1`] = ` +
+
+
+
+

+ Hello world! +

+
+ +
+
+
+`; diff --git a/ui/components/app/snaps/snap-ui-renderer/snap-ui-renderer.test.js b/ui/components/app/snaps/snap-ui-renderer/snap-ui-renderer.test.js index e6707f84e8c1..f87dcac0607a 100644 --- a/ui/components/app/snaps/snap-ui-renderer/snap-ui-renderer.test.js +++ b/ui/components/app/snaps/snap-ui-renderer/snap-ui-renderer.test.js @@ -1,15 +1,178 @@ -import { JSXElementStruct } from '@metamask/snaps-sdk/jsx'; -import { COMPONENT_MAPPING } from './components'; +import React from 'react'; +import { + Box, + Text, + Container, + Footer, + Button, + Input, +} from '@metamask/snaps-sdk/jsx'; +import configureMockStore from 'redux-mock-store'; +import mockState from '../../../../../test/data/mock-state.json'; +import { renderWithProvider } from '../../../../../test/lib/render-helpers'; +import { SnapUIRenderer } from './snap-ui-renderer'; +import { fireEvent } from '@testing-library/react'; +import thunk from 'redux-thunk'; +import * as backgroundConnection from '../../../../store/background-connection'; +import { BackgroundColor } from '../../../../helpers/constants/design-system'; -const EXCLUDED_COMPONENTS = ['Option', 'Radio', 'SelectorOption']; +jest.mock('../../../../store/background-connection', () => ({ + ...jest.requireActual('../../../../store/background-connection'), + submitRequestToBackground: jest.fn(), +})); -describe('Snap UI mapping', () => { - it('supports all exposed components', () => { - const elements = JSXElementStruct.schema - .map((struct) => JSON.parse(struct.schema.type.type)) - .filter((key) => !EXCLUDED_COMPONENTS.includes(key)); - expect(Object.keys(COMPONENT_MAPPING).sort()).toStrictEqual( - elements.sort(), +const { submitRequestToBackground } = jest.mocked(backgroundConnection); + +const MOCK_SNAP_ID = 'npm:@metamask/test-snap-bip44'; +const MOCK_INTERFACE_ID = 'interfaceId'; + +function renderInterface( + content, + { + useFooter = false, + useDelineator = false, + onCancel, + contentBackgroundColor, + } = {}, +) { + const mockStore = configureMockStore([thunk])({ + ...mockState, + metamask: { + ...mockState.metamask, + interfaces: { + [MOCK_INTERFACE_ID]: { + snapId: MOCK_SNAP_ID, + content, + state: {}, + context: null, + contentType: null, + }, + }, + }, + }); + return renderWithProvider( + , + mockStore, + ); +} + +describe('SnapUIRenderer', () => { + it('renders loading state', () => { + const { container } = renderInterface(null); + + expect(container.getElementsByClassName('pulse-loader').length).toBe(1); + expect(container).toMatchSnapshot(); + }); + + it('renders basic UI', () => { + const { container, getByText } = renderInterface( + Box({ children: Text({ children: 'Hello world!' }) }), + ); + + expect(getByText('Hello world!')).toBeDefined(); + expect(container).toMatchSnapshot(); + }); + + it('renders footers', () => { + const { container, getByText } = renderInterface( + Container({ + children: [ + Box({ children: Text({ children: 'Hello world!' }) }), + Footer({ children: Button({ children: 'Foo' }) }), + ], + }), + { useFooter: true }, + ); + + expect(getByText('Foo')).toBeDefined(); + expect(container).toMatchSnapshot(); + }); + + it('supports the onCancel prop', () => { + const onCancel = jest.fn(); + const { container, getByText } = renderInterface( + Container({ + children: [ + Box({ children: Text({ children: 'Hello world!' }) }), + Footer({ children: Button({ children: 'Foo' }) }), + ], + }), + { useFooter: true, onCancel }, ); + + const button = getByText('Cancel'); + expect(button).toBeDefined(); + expect(container).toMatchSnapshot(); + + fireEvent.click(button); + expect(onCancel).toHaveBeenCalled(); + }); + + it('supports the contentBackgroundColor prop', () => { + const { container, getByText } = renderInterface( + Container({ + children: [ + Box({ children: Text({ children: 'Hello world!' }) }), + Footer({ children: Button({ children: 'Foo' }) }), + ], + }), + { + useFooter: true, + contentBackgroundColor: BackgroundColor.backgroundDefault, + }, + ); + + expect(container.getElementsByClassName('mm-box--background-color-background-alternative').length).toBe(1); + expect(container).toMatchSnapshot(); + }); + + it('supports interactive inputs', () => { + const { container, getByText, getByRole } = renderInterface( + Container({ + children: [ + Box({ children: Input({ name: 'input' }) }), + Footer({ children: Button({ children: 'Foo' }) }), + ], + }), + { useFooter: true }, + ); + + const input = getByRole('textbox'); + fireEvent.change(input, { target: { value: 'a' } }); + + expect(submitRequestToBackground).toHaveBeenNthCalledWith( + 1, + 'updateInterfaceState', + [MOCK_INTERFACE_ID, { input: 'a' }], + ); + expect(submitRequestToBackground).toHaveBeenNthCalledWith( + 2, + 'handleSnapRequest', + [ + { + handler: 'onUserInput', + origin: '', + request: { + jsonrpc: '2.0', + method: ' ', + params: { + context: null, + event: { name: 'input', type: 'InputChangeEvent', value: 'a' }, + id: MOCK_INTERFACE_ID, + }, + }, + snapId: MOCK_SNAP_ID, + }, + ], + ); + + expect(container).toMatchSnapshot(); }); }); diff --git a/ui/components/app/snaps/snap-ui-renderer/utils.test.ts b/ui/components/app/snaps/snap-ui-renderer/utils.test.ts new file mode 100644 index 000000000000..1e12355cdffd --- /dev/null +++ b/ui/components/app/snaps/snap-ui-renderer/utils.test.ts @@ -0,0 +1,15 @@ +import { JSXElementStruct, Box, Text } from '@metamask/snaps-sdk/jsx'; +import { COMPONENT_MAPPING } from './components'; + +const EXCLUDED_COMPONENTS = ['Option', 'Radio', 'SelectorOption']; + +describe('Snap UI mapping', () => { + it('supports all exposed components', () => { + const elements = JSXElementStruct.schema + .map((struct) => JSON.parse(struct.schema.type.type)) + .filter((key) => !EXCLUDED_COMPONENTS.includes(key)); + expect(Object.keys(COMPONENT_MAPPING).sort()).toStrictEqual( + elements.sort(), + ); + }); +}); From 17ab173ed712ee08f6bc7055530edd7630de0768 Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Tue, 4 Feb 2025 14:29:43 +0100 Subject: [PATCH 02/12] Fix lint --- .../snap-ui-renderer/snap-ui-renderer.test.js | 18 +++++++++++------- .../app/snaps/snap-ui-renderer/utils.test.ts | 2 +- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/ui/components/app/snaps/snap-ui-renderer/snap-ui-renderer.test.js b/ui/components/app/snaps/snap-ui-renderer/snap-ui-renderer.test.js index f87dcac0607a..6ce5edfe8287 100644 --- a/ui/components/app/snaps/snap-ui-renderer/snap-ui-renderer.test.js +++ b/ui/components/app/snaps/snap-ui-renderer/snap-ui-renderer.test.js @@ -8,13 +8,13 @@ import { Input, } from '@metamask/snaps-sdk/jsx'; import configureMockStore from 'redux-mock-store'; -import mockState from '../../../../../test/data/mock-state.json'; -import { renderWithProvider } from '../../../../../test/lib/render-helpers'; -import { SnapUIRenderer } from './snap-ui-renderer'; import { fireEvent } from '@testing-library/react'; import thunk from 'redux-thunk'; +import { renderWithProvider } from '../../../../../test/lib/render-helpers'; +import mockState from '../../../../../test/data/mock-state.json'; import * as backgroundConnection from '../../../../store/background-connection'; import { BackgroundColor } from '../../../../helpers/constants/design-system'; +import { SnapUIRenderer } from './snap-ui-renderer'; jest.mock('../../../../store/background-connection', () => ({ ...jest.requireActual('../../../../store/background-connection'), @@ -67,7 +67,7 @@ describe('SnapUIRenderer', () => { it('renders loading state', () => { const { container } = renderInterface(null); - expect(container.getElementsByClassName('pulse-loader').length).toBe(1); + expect(container.getElementsByClassName('pulse-loader')).toHaveLength(1); expect(container).toMatchSnapshot(); }); @@ -116,7 +116,7 @@ describe('SnapUIRenderer', () => { }); it('supports the contentBackgroundColor prop', () => { - const { container, getByText } = renderInterface( + const { container } = renderInterface( Container({ children: [ Box({ children: Text({ children: 'Hello world!' }) }), @@ -129,12 +129,16 @@ describe('SnapUIRenderer', () => { }, ); - expect(container.getElementsByClassName('mm-box--background-color-background-alternative').length).toBe(1); + expect( + container.getElementsByClassName( + 'mm-box--background-color-background-alternative', + ), + ).toHaveLength(1); expect(container).toMatchSnapshot(); }); it('supports interactive inputs', () => { - const { container, getByText, getByRole } = renderInterface( + const { container, getByRole } = renderInterface( Container({ children: [ Box({ children: Input({ name: 'input' }) }), diff --git a/ui/components/app/snaps/snap-ui-renderer/utils.test.ts b/ui/components/app/snaps/snap-ui-renderer/utils.test.ts index 1e12355cdffd..e6707f84e8c1 100644 --- a/ui/components/app/snaps/snap-ui-renderer/utils.test.ts +++ b/ui/components/app/snaps/snap-ui-renderer/utils.test.ts @@ -1,4 +1,4 @@ -import { JSXElementStruct, Box, Text } from '@metamask/snaps-sdk/jsx'; +import { JSXElementStruct } from '@metamask/snaps-sdk/jsx'; import { COMPONENT_MAPPING } from './components'; const EXCLUDED_COMPONENTS = ['Option', 'Radio', 'SelectorOption']; From 96ea098e05292f07f95acc27810248dfd654e132 Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Tue, 4 Feb 2025 15:13:29 +0100 Subject: [PATCH 03/12] Fix types --- ui/components/app/snaps/snap-ui-renderer/utils.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ui/components/app/snaps/snap-ui-renderer/utils.test.ts b/ui/components/app/snaps/snap-ui-renderer/utils.test.ts index e6707f84e8c1..47c3444f7cab 100644 --- a/ui/components/app/snaps/snap-ui-renderer/utils.test.ts +++ b/ui/components/app/snaps/snap-ui-renderer/utils.test.ts @@ -1,3 +1,4 @@ +import type { Struct } from '@metamask/superstruct'; import { JSXElementStruct } from '@metamask/snaps-sdk/jsx'; import { COMPONENT_MAPPING } from './components'; @@ -5,7 +6,7 @@ const EXCLUDED_COMPONENTS = ['Option', 'Radio', 'SelectorOption']; describe('Snap UI mapping', () => { it('supports all exposed components', () => { - const elements = JSXElementStruct.schema + const elements = (JSXElementStruct.schema as Struct[]) .map((struct) => JSON.parse(struct.schema.type.type)) .filter((key) => !EXCLUDED_COMPONENTS.includes(key)); expect(Object.keys(COMPONENT_MAPPING).sort()).toStrictEqual( From 51ae20ec70176e3f65254b1cb5b21d692a2da61e Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Tue, 4 Feb 2025 16:04:25 +0100 Subject: [PATCH 04/12] Add more tests --- .../snap-ui-renderer.test.js.snap | 91 +++++++++++++++---- .../snap-ui-renderer/snap-ui-renderer.test.js | 42 +++++++-- .../app/snaps/snap-ui-renderer/utils.test.ts | 6 +- 3 files changed, 113 insertions(+), 26 deletions(-) diff --git a/ui/components/app/snaps/snap-ui-renderer/__snapshots__/snap-ui-renderer.test.js.snap b/ui/components/app/snaps/snap-ui-renderer/__snapshots__/snap-ui-renderer.test.js.snap index f2d49704a43f..8841e164185a 100644 --- a/ui/components/app/snaps/snap-ui-renderer/__snapshots__/snap-ui-renderer.test.js.snap +++ b/ui/components/app/snaps/snap-ui-renderer/__snapshots__/snap-ui-renderer.test.js.snap @@ -1,5 +1,39 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`SnapUIRenderer prefills interactive inputs with existing state 1`] = ` +
+
+
+
+
+
+ +
+
+
+
+
+
+`; + exports[`SnapUIRenderer renders basic UI 1`] = `
`; -exports[`SnapUIRenderer supports interactive inputs 1`] = ` +exports[`SnapUIRenderer supports container backgrounds 1`] = `
-
-
- -
-
+ Hello world! +

`; @@ -80,6 +84,10 @@ exports[`SnapUIRenderer re-focuses input after re-render 1`] = `
+
`; @@ -130,6 +138,10 @@ exports[`SnapUIRenderer re-renders when the interface changes 1`] = `
+ `; @@ -180,6 +192,10 @@ exports[`SnapUIRenderer re-syncs state when the interface changes 1`] = ` + `; @@ -203,6 +219,10 @@ exports[`SnapUIRenderer renders basic UI 1`] = `

+ `; @@ -247,6 +267,10 @@ exports[`SnapUIRenderer renders footers 1`] = ` + `; @@ -314,6 +338,10 @@ exports[`SnapUIRenderer supports container backgrounds 1`] = ` + `; @@ -360,6 +388,10 @@ exports[`SnapUIRenderer supports forms 1`] = ` + `; @@ -394,6 +426,10 @@ exports[`SnapUIRenderer supports interactive inputs 1`] = ` + `; @@ -438,6 +474,10 @@ exports[`SnapUIRenderer supports the contentBackgroundColor prop 1`] = ` + `; @@ -491,6 +531,10 @@ exports[`SnapUIRenderer supports the onCancel prop 1`] = ` + `; diff --git a/ui/components/app/snaps/snap-ui-renderer/snap-ui-renderer.js b/ui/components/app/snaps/snap-ui-renderer/snap-ui-renderer.js index 9f60a3e0769c..a4ccfb85de68 100644 --- a/ui/components/app/snaps/snap-ui-renderer/snap-ui-renderer.js +++ b/ui/components/app/snaps/snap-ui-renderer/snap-ui-renderer.js @@ -1,4 +1,4 @@ -import React, { memo, useMemo } from 'react'; +import React, { memo, useMemo, useRef } from 'react'; import PropTypes from 'prop-types'; import { useSelector } from 'react-redux'; import { Container } from '@metamask/snaps-sdk/jsx'; @@ -20,6 +20,15 @@ import { import { useI18nContext } from '../../../../hooks/useI18nContext'; import { mapToExtensionCompatibleColor, mapToTemplate } from './utils'; +// Component for tracking the number of re-renders +// DO NOT USE IN PRODUCTION +const PerformanceTracker = () => { + const rendersRef = useRef(0); + rendersRef.current++; + + return ; +}; + // Component that maps Snaps UI JSON format to MetaMask Template Renderer format const SnapUIRendererComponent = ({ snapId, @@ -33,6 +42,7 @@ const SnapUIRendererComponent = ({ useFooter = false, onCancel, contentBackgroundColor, + PERF_DEBUG, }) => { const t = useI18nContext(); @@ -115,6 +125,7 @@ const SnapUIRendererComponent = ({ }} > + {PERF_DEBUG && } ); @@ -137,4 +148,5 @@ SnapUIRendererComponent.propTypes = { useFooter: PropTypes.bool, onCancel: PropTypes.func, contentBackgroundColor: PropTypes.string, + PERF_DEBUG: PropTypes.bool, // DO NOT USE THIS IN PRODUCTION }; diff --git a/ui/components/app/snaps/snap-ui-renderer/snap-ui-renderer.test.js b/ui/components/app/snaps/snap-ui-renderer/snap-ui-renderer.test.js index 6bad3363481b..d4ca72430d09 100644 --- a/ui/components/app/snaps/snap-ui-renderer/snap-ui-renderer.test.js +++ b/ui/components/app/snaps/snap-ui-renderer/snap-ui-renderer.test.js @@ -9,7 +9,6 @@ import { Form, } from '@metamask/snaps-sdk/jsx'; import { fireEvent, waitFor } from '@testing-library/react'; -import thunk from 'redux-thunk'; import { renderWithProvider } from '../../../../../test/lib/render-helpers'; import mockState from '../../../../../test/data/mock-state.json'; import * as backgroundConnection from '../../../../store/background-connection'; @@ -88,11 +87,20 @@ function renderInterface( useFooter={useFooter} onCancel={onCancel} contentBackgroundColor={contentBackgroundColor} + PERF_DEBUG={true} />, store, ); - return { ...result, updateInterface }; + const getRenderCount = () => + parseInt( + result + .getByTestId('performance') + .getAttribute('data-renders'), + 10, + ); + + return { ...result, updateInterface, getRenderCount }; } describe('SnapUIRenderer', () => { @@ -108,11 +116,12 @@ describe('SnapUIRenderer', () => { }); it('renders basic UI', () => { - const { container, getByText } = renderInterface( + const { container, getByText, getRenderCount } = renderInterface( Box({ children: Text({ children: 'Hello world!' }) }), ); expect(getByText('Hello world!')).toBeDefined(); + expect(getRenderCount()).toBe(1); expect(container).toMatchSnapshot(); }); @@ -344,7 +353,7 @@ describe('SnapUIRenderer', () => { }); it('re-renders when the interface changes', () => { - const { container, getAllByRole, getByRole, updateInterface } = + const { container, getAllByRole, updateInterface, getRenderCount } = renderInterface(Box({ children: Input({ name: 'input' }) })); const inputs = getAllByRole('textbox'); @@ -357,11 +366,13 @@ describe('SnapUIRenderer', () => { const inputsAfterRerender = getAllByRole('textbox'); expect(inputsAfterRerender.length).toBe(2); + expect(getRenderCount()).toBe(2) + expect(container).toMatchSnapshot(); }); it('re-syncs state when the interface changes', () => { - const { container, getAllByRole, getByRole, updateInterface } = + const { container, getAllByRole, getRenderCount, updateInterface } = renderInterface(Box({ children: Input({ name: 'input' }) })); updateInterface( @@ -373,11 +384,13 @@ describe('SnapUIRenderer', () => { expect(inputsAfterRerender[0].value).toStrictEqual('bar'); expect(inputsAfterRerender[1].value).toStrictEqual('foo'); + expect(getRenderCount()).toBe(2) + expect(container).toMatchSnapshot(); }); it('re-focuses input after re-render', async () => { - const { container, getAllByRole, getByRole, updateInterface } = + const { container, getAllByRole, getByRole, updateInterface, getRenderCount } = renderInterface(Box({ children: Input({ name: 'input' }) })); const input = getByRole('textbox'); @@ -393,6 +406,8 @@ describe('SnapUIRenderer', () => { await waitFor(() => expect(inputs[0]).toHaveFocus()); + expect(getRenderCount()).toBe(2) + expect(container).toMatchSnapshot(); }); }); From 52cfd4b8ca04d252c1c7696876559e27072b1bc6 Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Wed, 5 Feb 2025 12:55:15 +0100 Subject: [PATCH 09/12] Fix lint --- .../snap-ui-renderer/snap-ui-renderer.js | 2 +- .../snap-ui-renderer/snap-ui-renderer.test.js | 45 +++++++++++-------- 2 files changed, 27 insertions(+), 20 deletions(-) diff --git a/ui/components/app/snaps/snap-ui-renderer/snap-ui-renderer.js b/ui/components/app/snaps/snap-ui-renderer/snap-ui-renderer.js index a4ccfb85de68..2852d35e24c5 100644 --- a/ui/components/app/snaps/snap-ui-renderer/snap-ui-renderer.js +++ b/ui/components/app/snaps/snap-ui-renderer/snap-ui-renderer.js @@ -24,7 +24,7 @@ import { mapToExtensionCompatibleColor, mapToTemplate } from './utils'; // DO NOT USE IN PRODUCTION const PerformanceTracker = () => { const rendersRef = useRef(0); - rendersRef.current++; + rendersRef.current += 1; return ; }; diff --git a/ui/components/app/snaps/snap-ui-renderer/snap-ui-renderer.test.js b/ui/components/app/snaps/snap-ui-renderer/snap-ui-renderer.test.js index d4ca72430d09..a85b203560d0 100644 --- a/ui/components/app/snaps/snap-ui-renderer/snap-ui-renderer.test.js +++ b/ui/components/app/snaps/snap-ui-renderer/snap-ui-renderer.test.js @@ -13,8 +13,8 @@ import { renderWithProvider } from '../../../../../test/lib/render-helpers'; import mockState from '../../../../../test/data/mock-state.json'; import * as backgroundConnection from '../../../../store/background-connection'; import { BackgroundColor } from '../../../../helpers/constants/design-system'; -import { SnapUIRenderer } from './snap-ui-renderer'; import configureStore from '../../../../store/store'; +import { SnapUIRenderer } from './snap-ui-renderer'; jest.mock('../../../../store/background-connection', () => ({ ...jest.requireActual('../../../../store/background-connection'), @@ -52,12 +52,12 @@ function renderInterface( }, }); - const reducer = (state, action) => { + const reducer = (storeState, action) => { if (action.type === 'updateInterface') { return { - ...state, + ...storeState, metamask: { - ...state.metamask, + ...storeState.metamask, interfaces: { [MOCK_INTERFACE_ID]: { snapId: MOCK_SNAP_ID, @@ -70,13 +70,17 @@ function renderInterface( }, }; } - return state; + return storeState; }; store.replaceReducer(reducer); - const updateInterface = (content, state = null) => { - store.dispatch({ type: 'updateInterface', content, state }); + const updateInterface = (newContent, newState = null) => { + store.dispatch({ + type: 'updateInterface', + content: newContent, + state: newState, + }); }; const result = renderWithProvider( @@ -87,16 +91,14 @@ function renderInterface( useFooter={useFooter} onCancel={onCancel} contentBackgroundColor={contentBackgroundColor} - PERF_DEBUG={true} + PERF_DEBUG />, store, ); const getRenderCount = () => parseInt( - result - .getByTestId('performance') - .getAttribute('data-renders'), + result.getByTestId('performance').getAttribute('data-renders'), 10, ); @@ -357,16 +359,16 @@ describe('SnapUIRenderer', () => { renderInterface(Box({ children: Input({ name: 'input' }) })); const inputs = getAllByRole('textbox'); - expect(inputs.length).toBe(1); + expect(inputs).toHaveLength(1); updateInterface( Box({ children: [Input({ name: 'input' }), Input({ name: 'input2' })] }), ); const inputsAfterRerender = getAllByRole('textbox'); - expect(inputsAfterRerender.length).toBe(2); + expect(inputsAfterRerender).toHaveLength(2); - expect(getRenderCount()).toBe(2) + expect(getRenderCount()).toBe(2); expect(container).toMatchSnapshot(); }); @@ -384,14 +386,19 @@ describe('SnapUIRenderer', () => { expect(inputsAfterRerender[0].value).toStrictEqual('bar'); expect(inputsAfterRerender[1].value).toStrictEqual('foo'); - expect(getRenderCount()).toBe(2) + expect(getRenderCount()).toBe(2); expect(container).toMatchSnapshot(); }); it('re-focuses input after re-render', async () => { - const { container, getAllByRole, getByRole, updateInterface, getRenderCount } = - renderInterface(Box({ children: Input({ name: 'input' }) })); + const { + container, + getAllByRole, + getByRole, + updateInterface, + getRenderCount, + } = renderInterface(Box({ children: Input({ name: 'input' }) })); const input = getByRole('textbox'); input.focus(); @@ -402,11 +409,11 @@ describe('SnapUIRenderer', () => { ); const inputs = getAllByRole('textbox'); - expect(inputs.length).toBe(2); + expect(inputs).toHaveLength(2); await waitFor(() => expect(inputs[0]).toHaveFocus()); - expect(getRenderCount()).toBe(2) + expect(getRenderCount()).toBe(2); expect(container).toMatchSnapshot(); }); From b23ae638c721ff2322000402bd135fb4f8fe2a2c Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Wed, 5 Feb 2025 14:49:49 +0100 Subject: [PATCH 10/12] Add field test --- .../snap-ui-renderer.test.js.snap | 78 +++++++++++ .../snap-ui-renderer/snap-ui-renderer.test.js | 131 ++++++++++++++++++ 2 files changed, 209 insertions(+) diff --git a/ui/components/app/snaps/snap-ui-renderer/__snapshots__/snap-ui-renderer.test.js.snap b/ui/components/app/snaps/snap-ui-renderer/__snapshots__/snap-ui-renderer.test.js.snap index 872671ba644f..dedcba203543 100644 --- a/ui/components/app/snaps/snap-ui-renderer/__snapshots__/snap-ui-renderer.test.js.snap +++ b/ui/components/app/snaps/snap-ui-renderer/__snapshots__/snap-ui-renderer.test.js.snap @@ -396,6 +396,84 @@ exports[`SnapUIRenderer supports forms 1`] = ` `; +exports[`SnapUIRenderer supports forms with fields 1`] = ` +
+
+
+
+
+
+ +
+ +
+
+
+ + +
+ +
+
+
+ +
+
+`; + exports[`SnapUIRenderer supports interactive inputs 1`] = `
{ expect(container).toMatchSnapshot(); }); + it('supports forms with fields', () => { + const { container, getByRole } = renderInterface( + Box({ + children: Form({ + name: 'form', + children: [ + Field({ label: 'My Input', children: Input({ name: 'input' }) }), + Field({ label: 'Checkbox', children: Checkbox({ name: 'checkbox' }) }), + Button({ type: 'submit', name: 'submit', children: 'Submit' }), + ], + }), + }), + ); + + const input = getByRole('textbox'); + fireEvent.change(input, { target: { value: 'abc' } }); + + expect(submitRequestToBackground).toHaveBeenNthCalledWith( + 1, + 'updateInterfaceState', + [MOCK_INTERFACE_ID, { form: { input: 'abc' } }], + ); + + expect(submitRequestToBackground).toHaveBeenNthCalledWith( + 2, + 'handleSnapRequest', + [ + { + handler: 'onUserInput', + origin: '', + request: { + jsonrpc: '2.0', + method: ' ', + params: { + context: null, + event: { name: 'input', type: 'InputChangeEvent', value: 'abc' }, + id: MOCK_INTERFACE_ID, + }, + }, + snapId: MOCK_SNAP_ID, + }, + ], + ); + + const checkbox = getByRole('checkbox'); + fireEvent.click(checkbox); + + expect(submitRequestToBackground).toHaveBeenNthCalledWith( + 3, + 'updateInterfaceState', + [MOCK_INTERFACE_ID, { form: { checkbox: true, input: 'abc' } }], + ); + + expect(submitRequestToBackground).toHaveBeenNthCalledWith( + 4, + 'handleSnapRequest', + [ + { + handler: 'onUserInput', + origin: '', + request: { + jsonrpc: '2.0', + method: ' ', + params: { + context: null, + event: { name: 'checkbox', type: 'InputChangeEvent', value: true }, + id: MOCK_INTERFACE_ID, + }, + }, + snapId: MOCK_SNAP_ID, + }, + ], + ); + + const button = getByRole('button'); + fireEvent.click(button); + + expect(submitRequestToBackground).toHaveBeenNthCalledWith( + 5, + 'handleSnapRequest', + [ + { + handler: 'onUserInput', + origin: '', + request: { + jsonrpc: '2.0', + method: ' ', + params: { + context: null, + event: { name: 'submit', type: 'ButtonClickEvent' }, + id: MOCK_INTERFACE_ID, + }, + }, + snapId: MOCK_SNAP_ID, + }, + ], + ); + + expect(submitRequestToBackground).toHaveBeenNthCalledWith( + 6, + 'handleSnapRequest', + [ + { + handler: 'onUserInput', + origin: '', + request: { + jsonrpc: '2.0', + method: ' ', + params: { + context: null, + event: { + name: 'form', + type: 'FormSubmitEvent', + value: { + checkbox: true, + input: 'abc', + }, + }, + id: MOCK_INTERFACE_ID, + }, + }, + snapId: MOCK_SNAP_ID, + }, + ], + ); + + expect(container).toMatchSnapshot(); + }); + it('re-renders when the interface changes', () => { const { container, getAllByRole, updateInterface, getRenderCount } = renderInterface(Box({ children: Input({ name: 'input' }) })); From 3ed45d991cd00c2de98506e0091b24583b35596a Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Wed, 5 Feb 2025 15:29:16 +0100 Subject: [PATCH 11/12] Update snapshot --- .../__snapshots__/snap-ui-renderer.test.js.snap | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/ui/components/app/snaps/snap-ui-renderer/__snapshots__/snap-ui-renderer.test.js.snap b/ui/components/app/snaps/snap-ui-renderer/__snapshots__/snap-ui-renderer.test.js.snap index dedcba203543..f0cc98e97313 100644 --- a/ui/components/app/snaps/snap-ui-renderer/__snapshots__/snap-ui-renderer.test.js.snap +++ b/ui/components/app/snaps/snap-ui-renderer/__snapshots__/snap-ui-renderer.test.js.snap @@ -450,9 +450,13 @@ exports[`SnapUIRenderer supports forms with fields 1`] = ` class="mm-checkbox__input-wrapper" > +
From 305a5522f620caf2f8728a780cb42a6a3eed3170 Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Fri, 7 Feb 2025 11:55:36 +0100 Subject: [PATCH 12/12] Add file input test --- .../snap-ui-renderer.test.js.snap | 63 ++++++++ .../snap-ui-renderer/snap-ui-renderer.test.js | 146 +++++++++++++++++- 2 files changed, 207 insertions(+), 2 deletions(-) diff --git a/ui/components/app/snaps/snap-ui-renderer/__snapshots__/snap-ui-renderer.test.js.snap b/ui/components/app/snaps/snap-ui-renderer/__snapshots__/snap-ui-renderer.test.js.snap index f0cc98e97313..42d019e2a1f0 100644 --- a/ui/components/app/snaps/snap-ui-renderer/__snapshots__/snap-ui-renderer.test.js.snap +++ b/ui/components/app/snaps/snap-ui-renderer/__snapshots__/snap-ui-renderer.test.js.snap @@ -346,6 +346,69 @@ exports[`SnapUIRenderer supports container backgrounds 1`] = `
`; +exports[`SnapUIRenderer supports file inputs 1`] = ` +
+
+
+
+
+
+ + +
+ +

+ Drop your file here +

+
+
+ +
+
+
+ +
+
+`; + exports[`SnapUIRenderer supports forms 1`] = `
{ name: 'form', children: [ Field({ label: 'My Input', children: Input({ name: 'input' }) }), - Field({ label: 'Checkbox', children: Checkbox({ name: 'checkbox' }) }), + Field({ + label: 'Checkbox', + children: Checkbox({ name: 'checkbox' }), + }), Button({ type: 'submit', name: 'submit', children: 'Submit' }), ], }), @@ -421,7 +426,11 @@ describe('SnapUIRenderer', () => { method: ' ', params: { context: null, - event: { name: 'checkbox', type: 'InputChangeEvent', value: true }, + event: { + name: 'checkbox', + type: 'InputChangeEvent', + value: true, + }, id: MOCK_INTERFACE_ID, }, }, @@ -548,4 +557,137 @@ describe('SnapUIRenderer', () => { expect(container).toMatchSnapshot(); }); + + it('supports file inputs', async () => { + const { container, getByRole } = renderInterface( + Box({ + children: Form({ + name: 'form', + children: [ + Field({ + label: 'My Input', + children: FileInput({ name: 'input' }), + }), + Button({ type: 'submit', name: 'submit', children: 'Submit' }), + ], + }), + }), + ); + + const file = new File(['foo'], 'foo.svg', { type: 'image/svg' }); + + // JSDOM doesn't support array buffer so we overwrite it + file.arrayBuffer = async () => { + return new Uint8Array([102, 111, 111]); + }; + + const input = container.querySelector('#input'); + await userEvent.upload(input, file); + + expect(submitRequestToBackground).toHaveBeenNthCalledWith( + 1, + 'updateInterfaceState', + [ + MOCK_INTERFACE_ID, + { + form: { + input: { + contentType: 'image/svg', + contents: 'Zm9v', + name: 'foo.svg', + size: 3, + }, + }, + }, + ], + ); + + expect(submitRequestToBackground).toHaveBeenNthCalledWith( + 2, + 'handleSnapRequest', + [ + { + handler: 'onUserInput', + origin: '', + request: { + jsonrpc: '2.0', + method: ' ', + params: { + context: null, + event: { + name: 'input', + type: 'FileUploadEvent', + file: { + contentType: 'image/svg', + contents: 'Zm9v', + name: 'foo.svg', + size: 3, + }, + }, + id: MOCK_INTERFACE_ID, + }, + }, + snapId: MOCK_SNAP_ID, + }, + ], + ); + + const button = getByRole('button'); + fireEvent.click(button); + + expect(submitRequestToBackground).toHaveBeenNthCalledWith( + 5, + 'handleSnapRequest', + [ + { + handler: 'onUserInput', + origin: '', + request: { + jsonrpc: '2.0', + method: ' ', + params: { + context: null, + event: { name: 'submit', type: 'ButtonClickEvent' }, + id: MOCK_INTERFACE_ID, + }, + }, + snapId: MOCK_SNAP_ID, + }, + ], + ); + + expect(submitRequestToBackground).toHaveBeenNthCalledWith( + 6, + 'handleSnapRequest', + [ + { + handler: 'onUserInput', + origin: '', + request: { + jsonrpc: '2.0', + method: ' ', + params: { + context: null, + event: { + name: 'form', + type: 'FormSubmitEvent', + value: { + input: { + contentType: 'image/svg', + contents: 'Zm9v', + name: 'foo.svg', + size: 3, + }, + }, + }, + id: MOCK_INTERFACE_ID, + }, + }, + snapId: MOCK_SNAP_ID, + }, + ], + ); + + expect(container).toMatchSnapshot(); + }); });