diff --git a/packages/examples/packages/send-flow/jest.config.js b/packages/examples/packages/send-flow/jest.config.js index d73c99b9a2..2df2e54842 100644 --- a/packages/examples/packages/send-flow/jest.config.js +++ b/packages/examples/packages/send-flow/jest.config.js @@ -33,4 +33,10 @@ module.exports = deepmerge(baseConfig, { '/node_modules/@metamask/$1', ], }, + + // Transform SVG files using our custom transformer + transform: { + ...baseConfig.transform, + '\\.svg$': '/test/transformers/svgTransformer.js', + }, }); diff --git a/packages/examples/packages/send-flow/snap.manifest.json b/packages/examples/packages/send-flow/snap.manifest.json index 9dc13faa72..b8b4d8f57d 100644 --- a/packages/examples/packages/send-flow/snap.manifest.json +++ b/packages/examples/packages/send-flow/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snaps.git" }, "source": { - "shasum": "u6ivnu9fXa7saZ10sruh8I3ZKZjeAur4W+9+LVp9BoU=", + "shasum": "FOyn/lPGc/Fu6cW6EgX8OwklVJ/+HBydyqz+IDE8M+U=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/packages/examples/packages/send-flow/src/images/btc.svg b/packages/examples/packages/send-flow/src/images/btc.svg index f5889766e2..80fa533b3f 100644 --- a/packages/examples/packages/send-flow/src/images/btc.svg +++ b/packages/examples/packages/send-flow/src/images/btc.svg @@ -1,12 +1 @@ - - - - - - - - - + diff --git a/packages/examples/packages/send-flow/src/images/jazzicon1.svg b/packages/examples/packages/send-flow/src/images/jazzicon1.svg index c7cc308bb7..a4a473b17c 100644 --- a/packages/examples/packages/send-flow/src/images/jazzicon1.svg +++ b/packages/examples/packages/send-flow/src/images/jazzicon1.svg @@ -1,11 +1 @@ - - - - - - - - - - - + diff --git a/packages/examples/packages/send-flow/src/images/jazzicon2.svg b/packages/examples/packages/send-flow/src/images/jazzicon2.svg index b00299acb0..a4bf570e01 100644 --- a/packages/examples/packages/send-flow/src/images/jazzicon2.svg +++ b/packages/examples/packages/send-flow/src/images/jazzicon2.svg @@ -1,11 +1 @@ - - - - - - - - - - - + diff --git a/packages/examples/packages/send-flow/src/index.test.tsx b/packages/examples/packages/send-flow/src/index.test.tsx index 568c7b5a2d..faaa360993 100644 --- a/packages/examples/packages/send-flow/src/index.test.tsx +++ b/packages/examples/packages/send-flow/src/index.test.tsx @@ -1,6 +1,9 @@ import { expect } from '@jest/globals'; import { installSnap } from '@metamask/snaps-jest'; +import { SendFlow } from './components'; +import { accountsArray } from './data'; + describe('onRpcRequest', () => { it('throws an error if the requested method does not exist', async () => { const { request } = await installSnap(); @@ -21,6 +24,222 @@ describe('onRpcRequest', () => { }); describe('display', () => { - it.todo('shows a custom dialog with the SendFlow interface'); + it('shows a custom dialog with the SendFlow interface', async () => { + const { request } = await installSnap(); + + const response = request({ + method: 'display', + }); + + const sendFlowInterface = await response.getInterface(); + + expect(sendFlowInterface).toRender( + , + ); + }); + }); +}); + +describe('onHomePage', () => { + it('returns a custom UI', async () => { + const { onHomePage } = await installSnap(); + + const response = await onHomePage(); + + const sendFlowInterface = response.getInterface(); + + expect(sendFlowInterface).toRender( + , + ); + }); +}); + +describe('onUserInput', () => { + it('handles account selection', async () => { + const { request } = await installSnap(); + + const response = request({ + method: 'display', + }); + + const sendFlowInterface = await response.getInterface(); + + await sendFlowInterface.selectFromSelector( + 'accountSelector', + accountsArray[1].address, + ); + + const updatedInterface = await response.getInterface(); + + expect(updatedInterface).toRender( + , + ); + }); + + it('handles amount input', async () => { + const { request } = await installSnap(); + + const response = request({ + method: 'display', + }); + + const sendFlowInterface = await response.getInterface(); + + await sendFlowInterface.typeInField('amount', '0.5'); + + const updatedInterface = await response.getInterface(); + + expect(updatedInterface).toRender( + , + ); + }); + + it('handles to input', async () => { + const { request } = await installSnap(); + + const response = request({ + method: 'display', + }); + + const sendFlowInterface = await response.getInterface(); + + await sendFlowInterface.typeInField( + 'to', + '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + ); + + const updatedInterface = await response.getInterface(); + + expect(updatedInterface).toRender( + , + ); + }); + + it('handles invalid input', async () => { + const { request } = await installSnap(); + + const response = request({ + method: 'display', + }); + + const sendFlowInterface = await response.getInterface(); + + await sendFlowInterface.typeInField('amount', '2'); + + const updatedInterface = await response.getInterface(); + + expect(updatedInterface).toRender( + , + ); + }); + + it('maintains state across multiple interactions', async () => { + const { request } = await installSnap(); + + const response = request({ + method: 'display', + }); + + const sendFlowInterface = await response.getInterface(); + + await sendFlowInterface.selectFromSelector( + 'accountSelector', + accountsArray[1].address, + ); + + await sendFlowInterface.typeInField('amount', '0.5'); + + const updatedInterface = await response.getInterface(); + + expect(updatedInterface).toRender( + , + ); + + await sendFlowInterface.typeInField( + 'to', + '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + ); + + const updatedInterface2 = await response.getInterface(); + + expect(updatedInterface2).toRender( + , + ); + + await sendFlowInterface.typeInField('amount', '0'); + + const updatedInterface3 = await response.getInterface(); + + expect(updatedInterface3).toRender( + , + ); }); }); diff --git a/packages/examples/packages/send-flow/src/index.tsx b/packages/examples/packages/send-flow/src/index.tsx index 103036dd8f..6e06fd8385 100644 --- a/packages/examples/packages/send-flow/src/index.tsx +++ b/packages/examples/packages/send-flow/src/index.tsx @@ -113,7 +113,9 @@ export const onUserInput: OnUserInputHandler = async ({ errors={formErrors} // For testing purposes, we display the avatar if the address is // a valid hex checksum address. - displayAvatar={isCaipHexAddress(event.value)} + displayAvatar={isCaipHexAddress( + event.name === 'to' ? event.value : sendForm.to, + )} /> ), }, @@ -134,6 +136,7 @@ export const onUserInput: OnUserInputHandler = async ({ total={total} fees={fees} errors={formErrors} + displayAvatar={isCaipHexAddress(sendForm.to)} /> ), }, diff --git a/packages/examples/packages/send-flow/test/transformers/svgTransformer.js b/packages/examples/packages/send-flow/test/transformers/svgTransformer.js new file mode 100644 index 0000000000..3b78c3ecbb --- /dev/null +++ b/packages/examples/packages/send-flow/test/transformers/svgTransformer.js @@ -0,0 +1,26 @@ +/** + * A custom transformer for SVG files in Jest tests. + */ +module.exports = { + /** + * Process an SVG file for Jest tests. + * + * @param {string} sourceText - The content of the SVG file. + * @returns {object} The transformed code. + */ + process(sourceText) { + return { + code: `module.exports = ${JSON.stringify(sourceText)};`, + }; + }, + + /** + * Generate a cache key for the transformation. + * + * @param {string} sourceText - The content of the SVG file. + * @returns {string} A cache key for Jest to use for caching purposes. + */ + getCacheKey(sourceText) { + return sourceText; + }, +}; diff --git a/packages/snaps-simulation/src/constants.ts b/packages/snaps-simulation/src/constants.ts index b69d4c817a..fe700b68cb 100644 --- a/packages/snaps-simulation/src/constants.ts +++ b/packages/snaps-simulation/src/constants.ts @@ -26,3 +26,8 @@ export const DEFAULT_CURRENCY = 'usd'; * The default JSON-RPC endpoint for Ethereum requests. */ export const DEFAULT_JSON_RPC_ENDPOINT = 'https://cloudflare-eth.com/'; + +/** + * The types of inputs that can be used in the `typeInField` interface action. + */ +export const TYPEABLE_INPUTS = ['Input', 'AddressInput']; diff --git a/packages/snaps-simulation/src/interface.test.tsx b/packages/snaps-simulation/src/interface.test.tsx index d887bb2f2c..1b81d4fa59 100644 --- a/packages/snaps-simulation/src/interface.test.tsx +++ b/packages/snaps-simulation/src/interface.test.tsx @@ -18,6 +18,7 @@ import { RadioGroup, Radio, Box, + AddressInput, Input, FileInput, Checkbox, @@ -924,6 +925,55 @@ describe('typeInField', () => { }); }); + it('updates the interface state and sends an InputChangeEvent for an AddressInput', async () => { + jest.spyOn(rootControllerMessenger, 'call'); + const content = ( + + ); + + const interfaceId = await interfaceController.createInterface( + MOCK_SNAP_ID, + content, + ); + + await typeInField( + rootControllerMessenger, + interfaceId, + content, + MOCK_SNAP_ID, + 'addressInput', + '0x1234567890123456789012345678901234567890', + ); + + expect(rootControllerMessenger.call).toHaveBeenCalledWith( + 'SnapInterfaceController:updateInterfaceState', + interfaceId, + { addressInput: 'eip155:0:0x1234567890123456789012345678901234567890' }, + ); + + expect(handleRpcRequestMock).toHaveBeenCalledWith(MOCK_SNAP_ID, { + origin: 'metamask', + handler: HandlerType.OnUserInput, + request: { + jsonrpc: '2.0', + method: ' ', + params: { + event: { + type: UserInputEventType.InputChangeEvent, + name: 'addressInput', + value: 'eip155:0:0x1234567890123456789012345678901234567890', + }, + id: interfaceId, + context: null, + }, + }, + }); + }); + it('throws if there is no inputs in the interface', async () => { const content = text('bar'); @@ -964,7 +1014,7 @@ describe('typeInField', () => { 'baz', ), ).rejects.toThrow( - 'Expected an element of type "Input", but found "Button".', + 'Expected an element of type "Input" or "AddressInput", but found "Button".', ); }); }); diff --git a/packages/snaps-simulation/src/interface.ts b/packages/snaps-simulation/src/interface.ts index cccebd5aa2..e0f2f22444 100644 --- a/packages/snaps-simulation/src/interface.ts +++ b/packages/snaps-simulation/src/interface.ts @@ -27,6 +27,7 @@ import deepEqual from 'fast-deep-equal'; import { type SagaIterator } from 'redux-saga'; import { call, put, select, take } from 'redux-saga/effects'; +import { TYPEABLE_INPUTS } from './constants'; import type { RootControllerMessenger } from './controllers'; import { getFileSize, getFileToUpload } from './files'; import type { Interface, RunSagaFunction } from './store'; @@ -37,7 +38,7 @@ import type { SnapInterface, SnapInterfaceActions, } from './types'; - +import { formatTypeErrorMessage } from './utils/errors'; /** * The maximum file size that can be uploaded. */ @@ -494,6 +495,22 @@ export function mergeValue( return { ...state, [name]: value }; } +/** + * Process the input value for an input element based on the element type. + * + * @param value - The original input value. + * @param element - The interface element. + * @returns The processed value. + */ +function processInputValue(value: string, element: NamedJSXElement): string { + if (element.type === 'AddressInput') { + const { chainId } = element.props; + return `${chainId}:${value}`; + } + + return value; +} + /** * Type a value in an interface element. * @@ -520,17 +537,19 @@ export async function typeInField( ); assert( - result.element.type === 'Input', - `Expected an element of type "Input", but found "${result.element.type}".`, + TYPEABLE_INPUTS.includes(result.element.type), + `Expected an element of type ${formatTypeErrorMessage(TYPEABLE_INPUTS)}, but found "${result.element.type}".`, ); + const newValue = processInputValue(value, result.element); + const { state, context } = controllerMessenger.call( 'SnapInterfaceController:getInterface', snapId, id, ); - const newState = mergeValue(state, name, value, result.form); + const newState = mergeValue(state, name, newValue, result.form); controllerMessenger.call( 'SnapInterfaceController:updateInterfaceState', @@ -548,7 +567,7 @@ export async function typeInField( event: { type: UserInputEventType.InputChangeEvent, name: result.element.props.name, - value, + value: newValue, }, id, context, diff --git a/packages/snaps-simulation/src/utils/errors.test.ts b/packages/snaps-simulation/src/utils/errors.test.ts new file mode 100644 index 0000000000..9f5c70b32a --- /dev/null +++ b/packages/snaps-simulation/src/utils/errors.test.ts @@ -0,0 +1,21 @@ +import { formatTypeErrorMessage } from './errors'; + +describe('formatTypeErrorMessage', () => { + it('should format error message for one type', () => { + const types = ['Input']; + const result = formatTypeErrorMessage(types); + expect(result).toBe('"Input"'); + }); + + it('should format error message for two types', () => { + const types = ['Input', 'AddressInput']; + const result = formatTypeErrorMessage(types); + expect(result).toBe('"Input" or "AddressInput"'); + }); + + it('should format error message for multiple types', () => { + const types = ['Input', 'AddressInput', 'NewInput']; + const result = formatTypeErrorMessage(types); + expect(result).toBe('"Input", "AddressInput" or "NewInput"'); + }); +}); diff --git a/packages/snaps-simulation/src/utils/errors.ts b/packages/snaps-simulation/src/utils/errors.ts new file mode 100644 index 0000000000..bb004ccdce --- /dev/null +++ b/packages/snaps-simulation/src/utils/errors.ts @@ -0,0 +1,23 @@ +/** + * Format types for error messages based on the number of types. + * + * @param types - Array of type names. + * @returns Formatted string for error message. + */ +export function formatTypeErrorMessage(types: string[]): string { + if (types.length === 1) { + return `"${types[0]}"`; + } + + if (types.length === 2) { + return `"${types[0]}" or "${types[1]}"`; + } + + const lastType = types[types.length - 1]; + const otherTypes = types + .slice(0, -1) + .map((type) => `"${type}"`) + .join(', '); + + return `${otherTypes} or "${lastType}"`; +}