diff --git a/app/components/Snaps/SnapUIAddress/SnapUIAddress.test.tsx b/app/components/Snaps/SnapUIAddress/SnapUIAddress.test.tsx
deleted file mode 100644
index f05913facc55..000000000000
--- a/app/components/Snaps/SnapUIAddress/SnapUIAddress.test.tsx
+++ /dev/null
@@ -1,193 +0,0 @@
-import React from 'react';
-import { SnapUIAddress } from './SnapUIAddress';
-import renderWithProvider from '../../../util/test/renderWithProvider';
-
-const baseMockState = {
- state: {
- engine: {
- backgroundState: {
- KeyringController: {
- keyrings: []
- },
- AccountsController: {
- internalAccounts: {
- accounts: {
- 'foo': {
- address: '0xab16a96D359eC26a11e2C2b3d8f8B8942d5Bfcdb',
- metadata: {
- name: 'My Account',
- }
- }
- }
- }
- },
- AddressBookController: {
- addressBook: {
- '0x1': {
- '0xab16a96D359eC26a11e2C2b3d8f8B8942d5Bfcda': {
- address: '0xab16a96D359eC26a11e2C2b3d8f8B8942d5Bfcda',
- name: 'Test Contact',
- }
- }
- }
- }
- }
- },
- }
-};
-
-const mockStateWithoutBlockies = {
- state: {
- ...baseMockState.state,
- settings: {
- useBlockieIcon: false,
- },
- },
-};
-
-const mockStateWithBlockies = {
- state: {
- ...baseMockState.state,
- settings: {
- useBlockieIcon: true,
- },
- },
-};
-
-describe('SnapUIAddress', () => {
- it('renders legacy Ethereum address', () => {
- const { toJSON } = renderWithProvider(
- ,
- mockStateWithoutBlockies,
- );
-
- expect(toJSON()).toMatchSnapshot();
- });
-
- it('renders Ethereum address', () => {
- const { toJSON } = renderWithProvider(
- ,
- mockStateWithoutBlockies,
- );
-
- expect(toJSON()).toMatchSnapshot();
- });
-
- it('renders account name', () => {
- const { getByText } = renderWithProvider(
- ,
- baseMockState
- );
-
- expect(getByText('My Account')).toBeDefined();
- });
-
- it('renders contact name', () => {
- const { getByText } = renderWithProvider(
- ,
- baseMockState
- );
-
- expect(getByText('Test Contact')).toBeDefined();
- });
-
-
- it('renders Ethereum address with blockie', () => {
- const { toJSON } = renderWithProvider(
- ,
- mockStateWithBlockies,
- );
-
- expect(toJSON()).toMatchSnapshot();
- });
-
- it('renders Bitcoin address', () => {
- const { toJSON } = renderWithProvider(
- ,
- mockStateWithoutBlockies,
- );
-
- expect(toJSON()).toMatchSnapshot();
- });
-
- it('renders Bitcoin address with blockie', () => {
- const { toJSON } = renderWithProvider(
- ,
- mockStateWithBlockies,
- );
-
- expect(toJSON()).toMatchSnapshot();
- });
-
- it('renders Cosmos address', () => {
- const { toJSON } = renderWithProvider(
- ,
- mockStateWithoutBlockies,
- );
-
- expect(toJSON()).toMatchSnapshot();
- });
-
- it('renders Cosmos address with blockie', () => {
- const { toJSON } = renderWithProvider(
- ,
- mockStateWithBlockies,
- );
-
- expect(toJSON()).toMatchSnapshot();
- });
-
- it('renders Polkadot address', () => {
- const { toJSON } = renderWithProvider(
- ,
- mockStateWithoutBlockies,
- );
-
- expect(toJSON()).toMatchSnapshot();
- });
-
- it('renders Polkadot address with blockie', () => {
- const { toJSON } = renderWithProvider(
- ,
- mockStateWithBlockies,
- );
-
- expect(toJSON()).toMatchSnapshot();
- });
-
- it('renders Starknet address', () => {
- const { toJSON } = renderWithProvider(
- ,
- mockStateWithoutBlockies,
- );
-
- expect(toJSON()).toMatchSnapshot();
- });
-
- it('renders Starknet address with blockie', () => {
- const { toJSON } = renderWithProvider(
- ,
- mockStateWithBlockies,
- );
-
- expect(toJSON()).toMatchSnapshot();
- });
-
- it('renders Hedera address', () => {
- const { toJSON } = renderWithProvider(
- ,
- mockStateWithoutBlockies,
- );
-
- expect(toJSON()).toMatchSnapshot();
- });
-
- it('renders Hedera address with blockie', () => {
- const { toJSON } = renderWithProvider(
- ,
- mockStateWithBlockies,
- );
-
- expect(toJSON()).toMatchSnapshot();
- });
-});
diff --git a/app/components/Snaps/SnapUIInput/SnapUIInput.test.tsx b/app/components/Snaps/SnapUIInput/SnapUIInput.test.tsx
deleted file mode 100644
index b93dcce459ed..000000000000
--- a/app/components/Snaps/SnapUIInput/SnapUIInput.test.tsx
+++ /dev/null
@@ -1,118 +0,0 @@
-import React from 'react';
-import { render, fireEvent } from '@testing-library/react-native';
-import { SnapUIInput } from './SnapUIInput';
-import { useSnapInterfaceContext } from '../SnapInterfaceContext';
-import { INPUT_TEST_ID } from '../../../component-library/components/Form/TextField/foundation/Input/Input.constants';
-
-// Mock the entire module
-jest.mock('../SnapInterfaceContext');
-
-describe('SnapUIInput', () => {
- const mockHandleInputChange = jest.fn();
- const mockSetCurrentFocusedInput = jest.fn();
- const mockGetValue = jest.fn();
-
- beforeEach(() => {
- // Set up the mock implementation before each test
- (useSnapInterfaceContext as jest.Mock).mockReturnValue({
- handleInputChange: mockHandleInputChange,
- getValue: mockGetValue,
- setCurrentFocusedInput: mockSetCurrentFocusedInput,
- focusedInput: null,
- });
-
- // Clear all mocks before each test
- jest.clearAllMocks();
- });
-
- it('renders with initial value', () => {
- mockGetValue.mockReturnValue('initial value');
-
- const { getByDisplayValue } = render();
-
- expect(getByDisplayValue('initial value')).toBeTruthy();
- });
-
- it('handles input changes', () => {
- const { getByTestId } = render();
-
- const input = getByTestId('textfield');
- fireEvent.changeText(input, 'new value');
-
- expect(mockHandleInputChange).toHaveBeenCalledWith(
- 'testInput',
- 'new value',
- undefined,
- );
- });
-
- it('handles form input changes', () => {
- const { getByTestId } = render(
- ,
- );
-
- const input = getByTestId('textfield');
- fireEvent.changeText(input, 'new value');
-
- expect(mockHandleInputChange).toHaveBeenCalledWith(
- 'testInput',
- 'new value',
- 'testForm',
- );
- });
-
- it('handles focus events', () => {
- const { getByTestId } = render();
-
- const input = getByTestId('textfield');
- fireEvent(input, 'focus');
-
- expect(mockSetCurrentFocusedInput).toHaveBeenCalledWith('testInput');
- });
-
- it('handles blur events', () => {
- const { getByTestId } = render();
-
- const input = getByTestId('textfield');
- fireEvent(input, 'blur');
-
- expect(mockSetCurrentFocusedInput).toHaveBeenCalledWith(null);
- });
-
- it('handles disabled input', () => {
- const { getByTestId } = render();
-
- const input = getByTestId(INPUT_TEST_ID);
- expect(input.props.editable).toBe(false);
- });
-
- it('updates value when initialValue changes', () => {
- mockGetValue.mockReturnValue('initial value');
-
- const { getByDisplayValue, rerender } = render(
- ,
- );
-
- expect(getByDisplayValue('initial value')).toBeTruthy();
-
- mockGetValue.mockReturnValue('updated value');
- rerender();
-
- expect(getByDisplayValue('updated value')).toBeTruthy();
- });
-
- it('maintains focus state when re-rendered', () => {
- (useSnapInterfaceContext as jest.Mock).mockReturnValue({
- handleInputChange: mockHandleInputChange,
- getValue: mockGetValue,
- setCurrentFocusedInput: mockSetCurrentFocusedInput,
- focusedInput: 'testInput',
- });
-
- const { getByTestId } = render();
- const input = getByTestId('textfield');
-
- expect(input).toBeTruthy();
- expect(useSnapInterfaceContext().focusedInput).toBe('testInput');
- });
-});
diff --git a/app/components/Snaps/SnapUIRenderer/SnapUIRenderer.test.ts b/app/components/Snaps/SnapUIRenderer/SnapUIRenderer.test.ts
new file mode 100644
index 000000000000..3953ea8f8404
--- /dev/null
+++ b/app/components/Snaps/SnapUIRenderer/SnapUIRenderer.test.ts
@@ -0,0 +1,251 @@
+import {
+ Box,
+ Text,
+ Container,
+ Footer,
+ Button,
+ Input,
+ Form,
+ Field,
+ Section,
+ Row,
+ Value,
+ Card,
+ Image as ImageComponent,
+} from '@metamask/snaps-sdk/jsx';
+import { fireEvent } from '@testing-library/react-native';
+import Engine from '../../../core/Engine/Engine';
+import { renderInterface, MOCK_INTERFACE_ID, MOCK_SNAP_ID } from './testUtils';
+
+jest.mock('../../../core/Engine/Engine', () => ({
+ controllerMessenger: {
+ call: jest.fn(),
+ },
+ context: {
+ SnapInterfaceController: {
+ updateInterfaceState: jest.fn(),
+ },
+ },
+}));
+
+const mockEngine = jest.mocked(Engine);
+
+describe('SnapUIRenderer', () => {
+ beforeEach(() => {
+ jest.resetAllMocks();
+ });
+
+ it('renders loading state', () => {
+ const { toJSON } = renderInterface(null);
+
+ expect(toJSON()).toMatchSnapshot();
+ });
+
+ it('renders basic UI', () => {
+ const { toJSON, getByText, getRenderCount } = renderInterface(
+ Box({ children: Text({ children: 'Hello world!' }) }),
+ );
+
+ expect(getByText('Hello world!')).toBeDefined();
+ expect(getRenderCount()).toBe(1);
+ expect(toJSON()).toMatchSnapshot();
+ });
+
+ it('renders footers', () => {
+ const { toJSON, getByText } = renderInterface(
+ Container({
+ children: [
+ Box({ children: Text({ children: 'Hello world!' }) }),
+ Footer({ children: Button({ children: 'Foo' }) }),
+ ],
+ }),
+ { useFooter: true },
+ );
+
+ expect(getByText('Foo')).toBeDefined();
+ expect(toJSON()).toMatchSnapshot();
+ });
+
+ it('adds a footer if required', () => {
+ const { toJSON, getByText } = renderInterface(
+ Container({
+ children: Box({ children: Text({ children: 'Hello world!' }) }),
+ }),
+ { useFooter: true },
+ );
+
+ expect(getByText('Close')).toBeDefined();
+ expect(toJSON()).toMatchSnapshot();
+ });
+
+ it('supports the onCancel prop', () => {
+ const onCancel = jest.fn();
+ const { toJSON, 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(toJSON()).toMatchSnapshot();
+
+ fireEvent.press(button);
+ expect(onCancel).toHaveBeenCalled();
+ });
+
+ it('supports interactive inputs', () => {
+ const { toJSON, getByTestId } = renderInterface(
+ Box({ children: Input({ name: 'input' }) }),
+ );
+
+ const input = getByTestId('input');
+ fireEvent.changeText(input, 'a');
+
+ expect(
+ mockEngine.context.SnapInterfaceController.updateInterfaceState,
+ ).toHaveBeenNthCalledWith(1, MOCK_INTERFACE_ID, { input: 'a' });
+
+ expect(mockEngine.controllerMessenger.call).toHaveBeenNthCalledWith(
+ 1,
+ 'SnapController:handleRequest',
+ {
+ handler: 'onUserInput',
+ origin: 'metamask',
+ request: {
+ jsonrpc: '2.0',
+ method: ' ',
+ params: {
+ event: { name: 'input', type: 'InputChangeEvent', value: 'a' },
+ id: MOCK_INTERFACE_ID,
+ },
+ },
+ snapId: MOCK_SNAP_ID,
+ },
+ );
+
+ expect(toJSON()).toMatchSnapshot();
+ });
+
+ it('prefills interactive inputs with existing state', () => {
+ const { toJSON, getByTestId } = renderInterface(
+ Box({ children: Input({ name: 'input' }) }),
+ { state: { input: 'bar' } },
+ );
+
+ const input = getByTestId('input');
+ expect(input).toBeDefined();
+ expect(input.props.value).toStrictEqual('bar');
+
+ expect(toJSON()).toMatchSnapshot();
+ });
+
+ it('re-renders when the interface changes', () => {
+ const { toJSON, getAllByTestId, updateInterface, getRenderCount } =
+ renderInterface(
+ Box({ children: Input({ name: 'input', type: 'number' }) }),
+ );
+
+ const inputs = getAllByTestId('input');
+ expect(inputs).toHaveLength(1);
+
+ updateInterface(
+ Box({
+ children: [
+ Input({ name: 'input', type: 'number' }),
+ Input({ name: 'input2', type: 'password' }),
+ ],
+ }),
+ );
+
+ const inputsAfterRerender = getAllByTestId('input');
+ expect(inputsAfterRerender).toHaveLength(2);
+
+ expect(getRenderCount()).toBe(2);
+
+ expect(toJSON()).toMatchSnapshot();
+ });
+
+ it('re-syncs state when the interface changes', () => {
+ const { toJSON, getAllByTestId, getRenderCount, updateInterface } =
+ renderInterface(Box({ children: Input({ name: 'input' }) }));
+
+ updateInterface(
+ Box({ children: [Input({ name: 'input' }), Input({ name: 'input2' })] }),
+ { input: 'bar', input2: 'foo' },
+ );
+
+ const inputsAfterRerender = getAllByTestId('input');
+ expect(inputsAfterRerender).toHaveLength(2);
+ expect(inputsAfterRerender[0].props.value).toStrictEqual('bar');
+ expect(inputsAfterRerender[1].props.value).toStrictEqual('foo');
+
+ expect(getRenderCount()).toBe(2);
+
+ expect(toJSON()).toMatchSnapshot();
+ });
+
+ it('supports fields with multiple components', () => {
+ const { toJSON } = renderInterface(
+ Box({
+ children: Form({
+ name: 'form',
+ children: [
+ Field({
+ label: 'My Input',
+ children: [
+ Box({
+ children: [
+ ImageComponent({ src: '' }),
+ ],
+ }),
+ Input({ name: 'input' }),
+ Button({ type: 'submit', name: 'submit', children: 'Submit' }),
+ ],
+ }),
+ ],
+ }),
+ }),
+ );
+
+ expect(toJSON()).toMatchSnapshot();
+ });
+
+ it('renders complex nested components', () => {
+ const { toJSON, getRenderCount } = renderInterface(
+ Container({
+ children: [
+ Box({
+ children: [
+ Section({
+ children: [
+ Row({
+ label: 'Key',
+ children: Value({ value: 'Value', extra: 'Extra' }),
+ }),
+ Card({
+ image: '',
+ title: 'CardTitle',
+ description: 'CardDescription',
+ value: 'CardValue',
+ extra: 'CardExtra',
+ }),
+ ],
+ }),
+ ],
+ }),
+ Footer({ children: Button({ children: 'Foo' }) }),
+ ],
+ }),
+ { useFooter: true },
+ );
+
+ expect(getRenderCount()).toBe(1);
+
+ expect(toJSON()).toMatchSnapshot();
+ });
+});
diff --git a/app/components/Snaps/SnapUIRenderer/SnapUIRenderer.test.tsx b/app/components/Snaps/SnapUIRenderer/SnapUIRenderer.test.tsx
deleted file mode 100644
index 6eed54da4786..000000000000
--- a/app/components/Snaps/SnapUIRenderer/SnapUIRenderer.test.tsx
+++ /dev/null
@@ -1,587 +0,0 @@
-import React from 'react';
-import {
- Box,
- Text,
- Container,
- Footer,
- Button,
- Input,
- JSXElement,
- Form,
- Field,
- Checkbox,
- Section,
- Row,
- Value,
- Card,
- Image as ImageComponent,
- Selector,
- SelectorOption,
-} from '@metamask/snaps-sdk/jsx';
-import { fireEvent, act } from '@testing-library/react-native';
-import renderWithProvider from '../../../util/test/renderWithProvider';
-import { SnapUIRenderer } from './SnapUIRenderer';
-import Engine from '../../../core/Engine/Engine';
-import { RootState } from '../../../reducers';
-import { FormState } from '@metamask/snaps-sdk';
-import { PayloadAction } from '@reduxjs/toolkit';
-
-jest.mock('../../../core/Engine/Engine', () => ({
- controllerMessenger: {
- call: jest.fn(),
- },
- context: {
- SnapInterfaceController: {
- updateInterfaceState: jest.fn(),
- },
- },
-}));
-
-const mockEngine = jest.mocked(Engine);
-
-const MOCK_SNAP_ID = 'npm:@metamask/test-snap-bip44';
-const MOCK_INTERFACE_ID = 'interfaceId';
-
-const noOp = () => {
- // no-op
-};
-
-function renderInterface(
- content: JSXElement | null,
- { useFooter = false, onCancel = noOp, state = {} } = {},
-) {
- const storeState = {
- engine: {
- backgroundState: {
- SubjectMetadataController: {
- subjectMetadata: {
- 'npm:@metamask/test-snap-bip44': {
- name: '@metamask/test-snap-bip44',
- version: '1.2.3',
- subjectType: 'snap',
- },
- },
- },
- SnapController: {
- snaps: {
- [MOCK_SNAP_ID]: {
- id: 'npm:@metamask/test-snap-bip44',
- origin: 'npm:@metamask/test-snap-bip44',
- version: '5.1.2',
- iconUrl: null,
- initialPermissions: {
- 'endowment:ethereum-provider': {},
- },
- manifest: {
- description: 'An example Snap that signs messages using BLS.',
- proposedName: 'BIP-44 Test Snap',
- repository: {
- type: 'git',
- url: 'https://github.com/MetaMask/test-snaps.git',
- },
- source: {
- location: {
- npm: {
- filePath: 'dist/bundle.js',
- packageName: '@metamask/test-snap-bip44',
- registry: 'https://registry.npmjs.org',
- },
- },
- shasum: 'L1k+dT9Q+y3KfIqzaH09MpDZVPS9ZowEh9w01ZMTWMU=',
- },
- version: '5.1.2',
- },
- versionHistory: [
- {
- date: 1680686075921,
- origin: 'https://metamask.github.io',
- version: '5.1.2',
- },
- ],
- },
- },
- },
- SnapInterfaceController: {
- interfaces: {
- [MOCK_INTERFACE_ID]: {
- snapId: MOCK_SNAP_ID,
- content,
- state,
- context: null,
- contentType: null,
- },
- },
- },
- },
- },
- };
-
- const result = renderWithProvider(
- ,
- { state: storeState as unknown as RootState },
- );
-
- const reducer = (
- reducerState: RootState,
- action: PayloadAction<{ content: JSXElement; state: FormState }>,
- ) => {
- if (action.type === 'updateInterface') {
- return {
- engine: {
- backgroundState: {
- ...reducerState.engine.backgroundState,
- SnapInterfaceController: {
- interfaces: {
- [MOCK_INTERFACE_ID]: {
- snapId: MOCK_SNAP_ID,
- content: action.payload.content,
- state: action.payload.state ?? state,
- context: null,
- contentType: null,
- },
- },
- },
- },
- },
- };
- }
- return reducerState;
- };
-
- const { store } = result;
-
- // @ts-expect-error Mock reducer doesn't fully match the type.
- store.replaceReducer(reducer);
-
- const updateInterface = (
- newContent: JSXElement,
- newState: FormState | null = null,
- ) => {
- act(() => {
- store.dispatch({
- type: 'updateInterface',
- payload: {
- content: newContent,
- state: newState,
- },
- });
- });
- };
-
- const getRenderCount = () =>
- parseInt(result.getByTestId('performance').props['data-renders'], 10);
-
- return { ...result, updateInterface, getRenderCount };
-}
-
-describe('SnapUIRenderer', () => {
- beforeEach(() => {
- jest.resetAllMocks();
- });
-
- it('renders loading state', () => {
- const { toJSON } = renderInterface(null);
-
- expect(toJSON()).toMatchSnapshot();
- });
-
- it('renders basic UI', () => {
- const { toJSON, getByText, getRenderCount } = renderInterface(
- Box({ children: Text({ children: 'Hello world!' }) }),
- );
-
- expect(getByText('Hello world!')).toBeDefined();
- expect(getRenderCount()).toBe(1);
- expect(toJSON()).toMatchSnapshot();
- });
-
- it('renders footers', () => {
- const { toJSON, getByText } = renderInterface(
- Container({
- children: [
- Box({ children: Text({ children: 'Hello world!' }) }),
- Footer({ children: Button({ children: 'Foo' }) }),
- ],
- }),
- { useFooter: true },
- );
-
- expect(getByText('Foo')).toBeDefined();
- expect(toJSON()).toMatchSnapshot();
- });
-
- it('adds a footer if required', () => {
- const { toJSON, getByText } = renderInterface(
- Container({
- children: Box({ children: Text({ children: 'Hello world!' }) }),
- }),
- { useFooter: true },
- );
-
- expect(getByText('Close')).toBeDefined();
- expect(toJSON()).toMatchSnapshot();
- });
-
- it('supports the onCancel prop', () => {
- const onCancel = jest.fn();
- const { toJSON, 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(toJSON()).toMatchSnapshot();
-
- fireEvent.press(button);
- expect(onCancel).toHaveBeenCalled();
- });
-
- it('supports interactive inputs', () => {
- const { toJSON, getByTestId } = renderInterface(
- Box({ children: Input({ name: 'input' }) }),
- );
-
- const input = getByTestId('input');
- fireEvent.changeText(input, 'a');
-
- expect(
- mockEngine.context.SnapInterfaceController.updateInterfaceState,
- ).toHaveBeenNthCalledWith(1, MOCK_INTERFACE_ID, { input: 'a' });
-
- expect(mockEngine.controllerMessenger.call).toHaveBeenNthCalledWith(
- 1,
- 'SnapController:handleRequest',
- {
- handler: 'onUserInput',
- origin: 'metamask',
- request: {
- jsonrpc: '2.0',
- method: ' ',
- params: {
- event: { name: 'input', type: 'InputChangeEvent', value: 'a' },
- id: MOCK_INTERFACE_ID,
- },
- },
- snapId: MOCK_SNAP_ID,
- },
- );
-
- expect(toJSON()).toMatchSnapshot();
- });
-
- it('prefills interactive inputs with existing state', () => {
- const { toJSON, getByTestId } = renderInterface(
- Box({ children: Input({ name: 'input' }) }),
- { state: { input: 'bar' } },
- );
-
- const input = getByTestId('input');
- expect(input).toBeDefined();
- expect(input.props.value).toStrictEqual('bar');
-
- expect(toJSON()).toMatchSnapshot();
- });
-
- it('re-renders when the interface changes', () => {
- const { toJSON, getAllByTestId, updateInterface, getRenderCount } =
- renderInterface(
- Box({ children: Input({ name: 'input', type: 'number' }) }),
- );
-
- const inputs = getAllByTestId('input');
- expect(inputs).toHaveLength(1);
-
- updateInterface(
- Box({
- children: [
- Input({ name: 'input', type: 'number' }),
- Input({ name: 'input2', type: 'password' }),
- ],
- }),
- );
-
- const inputsAfterRerender = getAllByTestId('input');
- expect(inputsAfterRerender).toHaveLength(2);
-
- expect(getRenderCount()).toBe(2);
-
- expect(toJSON()).toMatchSnapshot();
- });
-
- it('re-syncs state when the interface changes', () => {
- const { toJSON, getAllByTestId, getRenderCount, updateInterface } =
- renderInterface(Box({ children: Input({ name: 'input' }) }));
-
- updateInterface(
- Box({ children: [Input({ name: 'input' }), Input({ name: 'input2' })] }),
- { input: 'bar', input2: 'foo' },
- );
-
- const inputsAfterRerender = getAllByTestId('input');
- expect(inputsAfterRerender).toHaveLength(2);
- expect(inputsAfterRerender[0].props.value).toStrictEqual('bar');
- expect(inputsAfterRerender[1].props.value).toStrictEqual('foo');
-
- expect(getRenderCount()).toBe(2);
-
- expect(toJSON()).toMatchSnapshot();
- });
-
- it('supports forms with fields', () => {
- const { toJSON, getByTestId, getByText } = renderInterface(
- Box({
- children: Form({
- name: 'form',
- children: [
- Field({ label: 'My Input', children: Input({ name: 'input' }) }),
- Field({
- label: 'My Checkbox',
- children: Checkbox({
- name: 'checkbox',
- label: 'This is a checkbox',
- }),
- }),
- Field({
- label: 'My Selector',
- children: Selector({
- name: 'selector',
- title: 'Select an option',
- children: [
- SelectorOption({
- value: 'option1',
- children: Card({
- title: 'CardTitle1',
- description: 'CardDescription1',
- value: 'CardValue1',
- extra: 'CardExtra1',
- }),
- }),
- SelectorOption({
- value: 'option2',
- children: Card({
- title: 'CardTitle2',
- description: 'CardDescription2',
- value: 'CardValue2',
- extra: 'CardExtra2',
- }),
- }),
- ],
- }),
- }),
- Button({ type: 'submit', name: 'submit', children: 'Submit' }),
- ],
- }),
- }),
- { state: { form: { selector: 'option1' } } },
- );
-
- const input = getByTestId('input');
- fireEvent.changeText(input, 'abc');
-
- expect(
- mockEngine.context.SnapInterfaceController.updateInterfaceState,
- ).toHaveBeenNthCalledWith(1, MOCK_INTERFACE_ID, {
- form: { input: 'abc', selector: 'option1' },
- });
-
- expect(mockEngine.controllerMessenger.call).toHaveBeenNthCalledWith(
- 1,
- 'SnapController:handleRequest',
- {
- handler: 'onUserInput',
- origin: 'metamask',
- request: {
- jsonrpc: '2.0',
- method: ' ',
- params: {
- event: { name: 'input', type: 'InputChangeEvent', value: 'abc' },
- id: MOCK_INTERFACE_ID,
- },
- },
- snapId: MOCK_SNAP_ID,
- },
- );
-
- const checkbox = getByText('This is a checkbox');
- fireEvent.press(checkbox);
-
- expect(
- mockEngine.context.SnapInterfaceController.updateInterfaceState,
- ).toHaveBeenNthCalledWith(2, MOCK_INTERFACE_ID, {
- form: { input: 'abc', checkbox: true, selector: 'option1' },
- });
-
- expect(mockEngine.controllerMessenger.call).toHaveBeenNthCalledWith(
- 2,
- 'SnapController:handleRequest',
- {
- handler: 'onUserInput',
- origin: 'metamask',
- request: {
- jsonrpc: '2.0',
- method: ' ',
- params: {
- event: { name: 'checkbox', type: 'InputChangeEvent', value: true },
- id: MOCK_INTERFACE_ID,
- },
- },
- snapId: MOCK_SNAP_ID,
- },
- );
-
- const selector = getByText('CardTitle1');
- fireEvent.press(selector);
-
- const selectorItem = getByText('CardTitle2');
- fireEvent.press(selectorItem);
-
- expect(
- mockEngine.context.SnapInterfaceController.updateInterfaceState,
- ).toHaveBeenNthCalledWith(3, MOCK_INTERFACE_ID, {
- form: { input: 'abc', checkbox: true, selector: 'option2' },
- });
-
- expect(mockEngine.controllerMessenger.call).toHaveBeenNthCalledWith(
- 3,
- 'SnapController:handleRequest',
- {
- handler: 'onUserInput',
- origin: 'metamask',
- request: {
- jsonrpc: '2.0',
- method: ' ',
- params: {
- event: {
- name: 'selector',
- type: 'InputChangeEvent',
- value: 'option2',
- },
- id: MOCK_INTERFACE_ID,
- },
- },
- snapId: MOCK_SNAP_ID,
- },
- );
-
- const button = getByText('Submit');
- fireEvent.press(button);
-
- expect(mockEngine.controllerMessenger.call).toHaveBeenNthCalledWith(
- 4,
- 'SnapController:handleRequest',
- {
- handler: 'onUserInput',
- origin: 'metamask',
- request: {
- jsonrpc: '2.0',
- method: ' ',
- params: {
- event: { name: 'submit', type: 'ButtonClickEvent' },
- id: MOCK_INTERFACE_ID,
- },
- },
- snapId: MOCK_SNAP_ID,
- },
- );
-
- expect(mockEngine.controllerMessenger.call).toHaveBeenNthCalledWith(
- 5,
- 'SnapController:handleRequest',
- {
- handler: 'onUserInput',
- origin: 'metamask',
- request: {
- jsonrpc: '2.0',
- method: ' ',
- params: {
- event: {
- name: 'form',
- type: 'FormSubmitEvent',
- value: {
- checkbox: true,
- input: 'abc',
- selector: 'option2',
- },
- },
- id: MOCK_INTERFACE_ID,
- },
- },
- snapId: MOCK_SNAP_ID,
- },
- );
-
- expect(toJSON()).toMatchSnapshot();
- });
-
- it('supports fields with multiple components', () => {
- const { toJSON } = renderInterface(
- Box({
- children: Form({
- name: 'form',
- children: [
- Field({
- label: 'My Input',
- children: [
- Box({
- children: [
- ImageComponent({ src: '' }),
- ],
- }),
- Input({ name: 'input' }),
- Button({ type: 'submit', name: 'submit', children: 'Submit' }),
- ],
- }),
- ],
- }),
- }),
- );
-
- expect(toJSON()).toMatchSnapshot();
- });
-
- it('renders complex nested components', () => {
- const { toJSON, getRenderCount } = renderInterface(
- Container({
- children: [
- Box({
- children: [
- Section({
- children: [
- Row({
- label: 'Key',
- children: Value({ value: 'Value', extra: 'Extra' }),
- }),
- Card({
- image: '',
- title: 'CardTitle',
- description: 'CardDescription',
- value: 'CardValue',
- extra: 'CardExtra',
- }),
- ],
- }),
- ],
- }),
- Footer({ children: Button({ children: 'Foo' }) }),
- ],
- }),
- { useFooter: true },
- );
-
- expect(getRenderCount()).toBe(1);
-
- expect(toJSON()).toMatchSnapshot();
- });
-});
diff --git a/app/components/Snaps/SnapUIRenderer/__snapshots__/SnapUIRenderer.test.tsx.snap b/app/components/Snaps/SnapUIRenderer/__snapshots__/SnapUIRenderer.test.ts.snap
similarity index 58%
rename from app/components/Snaps/SnapUIRenderer/__snapshots__/SnapUIRenderer.test.tsx.snap
rename to app/components/Snaps/SnapUIRenderer/__snapshots__/SnapUIRenderer.test.ts.snap
index 4f1dede85b8d..b19490ab41da 100644
--- a/app/components/Snaps/SnapUIRenderer/__snapshots__/SnapUIRenderer.test.tsx.snap
+++ b/app/components/Snaps/SnapUIRenderer/__snapshots__/SnapUIRenderer.test.ts.snap
@@ -1794,1060 +1794,6 @@ exports[`SnapUIRenderer supports fields with multiple components 1`] = `
`;
-exports[`SnapUIRenderer supports forms with fields 1`] = `
-
-
-
-
-
-
-
-
- My Input
-
-
-
-
-
-
-
-
-
- My Checkbox
-
-
-
-
-
-
-
- This is a checkbox
-
-
-
-
-
-
- My Selector
-
-
-
-
-
-
- CardTitle2
-
-
- CardDescription2
-
-
-
-
-
- CardValue2
-
-
- CardExtra2
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Select an option
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- CardTitle1
-
-
- CardDescription1
-
-
-
-
-
- CardValue1
-
-
- CardExtra1
-
-
-
-
-
-
-
-
-
- CardTitle2
-
-
- CardDescription2
-
-
-
-
-
- CardValue2
-
-
- CardExtra2
-
-
-
-
-
-
-
-
-
-
-
-
-
- Submit
-
-
-
-
-
-
-
-
-
-`;
-
exports[`SnapUIRenderer supports interactive inputs 1`] = `
-
-
-
+
-
+
-
+
+
+
+
+
+
+
+
+
-
-
+ >
+ 128Lkh3...Mp8p6
+
+
+
+
-
- 128Lkh3...Mp8p6
-
+
`;
exports[`SnapUIAddress renders Bitcoin address with blockie 1`] = `
-
-
- 128Lkh3...Mp8p6
-
+
+
+
+
+
+ 128Lkh3...Mp8p6
+
+
+
+
+
+
`;
exports[`SnapUIAddress renders Cosmos address 1`] = `
-
-
-
+
-
+
-
+
+
+
+
+
+
+
+
+
-
-
+ >
+ cosmos1...6hdc0
+
+
+
+
-
- cosmos1...6hdc0
-
+
`;
exports[`SnapUIAddress renders Cosmos address with blockie 1`] = `
-
-
- cosmos1...6hdc0
-
+
+
+
+
+
+ cosmos1...6hdc0
+
+
+
+
+
+
`;
exports[`SnapUIAddress renders Ethereum address 1`] = `
-
-
-
+
-
+
-
+
+
+
+
+
+
+
+
+
-
-
+ >
+ 0xab16a...Bfcdb
+
+
+
+
-
- 0xab16a...Bfcdb
-
+
`;
exports[`SnapUIAddress renders Ethereum address with blockie 1`] = `
-
-
- 0xab16a...Bfcdb
-
+
+
+
+
+
+ 0xab16a...Bfcdb
+
+
+
+
+
+
`;
exports[`SnapUIAddress renders Hedera address 1`] = `
-
-
-
+
-
+
-
+
+
+
+
+
+
+
+
+
-
-
+ >
+ 0.0.123...zbhlt
+
+
+
+
-
- 0.0.123...zbhlt
-
+
`;
exports[`SnapUIAddress renders Hedera address with blockie 1`] = `
-
-
- 0.0.123...zbhlt
-
+
+
+
+
+
+ 0.0.123...zbhlt
+
+
+
+
+
+
`;
exports[`SnapUIAddress renders Polkadot address 1`] = `
-
-
-
+
-
+
-
+
+
+
+
+
+
+
+
+
-
-
+ >
+ 5hmuyxw...egmfy
+
+
+
+
-
- 5hmuyxw...egmfy
-
+
`;
exports[`SnapUIAddress renders Polkadot address with blockie 1`] = `
-
-
- 5hmuyxw...egmfy
-
+
+
+
+
+
+ 5hmuyxw...egmfy
+
+
+
+
+
+
`;
exports[`SnapUIAddress renders Starknet address 1`] = `
-
-
-
+
-
+
-
+
+
+
+
+
+
+
+
+
-
-
+ >
+ 0x02dd1...0ab57
+
+
+
+
-
- 0x02dd1...0ab57
-
+
`;
exports[`SnapUIAddress renders Starknet address with blockie 1`] = `
-
-
- 0x02dd1...0ab57
-
+
+
+
+
+
+ 0x02dd1...0ab57
+
+
+
+
+
+
`;
exports[`SnapUIAddress renders legacy Ethereum address 1`] = `
-
-
-
+
-
+
-
+
+
+
+
+
+
+
+
+
-
-
+ >
+ 0xab16a...Bfcdb
+
+
+
+
-
- 0xab16a...Bfcdb
-
+
`;
diff --git a/app/components/Snaps/SnapUIRenderer/components/__snapshots__/form.test.ts.snap b/app/components/Snaps/SnapUIRenderer/components/__snapshots__/form.test.ts.snap
new file mode 100644
index 000000000000..15a8e24f6c88
--- /dev/null
+++ b/app/components/Snaps/SnapUIRenderer/components/__snapshots__/form.test.ts.snap
@@ -0,0 +1,1214 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`SnapUIForm will render 1`] = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Submit
+
+
+
+
+
+
+
+
+
+`;
+
+exports[`SnapUIForm will render with fields 1`] = `
+
+
+
+
+
+
+
+
+ My Input
+
+
+
+
+
+
+
+
+
+ My Checkbox
+
+
+
+
+
+
+
+ This is a checkbox
+
+
+
+
+
+
+ My Selector
+
+
+
+
+
+
+ CardTitle2
+
+
+ CardDescription2
+
+
+
+
+
+ CardValue2
+
+
+ CardExtra2
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Select an option
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ CardTitle1
+
+
+ CardDescription1
+
+
+
+
+
+ CardValue1
+
+
+ CardExtra1
+
+
+
+
+
+
+
+
+
+ CardTitle2
+
+
+ CardDescription2
+
+
+
+
+
+ CardValue2
+
+
+ CardExtra2
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Submit
+
+
+
+
+
+
+
+
+
+`;
diff --git a/app/components/Snaps/SnapUIRenderer/components/__snapshots__/input.test.ts.snap b/app/components/Snaps/SnapUIRenderer/components/__snapshots__/input.test.ts.snap
new file mode 100644
index 000000000000..52e39382957e
--- /dev/null
+++ b/app/components/Snaps/SnapUIRenderer/components/__snapshots__/input.test.ts.snap
@@ -0,0 +1,247 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`SnapUIInput handles disabled input 1`] = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`;
+
+exports[`SnapUIInput renders with initial value 1`] = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`;
diff --git a/app/components/Snaps/SnapUIRenderer/components/address.test.ts b/app/components/Snaps/SnapUIRenderer/components/address.test.ts
new file mode 100644
index 000000000000..8295f1f150de
--- /dev/null
+++ b/app/components/Snaps/SnapUIRenderer/components/address.test.ts
@@ -0,0 +1,184 @@
+import { renderInterface } from '../testUtils';
+import { Address } from '@metamask/snaps-sdk/jsx';
+
+jest.mock('../../../../core/Engine/Engine');
+
+const withBlockies = {
+ useBlockieIcon: true,
+};
+
+const withoutBlockies = {
+ useBlockieIcon: false,
+};
+
+describe('SnapUIAddress', () => {
+ it('renders legacy Ethereum address', () => {
+ const { toJSON } = renderInterface(
+ Address({
+ address: '0xab16a96d359ec26a11e2c2b3d8f8b8942d5bfcdb',
+ }),
+ { stateSettings: withoutBlockies },
+ );
+
+ expect(toJSON()).toMatchSnapshot();
+ });
+
+ it('renders Ethereum address', () => {
+ const { toJSON } = renderInterface(
+ Address({
+ address: 'eip155:1:0xab16a96D359eC26a11e2C2b3d8f8B8942d5Bfcdb',
+ }),
+ { stateSettings: withoutBlockies },
+ );
+
+ expect(toJSON()).toMatchSnapshot();
+ });
+
+ it('renders account name', () => {
+ const { getByText } = renderInterface(
+ Address({
+ address: 'eip155:1:0xab16a96D359eC26a11e2C2b3d8f8B8942d5Bfcdb',
+ displayName: true,
+ }),
+ { stateSettings: withoutBlockies },
+ );
+
+ expect(getByText('My Account')).toBeDefined();
+ });
+
+ it('renders contact name', () => {
+ const { getByText } = renderInterface(
+ Address({
+ address: 'eip155:1:0xab16a96D359eC26a11e2C2b3d8f8B8942d5Bfcda',
+ displayName: true,
+ }),
+ );
+
+ expect(getByText('Test Contact')).toBeDefined();
+ });
+
+ it('renders Ethereum address with blockie', () => {
+ const { toJSON } = renderInterface(
+ Address({
+ address: 'eip155:1:0xab16a96D359eC26a11e2C2b3d8f8B8942d5Bfcdb',
+ }),
+ { stateSettings: withBlockies },
+ );
+
+ expect(toJSON()).toMatchSnapshot();
+ });
+
+ it('renders Bitcoin address', () => {
+ const { toJSON } = renderInterface(
+ Address({
+ address:
+ 'bip122:000000000019d6689c085ae165831e93:128Lkh3S7CkDTBZ8W7BbpsN3YYizJMp8p6',
+ }),
+ { stateSettings: withoutBlockies },
+ );
+
+ expect(toJSON()).toMatchSnapshot();
+ });
+
+ it('renders Bitcoin address with blockie', () => {
+ const { toJSON } = renderInterface(
+ Address({
+ address:
+ 'bip122:000000000019d6689c085ae165831e93:128Lkh3S7CkDTBZ8W7BbpsN3YYizJMp8p6',
+ }),
+ { stateSettings: withBlockies },
+ );
+
+ expect(toJSON()).toMatchSnapshot();
+ });
+
+ it('renders Cosmos address', () => {
+ const { toJSON } = renderInterface(
+ Address({
+ address:
+ 'cosmos:cosmoshub-3:cosmos1t2uflqwqe0fsj0shcfkrvpukewcw40yjj6hdc0',
+ }),
+ { stateSettings: withoutBlockies },
+ );
+
+ expect(toJSON()).toMatchSnapshot();
+ });
+
+ it('renders Cosmos address with blockie', () => {
+ const { toJSON } = renderInterface(
+ Address({
+ address:
+ 'cosmos:cosmoshub-3:cosmos1t2uflqwqe0fsj0shcfkrvpukewcw40yjj6hdc0',
+ }),
+ { stateSettings: withBlockies },
+ );
+
+ expect(toJSON()).toMatchSnapshot();
+ });
+
+ it('renders Polkadot address', () => {
+ const { toJSON } = renderInterface(
+ Address({
+ address:
+ 'polkadot:b0a8d493285c2df73290dfb7e61f870f:5hmuyxw9xdgbpptgypokw4thfyoe3ryenebr381z9iaegmfy',
+ }),
+ { stateSettings: withoutBlockies },
+ );
+
+ expect(toJSON()).toMatchSnapshot();
+ });
+
+ it('renders Polkadot address with blockie', () => {
+ const { toJSON } = renderInterface(
+ Address({
+ address:
+ 'polkadot:b0a8d493285c2df73290dfb7e61f870f:5hmuyxw9xdgbpptgypokw4thfyoe3ryenebr381z9iaegmfy',
+ }),
+ { stateSettings: withBlockies },
+ );
+
+ expect(toJSON()).toMatchSnapshot();
+ });
+
+ it('renders Starknet address', () => {
+ const { toJSON } = renderInterface(
+ Address({
+ address:
+ 'starknet:SN_GOERLI:0x02dd1b492765c064eac4039e3841aa5f382773b598097a40073bd8b48170ab57',
+ }),
+ { stateSettings: withoutBlockies },
+ );
+
+ expect(toJSON()).toMatchSnapshot();
+ });
+
+ it('renders Starknet address with blockie', () => {
+ const { toJSON } = renderInterface(
+ Address({
+ address:
+ 'starknet:SN_GOERLI:0x02dd1b492765c064eac4039e3841aa5f382773b598097a40073bd8b48170ab57',
+ }),
+ { stateSettings: withBlockies },
+ );
+
+ expect(toJSON()).toMatchSnapshot();
+ });
+
+ it('renders Hedera address', () => {
+ const { toJSON } = renderInterface(
+ Address({ address: 'hedera:mainnet:0.0.1234567890-zbhlt' }),
+ { stateSettings: withoutBlockies },
+ );
+
+ expect(toJSON()).toMatchSnapshot();
+ });
+
+ it('renders Hedera address with blockie', () => {
+ const { toJSON } = renderInterface(
+ Address({ address: 'hedera:mainnet:0.0.1234567890-zbhlt' }),
+ { stateSettings: withBlockies },
+ );
+
+ expect(toJSON()).toMatchSnapshot();
+ });
+});
diff --git a/app/components/Snaps/SnapUIRenderer/components/form.test.ts b/app/components/Snaps/SnapUIRenderer/components/form.test.ts
new file mode 100644
index 000000000000..c20103f9f8c5
--- /dev/null
+++ b/app/components/Snaps/SnapUIRenderer/components/form.test.ts
@@ -0,0 +1,222 @@
+import { Box, Form, Input, Button, Field, Checkbox, Selector, SelectorOption, Card } from '@metamask/snaps-sdk/jsx';
+import Engine from '../../../../core/Engine/Engine';
+import { fireEvent } from '@testing-library/react-native';
+import { MOCK_INTERFACE_ID, MOCK_SNAP_ID, renderInterface } from '../testUtils';
+
+jest.mock('../../../../core/Engine/Engine', () => ({
+ controllerMessenger: {
+ call: jest.fn(),
+ },
+ context: {
+ SnapInterfaceController: {
+ updateInterfaceState: jest.fn(),
+ },
+ },
+}));
+
+const mockEngine = jest.mocked(Engine);
+
+describe('SnapUIForm', () => {
+ it('will render', () => {
+ const { toJSON, getByText } = renderInterface(
+ Box({
+ children: Form({
+ name: 'form',
+ children: [
+ Input({ name: 'input' }),
+ Button({ type: 'submit', name: 'submit', children: 'Submit' }),
+ ],
+ }),
+ }),
+ );
+
+ expect(toJSON()).toMatchSnapshot();
+ expect(getByText('Submit')).toBeTruthy();
+ });
+
+ it('will render with fields', () => {
+ const { toJSON, getByTestId, getByText } = renderInterface(
+ Box({
+ children: Form({
+ name: 'form',
+ children: [
+ Field({ label: 'My Input', children: Input({ name: 'input' }) }),
+ Field({
+ label: 'My Checkbox',
+ children: Checkbox({
+ name: 'checkbox',
+ label: 'This is a checkbox',
+ }),
+ }),
+ Field({
+ label: 'My Selector',
+ children: Selector({
+ name: 'selector',
+ title: 'Select an option',
+ children: [
+ SelectorOption({
+ value: 'option1',
+ children: Card({
+ title: 'CardTitle1',
+ description: 'CardDescription1',
+ value: 'CardValue1',
+ extra: 'CardExtra1',
+ }),
+ }),
+ SelectorOption({
+ value: 'option2',
+ children: Card({
+ title: 'CardTitle2',
+ description: 'CardDescription2',
+ value: 'CardValue2',
+ extra: 'CardExtra2',
+ }),
+ }),
+ ],
+ }),
+ }),
+ Button({ type: 'submit', name: 'submit', children: 'Submit' }),
+ ],
+ }),
+ }),
+ { state: { form: { selector: 'option1' } } },
+ );
+
+ const input = getByTestId('input');
+ fireEvent.changeText(input, 'abc');
+
+ expect(
+ mockEngine.context.SnapInterfaceController.updateInterfaceState,
+ ).toHaveBeenNthCalledWith(1, MOCK_INTERFACE_ID, {
+ form: { input: 'abc', selector: 'option1' },
+ });
+
+ expect(mockEngine.controllerMessenger.call).toHaveBeenNthCalledWith(
+ 1,
+ 'SnapController:handleRequest',
+ {
+ handler: 'onUserInput',
+ origin: 'metamask',
+ request: {
+ jsonrpc: '2.0',
+ method: ' ',
+ params: {
+ event: { name: 'input', type: 'InputChangeEvent', value: 'abc' },
+ id: MOCK_INTERFACE_ID,
+ },
+ },
+ snapId: MOCK_SNAP_ID,
+ },
+ );
+
+ const checkbox = getByText('This is a checkbox');
+ fireEvent.press(checkbox);
+
+ expect(
+ mockEngine.context.SnapInterfaceController.updateInterfaceState,
+ ).toHaveBeenNthCalledWith(2, MOCK_INTERFACE_ID, {
+ form: { input: 'abc', checkbox: true, selector: 'option1' },
+ });
+
+ expect(mockEngine.controllerMessenger.call).toHaveBeenNthCalledWith(
+ 2,
+ 'SnapController:handleRequest',
+ {
+ handler: 'onUserInput',
+ origin: 'metamask',
+ request: {
+ jsonrpc: '2.0',
+ method: ' ',
+ params: {
+ event: { name: 'checkbox', type: 'InputChangeEvent', value: true },
+ id: MOCK_INTERFACE_ID,
+ },
+ },
+ snapId: MOCK_SNAP_ID,
+ },
+ );
+
+ const selector = getByText('CardTitle1');
+ fireEvent.press(selector);
+
+ const selectorItem = getByText('CardTitle2');
+ fireEvent.press(selectorItem);
+
+ expect(
+ mockEngine.context.SnapInterfaceController.updateInterfaceState,
+ ).toHaveBeenNthCalledWith(3, MOCK_INTERFACE_ID, {
+ form: { input: 'abc', checkbox: true, selector: 'option2' },
+ });
+
+ expect(mockEngine.controllerMessenger.call).toHaveBeenNthCalledWith(
+ 3,
+ 'SnapController:handleRequest',
+ {
+ handler: 'onUserInput',
+ origin: 'metamask',
+ request: {
+ jsonrpc: '2.0',
+ method: ' ',
+ params: {
+ event: {
+ name: 'selector',
+ type: 'InputChangeEvent',
+ value: 'option2',
+ },
+ id: MOCK_INTERFACE_ID,
+ },
+ },
+ snapId: MOCK_SNAP_ID,
+ },
+ );
+
+ const button = getByText('Submit');
+ fireEvent.press(button);
+
+ expect(mockEngine.controllerMessenger.call).toHaveBeenNthCalledWith(
+ 4,
+ 'SnapController:handleRequest',
+ {
+ handler: 'onUserInput',
+ origin: 'metamask',
+ request: {
+ jsonrpc: '2.0',
+ method: ' ',
+ params: {
+ event: { name: 'submit', type: 'ButtonClickEvent' },
+ id: MOCK_INTERFACE_ID,
+ },
+ },
+ snapId: MOCK_SNAP_ID,
+ },
+ );
+
+ expect(mockEngine.controllerMessenger.call).toHaveBeenNthCalledWith(
+ 5,
+ 'SnapController:handleRequest',
+ {
+ handler: 'onUserInput',
+ origin: 'metamask',
+ request: {
+ jsonrpc: '2.0',
+ method: ' ',
+ params: {
+ event: {
+ name: 'form',
+ type: 'FormSubmitEvent',
+ value: {
+ checkbox: true,
+ input: 'abc',
+ selector: 'option2',
+ },
+ },
+ id: MOCK_INTERFACE_ID,
+ },
+ },
+ snapId: MOCK_SNAP_ID,
+ },
+ );
+
+ expect(toJSON()).toMatchSnapshot();
+ });
+});
diff --git a/app/components/Snaps/SnapUIRenderer/components/input.test.ts b/app/components/Snaps/SnapUIRenderer/components/input.test.ts
new file mode 100644
index 000000000000..47e52437daf3
--- /dev/null
+++ b/app/components/Snaps/SnapUIRenderer/components/input.test.ts
@@ -0,0 +1,176 @@
+import { renderInterface } from '../testUtils';
+import { Input, Box, Form } from '@metamask/snaps-sdk/jsx';
+import { fireEvent } from '@testing-library/react-native';
+import { INPUT_TEST_ID } from '../../../../component-library/components/Form/TextField/foundation/Input/Input.constants';
+import { TEXTFIELD_TEST_ID } from '../../../../component-library/components/Form/TextField/TextField.constants';
+
+jest.mock('../../../../core/Engine/Engine', () => ({
+ controllerMessenger: {
+ call: jest.fn(),
+ },
+ context: {
+ SnapInterfaceController: {
+ updateInterfaceState: jest.fn(),
+ },
+ },
+}));
+
+describe('SnapUIInput', () => {
+ const clearBorderColor = '#b7bbc8';
+ const focusedBorderColor = '#4459ff';
+
+ beforeEach(() => {
+ jest.resetAllMocks();
+ });
+
+ it('renders with initial value', () => {
+ const { getByTestId, toJSON } = renderInterface(
+ Box({
+ children: Input({
+ name: 'testInput',
+ }),
+ }),
+ { state: { testInput: 'initial value' } },
+ );
+
+ const input = getByTestId(INPUT_TEST_ID);
+
+ expect(input.props.value).toBe('initial value');
+ expect(toJSON()).toMatchSnapshot();
+ });
+
+ it('handles input changes', () => {
+ const { getByTestId } = renderInterface(
+ Box({
+ children: Input({
+ name: 'testInput',
+ }),
+ }),
+ );
+
+ const input = getByTestId(INPUT_TEST_ID);
+ fireEvent.changeText(input, 'test');
+
+ expect(input.props.value).toBe('test');
+ });
+
+ it('handles form input changes', () => {
+ const { getByTestId } = renderInterface(
+ Box({
+ children: Form({
+ name: 'testForm',
+ children: [Input({ name: 'testInput' })],
+ }),
+ }),
+ );
+
+ const input = getByTestId(INPUT_TEST_ID);
+ fireEvent.changeText(input, 'test');
+
+ expect(input.props.value).toBe('test');
+ });
+
+ it('handles focus events', () => {
+ const { getByTestId } = renderInterface(
+ Box({
+ children: Input({
+ name: 'testInput',
+ }),
+ }),
+ );
+
+ const input = getByTestId(INPUT_TEST_ID);
+ const textfield = getByTestId(TEXTFIELD_TEST_ID);
+
+ const initialBorderColor = textfield.props.style.borderColor;
+ expect(initialBorderColor).toBe(clearBorderColor);
+
+ fireEvent(input, 'focus');
+
+ const afterFocusBorderColor = textfield.props.style.borderColor;
+ expect(afterFocusBorderColor).toBe(focusedBorderColor);
+ });
+
+ it('handles blur events', () => {
+ const { getByTestId } = renderInterface(
+ Box({
+ children: Input({
+ name: 'testInput',
+ }),
+ }),
+ );
+
+ const input = getByTestId(INPUT_TEST_ID);
+ const textfield = getByTestId(TEXTFIELD_TEST_ID);
+
+ fireEvent(input, 'focus');
+
+ const afterFocusBorderColor = textfield.props.style.borderColor;
+ expect(afterFocusBorderColor).toBe(focusedBorderColor);
+
+ fireEvent(input, 'blur');
+
+ const afterBlurBorderColor = textfield.props.style.borderColor;
+ expect(afterBlurBorderColor).toBe(clearBorderColor);
+ });
+
+ it('handles disabled input', () => {
+ const { getByTestId, toJSON } = renderInterface(
+ Box({
+ children: Input({
+ name: 'testInput',
+ disabled: true,
+ }),
+ }),
+ );
+
+ const input = getByTestId(INPUT_TEST_ID);
+ expect(input.props.editable).toBe(false);
+ expect(toJSON()).toMatchSnapshot();
+ });
+
+ it('updates value when initialValue changes', () => {
+ const { getByTestId, updateInterface, getByDisplayValue } = renderInterface(
+ Box({ children: Input({ name: 'testInput' }) }),
+ { state: { testInput: 'initial value' } }
+ );
+
+ const input = getByTestId(INPUT_TEST_ID);
+ expect(input.props.value).toBe('initial value');
+
+ updateInterface(
+ Box({ children: [Input({ name: 'testInput' }), Input({ name: 'testInput2' })] }),
+ { testInput: 'updated value' }
+ );
+
+ expect(getByDisplayValue('updated value')).toBeTruthy();
+ });
+
+ it('maintains focus state when re-rendered', () => {
+ const { getAllByTestId, updateInterface } = renderInterface(
+ Box({
+ children: Input({
+ name: 'testInput',
+ }),
+ }),
+ );
+
+ const input = getAllByTestId(INPUT_TEST_ID)[0];
+ const textfield = getAllByTestId(TEXTFIELD_TEST_ID)[0];
+
+ const initialBorderColor = textfield.props.style.borderColor;
+ expect(initialBorderColor).toBe(clearBorderColor);
+
+ fireEvent(input, 'focus');
+
+ const afterFocusBorderColor = textfield.props.style.borderColor;
+ expect(afterFocusBorderColor).toBe(focusedBorderColor);
+
+ updateInterface(
+ Box({ children: [Input({ name: 'testInput' }), Input({ name: 'testInput2' })] })
+ );
+
+ const afterTextfield = getAllByTestId(TEXTFIELD_TEST_ID)[0];
+ expect(afterTextfield.props.style.borderColor).toBe(focusedBorderColor);
+ });
+});
diff --git a/app/components/Snaps/SnapUIRenderer/testUtils.tsx b/app/components/Snaps/SnapUIRenderer/testUtils.tsx
new file mode 100644
index 000000000000..4c1d633efb53
--- /dev/null
+++ b/app/components/Snaps/SnapUIRenderer/testUtils.tsx
@@ -0,0 +1,209 @@
+import { JSXElement } from '@metamask/snaps-sdk/jsx';
+import React from 'react';
+import renderWithProvider from '../../../util/test/renderWithProvider';
+import { RootState } from '../../../reducers';
+import { PayloadAction } from '@reduxjs/toolkit';
+import { FormState, SnapId } from '@metamask/snaps-sdk';
+import { SnapUIRenderer } from './SnapUIRenderer';
+import { act } from '@testing-library/react-native';
+
+
+export const MOCK_SNAP_ID = 'npm:@metamask/test-snap-bip44';
+export const MOCK_INTERFACE_ID = 'interfaceId';
+
+interface RenderInterfaceOptions {
+ useFooter?: boolean;
+ onCancel?: () => void;
+ contentBackgroundColor?: string;
+ state?: Record;
+ stateSettings?: Record;
+ backgroundState?: Record;
+}
+
+const noOp = () => {
+ // no-op
+};
+
+
+/**
+ * Renders a Snap UI interface.
+ *
+ * @param content - The JSXElement to render.
+ * @param options - The options for rendering the interface.
+ * @param options.useFooter - Whether to render the footer.
+ * @param options.onCancel - The function to call when the interface is cancelled.
+ * @param options.state - The state of the interface.
+ * @param options.backgroundState - The initial background state.
+ * @param options.stateSettings - The initial state settings.
+ * @returns The rendered interface.
+ */
+export function renderInterface(
+ content: JSXElement | null,
+ {
+ useFooter = false,
+ onCancel = noOp,
+ state = {},
+ backgroundState = {},
+ stateSettings = {},
+ }: RenderInterfaceOptions = {},
+) {
+ const storeState = {
+ settings: stateSettings,
+ engine: {
+ backgroundState: {
+ ...backgroundState,
+ SubjectMetadataController: {
+ subjectMetadata: {
+ 'npm:@metamask/test-snap-bip44': {
+ name: '@metamask/test-snap-bip44',
+ version: '1.2.3',
+ subjectType: 'snap',
+ },
+ },
+ },
+ SnapController: {
+ snaps: {
+ [MOCK_SNAP_ID]: {
+ id: 'npm:@metamask/test-snap-bip44',
+ origin: 'npm:@metamask/test-snap-bip44',
+ version: '5.1.2',
+ iconUrl: null,
+ initialPermissions: {
+ 'endowment:ethereum-provider': {},
+ },
+ manifest: {
+ description: 'An example Snap that signs messages using BLS.',
+ proposedName: 'BIP-44 Test Snap',
+ repository: {
+ type: 'git',
+ url: 'https://github.com/MetaMask/test-snaps.git',
+ },
+ source: {
+ location: {
+ npm: {
+ filePath: 'dist/bundle.js',
+ packageName: '@metamask/test-snap-bip44',
+ registry: 'https://registry.npmjs.org',
+ },
+ },
+ shasum: 'L1k+dT9Q+y3KfIqzaH09MpDZVPS9ZowEh9w01ZMTWMU=',
+ },
+ version: '5.1.2',
+ },
+ versionHistory: [
+ {
+ date: 1680686075921,
+ origin: 'https://metamask.github.io',
+ version: '5.1.2',
+ },
+ ],
+ },
+ },
+ },
+ SnapInterfaceController: {
+ interfaces: {
+ [MOCK_INTERFACE_ID]: {
+ snapId: MOCK_SNAP_ID,
+ content,
+ state,
+ context: null,
+ contentType: null,
+ },
+ },
+ },
+ KeyringController: {
+ keyrings: []
+ },
+ AccountsController: {
+ internalAccounts: {
+ accounts: {
+ 'foo': {
+ address: '0xab16a96D359eC26a11e2C2b3d8f8B8942d5Bfcdb',
+ metadata: {
+ name: 'My Account',
+ }
+ }
+ }
+ }
+ },
+ AddressBookController: {
+ addressBook: {
+ '0x1': {
+ '0xab16a96D359eC26a11e2C2b3d8f8B8942d5Bfcda': {
+ address: '0xab16a96D359eC26a11e2C2b3d8f8B8942d5Bfcda',
+ name: 'Test Contact',
+ }
+ }
+ }
+ }
+ },
+ },
+ };
+
+ const result = renderWithProvider(
+ ,
+ { state: storeState as unknown as RootState },
+ );
+
+ const reducer = (
+ reducerState: RootState | undefined,
+ action: PayloadAction<{ content: JSXElement; state: FormState }>,
+ ): RootState => {
+ // Handle initial state
+ const currentState = reducerState || result.store.getState();
+
+ if (action.type === 'updateInterface') {
+ return {
+ ...currentState,
+ engine: {
+ ...currentState.engine,
+ backgroundState: {
+ ...currentState.engine.backgroundState,
+ SnapInterfaceController: {
+ interfaces: {
+ [MOCK_INTERFACE_ID]: {
+ snapId: MOCK_SNAP_ID as SnapId,
+ content: action.payload.content,
+ state: action.payload.state ?? state,
+ context: null,
+ contentType: null,
+ },
+ },
+ },
+ },
+ },
+ };
+ }
+ return currentState;
+ };
+
+ const { store } = result;
+
+ store.replaceReducer(reducer);
+
+ const updateInterface = (
+ newContent: JSXElement,
+ newState: FormState | null = null,
+ ) => {
+ act(() => {
+ store.dispatch({
+ type: 'updateInterface',
+ payload: {
+ content: newContent,
+ state: newState,
+ },
+ });
+ });
+ };
+
+ const getRenderCount = () =>
+ parseInt(result.getByTestId('performance').props['data-renders'], 10);
+
+ return { ...result, updateInterface, getRenderCount };
+}