diff --git a/packages/examples/packages/bip32/snap.manifest.json b/packages/examples/packages/bip32/snap.manifest.json index a614a56a11..59e5af2fde 100644 --- a/packages/examples/packages/bip32/snap.manifest.json +++ b/packages/examples/packages/bip32/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snaps.git" }, "source": { - "shasum": "oIth1iwHikuY19tkSxTGtl4YzdX1XAiGn2RJl+DAeCs=", + "shasum": "5o3Zg3/1/23XKZdIzzo1Sbwmp2uQNF1EpnzyLYqhfqo=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/packages/examples/packages/bip44/snap.manifest.json b/packages/examples/packages/bip44/snap.manifest.json index fff1ffa89f..a7b95f7390 100644 --- a/packages/examples/packages/bip44/snap.manifest.json +++ b/packages/examples/packages/bip44/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snaps.git" }, "source": { - "shasum": "Ta9rEMnKSdYz7ecxZh++RZyMSW9JVsQVqa+zFLLO87k=", + "shasum": "K0uZKIJkRUzXrIrbcNY/hjouFPSEVScyeJc0D5SvuIs=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/packages/examples/packages/browserify-plugin/snap.manifest.json b/packages/examples/packages/browserify-plugin/snap.manifest.json index a29e965971..4e0b2c8fbe 100644 --- a/packages/examples/packages/browserify-plugin/snap.manifest.json +++ b/packages/examples/packages/browserify-plugin/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snaps.git" }, "source": { - "shasum": "q9RIkIkHMu7cVWgMRnqa0iLb7gzeYAk9LA6WXlEJkUg=", + "shasum": "zjj26VuJEMPd17W0vAdpVONDt/JeYPacP5GwbRAiQZo=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/packages/examples/packages/browserify/snap.manifest.json b/packages/examples/packages/browserify/snap.manifest.json index 2a633d6a81..a37da4aaa7 100644 --- a/packages/examples/packages/browserify/snap.manifest.json +++ b/packages/examples/packages/browserify/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snaps.git" }, "source": { - "shasum": "Sehc3xVJmoP4CBbyL64schgPUfHu7Labnrtk5i5LH88=", + "shasum": "WEO5ozYWnfy690WSCYKkexaQnlkDbks1f+vXrRVQRTM=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/packages/examples/packages/cronjobs/snap.manifest.json b/packages/examples/packages/cronjobs/snap.manifest.json index e4e9dd2b74..db31729895 100644 --- a/packages/examples/packages/cronjobs/snap.manifest.json +++ b/packages/examples/packages/cronjobs/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snaps.git" }, "source": { - "shasum": "o5qiOur9Zl+yAjHScLzh0FfK7/jcKx7rGqotitpJ6Uo=", + "shasum": "jcA6NL+j9BPrnOiv5FOl+KnTyxAJ5UdThThk4zCoiu8=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/packages/examples/packages/dialogs/snap.manifest.json b/packages/examples/packages/dialogs/snap.manifest.json index 67035ee16f..019f02ba02 100644 --- a/packages/examples/packages/dialogs/snap.manifest.json +++ b/packages/examples/packages/dialogs/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snaps.git" }, "source": { - "shasum": "77wtrsk6BY8OSjG5pWALMSJzOmznWaaR7EiM3LGigjI=", + "shasum": "P+jpppDqo3e+Dsyk2KP+26NlKDyGwW+CxztRYiDkqnQ=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/packages/examples/packages/ethers-js/snap.manifest.json b/packages/examples/packages/ethers-js/snap.manifest.json index 598841f513..987a77f9f3 100644 --- a/packages/examples/packages/ethers-js/snap.manifest.json +++ b/packages/examples/packages/ethers-js/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snaps.git" }, "source": { - "shasum": "fwMTtk4KJjak+yf6666Vv/+syXmFVAnVPAsBkalK2Qc=", + "shasum": "ARrlf69swjj7vnhGGRLajr4MFr4hbKbh9G5R987WFoo=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/packages/examples/packages/get-entropy/snap.manifest.json b/packages/examples/packages/get-entropy/snap.manifest.json index 537974b0a1..35b3c35dff 100644 --- a/packages/examples/packages/get-entropy/snap.manifest.json +++ b/packages/examples/packages/get-entropy/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snaps.git" }, "source": { - "shasum": "ur2Jxp1kniP1jYLeexXHTB/+dgfJom1volcPQgSNX6c=", + "shasum": "dPssrjujrDEsuDMxogdHlFNJbGtuZjTpwwU5Lirbr6A=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/packages/examples/packages/images/snap.manifest.json b/packages/examples/packages/images/snap.manifest.json index 1e8c4f2536..5374917662 100644 --- a/packages/examples/packages/images/snap.manifest.json +++ b/packages/examples/packages/images/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snaps.git" }, "source": { - "shasum": "ewEvZGj7D/wUtBH8bDutuDuJ1leq8n9Penuu8yDAykA=", + "shasum": "lNV3t5b0qgpl6fMuTUdFA75wWL3zFzm3MvEokB8VF5U=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/packages/examples/packages/invoke-snap/packages/core-signer/snap.manifest.json b/packages/examples/packages/invoke-snap/packages/core-signer/snap.manifest.json index 2516ea1744..4a6144b766 100644 --- a/packages/examples/packages/invoke-snap/packages/core-signer/snap.manifest.json +++ b/packages/examples/packages/invoke-snap/packages/core-signer/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snaps.git" }, "source": { - "shasum": "Y0k3QSqHBYytNfYgmtrjwhbZ3e8WzXhOQFYPJCIuJfg=", + "shasum": "nBYyvFGnEv4kGTgEAvmhbfAafd85Z1dxbQTczGahxvA=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/packages/examples/packages/lifecycle-hooks/snap.manifest.json b/packages/examples/packages/lifecycle-hooks/snap.manifest.json index e044675125..0a6c94324e 100644 --- a/packages/examples/packages/lifecycle-hooks/snap.manifest.json +++ b/packages/examples/packages/lifecycle-hooks/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snaps.git" }, "source": { - "shasum": "Huxm0NrX6zvh0k9iGnStycNNcmHSGRK1EK/+jmv7nyI=", + "shasum": "Mgf0SLyA7yvGJ3+3y8f/Nf2GuMUzLHt4zRj8ZwVoC08=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/packages/examples/packages/signature-insights/snap.manifest.json b/packages/examples/packages/signature-insights/snap.manifest.json index a7fdafd08a..cbf246f896 100644 --- a/packages/examples/packages/signature-insights/snap.manifest.json +++ b/packages/examples/packages/signature-insights/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snaps.git" }, "source": { - "shasum": "mxW5zlXyghmveLeZIwJMZkwjqb7Y8peJut9uvbxgLCU=", + "shasum": "DdjMLFOqo+SfRA4nNJflLvSX3v/IwM23QtjPYzOLG1o=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/packages/examples/packages/transaction-insights/snap.manifest.json b/packages/examples/packages/transaction-insights/snap.manifest.json index 9cfcc1640e..ba03473e67 100644 --- a/packages/examples/packages/transaction-insights/snap.manifest.json +++ b/packages/examples/packages/transaction-insights/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snaps.git" }, "source": { - "shasum": "2HeDCuxgl9zngyHAGEyikHFuM3Rcek5Jc0lCeuPNFqA=", + "shasum": "psh5WYTtiqN1PD18OI3JLmBiMFuo8Uw0V56yiBMVZ4M=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/packages/snaps-rpc-methods/jest.config.js b/packages/snaps-rpc-methods/jest.config.js index 015ebd2bcd..106681f117 100644 --- a/packages/snaps-rpc-methods/jest.config.js +++ b/packages/snaps-rpc-methods/jest.config.js @@ -10,7 +10,7 @@ module.exports = deepmerge(baseConfig, { ], coverageThreshold: { global: { - branches: 92.07, + branches: 92.62, functions: 96.96, lines: 97.51, statements: 96.97, diff --git a/packages/snaps-rpc-methods/src/permitted/createInterface.test.tsx b/packages/snaps-rpc-methods/src/permitted/createInterface.test.tsx index 9d6c8685a4..2403d9fd56 100644 --- a/packages/snaps-rpc-methods/src/permitted/createInterface.test.tsx +++ b/packages/snaps-rpc-methods/src/permitted/createInterface.test.tsx @@ -1,7 +1,15 @@ import { JsonRpcEngine } from '@metamask/json-rpc-engine'; import { text, type CreateInterfaceResult } from '@metamask/snaps-sdk'; import type { JSXElement } from '@metamask/snaps-sdk/jsx'; -import { Text, Box } from '@metamask/snaps-sdk/jsx'; +import { + Text, + Box, + Field, + Input, + Form, + Container, + Footer, +} from '@metamask/snaps-sdk/jsx'; import type { JsonRpcRequest, PendingJsonRpcResponse } from '@metamask/utils'; import type { CreateInterfaceParameters } from './createInterface'; @@ -132,7 +140,114 @@ describe('snap_createInterface', () => { error: { code: -32602, message: - 'Invalid params: At path: ui -- Expected the value to satisfy a union of `union | union`, but received: "foo".', + 'Invalid params: At path: ui -- Expected type to be one of: "Address", "Bold", "Box", "Button", "Copyable", "Divider", "Dropdown", "RadioGroup", "FileInput", "Form", "Heading", "Input", "Image", "Italic", "Link", "Row", "Spinner", "Text", "Tooltip", "Checkbox", "Card", "Icon", "Selector", "Section", "Container", but received: undefined.', + stack: expect.any(String), + }, + id: 1, + jsonrpc: '2.0', + }); + }); + + it('throws on invalid UI', async () => { + const { implementation } = createInterfaceHandler; + + const createInterface = jest.fn().mockReturnValue('foo'); + + const hooks = { + createInterface, + }; + + const engine = new JsonRpcEngine(); + + engine.push((request, response, next, end) => { + const result = implementation( + request as JsonRpcRequest, + response as PendingJsonRpcResponse, + next, + end, + hooks, + ); + + result?.catch(end); + }); + + const response = await engine.handle({ + jsonrpc: '2.0', + id: 1, + method: 'snap_createInterface', + params: { + ui: ( + + + + + + ) as JSXElement, + }, + }); + + expect(response).toStrictEqual({ + error: { + code: -32602, + message: + 'Invalid params: At path: ui.props.children -- Expected type to be one of: "Address", "Bold", "Box", "Button", "Copyable", "Divider", "Dropdown", "RadioGroup", "FileInput", "Form", "Heading", "Input", "Image", "Italic", "Link", "Row", "Spinner", "Text", "Tooltip", "Checkbox", "Card", "Icon", "Selector", "Section", but received: "Field".', + stack: expect.any(String), + }, + id: 1, + jsonrpc: '2.0', + }); + }); + + it('throws on invalid nested UI', async () => { + const { implementation } = createInterfaceHandler; + + const createInterface = jest.fn().mockReturnValue('foo'); + + const hooks = { + createInterface, + }; + + const engine = new JsonRpcEngine(); + + engine.push((request, response, next, end) => { + const result = implementation( + request as JsonRpcRequest, + response as PendingJsonRpcResponse, + next, + end, + hooks, + ); + + result?.catch(end); + }); + + const response = await engine.handle({ + jsonrpc: '2.0', + id: 1, + method: 'snap_createInterface', + params: { + ui: ( + + +
+ + + +
+
+
+ Foo +
+
+ ) as JSXElement, + }, + }); + + expect(response).toStrictEqual({ + error: { + code: -32602, + message: + 'Invalid params: At path: ui.props.children.1.props.children.type -- Expected the literal `"Button"`, but received: "Text".', stack: expect.any(String), }, id: 1, diff --git a/packages/snaps-rpc-methods/src/restricted/dialog.test.tsx b/packages/snaps-rpc-methods/src/restricted/dialog.test.tsx index 79b58fb522..dd5d3c7fec 100644 --- a/packages/snaps-rpc-methods/src/restricted/dialog.test.tsx +++ b/packages/snaps-rpc-methods/src/restricted/dialog.test.tsx @@ -358,6 +358,30 @@ describe('implementation', () => { }, }); }); + + it('handles confirmations using an ID', async () => { + const hooks = getMockDialogHooks(); + const implementation = getDialogImplementation(hooks); + await implementation({ + context: { origin: 'foo' }, + method: 'snap_dialog', + params: { + type: DialogType.Confirmation, + id: 'baz', + }, + }); + + expect(hooks.requestUserApproval).toHaveBeenCalledTimes(1); + expect(hooks.requestUserApproval).toHaveBeenCalledWith({ + id: undefined, + origin: 'foo', + type: DIALOG_APPROVAL_TYPES[DialogType.Confirmation], + requestData: { + id: 'baz', + placeholder: undefined, + }, + }); + }); }); describe('prompts', () => { @@ -414,6 +438,31 @@ describe('implementation', () => { }, }); }); + + it('handles prompts using an ID', async () => { + const hooks = getMockDialogHooks(); + const implementation = getDialogImplementation(hooks); + await implementation({ + context: { origin: 'foo' }, + method: 'snap_dialog', + params: { + type: DialogType.Prompt, + id: 'baz', + placeholder: 'foobar', + }, + }); + + expect(hooks.requestUserApproval).toHaveBeenCalledTimes(1); + expect(hooks.requestUserApproval).toHaveBeenCalledWith({ + id: undefined, + origin: 'foo', + type: DIALOG_APPROVAL_TYPES[DialogType.Prompt], + requestData: { + id: 'baz', + placeholder: 'foobar', + }, + }); + }); }); describe('validation', () => { @@ -446,7 +495,7 @@ describe('implementation', () => { params: {} as any, }), ).rejects.toThrow( - 'Invalid params: Expected the value to satisfy a union of `object | object`, but received: [object Object]', + /Invalid params: At path: .* -- Expected type to be one of: .*, but received: .*/u, ); }); @@ -487,7 +536,7 @@ describe('implementation', () => { params: value as any, }), ).rejects.toThrow( - /Invalid params: At path: .* — Expected a value of type .*, but received: .*\./u, + /Invalid params: At path: .* -- Expected type to be one of: .*, but received: .*/u, ); }); @@ -508,7 +557,7 @@ describe('implementation', () => { }, }), ).rejects.toThrow( - /Invalid params: At path: placeholder — Expected a value of type string, but received: .*\./u, + /Invalid params: At path: placeholder -- Expected a string, but received: .*/u, ); }, ); @@ -528,7 +577,7 @@ describe('implementation', () => { }, }), ).rejects.toThrow( - 'Invalid params: At path: placeholder — Expected a string with a length between 1 and 40, but received one with a length of 0.', + 'Invalid params: At path: placeholder -- Expected a string with a length between `1` and `40` but received one with a length of `0`', ); }); @@ -548,7 +597,7 @@ describe('implementation', () => { }, }), ).rejects.toThrow( - 'Invalid params: Unknown key: placeholder, received: "foobar".', + 'Invalid params: At path: placeholder -- Expected a value of type `never`, but received: `"foobar"`', ); }, ); diff --git a/packages/snaps-rpc-methods/src/restricted/dialog.ts b/packages/snaps-rpc-methods/src/restricted/dialog.ts index 9f359b68a3..d4b5fb6f78 100644 --- a/packages/snaps-rpc-methods/src/restricted/dialog.ts +++ b/packages/snaps-rpc-methods/src/restricted/dialog.ts @@ -8,8 +8,8 @@ import { rpcErrors } from '@metamask/rpc-errors'; import { DialogType, enumValue, - union, ComponentOrElementStruct, + selectiveUnion, } from '@metamask/snaps-sdk'; import type { DialogParams, @@ -20,19 +20,10 @@ import type { ComponentOrElement, } from '@metamask/snaps-sdk'; import type { InferMatching } from '@metamask/snaps-utils'; -import { createUnion } from '@metamask/snaps-utils'; -import type { Infer, Struct } from '@metamask/superstruct'; -import { - create, - enums, - object, - optional, - size, - string, - type, -} from '@metamask/superstruct'; +import type { Infer } from '@metamask/superstruct'; +import { create, object, optional, size, string } from '@metamask/superstruct'; import type { Json, NonEmptyArray } from '@metamask/utils'; -import { hasProperty, isObject } from '@metamask/utils'; +import { hasProperty, isObject, isPlainObject } from '@metamask/utils'; import { type MethodHooksObject } from '../utils'; @@ -156,27 +147,22 @@ export const dialogBuilder = Object.freeze({ methodHooks, } as const); -// Note: We use `type` here instead of `object` because `type` does not validate -// the keys of the object, which is what we want. -const BaseParamsStruct = type({ - type: optional( - enums([DialogType.Alert, DialogType.Confirmation, DialogType.Prompt]), - ), -}); - const AlertParametersWithContentStruct = object({ type: enumValue(DialogType.Alert), content: ComponentOrElementStruct, }); + const AlertParametersWithIdStruct = object({ type: enumValue(DialogType.Alert), id: string(), }); -const AlertParametersStruct = union([ - AlertParametersWithContentStruct, - AlertParametersWithIdStruct, -]); +const AlertParametersStruct = selectiveUnion((value) => { + if (isPlainObject(value) && hasProperty(value, 'id')) { + return AlertParametersWithIdStruct; + } + return AlertParametersWithContentStruct; +}); const ConfirmationParametersWithContentStruct = object({ type: enumValue(DialogType.Confirmation), @@ -188,10 +174,12 @@ const ConfirmationParametersWithIdStruct = object({ id: string(), }); -const ConfirmationParametersStruct = union([ - ConfirmationParametersWithContentStruct, - ConfirmationParametersWithIdStruct, -]); +const ConfirmationParametersStruct = selectiveUnion((value) => { + if (isPlainObject(value) && hasProperty(value, 'id')) { + return ConfirmationParametersWithIdStruct; + } + return ConfirmationParametersWithContentStruct; +}); const PromptParametersWithContentStruct = object({ type: enumValue(DialogType.Prompt), @@ -205,10 +193,12 @@ const PromptParametersWithIdStruct = object({ placeholder: PlaceholderStruct, }); -const PromptParametersStruct = union([ - PromptParametersWithContentStruct, - PromptParametersWithIdStruct, -]); +const PromptParametersStruct = selectiveUnion((value) => { + if (isPlainObject(value) && hasProperty(value, 'id')) { + return PromptParametersWithIdStruct; + } + return PromptParametersWithContentStruct; +}); const DefaultParametersWithContentStruct = object({ content: ComponentOrElementStruct, @@ -218,29 +208,39 @@ const DefaultParametersWithIdStruct = object({ id: string(), }); -const DefaultParametersStruct = union([ - DefaultParametersWithContentStruct, - DefaultParametersWithIdStruct, -]); +const DefaultParametersStruct = selectiveUnion((value) => { + if (isPlainObject(value) && hasProperty(value, 'id')) { + return DefaultParametersWithIdStruct; + } + return DefaultParametersWithContentStruct; +}); -const DialogParametersStruct = union([ - AlertParametersStruct, - ConfirmationParametersStruct, - PromptParametersStruct, - DefaultParametersStruct, -]); +const DialogParametersStruct = selectiveUnion((value) => { + if (isPlainObject(value) && hasProperty(value, 'type')) { + switch (value.type) { + // We cannot use typedUnion here unfortunately. + case DialogType.Alert: + return AlertParametersStruct; + case DialogType.Confirmation: + return ConfirmationParametersStruct; + case DialogType.Prompt: + return PromptParametersStruct; + default: + throw new Error( + `The "type" property must be one of: ${Object.values(DialogType).join( + ', ', + )}.`, + ); + } + } + return DefaultParametersStruct; +}); export type DialogParameters = InferMatching< typeof DialogParametersStruct, DialogParams >; -const structs: Record> = { - [DialogType.Alert]: AlertParametersStruct, - [DialogType.Confirmation]: ConfirmationParametersStruct, - [DialogType.Prompt]: PromptParametersStruct, -}; - /** * Builds the method implementation for `snap_dialog`. * @@ -271,17 +271,20 @@ export function getDialogImplementation({ }); } - const validatedType = getValidatedType(params); - - const approvalType = validatedType - ? DIALOG_APPROVAL_TYPES[validatedType] - : DIALOG_APPROVAL_TYPES.default; - - const validatedParams = getValidatedParams(params, validatedType); + const validatedParams = getValidatedParams(params); const placeholder = isPromptDialog(validatedParams) ? validatedParams.placeholder : undefined; + const validatedType = hasProperty(validatedParams, 'type') + ? validatedParams.type + : 'default'; + + const approvalType = + DIALOG_APPROVAL_TYPES[ + validatedType as keyof typeof DIALOG_APPROVAL_TYPES + ]; + if (hasProperty(validatedParams, 'content')) { const id = await createInterface( origin, @@ -289,7 +292,7 @@ export function getDialogImplementation({ ); return requestUserApproval({ - id: validatedType ? undefined : id, + id: approvalType === DIALOG_APPROVAL_TYPES.default ? id : undefined, origin, type: approvalType, requestData: { id, placeholder }, @@ -299,7 +302,10 @@ export function getDialogImplementation({ validateInterface(origin, validatedParams.id, getInterface); return requestUserApproval({ - id: validatedType ? undefined : validatedParams.id, + id: + approvalType === DIALOG_APPROVAL_TYPES.default + ? validatedParams.id + : undefined, origin, type: approvalType, requestData: { id: validatedParams.id, placeholder }, @@ -337,25 +343,6 @@ function getDialogType(params: DialogParameters): DialogType | undefined { return hasProperty(params, 'type') ? (params.type as DialogType) : undefined; } -/** - * Get the validated type of the dialog parameters. Throws an error if the type - * is invalid. - * - * @param params - The parameters to validate. - * @returns The validated type of the dialog parameters. - */ -function getValidatedType(params: unknown): DialogType | undefined { - try { - return create(params, BaseParamsStruct).type; - } catch (error) { - throw rpcErrors.invalidParams({ - message: `The "type" property must be one of: ${Object.values( - DialogType, - ).join(', ')}.`, - }); - } -} - /** * Checks if the dialog parameters are for a prompt dialog. * @@ -371,17 +358,11 @@ function isPromptDialog(params: DialogParameters): params is PromptDialog { * type. Throws if validation fails. * * @param params - The unvalidated params object from the method request. - * @param validatedType - The validated dialog type. * @returns The validated confirm method parameter object. */ -function getValidatedParams( - params: unknown, - validatedType: DialogType | undefined, -): DialogParameters { +function getValidatedParams(params: unknown): DialogParameters { try { - return validatedType - ? createUnion(params, structs[validatedType], 'type') - : create(params, DefaultParametersStruct); + return create(params, DialogParametersStruct); } catch (error) { throw rpcErrors.invalidParams({ message: `Invalid params: ${error.message}`, diff --git a/packages/snaps-sdk/src/index.ts b/packages/snaps-sdk/src/index.ts index c2a0cdd457..c90eb4c6fa 100644 --- a/packages/snaps-sdk/src/index.ts +++ b/packages/snaps-sdk/src/index.ts @@ -9,6 +9,8 @@ export { literal, union, enumValue, + typedUnion, + selectiveUnion, } from './internals'; // Re-exported from `@metamask/utils` for convenience. diff --git a/packages/snaps-sdk/src/internals/structs.test.ts b/packages/snaps-sdk/src/internals/structs.test.ts index 086acbd220..2e7a007274 100644 --- a/packages/snaps-sdk/src/internals/structs.test.ts +++ b/packages/snaps-sdk/src/internals/structs.test.ts @@ -1,7 +1,20 @@ -import { is, validate } from '@metamask/superstruct'; - -import { Text } from '../jsx'; -import { BoxStruct, FieldStruct, TextStruct } from '../jsx/validation'; +import { + create, + defaulted, + is, + object, + string, + validate, +} from '@metamask/superstruct'; + +import type { BoxElement } from '../jsx'; +import { Footer, Icon, Text, Button, Box } from '../jsx'; +import { + BoxStruct, + FieldStruct, + FooterStruct, + TextStruct, +} from '../jsx/validation'; import { enumValue, literal, typedUnion, union } from './structs'; describe('enumValue', () => { @@ -53,7 +66,7 @@ describe('typedUnion', () => { const result = validate(Text({}), unionStruct); expect(result[0]?.message).toBe( - 'At path: props.children -- Expected the value to satisfy a union of `union | array`, but received: undefined', + 'At path: props.children -- Expected type to be one of: "Bold", "Italic", "Link", "Icon", but received: undefined', ); }); @@ -62,7 +75,49 @@ describe('typedUnion', () => { const result = validate(Text({}), nestedUnionStruct); expect(result[0]?.message).toBe( - 'At path: props.children -- Expected the value to satisfy a union of `union | array`, but received: undefined', + 'At path: props.children -- Expected type to be one of: "Bold", "Italic", "Link", "Icon", but received: undefined', + ); + }); + + it('validates refined elements', () => { + const refinedUnionStruct = typedUnion([BoxStruct, FooterStruct]); + const result = validate( + Footer({ children: Button({ children: Icon({ name: 'wallet' }) }) }), + refinedUnionStruct, + ); + + expect(result[0]?.message).toBe( + 'At path: props.children -- Footer buttons may only contain text.', + ); + }); + + it('supports coercion', () => { + const coercedStruct = typedUnion([ + BoxStruct, + defaulted(object({ type: literal('Custom'), key: string() }), { + type: 'Custom', + key: 'foo', + }), + ]); + + const result = create({ type: 'Custom' }, coercedStruct); + expect(result.key).toBe('foo'); + + const result2 = create( + Box({ children: Text({ children: 'foo' }) }), + coercedStruct, + ) as BoxElement; + expect(result2.props.children).toStrictEqual({ + type: 'Text', + key: null, + props: { children: 'foo' }, + }); + + expect(() => create(Button({ children: 'foo' }), coercedStruct)).toThrow( + 'Expected type to be one of: "Box", "Custom", but received: "Button"', + ); + expect(() => create('foo', coercedStruct)).toThrow( + 'Expected type to be one of: "Box", "Custom", but received: undefined', ); }); diff --git a/packages/snaps-sdk/src/internals/structs.ts b/packages/snaps-sdk/src/internals/structs.ts index b51bd6255d..8d559b5baf 100644 --- a/packages/snaps-sdk/src/internals/structs.ts +++ b/packages/snaps-sdk/src/internals/structs.ts @@ -6,6 +6,7 @@ import { literal as superstructLiteral, union as superstructUnion, } from '@metamask/superstruct'; +import type { PlainObject } from '@metamask/utils'; import { hasProperty, isPlainObject } from '@metamask/utils'; import type { EnumToUnion } from './helpers'; @@ -97,6 +98,7 @@ export function typedUnion( : struct, ) .flat(Infinity); + const types = flatStructs.map(({ schema }) => schema.type.type); return new Struct({ type: 'union', schema: flatStructs, @@ -116,9 +118,28 @@ export function typedUnion( yield entry; } }, - validator(value, context) { - const types = flatStructs.map(({ schema }) => schema.type.type); + coercer(value, context) { + if (!isPlainObject(value) || !hasProperty(value, 'type')) { + return value; + } + + const { type } = value; + const struct = flatStructs.find(({ schema }) => is(type, schema.type)); + if (struct) { + return struct.coercer(value, context); + } + + return value; + }, + // At this point we know the value to be an object. + *refiner(value: PlainObject, context) { + const struct = flatStructs.find(({ schema }) => + is(value.type, schema.type), + ); + yield* struct.refiner(value, context); + }, + validator(value, context) { if ( !isPlainObject(value) || !hasProperty(value, 'type') || @@ -144,3 +165,43 @@ export function typedUnion( }, }) as unknown as Struct | InferStructTuple[number], null>; } + +/** + * Create a custom union struct that uses a `selector` function for choosing + * the validation path. + * + * @param selector - The selector function choosing the struct to validate with. + * @returns The `superstruct` struct, which validates that the value satisfies + * one of the structs. + */ +export function selectiveUnion AnyStruct>( + selector: Selector, +): Struct>, null> { + return new Struct({ + type: 'union', + schema: null, + *entries(value, context) { + const struct = selector(value); + + for (const entry of struct.entries(value, context)) { + yield entry; + } + }, + *refiner(value, context) { + const struct = selector(value); + + yield* struct.refiner(value, context); + }, + coercer(value, context) { + const struct = selector(value); + + return struct.coercer(value, context); + }, + validator(value, context) { + const struct = selector(value); + + // This only validates the root of the struct, entries does the rest of the work. + return struct.validator(value, context); + }, + }); +} diff --git a/packages/snaps-sdk/src/jsx/validation.ts b/packages/snaps-sdk/src/jsx/validation.ts index a29941472b..7f98a654e8 100644 --- a/packages/snaps-sdk/src/jsx/validation.ts +++ b/packages/snaps-sdk/src/jsx/validation.ts @@ -28,7 +28,13 @@ import { } from '@metamask/utils'; import type { Describe } from '../internals'; -import { literal, nullUnion, svg, typedUnion } from '../internals'; +import { + literal, + nullUnion, + selectiveUnion, + svg, + typedUnion, +} from '../internals'; import type { EmptyObject } from '../types'; import type { GenericSnapElement, @@ -106,10 +112,12 @@ export const ElementStruct: Describe = object({ function nestable( struct: Struct, ): Struct, any> { - const nestableStruct: Struct> = nullUnion([ - struct, - array(lazy(() => nestableStruct)), - ]); + const nestableStruct: Struct> = selectiveUnion((value) => { + if (Array.isArray(value)) { + return array(lazy(() => nestableStruct)); + } + return struct; + }); return nestableStruct; } @@ -127,7 +135,22 @@ function children( Nestable | InferStructTuple[number] | boolean | null>, null > { - return nestable(nullable(nullUnion([...structs, boolean()]))); + return nestable( + nullable( + selectiveUnion((value) => { + if (typeof value === 'boolean') { + return boolean(); + } + if (structs.length === 1) { + return structs[0]; + } + return nullUnion(structs); + }), + ), + ) as unknown as Struct< + Nestable | InferStructTuple[number] | boolean | null>, + null + >; } /** @@ -209,7 +232,7 @@ export const InputStruct: Describe = element('Input', { */ export const OptionStruct: Describe = element('Option', { value: string(), - children: nullUnion([string()]), + children: string(), }); /** @@ -495,10 +518,13 @@ export const SectionStruct: Describe = element('Section', { * A subset of JSX elements that are allowed as children of the Footer component. * This set should include a single button or a tuple of two buttons. */ -export const FooterChildStruct = nullUnion([ - tuple([FooterButtonStruct, FooterButtonStruct]), - FooterButtonStruct, -]); + +export const FooterChildStruct = selectiveUnion((value) => { + if (Array.isArray(value)) { + return tuple([FooterButtonStruct, FooterButtonStruct]); + } + return FooterButtonStruct; +}); /** * A struct for the {@link FooterElement} type. @@ -548,11 +574,12 @@ export const LinkStruct: Describe = element('Link', { */ export const TextStruct: Describe = element('Text', { children: children([ - string(), - BoldStruct, - ItalicStruct, - LinkStruct, - IconStruct, + selectiveUnion((value) => { + if (typeof value === 'string') { + return string(); + } + return typedUnion([BoldStruct, ItalicStruct, LinkStruct, IconStruct]); + }), ]), alignment: optional( nullUnion([literal('start'), literal('center'), literal('end')]), @@ -573,28 +600,36 @@ export const TextStruct: Describe = element('Text', { * A subset of JSX elements that are allowed as children of the Tooltip component. * This set should include all text components and the Image. */ -export const TooltipChildStruct = nullUnion([ - TextStruct, - BoldStruct, - ItalicStruct, - LinkStruct, - ImageStruct, - IconStruct, - boolean(), -]); +export const TooltipChildStruct = selectiveUnion((value) => { + if (typeof value === 'boolean') { + return boolean(); + } + return typedUnion([ + TextStruct, + BoldStruct, + ItalicStruct, + LinkStruct, + ImageStruct, + IconStruct, + ]); +}); /** * A subset of JSX elements that are allowed as content of the Tooltip component. * This set should include all text components. */ -export const TooltipContentStruct = nullUnion([ - TextStruct, - BoldStruct, - ItalicStruct, - LinkStruct, - IconStruct, - string(), -]); +export const TooltipContentStruct = selectiveUnion((value) => { + if (typeof value === 'string') { + return string(); + } + return typedUnion([ + TextStruct, + BoldStruct, + ItalicStruct, + LinkStruct, + IconStruct, + ]); +}); /** * A struct for the {@link TooltipElement} type. @@ -609,7 +644,7 @@ export const TooltipStruct: Describe = element('Tooltip', { */ export const RowStruct: Describe = element('Row', { label: string(), - children: nullUnion([AddressStruct, ImageStruct, TextStruct, ValueStruct]), + children: typedUnion([AddressStruct, ImageStruct, TextStruct, ValueStruct]), variant: optional( nullUnion([literal('default'), literal('warning'), literal('critical')]), ), @@ -659,10 +694,12 @@ export const BoxChildStruct = typedUnion([ export const ContainerStruct: Describe = element( 'Container', { - children: nullUnion([ - tuple([BoxChildStruct, FooterStruct]), - BoxChildStruct, - ]) as unknown as Struct< + children: selectiveUnion((value) => { + if (Array.isArray(value)) { + return tuple([BoxChildStruct, FooterStruct]); + } + return BoxChildStruct; + }) as unknown as Struct< [GenericSnapElement, FooterElement] | GenericSnapElement, null >, diff --git a/packages/snaps-sdk/src/types/interface.test.ts b/packages/snaps-sdk/src/types/interface.test.ts index 6c2a1a44e0..5f1c2ac14a 100644 --- a/packages/snaps-sdk/src/types/interface.test.ts +++ b/packages/snaps-sdk/src/types/interface.test.ts @@ -1,6 +1,12 @@ import { assert } from '@metamask/superstruct'; -import { FormStateStruct, InterfaceStateStruct } from './interface'; +import { Text } from '../jsx'; +import { text } from '../ui'; +import { + ComponentOrElementStruct, + FormStateStruct, + InterfaceStateStruct, +} from './interface'; describe('FormStateStruct', () => { it('passes for a valid form state', () => { @@ -15,3 +21,15 @@ describe('InterfaceStateStruct', () => { ).not.toThrow(); }); }); + +describe('ComponentOrElementStruct', () => { + it('validates JSX components', () => { + expect(() => + assert(Text({ children: 'foo' }), ComponentOrElementStruct), + ).not.toThrow(); + }); + + it('validates legacy components', () => { + expect(() => assert(text('foo'), ComponentOrElementStruct)).not.toThrow(); + }); +}); diff --git a/packages/snaps-sdk/src/types/interface.ts b/packages/snaps-sdk/src/types/interface.ts index 86d2ed311c..3e8290cb3c 100644 --- a/packages/snaps-sdk/src/types/interface.ts +++ b/packages/snaps-sdk/src/types/interface.ts @@ -6,8 +6,9 @@ import { string, union, } from '@metamask/superstruct'; -import { JsonStruct } from '@metamask/utils'; +import { JsonStruct, hasProperty, isObject } from '@metamask/utils'; +import { selectiveUnion } from '../internals'; import type { JSXElement } from '../jsx'; import { RootJSXElementStruct } from '../jsx'; import type { Component } from '../ui'; @@ -35,10 +36,12 @@ export type FormState = Infer; export type InterfaceState = Infer; export type ComponentOrElement = Component | JSXElement; -export const ComponentOrElementStruct = union([ - ComponentStruct, - RootJSXElementStruct, -]); +export const ComponentOrElementStruct = selectiveUnion((value) => { + if (isObject(value) && !hasProperty(value, 'props')) { + return ComponentStruct; + } + return RootJSXElementStruct; +}); export const InterfaceContextStruct = record(string(), JsonStruct); export type InterfaceContext = Infer;