diff --git a/packages/snaps-cli/src/commands/build/implementation.test.ts b/packages/snaps-cli/src/commands/build/implementation.test.ts index d8c46c16fe..bc6c13c9ab 100644 --- a/packages/snaps-cli/src/commands/build/implementation.test.ts +++ b/packages/snaps-cli/src/commands/build/implementation.test.ts @@ -106,9 +106,10 @@ describe('build', () => { await build(config); - // Manifest checksum mismatch is the warning expect(warn).toHaveBeenCalledWith( - expect.stringMatching(/Compiled 1 file in \d+ms with 1 warning\./u), + expect.stringMatching( + /Compiled \d+ files? in \d+ms with \d+ warnings?\./u, + ), ); const output = await fs.readFile('/snap/output.js', 'utf8'); @@ -140,9 +141,10 @@ describe('build', () => { await build(config); - // Manifest checksum mismatch is the warning expect(warn).toHaveBeenCalledWith( - expect.stringMatching(/Compiled 1 file in \d+ms with 1 warning\./u), + expect.stringMatching( + /Compiled \d+ files? in \d+ms with \d+ warnings?\./u, + ), ); const output = await fs.readFile('/snap/output.js', 'utf8'); diff --git a/packages/snaps-cli/src/commands/watch/watch.e2e.test.ts b/packages/snaps-cli/src/commands/watch/watch.e2e.test.ts index e640ff942e..5ed539fed8 100644 --- a/packages/snaps-cli/src/commands/watch/watch.e2e.test.ts +++ b/packages/snaps-cli/src/commands/watch/watch.e2e.test.ts @@ -26,13 +26,13 @@ describe('mm-snap watch', () => { async (command) => { runner = getCommandRunner(command, ['--port', '0']); await runner.waitForStderr( - /Compiled \d+ files? in \d+ms with 1 warning\./u, + /Compiled \d+ files? in \d+ms with \d+ warnings?\./u, ); await fs.writeFile(SNAP_FILE, originalFile); await runner.waitForStdout(/Changes detected in .+, recompiling\./u); await runner.waitForStderr( - /Compiled \d+ files? in \d+ms with 1 warning\./u, + /Compiled \d+ files? in \d+ms with \d+ warnings?\./u, ); expect(runner.stdout).toContainEqual( @@ -50,7 +50,9 @@ describe('mm-snap watch', () => { expect.stringMatching(/Building the Snap bundle\./u), ); expect(runner.stderr).toContainEqual( - expect.stringMatching(/Compiled \d+ files? in \d+ms with 1 warning\./u), + expect.stringMatching( + /Compiled \d+ files? in \d+ms with \d+ warnings?\./u, + ), ); expect(runner.stderr).toContainEqual( expect.stringContaining( diff --git a/packages/snaps-utils/coverage.json b/packages/snaps-utils/coverage.json index d33640423b..36a5b2b72a 100644 --- a/packages/snaps-utils/coverage.json +++ b/packages/snaps-utils/coverage.json @@ -1,6 +1,6 @@ { "branches": 99.76, - "functions": 99.01, - "lines": 98.62, - "statements": 97.18 + "functions": 99.02, + "lines": 98.66, + "statements": 97.25 } diff --git a/packages/snaps-utils/jest.config.js b/packages/snaps-utils/jest.config.js index 1a1f27e109..34149a0dd6 100644 --- a/packages/snaps-utils/jest.config.js +++ b/packages/snaps-utils/jest.config.js @@ -19,4 +19,7 @@ module.exports = deepmerge(baseConfig, { // https://github.com/facebook/jest/issues/5274 './src/eval-worker.ts', ], + + // This is required for `jest-fetch-mock` to work. + resetMocks: false, }); diff --git a/packages/snaps-utils/package.json b/packages/snaps-utils/package.json index df80c98c7b..a9ae4a018a 100644 --- a/packages/snaps-utils/package.json +++ b/packages/snaps-utils/package.json @@ -125,6 +125,7 @@ "istanbul-lib-report": "^3.0.0", "istanbul-reports": "^3.1.5", "jest": "^29.0.2", + "jest-fetch-mock": "^3.0.3", "jest-silent-reporter": "^0.6.0", "memfs": "^3.4.13", "prettier": "^3.3.3", diff --git a/packages/snaps-utils/src/fs.test.ts b/packages/snaps-utils/src/fs.test.ts index 4bdfa245ed..8ff250d761 100644 --- a/packages/snaps-utils/src/fs.test.ts +++ b/packages/snaps-utils/src/fs.test.ts @@ -1,12 +1,13 @@ import { promises as fs } from 'fs'; import * as path from 'path'; -import { join } from 'path'; +import { join, dirname } from 'path'; import { getOutfilePath, isDirectory, isFile, readJsonFile, + useFileSystemCache, useTemporaryFile, validateDirPath, validateFilePath, @@ -20,6 +21,7 @@ jest.mock('fs'); const BASE_PATH = '/snap'; const MANIFEST_PATH = join(BASE_PATH, NpmSnapFileNames.Manifest); +const CACHE_PATH = join(process.cwd(), 'node_modules/.cache/snaps'); /** * Clears out all the files in the in-memory file system, and writes the default @@ -27,6 +29,7 @@ const MANIFEST_PATH = join(BASE_PATH, NpmSnapFileNames.Manifest); */ async function resetFileSystem() { await fs.rm(BASE_PATH, { recursive: true, force: true }); + await fs.rm(CACHE_PATH, { recursive: true, force: true }); // Create `dist` folder. await fs.mkdir(join(BASE_PATH, 'dist'), { recursive: true }); @@ -262,3 +265,95 @@ describe('useTemporaryFile', () => { expect(await isFile(filePath)).toBe(false); }); }); + +describe('useFileSystemCache', () => { + beforeEach(async () => { + await resetFileSystem(); + }); + + const cachedFunction = useFileSystemCache('foo', 5000, async () => { + return 'foo'; + }); + + const cachedFilePath = join(CACHE_PATH, 'foo.json'); + + it('writes cached value to the file system', async () => { + const spy = jest.spyOn(fs, 'writeFile'); + expect(await cachedFunction()).toBe('foo'); + + expect(spy).toHaveBeenCalledTimes(1); + + const cacheValue = await fs.readFile(cachedFilePath, 'utf8'); + const cacheJson = JSON.parse(cacheValue); + + expect(cacheJson).toStrictEqual({ + timestamp: expect.any(Number), + value: 'foo', + }); + }); + + it('reads cached value from the file system', async () => { + const readSpy = jest.spyOn(fs, 'readFile'); + const writeSpy = jest.spyOn(fs, 'writeFile'); + + expect(await cachedFunction()).toBe('foo'); + expect(await cachedFunction()).toBe('foo'); + + expect(readSpy).toHaveBeenCalledTimes(2); + expect(writeSpy).toHaveBeenCalledTimes(1); + + const cacheValue = await fs.readFile(cachedFilePath, 'utf8'); + const cacheJson = JSON.parse(cacheValue); + + expect(cacheJson).toStrictEqual({ + timestamp: expect.any(Number), + value: 'foo', + }); + }); + + it('discards cached value if it is expired', async () => { + await fs.mkdir(dirname(cachedFilePath), { recursive: true }); + await fs.writeFile( + cachedFilePath, + JSON.stringify({ timestamp: Date.now() - 6000, value: 'bar' }), + ); + + const readSpy = jest.spyOn(fs, 'readFile'); + const writeSpy = jest.spyOn(fs, 'writeFile'); + + expect(await cachedFunction()).toBe('foo'); + + expect(readSpy).toHaveBeenCalledTimes(1); + expect(writeSpy).toHaveBeenCalledTimes(1); + + const cacheValue = await fs.readFile(cachedFilePath, 'utf8'); + const cacheJson = JSON.parse(cacheValue); + + expect(cacheJson).toStrictEqual({ + timestamp: expect.any(Number), + value: 'foo', + }); + }); + + it('skips persisting undefined', async () => { + const fn = useFileSystemCache('foo', 5000, async () => { + return undefined; + }); + + const spy = jest.spyOn(fs, 'writeFile'); + expect(await fn()).toBeUndefined(); + + expect(spy).toHaveBeenCalledTimes(0); + }); + + it('skips persisting null', async () => { + const fn = useFileSystemCache('foo', 5000, async () => { + return null; + }); + + const spy = jest.spyOn(fs, 'writeFile'); + expect(await fn()).toBeNull(); + + expect(spy).toHaveBeenCalledTimes(0); + }); +}); diff --git a/packages/snaps-utils/src/fs.ts b/packages/snaps-utils/src/fs.ts index 3a0f6d6af2..0bb142553e 100644 --- a/packages/snaps-utils/src/fs.ts +++ b/packages/snaps-utils/src/fs.ts @@ -182,3 +182,56 @@ export async function useTemporaryFile( } } } + +/** + * Use the file system to cache a return value with a given key and TTL. + * + * @param cacheKey - The key to use for the cache. + * @param ttl - The time-to-live in milliseconds. + * @param fn - The callback function to wrap. + * @returns The result from the callback. + */ +export function useFileSystemCache( + cacheKey: string, + ttl: number, + fn: () => Promise, +) { + return async () => { + const filePath = pathUtils.join( + process.cwd(), + 'node_modules/.cache/snaps', + `${cacheKey}.json`, + ); + + try { + const cacheContents = await fs.readFile(filePath, 'utf8'); + const json = JSON.parse(cacheContents); + + if (json.timestamp + ttl > Date.now()) { + return json.value; + } + } catch { + // No-op + } + + const value = await fn(); + + // Null or undefined is not persisted. + if (value === null || value === undefined) { + return value; + } + + try { + await fs.mkdir(pathUtils.dirname(filePath), { recursive: true }); + + const json = { timestamp: Date.now(), value }; + await fs.writeFile(filePath, JSON.stringify(json), { + encoding: 'utf8', + }); + } catch { + // No-op + } + + return value; + }; +} diff --git a/packages/snaps-utils/src/manifest/manifest.test.ts b/packages/snaps-utils/src/manifest/manifest.test.ts index 78cffe923e..72c917c23e 100644 --- a/packages/snaps-utils/src/manifest/manifest.test.ts +++ b/packages/snaps-utils/src/manifest/manifest.test.ts @@ -1,4 +1,5 @@ import { promises as fs } from 'fs'; +import fetchMock from 'jest-fetch-mock'; import { join } from 'path'; import { @@ -34,6 +35,17 @@ const BASE_PATH = '/snap'; const MANIFEST_PATH = join(BASE_PATH, NpmSnapFileNames.Manifest); const PACKAGE_JSON_PATH = join(BASE_PATH, NpmSnapFileNames.PackageJson); +const MOCK_GITHUB_RESPONSE = JSON.stringify({ + // eslint-disable-next-line @typescript-eslint/naming-convention + target_commitish: '5fceb7ed2ef18a3984786db1161a76ca5c8e15b9', +}); + +const MOCK_PACKAGE_JSON = JSON.stringify({ + dependencies: { + '@metamask/snaps-sdk': getPlatformVersion(), + }, +}); + /** * Get the default manifest for the current platform version. * @@ -73,9 +85,16 @@ async function resetFileSystem() { describe('checkManifest', () => { beforeEach(async () => { + fetchMock.enableMocks(); + fetchMock.mockResponses(MOCK_GITHUB_RESPONSE, MOCK_PACKAGE_JSON); + await resetFileSystem(); }); + afterAll(() => { + fetchMock.disableMocks(); + }); + it('returns the status and warnings after processing', async () => { const { updated, reports } = await checkManifest(BASE_PATH); expect(reports).toHaveLength(0); diff --git a/packages/snaps-utils/src/manifest/validators/index.ts b/packages/snaps-utils/src/manifest/validators/index.ts index 985e58c58b..c14be5a9b3 100644 --- a/packages/snaps-utils/src/manifest/validators/index.ts +++ b/packages/snaps-utils/src/manifest/validators/index.ts @@ -10,6 +10,7 @@ export * from './manifest-localization'; export * from './package-json-recommended-fields'; export * from './package-name-match'; export * from './platform-version'; +export * from './production-platform-version'; export * from './repository-match'; export * from './version-match'; export * from './icon-declared'; diff --git a/packages/snaps-utils/src/manifest/validators/production-platform-version.test.ts b/packages/snaps-utils/src/manifest/validators/production-platform-version.test.ts new file mode 100644 index 0000000000..9fc790f6a7 --- /dev/null +++ b/packages/snaps-utils/src/manifest/validators/production-platform-version.test.ts @@ -0,0 +1,127 @@ +import type { SemVerVersion } from '@metamask/utils'; +import assert from 'assert'; +import { promises as fs } from 'fs'; +import fetchMock from 'jest-fetch-mock'; +import { join } from 'path'; + +import { productionPlatformVersion } from './production-platform-version'; +import { getMockSnapFiles, getSnapManifest } from '../../test-utils'; + +jest.mock('fs'); + +const MOCK_GITHUB_RESPONSE = JSON.stringify({ + // eslint-disable-next-line @typescript-eslint/naming-convention + target_commitish: '5fceb7ed2ef18a3984786db1161a76ca5c8e15b9', +}); + +const MOCK_PACKAGE_JSON = JSON.stringify({ + dependencies: { + '@metamask/snaps-sdk': '6.1.0', + }, +}); + +const CACHE_PATH = join(process.cwd(), 'node_modules/.cache/snaps'); + +describe('productionPlatformVersion', () => { + beforeAll(() => { + fetchMock.enableMocks(); + }); + + beforeEach(async () => { + fetchMock.resetMocks(); + + await fs.rm(CACHE_PATH, { recursive: true, force: true }); + }); + + afterAll(() => { + fetchMock.disableMocks(); + }); + + it('reports if the version is greater than the production version', async () => { + fetchMock.mockResponses(MOCK_GITHUB_RESPONSE, MOCK_PACKAGE_JSON); + + const report = jest.fn(); + assert(productionPlatformVersion.semanticCheck); + + await productionPlatformVersion.semanticCheck( + getMockSnapFiles({ + manifest: getSnapManifest({ + platformVersion: '6.5.0' as SemVerVersion, + }), + }), + { report }, + ); + + expect(report).toHaveBeenCalledTimes(1); + expect(report).toHaveBeenCalledWith( + expect.stringContaining( + 'The specified platform version "6.5.0" is not supported in the production version of MetaMask. The current maximum supported version is "6.1.0". To resolve this, downgrade `@metamask/snaps-sdk` to a compatible version.', + ), + ); + }); + + it('does nothing if the version is less than the production version', async () => { + fetchMock.mockResponses(MOCK_GITHUB_RESPONSE, MOCK_PACKAGE_JSON); + + const report = jest.fn(); + assert(productionPlatformVersion.semanticCheck); + + await productionPlatformVersion.semanticCheck( + getMockSnapFiles({ + manifest: getSnapManifest({ + platformVersion: '6.0.0' as SemVerVersion, + }), + }), + { report }, + ); + + expect(report).toHaveBeenCalledTimes(0); + }); + + it('does nothing if the version is equal to the production version', async () => { + fetchMock.mockResponses(MOCK_GITHUB_RESPONSE, MOCK_PACKAGE_JSON); + + const report = jest.fn(); + assert(productionPlatformVersion.semanticCheck); + + await productionPlatformVersion.semanticCheck( + getMockSnapFiles({ + manifest: getSnapManifest({ + platformVersion: '6.1.0' as SemVerVersion, + }), + }), + { report }, + ); + + expect(report).toHaveBeenCalledTimes(0); + }); + + it('does nothing if the version is not set', async () => { + const report = jest.fn(); + assert(productionPlatformVersion.semanticCheck); + + const rawManifest = getSnapManifest(); + delete rawManifest.platformVersion; + + const files = getMockSnapFiles({ + manifest: rawManifest, + }); + + await productionPlatformVersion.semanticCheck(files, { report }); + + expect(report).toHaveBeenCalledTimes(0); + }); + + it('does nothing if the request to check the production version fails', async () => { + fetchMock.mockResponse(async () => ({ status: 404, body: 'Not found' })); + + const report = jest.fn(); + assert(productionPlatformVersion.semanticCheck); + + const files = getMockSnapFiles(); + + await productionPlatformVersion.semanticCheck(files, { report }); + + expect(report).toHaveBeenCalledTimes(0); + }); +}); diff --git a/packages/snaps-utils/src/manifest/validators/production-platform-version.ts b/packages/snaps-utils/src/manifest/validators/production-platform-version.ts new file mode 100644 index 0000000000..68a1712ad9 --- /dev/null +++ b/packages/snaps-utils/src/manifest/validators/production-platform-version.ts @@ -0,0 +1,67 @@ +import { Duration, inMilliseconds } from '@metamask/utils'; +import { minVersion, gt } from 'semver'; + +import { useFileSystemCache } from '../../fs'; +import type { ValidatorMeta } from '../validator-types'; + +/** + * Determine the production version of the Snaps platform by inspecting + * the latest GitHub release of the MetaMask extension. + * + * @returns The production version of the Snaps platform or null if any error occurred. + */ +const determineProductionVersion = useFileSystemCache( + 'snaps-production-version', + inMilliseconds(3, Duration.Day), + async () => { + try { + const latestRelease = await fetch( + 'https://api.github.com/repos/metamask/metamask-extension/releases/latest', + ); + + const latestReleaseJson = await latestRelease.json(); + + const latestReleaseCommit = latestReleaseJson.target_commitish; + + const packageJsonResponse = await fetch( + `https://api.github.com/repos/metamask/metamask-extension/contents/package.json?ref=${latestReleaseCommit}`, + { headers: new Headers({ accept: 'application/vnd.github.raw+json' }) }, + ); + + const packageJson = await packageJsonResponse.json(); + + const versionRange = packageJson.dependencies['@metamask/snaps-sdk']; + + return minVersion(versionRange)?.format(); + } catch { + return null; + } + }, +); + +/** + * Check if the platform version in manifest exceeds the version + * used in production. + */ +export const productionPlatformVersion: ValidatorMeta = { + severity: 'warning', + async semanticCheck(files, context) { + const manifestPlatformVersion = files.manifest.result.platformVersion; + + if (!manifestPlatformVersion) { + return; + } + + const maximumVersion = await determineProductionVersion(); + + if (!maximumVersion) { + return; + } + + if (gt(manifestPlatformVersion, maximumVersion)) { + context.report( + `The specified platform version "${manifestPlatformVersion}" is not supported in the production version of MetaMask. The current maximum supported version is "${maximumVersion}". To resolve this, downgrade \`@metamask/snaps-sdk\` to a compatible version.`, + ); + } + }, +}; diff --git a/yarn.lock b/yarn.lock index aa756b5f60..c27677333f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4564,6 +4564,7 @@ __metadata: istanbul-lib-report: "npm:^3.0.0" istanbul-reports: "npm:^3.1.5" jest: "npm:^29.0.2" + jest-fetch-mock: "npm:^3.0.3" jest-silent-reporter: "npm:^0.6.0" luxon: "npm:^3.5.0" marked: "npm:^12.0.1"