@metamask/snaps-simulation
Version:
A simulation framework for MetaMask Snaps, enabling headless testing of Snaps in a controlled environment
694 lines • 29.5 kB
JavaScript
;
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