UNPKG

@metamask/snaps-simulation

Version:

A simulation framework for MetaMask Snaps, enabling headless testing of Snaps in a controlled environment

694 lines 29.5 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.getInterface = exports.getInterfaceActions = exports.uploadFile = exports.waitForUpdate = exports.selectFromSelector = exports.getValueFromSelector = exports.selectFromRadioGroup = exports.selectInDropdown = exports.typeInField = exports.mergeValue = exports.clickElement = exports.getElementByType = exports.getElement = exports.resolveWithSaga = exports.getInterfaceResponse = void 0; const snaps_rpc_methods_1 = require("@metamask/snaps-rpc-methods"); const snaps_sdk_1 = require("@metamask/snaps-sdk"); const snaps_utils_1 = require("@metamask/snaps-utils"); const utils_1 = require("@metamask/utils"); const fast_deep_equal_1 = __importDefault(require("fast-deep-equal")); const effects_1 = require("redux-saga/effects"); const constants_1 = require("./constants.cjs"); const files_1 = require("./files.cjs"); const store_1 = require("./store/index.cjs"); const errors_1 = require("./utils/errors.cjs"); /** * The maximum file size that can be uploaded. */ const MAX_FILE_SIZE = 10000000; // 10 MB /** * The elements based on the Selector component. */ const SELECTOR_ELEMENTS = ['Selector', 'AccountSelector', 'AssetSelector']; /** * Get a user interface object from a type and content object. * * @param runSaga - A function to run a saga outside the usual Redux flow. * @param type - The type of the interface. * @param id - The interface ID. * @param content - The content to show in the interface. * @param interfaceActions - The actions to interact with the interface. * @returns The user interface object. */ function getInterfaceResponse(runSaga, type, id, content, interfaceActions) { switch (type) { case snaps_rpc_methods_1.DIALOG_APPROVAL_TYPES[snaps_sdk_1.DialogType.Alert]: return { ...interfaceActions, type: snaps_sdk_1.DialogType.Alert, content, id, ok: resolveWith(runSaga, null), }; case snaps_rpc_methods_1.DIALOG_APPROVAL_TYPES[snaps_sdk_1.DialogType.Confirmation]: return { ...interfaceActions, type: snaps_sdk_1.DialogType.Confirmation, content, id, ok: resolveWith(runSaga, true), cancel: resolveWith(runSaga, false), }; case snaps_rpc_methods_1.DIALOG_APPROVAL_TYPES[snaps_sdk_1.DialogType.Prompt]: return { ...interfaceActions, type: snaps_sdk_1.DialogType.Prompt, content, id, ok: resolveWithInput(runSaga), cancel: resolveWith(runSaga, null), }; case snaps_rpc_methods_1.DIALOG_APPROVAL_TYPES.default: { const footer = getElementByType(content, 'Footer'); // No Footer defined so we apply a default footer. if (!footer) { return { ...interfaceActions, content, id, ok: resolveWith(runSaga, null), cancel: resolveWith(runSaga, null), }; } // Only one button in footer so we apply a default cancel button. if ((0, snaps_utils_1.getJsxChildren)(footer).length === 1) { return { ...interfaceActions, content, id, cancel: resolveWith(runSaga, null), }; } // We have two buttons in the footer so we assume the snap handles the approval of the interface. return { ...interfaceActions, content, id, }; } case 'Notification': { return { ...interfaceActions, content, id, }; } default: throw new Error(`Unknown or unsupported dialog type: "${String(type)}".`); } } exports.getInterfaceResponse = getInterfaceResponse; /** * Resolve the current user interface with the given value. * * @param value - The value to resolve the user interface with. * @yields Puts the resolve user interface action. */ function* resolveWithSaga(value) { yield (0, effects_1.put)((0, store_1.resolveInterface)(value)); } exports.resolveWithSaga = resolveWithSaga; /** * Resolve the current user interface with the given value. This returns a * function that can be used to resolve the user interface. * * @param runSaga - A function to run a saga outside the usual Redux flow. * @param value - The value to resolve the user interface with. * @returns A function that can be used to resolve the user interface. */ function resolveWith(runSaga, value) { return async () => { await runSaga(resolveWithSaga, value).toPromise(); }; } /** * Resolve the current user interface with the provided input. This returns a * function that can be used to resolve the user interface. * * @param runSaga - A function to run a saga outside the usual Redux flow. * @returns A function that can be used to resolve the user interface. */ function resolveWithInput(runSaga) { return async (value = '') => { await runSaga(resolveWithSaga, value).toPromise(); }; } /** * Get the stored user interface from the store. * * @param controllerMessenger - The controller messenger used to call actions. * @param snapId - The Snap ID. * @yields Takes the set interface action. * @returns The user interface object. */ function* getStoredInterface(controllerMessenger, snapId) { const currentInterface = yield (0, effects_1.select)(store_1.getCurrentInterface); if (currentInterface) { const { content } = controllerMessenger.call('SnapInterfaceController:getInterface', snapId, currentInterface.id); return { ...currentInterface, content }; } const { payload } = yield (0, effects_1.take)(store_1.setInterface.type); const { content } = controllerMessenger.call('SnapInterfaceController:getInterface', snapId, payload.id); return { ...payload, content }; } /** * Check if a JSX element is a JSX element with a given name. * * @param element - The JSX element. * @param name - The element name. * @returns True if the element is a JSX element with the given name, otherwise * false. */ function isJSXElementWithName(element, name) { return (0, utils_1.hasProperty)(element.props, 'name') && element.props.name === name; } /** * Find an element inside a form element in a JSX tree. * * @param form - The form element. * @param name - The element name. * @returns An object containing the element and the form name if it's contained * in a form, otherwise undefined. */ function getFormElement(form, name) { const element = (0, snaps_utils_1.walkJsx)(form, (childElement) => { if (isJSXElementWithName(childElement, name)) { return childElement; } return undefined; }); if (element === undefined) { return undefined; } return { element, form: form.props.name }; } /** * Get an object containing the element, and optional form that's associated * with the element if any. * * @param element - The JSX element. * @returns An object containing the element and optional form. */ function getElementWithOptionalForm(element) { if (element.type !== 'Button' || !element.props.form) { return { element }; } return { element, form: element.props.form }; } /** * Get an element from a JSX tree with the given name. * * @param content - The interface content. * @param name - The element name. * @returns An object containing the element and the form name if it's contained * in a form, otherwise undefined. */ function getElement(content, name) { if (isJSXElementWithName(content, name)) { return getElementWithOptionalForm(content); } return (0, snaps_utils_1.walkJsx)(content, (element) => { if (element.type === 'Form') { return getFormElement(element, name); } if (isJSXElementWithName(element, name)) { return getElementWithOptionalForm(element); } return undefined; }); } exports.getElement = getElement; /** * Get an element from a JSX tree with the given type. * * @param content - The interface content. * @param type - The element type. * @returns The element with the given type. */ function getElementByType(content, type) { return (0, snaps_utils_1.walkJsx)(content, (element) => { if (element.type === type) { return element; } return undefined; }); } exports.getElementByType = getElementByType; /** * Handle submitting event requests to OnUserInput including unwrapping potential errors. * * @param controllerMessenger - The controller messenger used to call actions. * @param snapId - The Snap ID. * @param id - The interface ID. * @param event - The event to submit. * @param context - The interface context. */ async function handleEvent(controllerMessenger, snapId, id, event, context) { try { await controllerMessenger.call('ExecutionService:handleRpcRequest', snapId, { origin: 'metamask', handler: snaps_utils_1.HandlerType.OnUserInput, request: { jsonrpc: '2.0', method: ' ', params: { event, id, context, }, }, }); } catch (error) { const [unwrapped] = (0, snaps_utils_1.unwrapError)(error); throw unwrapped; } } /** * Click on an element of the Snap interface. * * @param controllerMessenger - The controller messenger used to call actions. * @param id - The interface ID. * @param content - The interface content. * @param snapId - The Snap ID. * @param name - The element name. */ async function clickElement(controllerMessenger, id, content, snapId, name) { const result = getElement(content, name); (0, snaps_sdk_1.assert)(result !== undefined, `Could not find an element in the interface with the name "${name}".`); (0, snaps_sdk_1.assert)(result.element.type === 'Button' || result.element.type === 'Checkbox', `Expected an element of type "Button" or "Checkbox", but found "${result.element.type}".`); const { state, context } = controllerMessenger.call('SnapInterfaceController:getInterface', snapId, id); const { type } = result.element; const elementName = result.element.props.name; const formState = (result.form ? state[result.form] : state); const currentValue = formState[elementName]; switch (type) { case 'Button': { // Button click events are always triggered. await handleEvent(controllerMessenger, snapId, id, { type: snaps_sdk_1.UserInputEventType.ButtonClickEvent, name: elementName, }, context); if (result.form && result.element.props.type === 'submit') { await handleEvent(controllerMessenger, snapId, id, { type: snaps_sdk_1.UserInputEventType.FormSubmitEvent, name: result.form, value: state[result.form], }, context); } break; } case 'Checkbox': { const newValue = !currentValue; const newState = mergeValue(state, name, newValue, result.form); controllerMessenger.call('SnapInterfaceController:updateInterfaceState', id, newState); await handleEvent(controllerMessenger, snapId, id, { type: snaps_sdk_1.UserInputEventType.InputChangeEvent, name: elementName, value: newValue, }, context); break; } /* istanbul ignore next */ default: (0, utils_1.assertExhaustive)(type); } } exports.clickElement = clickElement; /** * Merge a value in the interface state. * * @param state - The actual interface state. * @param name - The component name that changed value. * @param value - The new value. * @param form - The form name if the element is in one. * @returns The state with the merged value. */ function mergeValue(state, name, value, form) { if (form) { return { ...state, [form]: { ...state[form], [name]: value, }, }; } return { ...state, [name]: value }; } exports.mergeValue = mergeValue; /** * 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, element) { if (element.type === 'AddressInput') { const { chainId } = element.props; return `${chainId}:${value}`; } return value; } /** * Type a value in an interface element. * * @param controllerMessenger - The controller messenger used to call actions. * @param id - The interface ID. * @param content - The interface Components. * @param snapId - The Snap ID. * @param name - The element name. * @param value - The value to type in the element. */ async function typeInField(controllerMessenger, id, content, snapId, name, value) { const result = getElement(content, name); (0, snaps_sdk_1.assert)(result !== undefined, `Could not find an element in the interface with the name "${name}".`); (0, snaps_sdk_1.assert)(constants_1.TYPEABLE_INPUTS.includes(result.element.type), `Expected an element of type ${(0, errors_1.formatTypeErrorMessage)(constants_1.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, newValue, result.form); controllerMessenger.call('SnapInterfaceController:updateInterfaceState', id, newState); await controllerMessenger.call('ExecutionService:handleRpcRequest', snapId, { origin: 'metamask', handler: snaps_utils_1.HandlerType.OnUserInput, request: { jsonrpc: '2.0', method: ' ', params: { event: { type: snaps_sdk_1.UserInputEventType.InputChangeEvent, name: result.element.props.name, value: newValue, }, id, context, }, }, }); } exports.typeInField = typeInField; /** * Type a value in an interface element. * * @param controllerMessenger - The controller messenger used to call actions. * @param id - The interface ID. * @param content - The interface Components. * @param snapId - The Snap ID. * @param name - The element name. * @param value - The value to type in the element. */ async function selectInDropdown(controllerMessenger, id, content, snapId, name, value) { const result = getElement(content, name); (0, snaps_sdk_1.assert)(result !== undefined, `Could not find an element in the interface with the name "${name}".`); (0, snaps_sdk_1.assert)(result.element.type === 'Dropdown', `Expected an element of type "Dropdown", but found "${result.element.type}".`); const options = (0, snaps_utils_1.getJsxChildren)(result.element); const selectedOption = options.find((option) => (0, utils_1.hasProperty)(option.props, 'value') && option.props.value === value); (0, snaps_sdk_1.assert)(selectedOption !== undefined, `The dropdown with the name "${name}" does not contain "${value}".`); const { state, context } = controllerMessenger.call('SnapInterfaceController:getInterface', snapId, id); const newState = mergeValue(state, name, value, result.form); controllerMessenger.call('SnapInterfaceController:updateInterfaceState', id, newState); await controllerMessenger.call('ExecutionService:handleRpcRequest', snapId, { origin: 'metamask', handler: snaps_utils_1.HandlerType.OnUserInput, request: { jsonrpc: '2.0', method: ' ', params: { event: { type: snaps_sdk_1.UserInputEventType.InputChangeEvent, name: result.element.props.name, value, }, id, context, }, }, }); } exports.selectInDropdown = selectInDropdown; /** * Choose an option with value from radio group interface element. * * @param controllerMessenger - The controller messenger used to call actions. * @param id - The interface ID. * @param content - The interface Components. * @param snapId - The Snap ID. * @param name - The element name. * @param value - The value to type in the element. */ async function selectFromRadioGroup(controllerMessenger, id, content, snapId, name, value) { const result = getElement(content, name); (0, snaps_sdk_1.assert)(result !== undefined, `Could not find an element in the interface with the name "${name}".`); (0, snaps_sdk_1.assert)(result.element.type === 'RadioGroup', `Expected an element of type "RadioGroup", but found "${result.element.type}".`); const options = (0, snaps_utils_1.getJsxChildren)(result.element); const selectedOption = options.find((option) => (0, utils_1.hasProperty)(option.props, 'value') && option.props.value === value); (0, snaps_sdk_1.assert)(selectedOption !== undefined, `The RadioGroup with the name "${name}" does not contain "${value}".`); const { state, context } = controllerMessenger.call('SnapInterfaceController:getInterface', snapId, id); const newState = mergeValue(state, name, value, result.form); controllerMessenger.call('SnapInterfaceController:updateInterfaceState', id, newState); await controllerMessenger.call('ExecutionService:handleRpcRequest', snapId, { origin: 'metamask', handler: snaps_utils_1.HandlerType.OnUserInput, request: { jsonrpc: '2.0', method: ' ', params: { event: { type: snaps_sdk_1.UserInputEventType.InputChangeEvent, name: result.element.props.name, value, }, id, context, }, }, }); } exports.selectFromRadioGroup = selectFromRadioGroup; /** * Get the value from a Selector interface element. * * @param element - The Selector element to get the value from. * @param options - The simulation options. * @param value - The value to get from the Selector. * * @returns The value from the Selector element. */ function getValueFromSelector(element, options, value) { switch (element.type) { case 'Selector': { const selectorOptions = (0, snaps_utils_1.getJsxChildren)(element); const selectedOption = selectorOptions.find((option) => (0, utils_1.hasProperty)(option.props, 'value') && option.props.value === value); (0, snaps_sdk_1.assert)(selectedOption !== undefined, `The Selector with the name "${element.props.name}" does not contain "${value}".`); return value; } case 'AccountSelector': { const { accounts } = options; const selectedAccount = accounts.find((account) => account.id === value); (0, snaps_sdk_1.assert)(selectedAccount !== undefined, `The AccountSelector with the name "${element.props.name}" does not contain an account with ID "${value}".`); return { accountId: selectedAccount.id, addresses: (0, snaps_utils_1.createAccountList)(selectedAccount.address, (0, snaps_utils_1.createChainIdList)(selectedAccount.scopes, element.props.chainIds)), }; } case 'AssetSelector': { const { assets, accounts } = options; const selectedAsset = assets[value]; const { address } = (0, utils_1.parseCaipAccountId)(element.props.addresses[0]); const account = accounts.find((simulationAccount) => simulationAccount.address === address); const accountHasAsset = account?.assets?.some((asset) => asset === value); (0, snaps_sdk_1.assert)(selectedAsset !== undefined && accountHasAsset, `The AssetSelector with the name "${element.props.name}" does not contain an asset with ID "${value}".`); return { asset: value, name: selectedAsset.name, symbol: selectedAsset.symbol, }; } default: throw new Error(`Expected an element of type ${(0, errors_1.formatTypeErrorMessage)(SELECTOR_ELEMENTS)}, but found "${element.type}".`); } } exports.getValueFromSelector = getValueFromSelector; /** * Choose an option with value from a Selector interface element. * * @param controllerMessenger - The controller messenger used to call actions. * @param options - The simulation options. * @param id - The interface ID. * @param content - The interface Components. * @param snapId - The Snap ID. * @param name - The element name. * @param value - The value to type in the element. */ async function selectFromSelector(controllerMessenger, options, id, content, snapId, name, value) { const result = getElement(content, name); (0, snaps_sdk_1.assert)(result !== undefined, `Could not find an element in the interface with the name "${name}".`); const selectedValue = getValueFromSelector(result.element, options, value); const { state, context } = controllerMessenger.call('SnapInterfaceController:getInterface', snapId, id); const newState = mergeValue(state, name, selectedValue, result.form); controllerMessenger.call('SnapInterfaceController:updateInterfaceState', id, newState); await controllerMessenger.call('ExecutionService:handleRpcRequest', snapId, { origin: 'metamask', handler: snaps_utils_1.HandlerType.OnUserInput, request: { jsonrpc: '2.0', method: ' ', params: { event: { type: snaps_sdk_1.UserInputEventType.InputChangeEvent, name: result.element.props.name, value: selectedValue, }, id, context, }, }, }); } exports.selectFromSelector = selectFromSelector; /** * Wait for an interface to be updated. * * @param controllerMessenger - The controller messenger used to call actions. * @param options - The simulation options. * @param snapId - The Snap ID. * @param id - The interface ID. * @param originalContent - The original interface content. * @returns A promise that resolves to the updated interface. */ async function waitForUpdate(controllerMessenger, options, snapId, id, originalContent) { return new Promise((resolve) => { const listener = (state) => { const currentInterface = state.interfaces[id]; const newContent = currentInterface?.content; if (!(0, fast_deep_equal_1.default)(originalContent, newContent)) { controllerMessenger.unsubscribe('SnapInterfaceController:stateChange', listener); const actions = getInterfaceActions(snapId, controllerMessenger, options, { content: newContent, id, }); resolve({ ...actions, content: newContent }); } }; controllerMessenger.subscribe('SnapInterfaceController:stateChange', listener); }); } exports.waitForUpdate = waitForUpdate; /** * Get a formatted file size. * * @param size - The file size in bytes. * @returns The formatted file size in MB, with two decimal places. * @example * getFormattedFileSize(1_000_000); // '1.00 MB' */ function getFormattedFileSize(size) { return `${(size / 1000000).toFixed(2)} MB`; } /** * Upload a file to an interface element. * * @param controllerMessenger - The controller messenger used to call actions. * @param id - The interface ID. * @param content - The interface Components. * @param snapId - The Snap ID. * @param name - The element name. * @param file - The file to upload. This can be a path to a file or a * `Uint8Array` containing the file contents. If this is a path, the file is * resolved relative to the current working directory. * @param options - The file options. * @param options.fileName - The name of the file. By default, this is * inferred from the file path if it's a path, and defaults to an empty string * if it's a `Uint8Array`. * @param options.contentType - The content type of the file. By default, this * is inferred from the file name if it's a path, and defaults to * `application/octet-stream` if it's a `Uint8Array` or the content type * cannot be inferred from the file name. */ async function uploadFile(controllerMessenger, id, content, snapId, name, file, options) { const result = getElement(content, name); (0, snaps_sdk_1.assert)(result !== undefined, `Could not find an element in the interface with the name "${name}".`); (0, snaps_sdk_1.assert)(result.element.type === 'FileInput', `Expected an element of type "FileInput", but found "${result.element.type}".`); const { state, context } = controllerMessenger.call('SnapInterfaceController:getInterface', snapId, id); const fileSize = await (0, files_1.getFileSize)(file); if (fileSize > MAX_FILE_SIZE) { throw new Error(`The file size (${getFormattedFileSize(fileSize)}) exceeds the maximum allowed size of ${getFormattedFileSize(MAX_FILE_SIZE)}.`); } const fileObject = await (0, files_1.getFileToUpload)(file, options); const newState = mergeValue(state, name, fileObject, result.form); controllerMessenger.call('SnapInterfaceController:updateInterfaceState', id, newState); await controllerMessenger.call('ExecutionService:handleRpcRequest', snapId, { origin: 'metamask', handler: snaps_utils_1.HandlerType.OnUserInput, request: { jsonrpc: '2.0', method: ' ', params: { event: { type: snaps_sdk_1.UserInputEventType.FileUploadEvent, name: result.element.props.name, file: fileObject, }, id, context, }, }, }); } exports.uploadFile = uploadFile; /** * Get the user interface actions for a Snap interface. These actions can be * used to interact with the interface. * * @param snapId - The Snap ID. * @param controllerMessenger - The controller messenger used to call actions. * @param simulationOptions - The simulation options. * @param interface - The interface object. * @param interface.content - The interface content. * @param interface.id - The interface ID. * @returns The user interface actions. */ function getInterfaceActions(snapId, controllerMessenger, simulationOptions, { content, id }) { return { clickElement: async (name) => { await clickElement(controllerMessenger, id, content, snapId, name); }, typeInField: async (name, value) => { await typeInField(controllerMessenger, id, content, snapId, name, value); }, selectInDropdown: async (name, value) => { await selectInDropdown(controllerMessenger, id, content, snapId, name, value); }, selectFromRadioGroup: async (name, value) => { await selectFromRadioGroup(controllerMessenger, id, content, snapId, name, value); }, selectFromSelector: async (name, value) => { await selectFromSelector(controllerMessenger, simulationOptions, id, content, snapId, name, value); }, uploadFile: async (name, file, options) => { await uploadFile(controllerMessenger, id, content, snapId, name, file, options); }, waitForUpdate: async () => waitForUpdate(controllerMessenger, simulationOptions, snapId, id, content), }; } exports.getInterfaceActions = getInterfaceActions; /** * Get a user interface object from a Snap. * * @param runSaga - A function to run a saga outside the usual Redux flow. * @param snapId - The Snap ID. * @param controllerMessenger - The controller messenger used to call actions. * @param options - The simulation options. * @yields Takes the set interface action. * @returns The user interface object. */ function* getInterface(runSaga, snapId, controllerMessenger, options) { const storedInterface = yield (0, effects_1.call)(getStoredInterface, controllerMessenger, snapId); const interfaceActions = getInterfaceActions(snapId, controllerMessenger, options, storedInterface); return getInterfaceResponse(runSaga, storedInterface.type, storedInterface.id, storedInterface.content, interfaceActions); } exports.getInterface = getInterface; //# sourceMappingURL=interface.cjs.map