Skip to content

Commit

Permalink
Add support for file inputs to snaps-jest (#2494)
Browse files Browse the repository at this point in the history
This adds a new method `uploadFile` to the interface object returned by
`snaps-jest`, to "upload" a file to the Snap. This simulates the file
input component.
  • Loading branch information
Mrtenz authored Jun 20, 2024
1 parent e01df64 commit 8ab64ec
Show file tree
Hide file tree
Showing 14 changed files with 748 additions and 65 deletions.
2 changes: 1 addition & 1 deletion packages/examples/packages/file-upload/snap.manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -29,11 +29,13 @@ export const UploadForm: SnapComponent<InteractiveFormProps> = ({ files }) => {
return (
<Box>
<Heading>File Upload</Heading>
<Form name="foo">
<Form name="file-upload-form">
<Field>
<FileInput name="file" />
<FileInput name="file-input" />
</Field>
<Button type="submit">Submit</Button>
<Button name="submit-file-upload-form" type="submit">
Submit
</Button>
</Form>
<FileList files={files} />
</Box>
Expand Down
104 changes: 104 additions & 0 deletions packages/examples/packages/file-upload/src/index.test.tsx
Original file line number Diff line number Diff line change
@@ -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 = '<svg>foo</svg>';
const MOCK_IMAGE_BYTES = stringToBytes(MOCK_IMAGE);

const MOCK_OTHER_IMAGE = '<svg>bar</svg>';
const MOCK_OTHER_IMAGE_BYTES = stringToBytes(MOCK_OTHER_IMAGE);

describe('onRpcRequest', () => {
it('throws an error if the requested method does not exist', async () => {
Expand All @@ -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(
<UploadForm
files={[
{
name: 'image.svg',
contentType: 'image/svg+xml',
size: MOCK_IMAGE_BYTES.length,
contents: bytesToBase64(MOCK_IMAGE_BYTES),
},
]}
/>,
);

await ui.uploadFile('file-input', MOCK_OTHER_IMAGE_BYTES, {
fileName: 'other-image.svg',
contentType: 'image/svg+xml',
});

expect(await response.getInterface()).toRender(
<UploadForm
files={[
{
name: 'image.svg',
contentType: 'image/svg+xml',
size: MOCK_IMAGE_BYTES.length,
contents: bytesToBase64(MOCK_IMAGE_BYTES),
},
{
name: 'other-image.svg',
contentType: 'image/svg+xml',
size: MOCK_OTHER_IMAGE_BYTES.length,
contents: bytesToBase64(MOCK_OTHER_IMAGE_BYTES),
},
]}
/>,
);
});

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(
<UploadedFiles
file={{
name: 'image.svg',
contentType: 'image/svg+xml',
size: MOCK_IMAGE_BYTES.length,
contents: bytesToBase64(MOCK_IMAGE_BYTES),
}}
/>,
);
});

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(
<UploadedFiles file={null} />,
);
});
});
});
6 changes: 3 additions & 3 deletions packages/examples/packages/file-upload/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -111,7 +111,7 @@ export const onUserInput: OnUserInputHandler = async ({ id, event }) => {
method: 'snap_updateInterface',
params: {
id,
ui: <UploadedFiles file={value.file} />,
ui: <UploadedFiles file={value['file-input']} />,
},
});
}
Expand Down
2 changes: 2 additions & 0 deletions packages/snaps-jest/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions packages/snaps-jest/src/helpers.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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),
});
Expand Down Expand Up @@ -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),
});
Expand Down Expand Up @@ -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),
});

Expand Down
2 changes: 2 additions & 0 deletions packages/snaps-jest/src/internals/request.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,7 @@ describe('getInterfaceApi', () => {
clickElement: expect.any(Function),
typeInField: expect.any(Function),
selectInDropdown: expect.any(Function),
uploadFile: expect.any(Function),
});
});

Expand Down Expand Up @@ -302,6 +303,7 @@ describe('getInterfaceApi', () => {
clickElement: expect.any(Function),
typeInField: expect.any(Function),
selectInDropdown: expect.any(Function),
uploadFile: expect.any(Function),
});
});

Expand Down
48 changes: 12 additions & 36 deletions packages/snaps-jest/src/internals/request.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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';

Expand Down Expand Up @@ -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.
Expand All @@ -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,
};
};
}
Expand Down
Loading

0 comments on commit 8ab64ec

Please sign in to comment.