diff --git a/package.json b/package.json index 10ab1708..5f83f402 100644 --- a/package.json +++ b/package.json @@ -59,6 +59,7 @@ "eslint-plugin-prettier": "^4.2.1", "eslint-plugin-promise": "^6.1.1", "fast-deep-equal": "^3.1.3", + "image-size": "^1.1.1", "jest": "^28.1.3", "jest-it-up": "^2.0.2", "prettier": "^2.7.1", diff --git a/scripts/verify-snaps.ts b/scripts/verify-snaps.ts index c89a6951..9bcb5d93 100644 --- a/scripts/verify-snaps.ts +++ b/scripts/verify-snaps.ts @@ -1,14 +1,19 @@ import { detectSnapLocation, fetchSnap } from '@metamask/snaps-controllers'; import type { SnapId } from '@metamask/snaps-sdk'; import { getLocalizedSnapManifest } from '@metamask/snaps-utils'; -import { assertIsSemVerVersion } from '@metamask/utils'; +import { assertIsSemVerVersion, getErrorMessage } from '@metamask/utils'; import deepEqual from 'fast-deep-equal'; +import { imageSize as imageSizeSync } from 'image-size'; +import { resolve } from 'path'; import semver from 'semver/preload'; import type { Infer } from 'superstruct'; +import { promisify } from 'util'; import type { VerifiedSnapStruct } from '../src'; import registry from '../src/registry.json'; +const imageSize = promisify(imageSizeSync); + type VerifiedSnap = Infer; /** @@ -65,6 +70,51 @@ async function verifySnapVersion( } } +/** + * Get the size of an image. + * + * @param path - The path to the image. + * @param snapId - The snap ID. + */ +async function getImageSize(path: string, snapId: string) { + try { + return await imageSize(path); + } catch (error) { + throw new Error( + `Could not determine the size of screenshot "${path}" for "${snapId}": ${getErrorMessage( + error, + )}.`, + ); + } +} + +/** + * Verify that the screenshots for a snap exist and have the correct dimensions. + * + * @param snapId - The snap ID. + * @param screenshots - The screenshots. + * @throws If a screenshot does not exist or has the wrong dimensions. + */ +async function verifyScreenshots(snapId: string, screenshots: string[]) { + const basePath = resolve(__dirname, '..', 'src'); + + for (const screenshot of screenshots) { + const path = resolve(basePath, screenshot); + const size = await getImageSize(path, snapId); + if (!size?.width || !size?.height) { + throw new Error( + `Could not determine the size of screenshot "${screenshot}" for "${snapId}".`, + ); + } + + if (size.width !== 960 || size.height !== 540) { + throw new Error( + `Screenshot "${screenshot}" for "${snapId}" does not have the correct dimensions. Expected 960x540, got ${size.width}x${size.height}.`, + ); + } + } +} + /** * Verify a snap. * @@ -91,6 +141,14 @@ async function verifySnap(snap: VerifiedSnap) { process.exitCode = 1; }); } + + const { screenshots } = snap.metadata; + if (screenshots) { + await verifyScreenshots(snap.id, screenshots).catch((error) => { + console.error(error.message); + process.exitCode = 1; + }); + } } /** diff --git a/src/index.ts b/src/index.ts index 9c465ae5..b900496e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,6 +5,8 @@ import { } from '@metamask/utils'; import type { Infer } from 'superstruct'; import { + pattern, + size, object, array, record, @@ -53,6 +55,11 @@ export const AdditionalSourceCodeStruct = object({ url: string(), }); +export const ImagePathStruct = pattern( + string(), + /\.\/images\/.*\/\d+\.(?:png|jpe?g)$/u, +); + export const VerifiedSnapStruct = object({ id: NpmIdStruct, metadata: object({ @@ -80,6 +87,7 @@ export const VerifiedSnapStruct = object({ privacyPolicy: optional(string()), termsOfUse: optional(string()), additionalSourceCode: optional(array(AdditionalSourceCodeStruct)), + screenshots: optional(size(array(ImagePathStruct), 3, 3)), }), versions: record(VersionStruct, VerifiedSnapVersionStruct), }); diff --git a/src/registry.test.ts b/src/registry.test.ts index f6c02a3f..be85fe02 100644 --- a/src/registry.test.ts +++ b/src/registry.test.ts @@ -58,6 +58,11 @@ describe('Snaps Registry', () => { url: 'https://metamask.io/example/source-code3', }, ], + screenshots: [ + './images/example-snap/1.png', + './images/example-snap/2.jpg', + './images/example-snap/3.jpeg', + ], }, versions: { ['0.1.0' as SemVerVersion]: { @@ -120,7 +125,7 @@ describe('Snaps Registry', () => { expect(() => assert(registryDb, SnapsRegistryDatabaseStruct)).not.toThrow(); }); - it('should throw when the metadata has an unexpected field', () => { + it('throws when the metadata has an unexpected field', () => { const registryDb: SnapsRegistryDatabase = { verifiedSnaps: { 'npm:example-snap': { @@ -145,4 +150,60 @@ describe('Snaps Registry', () => { 'At path: verifiedSnaps.npm:example-snap.metadata.unexpected -- Expected a value of type `never`, but received: `"field"`', ); }); + + it('throws when the screenshots are invalid', () => { + expect(() => + assert( + { + verifiedSnaps: { + 'npm:example-snap': { + id: 'npm:example-snap', + metadata: { + name: 'Example Snap', + screenshots: ['./images/example-snap/1.png'], + }, + versions: { + ['0.1.0' as SemVerVersion]: { + checksum: 'A83r5/ZIcKeKw3An13HBeV4CAofj7jGK5hOStmHY6A0=', + }, + }, + }, + }, + blockedSnaps: [], + }, + SnapsRegistryDatabaseStruct, + ), + ).toThrow( + 'At path: verifiedSnaps.npm:example-snap.metadata.screenshots -- Expected a array with a length of `3` but received one with a length of `1`', + ); + + expect(() => + assert( + { + verifiedSnaps: { + 'npm:example-snap': { + id: 'npm:example-snap', + metadata: { + name: 'Example Snap', + screenshots: [ + './images/example-snap/1.png', + './images/example-snap/2.png', + './images/example-snap/3.gif', + ], + }, + versions: { + ['0.1.0' as SemVerVersion]: { + checksum: 'A83r5/ZIcKeKw3An13HBeV4CAofj7jGK5hOStmHY6A0=', + }, + }, + }, + }, + blockedSnaps: [], + }, + SnapsRegistryDatabaseStruct, + ), + ).toThrow( + 'At path: verifiedSnaps.npm:example-snap.metadata.screenshots.2 -- Expected a string matching `/\\.\\/images\\/.*\\/\\d+\\.(?:png|jpe?g)$/` but received "./images/example-snap/3.gif"', + ); + }); }); diff --git a/yarn.lock b/yarn.lock index e078c457..0641d1a7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1236,6 +1236,7 @@ __metadata: eslint-plugin-prettier: ^4.2.1 eslint-plugin-promise: ^6.1.1 fast-deep-equal: ^3.1.3 + image-size: ^1.1.1 jest: ^28.1.3 jest-it-up: ^2.0.2 prettier: ^2.7.1 @@ -4065,6 +4066,17 @@ __metadata: languageName: node linkType: hard +"image-size@npm:^1.1.1": + version: 1.1.1 + resolution: "image-size@npm:1.1.1" + dependencies: + queue: 6.0.2 + bin: + image-size: bin/image-size.js + checksum: 23b3a515dded89e7f967d52b885b430d6a5a903da954fce703130bfb6069d738d80e6588efd29acfaf5b6933424a56535aa7bf06867e4ebd0250c2ee51f19a4a + languageName: node + linkType: hard + "immer@npm:^9.0.6": version: 9.0.21 resolution: "immer@npm:9.0.21" @@ -5913,6 +5925,15 @@ __metadata: languageName: node linkType: hard +"queue@npm:6.0.2": + version: 6.0.2 + resolution: "queue@npm:6.0.2" + dependencies: + inherits: ~2.0.3 + checksum: ebc23639248e4fe40a789f713c20548e513e053b3dc4924b6cb0ad741e3f264dcff948225c8737834dd4f9ec286dbc06a1a7c13858ea382d9379f4303bcc0916 + languageName: node + linkType: hard + "react-is@npm:^18.0.0": version: 18.2.0 resolution: "react-is@npm:18.2.0"