diff --git a/packages/examples/packages/file-upload/snap.manifest.json b/packages/examples/packages/file-upload/snap.manifest.json index 4629ac1de9..5b0ca54a5b 100644 --- a/packages/examples/packages/file-upload/snap.manifest.json +++ b/packages/examples/packages/file-upload/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snaps.git" }, "source": { - "shasum": "a1HNLmGMJmyo+CzILPMOkZKUyNoL3Sc++9Fp1e4ZEus=", + "shasum": "OK/QqfPZc/L7VitrdRypHggyR4/4PnAG/j9erpJfJww=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/packages/examples/packages/file-upload/src/components/UploadForm.tsx b/packages/examples/packages/file-upload/src/components/UploadForm.tsx index e4eddc93c1..c3358c048c 100644 --- a/packages/examples/packages/file-upload/src/components/UploadForm.tsx +++ b/packages/examples/packages/file-upload/src/components/UploadForm.tsx @@ -18,7 +18,7 @@ export type UploadFormState = { /** * The file that was uploaded, or `null` if no file was uploaded. */ - file: File | null; + 'file-input': File | null; }; export type InteractiveFormProps = { @@ -29,11 +29,13 @@ export const UploadForm: SnapComponent = ({ files }) => { return ( File Upload -
+ - + - +
diff --git a/packages/examples/packages/file-upload/src/index.test.tsx b/packages/examples/packages/file-upload/src/index.test.tsx index 860d6c4b6c..833953f88e 100644 --- a/packages/examples/packages/file-upload/src/index.test.tsx +++ b/packages/examples/packages/file-upload/src/index.test.tsx @@ -1,5 +1,14 @@ import { expect } from '@jest/globals'; import { installSnap } from '@metamask/snaps-jest'; +import { bytesToBase64, stringToBytes } from '@metamask/utils'; + +import { UploadedFiles, UploadForm } from './components'; + +const MOCK_IMAGE = 'foo'; +const MOCK_IMAGE_BYTES = stringToBytes(MOCK_IMAGE); + +const MOCK_OTHER_IMAGE = 'bar'; +const MOCK_OTHER_IMAGE_BYTES = stringToBytes(MOCK_OTHER_IMAGE); describe('onRpcRequest', () => { it('throws an error if the requested method does not exist', async () => { @@ -19,4 +28,99 @@ describe('onRpcRequest', () => { }, }); }); + + describe('dialog', () => { + it('shows a dialog with an upload form and displays the files', async () => { + const { request } = await installSnap(); + + const response = request({ + method: 'dialog', + }); + + const ui = await response.getInterface(); + await ui.uploadFile('file-input', MOCK_IMAGE_BYTES, { + fileName: 'image.svg', + contentType: 'image/svg+xml', + }); + + expect(await response.getInterface()).toRender( + , + ); + + await ui.uploadFile('file-input', MOCK_OTHER_IMAGE_BYTES, { + fileName: 'other-image.svg', + contentType: 'image/svg+xml', + }); + + expect(await response.getInterface()).toRender( + , + ); + }); + + it('shows the latest uploaded file when submitting the form', async () => { + const { request } = await installSnap(); + + const response = request({ + method: 'dialog', + }); + + const ui = await response.getInterface(); + await ui.uploadFile('file-input', MOCK_IMAGE_BYTES, { + fileName: 'image.svg', + contentType: 'image/svg+xml', + }); + + await ui.clickElement('submit-file-upload-form'); + + expect(await response.getInterface()).toRender( + , + ); + }); + + it('shows "No files uploaded" when submitting the form without uploading a file', async () => { + const { request } = await installSnap(); + + const response = request({ + method: 'dialog', + }); + + const ui = await response.getInterface(); + await ui.clickElement('submit-file-upload-form'); + + expect(await response.getInterface()).toRender( + , + ); + }); + }); }); diff --git a/packages/examples/packages/file-upload/src/index.tsx b/packages/examples/packages/file-upload/src/index.tsx index e4ebb4b95d..0b169603eb 100644 --- a/packages/examples/packages/file-upload/src/index.tsx +++ b/packages/examples/packages/file-upload/src/index.tsx @@ -12,8 +12,8 @@ import { UploadedFiles, UploadForm } from './components'; * Handle incoming JSON-RPC requests from the dapp, sent through the * `wallet_invokeSnap` method. This handler handles one method: * - * - `dialog`: Create a `snap_dialog` with an interactive interface. This demonstrates - * that a snap can show an interactive `snap_dialog` that the user can interact with. + * - `dialog`: Create a `snap_dialog` with an an upload form. The form allows + * the user to upload files, and the uploaded files are displayed in the UI. * * @param params - The request parameters. * @param params.request - The JSON-RPC request object. @@ -111,7 +111,7 @@ export const onUserInput: OnUserInputHandler = async ({ id, event }) => { method: 'snap_updateInterface', params: { id, - ui: , + ui: , }, }); } diff --git a/packages/snaps-jest/package.json b/packages/snaps-jest/package.json index d7e9eb4fa7..b908581780 100644 --- a/packages/snaps-jest/package.json +++ b/packages/snaps-jest/package.json @@ -56,6 +56,7 @@ "express": "^4.18.2", "jest-environment-node": "^29.5.0", "jest-matcher-utils": "^29.5.0", + "mime": "^3.0.0", "readable-stream": "^3.6.2", "redux": "^4.2.1", "redux-saga": "^1.2.3", @@ -72,6 +73,7 @@ "@swc/core": "1.3.78", "@swc/jest": "^0.2.26", "@types/jest": "^27.5.1", + "@types/mime": "^3.0.0", "@types/semver": "^7.5.0", "@typescript-eslint/eslint-plugin": "^5.42.1", "@typescript-eslint/parser": "^5.42.1", diff --git a/packages/snaps-jest/src/helpers.test.tsx b/packages/snaps-jest/src/helpers.test.tsx index faada8db17..f1703ce90e 100644 --- a/packages/snaps-jest/src/helpers.test.tsx +++ b/packages/snaps-jest/src/helpers.test.tsx @@ -404,6 +404,7 @@ describe('installSnap', () => { clickElement: expect.any(Function), typeInField: expect.any(Function), selectInDropdown: expect.any(Function), + uploadFile: expect.any(Function), ok: expect.any(Function), cancel: expect.any(Function), }); @@ -463,6 +464,7 @@ describe('installSnap', () => { clickElement: expect.any(Function), typeInField: expect.any(Function), selectInDropdown: expect.any(Function), + uploadFile: expect.any(Function), ok: expect.any(Function), cancel: expect.any(Function), }); @@ -521,6 +523,7 @@ describe('installSnap', () => { clickElement: expect.any(Function), typeInField: expect.any(Function), selectInDropdown: expect.any(Function), + uploadFile: expect.any(Function), ok: expect.any(Function), }); diff --git a/packages/snaps-jest/src/internals/request.test.tsx b/packages/snaps-jest/src/internals/request.test.tsx index a9c5883073..ff830deba2 100644 --- a/packages/snaps-jest/src/internals/request.test.tsx +++ b/packages/snaps-jest/src/internals/request.test.tsx @@ -272,6 +272,7 @@ describe('getInterfaceApi', () => { clickElement: expect.any(Function), typeInField: expect.any(Function), selectInDropdown: expect.any(Function), + uploadFile: expect.any(Function), }); }); @@ -302,6 +303,7 @@ describe('getInterfaceApi', () => { clickElement: expect.any(Function), typeInField: expect.any(Function), selectInDropdown: expect.any(Function), + uploadFile: expect.any(Function), }); }); diff --git a/packages/snaps-jest/src/internals/request.ts b/packages/snaps-jest/src/internals/request.ts index dae5083066..44c4da8db1 100644 --- a/packages/snaps-jest/src/internals/request.ts +++ b/packages/snaps-jest/src/internals/request.ts @@ -1,9 +1,9 @@ import type { AbstractExecutionService } from '@metamask/snaps-controllers'; import { - type SnapId, - type JsonRpcError, type ComponentOrElement, ComponentOrElementStruct, + type JsonRpcError, + type SnapId, } from '@metamask/snaps-sdk'; import type { HandlerType } from '@metamask/snaps-utils'; import { unwrapError } from '@metamask/snaps-utils'; @@ -21,15 +21,13 @@ import type { SnapHandlerInterface, SnapRequest, } from '../types'; +import type { RunSagaFunction, Store } from './simulation'; import { clearNotifications, - clickElement, getInterface, + getInterfaceActions, getNotifications, - typeInField, - selectInDropdown, } from './simulation'; -import type { RunSagaFunction, Store } from './simulation'; import type { RootControllerMessenger } from './simulation/controllers'; import { SnapResponseStruct } from './structs'; @@ -195,7 +193,8 @@ export async function getInterfaceFromResult( } /** - * Get the response content from the SnapInterfaceController and include the interaction methods. + * Get the response content from the `SnapInterfaceController` and include the + * interaction methods. * * @param result - The handler result object. * @param snapId - The Snap ID. @@ -221,37 +220,14 @@ export async function getInterfaceApi( interfaceId, ); + const actions = getInterfaceActions(snapId, controllerMessenger, { + id: interfaceId, + content, + }); + return { content, - clickElement: async (name) => { - await clickElement( - controllerMessenger, - interfaceId, - content, - snapId, - name, - ); - }, - typeInField: async (name, value) => { - await typeInField( - controllerMessenger, - interfaceId, - content, - snapId, - name, - value, - ); - }, - selectInDropdown: async (name, value) => { - await selectInDropdown( - controllerMessenger, - interfaceId, - content, - snapId, - name, - value, - ); - }, + ...actions, }; }; } diff --git a/packages/snaps-jest/src/internals/simulation/files.test.ts b/packages/snaps-jest/src/internals/simulation/files.test.ts index f3281119d8..9f485e7fc6 100644 --- a/packages/snaps-jest/src/internals/simulation/files.test.ts +++ b/packages/snaps-jest/src/internals/simulation/files.test.ts @@ -1,7 +1,13 @@ import { VirtualFile } from '@metamask/snaps-utils'; import { stringToBytes } from '@metamask/utils'; +import { resolve } from 'path'; -import { getSnapFile } from './files'; +import { + getContentType, + getFileSize, + getFileToUpload, + getSnapFile, +} from './files'; describe('getSnapFile', () => { it('returns the file', async () => { @@ -26,3 +32,87 @@ describe('getSnapFile', () => { expect(await getSnapFile(files, 'bar')).toBeNull(); }); }); + +describe('getContentType', () => { + it('returns the content type', () => { + expect(getContentType('.jpg')).toBe('image/jpeg'); + expect(getContentType('.png')).toBe('image/png'); + expect(getContentType('.gif')).toBe('image/gif'); + }); + + it('returns the default content type for an unknown file extension', () => { + expect(getContentType('.foo')).toBe('application/octet-stream'); + }); + + it('returns the default content type for an empty string', () => { + expect(getContentType('')).toBe('application/octet-stream'); + }); +}); + +describe('getFileSize', () => { + it('returns the file size for a `Uint8Array`', async () => { + expect(await getFileSize(new Uint8Array([1, 2, 3]))).toBe(3); + }); + + it('returns the file size for a file path', async () => { + expect( + await getFileSize(resolve(__dirname, '../../test-utils/snap/snap.js')), + ).toBe(112); + }); +}); + +describe('getFileToUpload', () => { + const MOCK_FILE = resolve(__dirname, '../../test-utils/snap/snap.js'); + + it('returns the file object', async () => { + const file = await getFileToUpload(MOCK_FILE, { + fileName: 'bar.js', + contentType: 'application/foo', + }); + + expect(file).toStrictEqual({ + name: 'bar.js', + contentType: 'application/foo', + size: 112, + contents: + 'Ly8gZXNsaW50LWRpc2FibGUtbmV4dC1saW5lIG5vLWNvbnNvbGUKY29uc29sZS5sb2coJ0hlbGxvLCB3b3JsZCEnKTsKCm1vZHVsZS5leHBvcnRzLm9uUnBjUmVxdWVzdCA9ICgpID0+IG51bGw7Cg==', + }); + }); + + it('returns the file object with content type inferred from the file name', async () => { + const file = await getFileToUpload(MOCK_FILE, { + fileName: 'bar.js', + }); + + expect(file).toStrictEqual({ + name: 'bar.js', + contentType: 'application/javascript', + size: 112, + contents: + 'Ly8gZXNsaW50LWRpc2FibGUtbmV4dC1saW5lIG5vLWNvbnNvbGUKY29uc29sZS5sb2coJ0hlbGxvLCB3b3JsZCEnKTsKCm1vZHVsZS5leHBvcnRzLm9uUnBjUmVxdWVzdCA9ICgpID0+IG51bGw7Cg==', + }); + }); + + it('returns the file object with the default file name', async () => { + const file = await getFileToUpload(MOCK_FILE); + + expect(file).toStrictEqual({ + name: 'snap.js', + contentType: 'application/javascript', + size: 112, + contents: + 'Ly8gZXNsaW50LWRpc2FibGUtbmV4dC1saW5lIG5vLWNvbnNvbGUKY29uc29sZS5sb2coJ0hlbGxvLCB3b3JsZCEnKTsKCm1vZHVsZS5leHBvcnRzLm9uUnBjUmVxdWVzdCA9ICgpID0+IG51bGw7Cg==', + }); + }); + + it('returns the file object from a Uint8Array', async () => { + const file = await getFileToUpload(new Uint8Array([1, 2, 3])); + + expect(file).toStrictEqual({ + name: '', + contentType: 'application/octet-stream', + size: 3, + contents: 'AQID', + }); + }); +}); diff --git a/packages/snaps-jest/src/internals/simulation/files.ts b/packages/snaps-jest/src/internals/simulation/files.ts index 742bad8c8b..7e0631a558 100644 --- a/packages/snaps-jest/src/internals/simulation/files.ts +++ b/packages/snaps-jest/src/internals/simulation/files.ts @@ -1,6 +1,13 @@ +import type { File } from '@metamask/snaps-sdk'; import { AuxiliaryFileEncoding } from '@metamask/snaps-sdk'; import type { VirtualFile } from '@metamask/snaps-utils'; import { encodeAuxiliaryFile, normalizeRelative } from '@metamask/snaps-utils'; +import { bytesToBase64 } from '@metamask/utils'; +import { readFile, stat } from 'fs/promises'; +import mime from 'mime'; +import { basename, extname, resolve } from 'path'; + +import type { FileOptions } from '../../types'; /** * Get a statically defined Snap file from an array of files. @@ -26,3 +33,70 @@ export async function getSnapFile( return await encodeAuxiliaryFile(base64, encoding); } + +/** + * Get the content type of a file based on its extension. + * + * @param extension - The file extension. + * @returns The content type of the file. If the content type cannot be inferred + * from the extension, `application/octet-stream` is returned. + */ +export function getContentType(extension: string) { + return mime.getType(extension) ?? 'application/octet-stream'; +} + +/** + * Get the size of a file, from a file path or a `Uint8Array`. + * + * @param file - The file to get the size of. 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. + * @returns The size of the file in bytes. + */ +export async function getFileSize(file: string | Uint8Array) { + if (typeof file === 'string') { + const { size } = await stat(resolve(process.cwd(), file)); + return size; + } + + return file.length; +} + +/** + * Get a file object to upload, from a file path or a `Uint8Array`, with an + * optional file name and content type. + * + * @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. + */ +export async function getFileToUpload( + file: string | Uint8Array, + { fileName, contentType }: FileOptions = {}, +): Promise { + if (typeof file === 'string') { + const buffer = await readFile(resolve(process.cwd(), file)); + + return { + name: fileName ?? basename(file), + size: buffer.byteLength, + contentType: contentType ?? getContentType(extname(file)), + contents: bytesToBase64(buffer), + }; + } + + return { + name: fileName ?? '', + size: file.length, + contentType: contentType ?? 'application/octet-stream', + contents: bytesToBase64(file), + }; +} diff --git a/packages/snaps-jest/src/internals/simulation/interface.test.tsx b/packages/snaps-jest/src/internals/simulation/interface.test.tsx index bc47197e96..c7186607a9 100644 --- a/packages/snaps-jest/src/internals/simulation/interface.test.tsx +++ b/packages/snaps-jest/src/internals/simulation/interface.test.tsx @@ -16,6 +16,7 @@ import { Option, Box, Input, + FileInput, } from '@metamask/snaps-sdk/jsx'; import { getJsxElementFromComponent, @@ -40,6 +41,7 @@ import { mergeValue, selectInDropdown, typeInField, + uploadFile, } from './interface'; import type { RunSagaFunction } from './store'; import { createStore, resolveInterface, setInterface } from './store'; @@ -63,6 +65,7 @@ describe('getInterfaceResponse', () => { clickElement: jest.fn(), typeInField: jest.fn(), selectInDropdown: jest.fn(), + uploadFile: jest.fn(), }; it('returns an `ok` function that resolves the user interface with `null` for alert dialogs', async () => { @@ -80,6 +83,7 @@ describe('getInterfaceResponse', () => { clickElement: expect.any(Function), typeInField: expect.any(Function), selectInDropdown: expect.any(Function), + uploadFile: expect.any(Function), ok: expect.any(Function), }); @@ -103,6 +107,7 @@ describe('getInterfaceResponse', () => { clickElement: expect.any(Function), typeInField: expect.any(Function), selectInDropdown: expect.any(Function), + uploadFile: expect.any(Function), ok: expect.any(Function), cancel: expect.any(Function), }); @@ -128,6 +133,7 @@ describe('getInterfaceResponse', () => { clickElement: expect.any(Function), typeInField: expect.any(Function), selectInDropdown: expect.any(Function), + uploadFile: expect.any(Function), ok: expect.any(Function), cancel: expect.any(Function), }); @@ -152,6 +158,7 @@ describe('getInterfaceResponse', () => { clickElement: expect.any(Function), typeInField: expect.any(Function), selectInDropdown: expect.any(Function), + uploadFile: expect.any(Function), ok: expect.any(Function), cancel: expect.any(Function), }); @@ -176,6 +183,7 @@ describe('getInterfaceResponse', () => { clickElement: expect.any(Function), typeInField: expect.any(Function), selectInDropdown: expect.any(Function), + uploadFile: expect.any(Function), ok: expect.any(Function), cancel: expect.any(Function), }); @@ -201,6 +209,7 @@ describe('getInterfaceResponse', () => { clickElement: expect.any(Function), typeInField: expect.any(Function), selectInDropdown: expect.any(Function), + uploadFile: expect.any(Function), ok: expect.any(Function), cancel: expect.any(Function), }); @@ -707,6 +716,169 @@ describe('selectInDropdown', () => { }); }); +describe('uploadFile', () => { + it('uploads a file and sends an `FileUploadEvent` to the Snap', async () => { + const rootControllerMessenger = getRootControllerMessenger(); + const controllerMessenger = getRestrictedSnapInterfaceControllerMessenger( + rootControllerMessenger, + ); + + const interfaceController = new SnapInterfaceController({ + messenger: controllerMessenger, + }); + + const handleRpcRequestMock = jest.fn(); + + rootControllerMessenger.registerActionHandler( + 'ExecutionService:handleRpcRequest', + handleRpcRequestMock, + ); + + const content = ( + + + + ); + + const interfaceId = await interfaceController.createInterface( + MOCK_SNAP_ID, + content, + ); + + await uploadFile( + rootControllerMessenger, + interfaceId, + content, + MOCK_SNAP_ID, + 'foo', + new Uint8Array([1, 2, 3]), + ); + + expect(handleRpcRequestMock).toHaveBeenCalledWith(MOCK_SNAP_ID, { + origin: '', + handler: HandlerType.OnUserInput, + request: { + jsonrpc: '2.0', + method: ' ', + params: { + event: { + type: UserInputEventType.FileUploadEvent, + name: 'foo', + file: { + name: '', + size: 3, + contentType: 'application/octet-stream', + contents: 'AQID', + }, + }, + id: interfaceId, + context: null, + }, + }, + }); + }); + + it('uploads a file with a custom file name and MIME type', async () => { + const rootControllerMessenger = getRootControllerMessenger(); + const controllerMessenger = getRestrictedSnapInterfaceControllerMessenger( + rootControllerMessenger, + ); + + const interfaceController = new SnapInterfaceController({ + messenger: controllerMessenger, + }); + + const handleRpcRequestMock = jest.fn(); + + rootControllerMessenger.registerActionHandler( + 'ExecutionService:handleRpcRequest', + handleRpcRequestMock, + ); + + const content = ( + + + + ); + + const interfaceId = await interfaceController.createInterface( + MOCK_SNAP_ID, + content, + ); + + await uploadFile( + rootControllerMessenger, + interfaceId, + content, + MOCK_SNAP_ID, + 'foo', + new Uint8Array([1, 2, 3]), + { + fileName: 'bar', + contentType: 'text/plain', + }, + ); + + expect(handleRpcRequestMock).toHaveBeenCalledWith(MOCK_SNAP_ID, { + origin: '', + handler: HandlerType.OnUserInput, + request: { + jsonrpc: '2.0', + method: ' ', + params: { + event: { + type: UserInputEventType.FileUploadEvent, + name: 'foo', + file: { + name: 'bar', + size: 3, + contentType: 'text/plain', + contents: 'AQID', + }, + }, + id: interfaceId, + context: null, + }, + }, + }); + }); + + it('throws an error if the file size exceeds the maximum allowed size', async () => { + const rootControllerMessenger = getRootControllerMessenger(); + const controllerMessenger = getRestrictedSnapInterfaceControllerMessenger( + rootControllerMessenger, + ); + + const interfaceController = new SnapInterfaceController({ + messenger: controllerMessenger, + }); + + const content = ( + + + + ); + + const interfaceId = await interfaceController.createInterface( + MOCK_SNAP_ID, + content, + ); + + await expect( + uploadFile( + rootControllerMessenger, + interfaceId, + content, + MOCK_SNAP_ID, + 'foo', + new Uint8Array(11_000_000), + ), + ).rejects.toThrow( + 'The file size (11.00 MB) exceeds the maximum allowed size of 10.00 MB.', + ); + }); +}); + describe('getInterface', () => { const rootControllerMessenger = getRootControllerMessenger(); const controllerMessenger = getRestrictedSnapInterfaceControllerMessenger( @@ -738,6 +910,7 @@ describe('getInterface', () => { clickElement: expect.any(Function), typeInField: expect.any(Function), selectInDropdown: expect.any(Function), + uploadFile: expect.any(Function), ok: expect.any(Function), }); }); @@ -765,6 +938,7 @@ describe('getInterface', () => { clickElement: expect.any(Function), typeInField: expect.any(Function), selectInDropdown: expect.any(Function), + uploadFile: expect.any(Function), ok: expect.any(Function), }); }); @@ -901,4 +1075,56 @@ describe('getInterface', () => { }, ); }); + + it('sends a request to the snap when `uploadFile` is called', async () => { + jest.spyOn(rootControllerMessenger, 'call'); + const { store, runSaga } = createStore(getMockOptions()); + + const content = ( + + + + ); + const id = await interfaceController.createInterface(MOCK_SNAP_ID, content); + const type = DialogType.Alert; + const ui = { type, id }; + + store.dispatch(setInterface(ui)); + + const result = await runSaga( + getInterface, + runSaga, + MOCK_SNAP_ID, + rootControllerMessenger, + ).toPromise(); + + await result.uploadFile('foo', new Uint8Array([1, 2, 3])); + + expect(rootControllerMessenger.call).toHaveBeenCalledWith( + 'ExecutionService:handleRpcRequest', + MOCK_SNAP_ID, + { + origin: '', + handler: HandlerType.OnUserInput, + request: { + jsonrpc: '2.0', + method: ' ', + params: { + event: { + type: UserInputEventType.FileUploadEvent, + name: 'foo', + file: { + name: '', + size: 3, + contentType: 'application/octet-stream', + contents: 'AQID', + }, + }, + id, + context: null, + }, + }, + }, + ); + }); }); diff --git a/packages/snaps-jest/src/internals/simulation/interface.ts b/packages/snaps-jest/src/internals/simulation/interface.ts index 8821918562..a96b79b674 100644 --- a/packages/snaps-jest/src/internals/simulation/interface.ts +++ b/packages/snaps-jest/src/internals/simulation/interface.ts @@ -4,6 +4,7 @@ import type { InterfaceState, SnapId, UserInputEvent, + File, } from '@metamask/snaps-sdk'; import { DialogType, UserInputEventType, assert } from '@metamask/snaps-sdk'; import type { FormElement, JSXElement } from '@metamask/snaps-sdk/jsx'; @@ -18,11 +19,21 @@ import type { PayloadAction } from '@reduxjs/toolkit'; import { type SagaIterator } from 'redux-saga'; import { call, put, select, take } from 'redux-saga/effects'; -import type { SnapInterface, SnapInterfaceActions } from '../../types'; +import type { + FileOptions, + SnapInterface, + SnapInterfaceActions, +} from '../../types'; import type { RootControllerMessenger } from './controllers'; +import { getFileSize, getFileToUpload } from './files'; import type { Interface, RunSagaFunction } from './store'; import { getCurrentInterface, resolveInterface, setInterface } from './store'; +/** + * The maximum file size that can be uploaded. + */ +const MAX_FILE_SIZE = 10_000_000; // 10 MB + /** * Get a user interface object from a type and content object. * @@ -341,7 +352,7 @@ export async function clickElement( export function mergeValue( state: InterfaceState, name: string, - value: string | null, + value: string | File | null, form?: string, ): InterfaceState { if (form) { @@ -495,32 +506,128 @@ export async function selectInDropdown( } /** - * Get a user interface object from a Snap. + * 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: number) { + return `${(size / 1_000_000).toFixed(2)} MB`; +} + +/** + * Upload a file to an interface element. * - * @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. - * @yields Takes the set interface action. - * @returns The user interface object. + * @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. */ -export function* getInterface( - runSaga: RunSagaFunction, - snapId: SnapId, +export async function uploadFile( controllerMessenger: RootControllerMessenger, -): SagaIterator { - const { type, id, content } = yield call( - getStoredInterface, - controllerMessenger, + id: string, + content: JSXElement, + snapId: SnapId, + name: string, + file: string | Uint8Array, + options?: FileOptions, +) { + const result = getElement(content, name); + + assert( + result !== undefined, + `Could not find an element in the interface with the name "${name}".`, + ); + + 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 interfaceActions = { + const fileSize = await 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 getFileToUpload(file, options); + const newState = mergeValue(state, name, fileObject, result.form); + + controllerMessenger.call( + 'SnapInterfaceController:updateInterfaceState', + id, + newState, + ); + + await controllerMessenger.call('ExecutionService:handleRpcRequest', snapId, { + origin: '', + handler: HandlerType.OnUserInput, + request: { + jsonrpc: '2.0', + method: ' ', + params: { + event: { + type: UserInputEventType.FileUploadEvent, + name: result.element.props.name, + file: fileObject, + }, + id, + context, + }, + }, + }); +} + +/** + * 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 interface - The interface object. + * @param interface.content - The interface content. + * @param interface.id - The interface ID. + * @returns The user interface actions. + */ +export function getInterfaceActions( + snapId: SnapId, + controllerMessenger: RootControllerMessenger, + { content, id }: Omit & { content: JSXElement }, +): SnapInterfaceActions { + return { clickElement: async (name: string) => { await clickElement(controllerMessenger, id, content, snapId, name); }, + typeInField: async (name: string, value: string) => { await typeInField(controllerMessenger, id, content, snapId, name, value); }, + selectInDropdown: async (name: string, value: string) => { await selectInDropdown( controllerMessenger, @@ -531,7 +638,55 @@ export function* getInterface( value, ); }, + + uploadFile: async ( + name: string, + file: string | Uint8Array, + options?: FileOptions, + ) => { + await uploadFile( + controllerMessenger, + id, + content, + snapId, + name, + file, + options, + ); + }, }; +} - return getInterfaceResponse(runSaga, type, content, interfaceActions); +/** + * 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. + * @yields Takes the set interface action. + * @returns The user interface object. + */ +export function* getInterface( + runSaga: RunSagaFunction, + snapId: SnapId, + controllerMessenger: RootControllerMessenger, +): SagaIterator { + const storedInterface = yield call( + getStoredInterface, + controllerMessenger, + snapId, + ); + + const interfaceActions = getInterfaceActions( + snapId, + controllerMessenger, + storedInterface, + ); + + return getInterfaceResponse( + runSaga, + storedInterface.type, + storedInterface.content, + interfaceActions, + ); } diff --git a/packages/snaps-jest/src/types.ts b/packages/snaps-jest/src/types.ts index bdf3652f31..5cd9be62fd 100644 --- a/packages/snaps-jest/src/types.ts +++ b/packages/snaps-jest/src/types.ts @@ -81,6 +81,22 @@ export type SignatureOptions = Infer; */ export type SnapOptions = Infer; +/** + * Options for uploading a file. + * + * @property 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`. + * @property 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. + */ +export type FileOptions = { + fileName?: string; + contentType?: string; +}; + export type SnapInterfaceActions = { /** * Click on an interface element. @@ -104,6 +120,28 @@ export type SnapInterfaceActions = { * @param value - The value to type. */ selectInDropdown(name: string, value: string): Promise; + + /** + * Upload a file. + * + * @param name - The element name to upload the file to. + * @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. + */ + uploadFile( + name: string, + file: string | Uint8Array, + options?: FileOptions, + ): Promise; }; /** diff --git a/yarn.lock b/yarn.lock index 2a27f0823d..afca094488 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5770,6 +5770,7 @@ __metadata: "@swc/core": 1.3.78 "@swc/jest": ^0.2.26 "@types/jest": ^27.5.1 + "@types/mime": ^3.0.0 "@types/semver": ^7.5.0 "@typescript-eslint/eslint-plugin": ^5.42.1 "@typescript-eslint/parser": ^5.42.1 @@ -5788,6 +5789,7 @@ __metadata: jest-environment-node: ^29.5.0 jest-it-up: ^2.0.0 jest-matcher-utils: ^29.5.0 + mime: ^3.0.0 prettier: ^2.7.1 prettier-plugin-packagejson: ^2.2.11 readable-stream: ^3.6.2 @@ -7774,10 +7776,10 @@ __metadata: languageName: node linkType: hard -"@types/mime@npm:*": - version: 3.0.1 - resolution: "@types/mime@npm:3.0.1" - checksum: 4040fac73fd0cea2460e29b348c1a6173da747f3a87da0dbce80dd7a9355a3d0e51d6d9a401654f3e5550620e3718b5a899b2ec1debf18424e298a2c605346e7 +"@types/mime@npm:*, @types/mime@npm:^3.0.0": + version: 3.0.4 + resolution: "@types/mime@npm:3.0.4" + checksum: a6139c8e1f705ef2b064d072f6edc01f3c099023ad7c4fce2afc6c2bf0231888202adadbdb48643e8e20da0ce409481a49922e737eca52871b3dc08017455843 languageName: node linkType: hard @@ -17381,6 +17383,15 @@ __metadata: languageName: node linkType: hard +"mime@npm:^3.0.0": + version: 3.0.0 + resolution: "mime@npm:3.0.0" + bin: + mime: cli.js + checksum: f43f9b7bfa64534e6b05bd6062961681aeb406a5b53673b53b683f27fcc4e739989941836a355eef831f4478923651ecc739f4a5f6e20a76487b432bfd4db928 + languageName: node + linkType: hard + "mimic-fn@npm:^2.1.0": version: 2.1.0 resolution: "mimic-fn@npm:2.1.0"