From 740734a3cb5b6b7758d8f9c1558e8651621d66f3 Mon Sep 17 00:00:00 2001 From: Maarten Zuidhoorn Date: Wed, 7 Jan 2026 11:42:11 +0100 Subject: [PATCH 1/8] Add option for custom manifest to dev server --- .../packages/bip32/snap.manifest.dev.json | 51 +++++++++++++++++++ packages/snaps-cli/src/builders.ts | 7 +++ .../src/commands/manifest/implementation.ts | 3 +- .../snaps-cli/src/commands/sandbox/server.ts | 2 +- .../snaps-cli/src/commands/watch/index.ts | 4 +- .../snaps-cli/src/commands/watch/watch.ts | 21 ++++++-- packages/snaps-cli/src/types/yargs.d.ts | 1 + packages/snaps-cli/src/webpack/server.ts | 40 +++++++++++++-- packages/snaps-rollup-plugin/src/plugin.ts | 11 ++-- packages/snaps-utils/src/manifest/manifest.ts | 10 ++-- packages/snaps-webpack-plugin/src/plugin.ts | 37 +++++++------- 11 files changed, 145 insertions(+), 42 deletions(-) create mode 100644 packages/examples/packages/bip32/snap.manifest.dev.json diff --git a/packages/examples/packages/bip32/snap.manifest.dev.json b/packages/examples/packages/bip32/snap.manifest.dev.json new file mode 100644 index 0000000000..733d82d22a --- /dev/null +++ b/packages/examples/packages/bip32/snap.manifest.dev.json @@ -0,0 +1,51 @@ +{ + "version": "2.3.0", + "description": "MetaMask example snap demonstrating the use of `snap_getBip32Entropy`.", + "proposedName": "BIP-32 Example Snap", + "repository": { + "type": "git", + "url": "https://github.com/MetaMask/snaps.git" + }, + "source": { + "shasum": "yW/4a7WQ2dCmZHAaX6P2tihFF7azVPkpQ0iJlb/v61A=", + "location": { + "npm": { + "filePath": "dist/bundle.js", + "packageName": "@metamask/bip32-example-snap", + "registry": "https://registry.npmjs.org" + } + } + }, + "initialConnections": { + "http://localhost:9000": {} + }, + "initialPermissions": { + "endowment:rpc": { + "dapps": true, + "snaps": true + }, + "snap_dialog": {}, + "snap_getBip32Entropy": [ + { + "path": ["m", "44'", "0'"], + "curve": "secp256k1" + }, + { + "path": ["m", "44'", "0'"], + "curve": "ed25519" + }, + { + "path": ["m", "44'", "0'"], + "curve": "ed25519Bip32" + } + ], + "snap_getBip32PublicKey": [ + { + "path": ["m", "44'", "0'"], + "curve": "secp256k1" + } + ] + }, + "platformVersion": "10.3.0", + "manifestVersion": "0.1" +} diff --git a/packages/snaps-cli/src/builders.ts b/packages/snaps-cli/src/builders.ts index 694610ba8f..8445be40b1 100644 --- a/packages/snaps-cli/src/builders.ts +++ b/packages/snaps-cli/src/builders.ts @@ -36,6 +36,13 @@ const builders = { normalize: true, }, + manifest: { + alias: 'm', + describe: 'Path to snap.manifest.json file', + type: 'string', + normalize: true, + }, + port: { alias: 'p', describe: 'Local server port for testing', diff --git a/packages/snaps-cli/src/commands/manifest/implementation.ts b/packages/snaps-cli/src/commands/manifest/implementation.ts index 573b090b43..608e864390 100644 --- a/packages/snaps-cli/src/commands/manifest/implementation.ts +++ b/packages/snaps-cli/src/commands/manifest/implementation.ts @@ -4,7 +4,6 @@ import { writeManifest } from '@metamask/snaps-webpack-plugin'; import { assert } from '@metamask/utils'; import { red, yellow, green } from 'chalk'; import type { Ora } from 'ora'; -import { dirname } from 'path'; import { error, info, warn } from '../../utils'; @@ -26,7 +25,7 @@ export async function manifest( exports?: string[], spinner?: Ora, ): Promise { - const { reports, updated } = await checkManifest(dirname(path), { + const { reports, updated } = await checkManifest(path, { exports, handlerEndowments, updateAndWriteManifest: write, diff --git a/packages/snaps-cli/src/commands/sandbox/server.ts b/packages/snaps-cli/src/commands/sandbox/server.ts index 0d67fbc2f8..70a6e467a4 100644 --- a/packages/snaps-cli/src/commands/sandbox/server.ts +++ b/packages/snaps-cli/src/commands/sandbox/server.ts @@ -11,7 +11,7 @@ import { getServer } from '../../webpack'; * @returns The server instance. */ export async function startSandbox(config: ProcessedConfig) { - const server = getServer(config, [ + const server = getServer(config, {}, [ (app) => { app.use( '/__sandbox__', diff --git a/packages/snaps-cli/src/commands/watch/index.ts b/packages/snaps-cli/src/commands/watch/index.ts index 992a7a28e3..eedfcb06dd 100644 --- a/packages/snaps-cli/src/commands/watch/index.ts +++ b/packages/snaps-cli/src/commands/watch/index.ts @@ -1,3 +1,4 @@ +import { resolve } from 'path'; import type yargs from 'yargs'; import { watchHandler } from './watch'; @@ -8,10 +9,11 @@ const command = { command: ['watch', 'w'], desc: 'Build Snap on change', builder: (yarg: yargs.Argv) => { - yarg.option('port', builders.port); + yarg.option('port', builders.port).option('manifest', builders.manifest); }, handler: async (argv: YargsArgs) => watchHandler(argv.context.config, { + manifestPath: argv.manifest && resolve(process.cwd(), argv.manifest), port: argv.port, }), }; diff --git a/packages/snaps-cli/src/commands/watch/watch.ts b/packages/snaps-cli/src/commands/watch/watch.ts index 2e915c6293..6a3cae7f6d 100644 --- a/packages/snaps-cli/src/commands/watch/watch.ts +++ b/packages/snaps-cli/src/commands/watch/watch.ts @@ -8,6 +8,11 @@ import { executeSteps, info } from '../../utils'; import { getServer } from '../../webpack'; type WatchOptions = { + /** + * The path to the manifest file. + */ + manifestPath?: string; + /** * The port to listen on. */ @@ -36,7 +41,7 @@ const steps: Steps = [ name: 'Starting the development server.', condition: ({ config }) => config.server.enabled, task: async ({ config, options, spinner }) => { - const server = getServer(config); + const server = getServer(config, options); const { port } = await server.listen(options.port ?? config.server.port); info(`The server is listening on http://localhost:${port}.`, spinner); @@ -44,8 +49,18 @@ const steps: Steps = [ }, { name: 'Building the Snap bundle.', - task: async ({ config, spinner }) => { - await watch(config, { spinner }); + task: async ({ config, options, spinner }) => { + const configWithManifest = options.manifestPath + ? { + ...config, + manifest: { + ...config.manifest, + path: options.manifestPath, + }, + } + : config; + + await watch(configWithManifest, { spinner }); }, }, ]; diff --git a/packages/snaps-cli/src/types/yargs.d.ts b/packages/snaps-cli/src/types/yargs.d.ts index b3ec09f966..367e581b5b 100644 --- a/packages/snaps-cli/src/types/yargs.d.ts +++ b/packages/snaps-cli/src/types/yargs.d.ts @@ -33,6 +33,7 @@ type YargsArgs = { dist: string; src: string; eval: boolean; + manifest?: string; outfileName: string; serve: boolean; directory?: string; diff --git a/packages/snaps-cli/src/webpack/server.ts b/packages/snaps-cli/src/webpack/server.ts index 557ec5be5f..f2e5560414 100644 --- a/packages/snaps-cli/src/webpack/server.ts +++ b/packages/snaps-cli/src/webpack/server.ts @@ -8,6 +8,16 @@ import { join, relative, resolve as resolvePath, sep, posix } from 'path'; import type { ProcessedConfig } from '../config'; +/** + * Options for the {@link getServer} function. + */ +type ServerOptions = { + /** + * The path to the manifest file to serve as `/snap.manifest.json`. + */ + manifestPath?: string; +}; + /** * Get the relative path from one path to another. * @@ -87,11 +97,20 @@ export function getAllowedPaths( * * @param request - The request object. * @param config - The config object. + * @param options - The server options. + * @param options.manifestPath - The path to the manifest file to serve as + * `/snap.manifest.json`. * @returns A promise that resolves to `true` if the path is allowed, or * `false` if it is not. */ -async function isAllowedPath(request: Request, config: ProcessedConfig) { - const manifestPath = join(config.server.root, NpmSnapFileNames.Manifest); +async function isAllowedPath( + request: Request, + config: ProcessedConfig, + options: ServerOptions, +) { + const { manifestPath = join(config.server.root, NpmSnapFileNames.Manifest) } = + options; + const { result } = await readJsonFile(manifestPath); const allowedPaths = getAllowedPaths(config, result); @@ -109,6 +128,9 @@ type Middleware = (app: Express) => void; * difficult to customize. * * @param config - The config object. + * @param options - The server options. + * @param options.manifestPath - The path to the manifest file to serve as + * `/snap.manifest.json`. * @param middleware - An array of middleware functions to run before serving * the static files. * @returns An object with a `listen` method that returns a promise that @@ -116,8 +138,12 @@ type Middleware = (app: Express) => void; */ export function getServer( config: ProcessedConfig, + options: ServerOptions = {}, middleware: Middleware[] = [], ) { + const { manifestPath = join(config.server.root, NpmSnapFileNames.Manifest) } = + options; + const app = express(); // Run "middleware" functions before serving the static files. @@ -125,7 +151,7 @@ export function getServer( // Check for allowed paths in the request URL. app.use((request, response, next) => { - isAllowedPath(request, config) + isAllowedPath(request, config, options) .then((allowed) => { if (allowed) { // eslint-disable-next-line promise/no-callback-in-promise @@ -140,6 +166,14 @@ export function getServer( .catch(next); }); + app.get('/snap.manifest.json', (_request, response, next) => { + response.sendFile(manifestPath, (error) => { + if (error) { + next(error); + } + }); + }); + // Serve the static files. app.use( expressStatic(config.server.root, { diff --git a/packages/snaps-rollup-plugin/src/plugin.ts b/packages/snaps-rollup-plugin/src/plugin.ts index 2e932dd8a0..951c248db5 100644 --- a/packages/snaps-rollup-plugin/src/plugin.ts +++ b/packages/snaps-rollup-plugin/src/plugin.ts @@ -74,13 +74,10 @@ export default function snaps(options?: Partial): Plugin { } if (defaultOptions.manifestPath) { - const { reports } = await checkManifest( - pathUtils.dirname(defaultOptions.manifestPath), - { - updateAndWriteManifest: defaultOptions.writeManifest, - sourceCode: await fs.readFile(output.file, 'utf8'), - }, - ); + const { reports } = await checkManifest(defaultOptions.manifestPath, { + updateAndWriteManifest: defaultOptions.writeManifest, + sourceCode: await fs.readFile(output.file, 'utf8'), + }); const errorsUnfixed = reports .filter((report) => report.severity === 'error' && !report.wasFixed) diff --git a/packages/snaps-utils/src/manifest/manifest.ts b/packages/snaps-utils/src/manifest/manifest.ts index 4866bf9d98..f8119d46b1 100644 --- a/packages/snaps-utils/src/manifest/manifest.ts +++ b/packages/snaps-utils/src/manifest/manifest.ts @@ -2,7 +2,7 @@ import { getErrorMessage } from '@metamask/snaps-sdk'; import type { Json } from '@metamask/utils'; import { assert, isPlainObject } from '@metamask/utils'; import { promises as fs } from 'fs'; -import pathUtils from 'path'; +import pathUtils, { dirname } from 'path'; import type { SnapManifest } from './validation'; import type { ValidatorResults } from './validator'; @@ -92,7 +92,7 @@ export type WriteFileFunction = (path: string, data: string) => Promise; * the fixed version to disk if `writeManifest` is true. Throws if validation * fails. * - * @param basePath - The path to the folder with the manifest files. + * @param manifestPath - The path to the manifest file. * @param options - Additional options for the function. * @param options.sourceCode - The source code of the Snap. * @param options.writeFileFn - The function to use to write the manifest to @@ -111,7 +111,7 @@ export type WriteFileFunction = (path: string, data: string) => Promise; * were encountered during processing of the manifest files. */ export async function checkManifest( - basePath: string, + manifestPath: string, { updateAndWriteManifest = true, sourceCode, @@ -121,7 +121,7 @@ export async function checkManifest( watchMode = false, }: CheckManifestOptions = {}, ): Promise { - const manifestPath = pathUtils.join(basePath, NpmSnapFileNames.Manifest); + const basePath = dirname(manifestPath); const manifestFile = await readJsonFile(manifestPath); const unvalidatedManifest = manifestFile.result; @@ -188,7 +188,7 @@ export async function checkManifest( try { await writeFileFn( - pathUtils.join(basePath, NpmSnapFileNames.Manifest), + manifestPath, manifestResults.files.manifest.toString(), ); } catch (error) { diff --git a/packages/snaps-webpack-plugin/src/plugin.ts b/packages/snaps-webpack-plugin/src/plugin.ts index 0f689c724e..99b08db4a5 100644 --- a/packages/snaps-webpack-plugin/src/plugin.ts +++ b/packages/snaps-webpack-plugin/src/plugin.ts @@ -187,27 +187,24 @@ export default class SnapsWebpackPlugin { } if (this.options.manifestPath) { - const { reports } = await checkManifest( - pathUtils.dirname(this.options.manifestPath), - { - updateAndWriteManifest: this.options.writeManifest, - sourceCode: bundleContent, - exports, - handlerEndowments, - watchMode: compiler.watchMode, - writeFileFn: async (path, data) => { - assert( - compiler.outputFileSystem, - 'Expected compiler to have an output file system.', - ); - return writeManifest( - path, - data, - promisify(compiler.outputFileSystem.writeFile), - ); - }, + const { reports } = await checkManifest(this.options.manifestPath, { + updateAndWriteManifest: this.options.writeManifest, + sourceCode: bundleContent, + exports, + handlerEndowments, + watchMode: compiler.watchMode, + writeFileFn: async (path, data) => { + assert( + compiler.outputFileSystem, + 'Expected compiler to have an output file system.', + ); + return writeManifest( + path, + data, + promisify(compiler.outputFileSystem.writeFile), + ); }, - ); + }); const errors = reports .filter((report) => report.severity === 'error' && !report.wasFixed) From 02973859f80e6589049d8100a212326b60f8b866 Mon Sep 17 00:00:00 2001 From: Maarten Zuidhoorn Date: Thu, 8 Jan 2026 10:04:09 +0100 Subject: [PATCH 2/8] Add missing headers --- packages/snaps-cli/src/webpack/server.ts | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/packages/snaps-cli/src/webpack/server.ts b/packages/snaps-cli/src/webpack/server.ts index f2e5560414..318f5ae897 100644 --- a/packages/snaps-cli/src/webpack/server.ts +++ b/packages/snaps-cli/src/webpack/server.ts @@ -167,11 +167,20 @@ export function getServer( }); app.get('/snap.manifest.json', (_request, response, next) => { - response.sendFile(manifestPath, (error) => { - if (error) { - next(error); - } - }); + response.sendFile( + manifestPath, + { + headers: { + 'Cache-Control': 'no-cache', + 'Access-Control-Allow-Origin': '*', + }, + }, + (error) => { + if (error) { + next(error); + } + }, + ); }); // Serve the static files. From dd231594660649b8ffb5a02d1d2026f6aab10356 Mon Sep 17 00:00:00 2001 From: Maarten Zuidhoorn Date: Thu, 8 Jan 2026 13:12:10 +0100 Subject: [PATCH 3/8] Simplify implementation by merging specified manifest before calling watch handler --- .../snaps-cli/src/commands/sandbox/server.ts | 2 +- .../snaps-cli/src/commands/watch/index.ts | 18 +++++++-- .../snaps-cli/src/commands/watch/watch.ts | 21 ++-------- packages/snaps-cli/src/webpack/server.ts | 38 +++---------------- 4 files changed, 24 insertions(+), 55 deletions(-) diff --git a/packages/snaps-cli/src/commands/sandbox/server.ts b/packages/snaps-cli/src/commands/sandbox/server.ts index 70a6e467a4..0d67fbc2f8 100644 --- a/packages/snaps-cli/src/commands/sandbox/server.ts +++ b/packages/snaps-cli/src/commands/sandbox/server.ts @@ -11,7 +11,7 @@ import { getServer } from '../../webpack'; * @returns The server instance. */ export async function startSandbox(config: ProcessedConfig) { - const server = getServer(config, {}, [ + const server = getServer(config, [ (app) => { app.use( '/__sandbox__', diff --git a/packages/snaps-cli/src/commands/watch/index.ts b/packages/snaps-cli/src/commands/watch/index.ts index eedfcb06dd..35db4c7357 100644 --- a/packages/snaps-cli/src/commands/watch/index.ts +++ b/packages/snaps-cli/src/commands/watch/index.ts @@ -11,11 +11,21 @@ const command = { builder: (yarg: yargs.Argv) => { yarg.option('port', builders.port).option('manifest', builders.manifest); }, - handler: async (argv: YargsArgs) => - watchHandler(argv.context.config, { - manifestPath: argv.manifest && resolve(process.cwd(), argv.manifest), + handler: async (argv: YargsArgs) => { + const configWithManifest = argv.manifest + ? { + ...argv.context.config, + manifest: { + ...argv.context.config.manifest, + path: resolve(process.cwd(), argv.manifest), + }, + } + : argv.context.config; + + return await watchHandler(configWithManifest, { port: argv.port, - }), + }); + }, }; export * from './implementation'; diff --git a/packages/snaps-cli/src/commands/watch/watch.ts b/packages/snaps-cli/src/commands/watch/watch.ts index 6a3cae7f6d..2e915c6293 100644 --- a/packages/snaps-cli/src/commands/watch/watch.ts +++ b/packages/snaps-cli/src/commands/watch/watch.ts @@ -8,11 +8,6 @@ import { executeSteps, info } from '../../utils'; import { getServer } from '../../webpack'; type WatchOptions = { - /** - * The path to the manifest file. - */ - manifestPath?: string; - /** * The port to listen on. */ @@ -41,7 +36,7 @@ const steps: Steps = [ name: 'Starting the development server.', condition: ({ config }) => config.server.enabled, task: async ({ config, options, spinner }) => { - const server = getServer(config, options); + const server = getServer(config); const { port } = await server.listen(options.port ?? config.server.port); info(`The server is listening on http://localhost:${port}.`, spinner); @@ -49,18 +44,8 @@ const steps: Steps = [ }, { name: 'Building the Snap bundle.', - task: async ({ config, options, spinner }) => { - const configWithManifest = options.manifestPath - ? { - ...config, - manifest: { - ...config.manifest, - path: options.manifestPath, - }, - } - : config; - - await watch(configWithManifest, { spinner }); + task: async ({ config, spinner }) => { + await watch(config, { spinner }); }, }, ]; diff --git a/packages/snaps-cli/src/webpack/server.ts b/packages/snaps-cli/src/webpack/server.ts index 318f5ae897..552853c878 100644 --- a/packages/snaps-cli/src/webpack/server.ts +++ b/packages/snaps-cli/src/webpack/server.ts @@ -4,20 +4,10 @@ import type { Express, Request } from 'express'; import express, { static as expressStatic } from 'express'; import type { Server } from 'http'; import type { AddressInfo } from 'net'; -import { join, relative, resolve as resolvePath, sep, posix } from 'path'; +import { relative, resolve as resolvePath, sep, posix } from 'path'; import type { ProcessedConfig } from '../config'; -/** - * Options for the {@link getServer} function. - */ -type ServerOptions = { - /** - * The path to the manifest file to serve as `/snap.manifest.json`. - */ - manifestPath?: string; -}; - /** * Get the relative path from one path to another. * @@ -97,21 +87,11 @@ export function getAllowedPaths( * * @param request - The request object. * @param config - The config object. - * @param options - The server options. - * @param options.manifestPath - The path to the manifest file to serve as - * `/snap.manifest.json`. * @returns A promise that resolves to `true` if the path is allowed, or * `false` if it is not. */ -async function isAllowedPath( - request: Request, - config: ProcessedConfig, - options: ServerOptions, -) { - const { manifestPath = join(config.server.root, NpmSnapFileNames.Manifest) } = - options; - - const { result } = await readJsonFile(manifestPath); +async function isAllowedPath(request: Request, config: ProcessedConfig) { + const { result } = await readJsonFile(config.manifest.path); const allowedPaths = getAllowedPaths(config, result); const path = request.path.slice(1); @@ -128,9 +108,6 @@ type Middleware = (app: Express) => void; * difficult to customize. * * @param config - The config object. - * @param options - The server options. - * @param options.manifestPath - The path to the manifest file to serve as - * `/snap.manifest.json`. * @param middleware - An array of middleware functions to run before serving * the static files. * @returns An object with a `listen` method that returns a promise that @@ -138,12 +115,8 @@ type Middleware = (app: Express) => void; */ export function getServer( config: ProcessedConfig, - options: ServerOptions = {}, middleware: Middleware[] = [], ) { - const { manifestPath = join(config.server.root, NpmSnapFileNames.Manifest) } = - options; - const app = express(); // Run "middleware" functions before serving the static files. @@ -151,7 +124,7 @@ export function getServer( // Check for allowed paths in the request URL. app.use((request, response, next) => { - isAllowedPath(request, config, options) + isAllowedPath(request, config) .then((allowed) => { if (allowed) { // eslint-disable-next-line promise/no-callback-in-promise @@ -166,9 +139,10 @@ export function getServer( .catch(next); }); + // Serve the manifest file at the expected URL. app.get('/snap.manifest.json', (_request, response, next) => { response.sendFile( - manifestPath, + config.manifest.path, { headers: { 'Cache-Control': 'no-cache', From 1e77e2be9d5c8953372160ba9d52f373f4ef2e20 Mon Sep 17 00:00:00 2001 From: Maarten Zuidhoorn Date: Thu, 8 Jan 2026 14:09:05 +0100 Subject: [PATCH 4/8] Fix existing tests --- packages/snaps-cli/src/webpack/server.test.ts | 6 ++++ packages/snaps-cli/src/webpack/server.ts | 7 +++-- .../snaps-rollup-plugin/src/plugin.test.ts | 4 +-- .../snaps-utils/src/manifest/manifest.test.ts | 28 +++++++++---------- .../snaps-webpack-plugin/src/plugin.test.ts | 8 +++--- 5 files changed, 31 insertions(+), 22 deletions(-) diff --git a/packages/snaps-cli/src/webpack/server.test.ts b/packages/snaps-cli/src/webpack/server.test.ts index cf1cae39aa..1814f60582 100644 --- a/packages/snaps-cli/src/webpack/server.test.ts +++ b/packages/snaps-cli/src/webpack/server.test.ts @@ -207,6 +207,9 @@ describe('getServer', () => { root: '/foo', port: 0, }, + manifest: { + path: '/foo/snap.manifest.json', + }, }); const server = getServer(config); @@ -229,6 +232,9 @@ describe('getServer', () => { root: '/foo', port: 0, }, + manifest: { + path: '/foo/snap.manifest.json', + }, }); const server = getServer(config); diff --git a/packages/snaps-cli/src/webpack/server.ts b/packages/snaps-cli/src/webpack/server.ts index 552853c878..635de2fe59 100644 --- a/packages/snaps-cli/src/webpack/server.ts +++ b/packages/snaps-cli/src/webpack/server.ts @@ -91,7 +91,10 @@ export function getAllowedPaths( * `false` if it is not. */ async function isAllowedPath(request: Request, config: ProcessedConfig) { - const { result } = await readJsonFile(config.manifest.path); + const { result } = await readJsonFile( + resolvePath(config.server.root, config.manifest.path), + ); + const allowedPaths = getAllowedPaths(config, result); const path = request.path.slice(1); @@ -142,7 +145,7 @@ export function getServer( // Serve the manifest file at the expected URL. app.get('/snap.manifest.json', (_request, response, next) => { response.sendFile( - config.manifest.path, + resolvePath(config.server.root, config.manifest.path), { headers: { 'Cache-Control': 'no-cache', diff --git a/packages/snaps-rollup-plugin/src/plugin.test.ts b/packages/snaps-rollup-plugin/src/plugin.test.ts index 1e86c6d4b2..eef5b9311a 100644 --- a/packages/snaps-rollup-plugin/src/plugin.test.ts +++ b/packages/snaps-rollup-plugin/src/plugin.test.ts @@ -237,7 +237,7 @@ describe('snaps', () => { }); expect(mock).toHaveBeenCalledTimes(1); - expect(mock).toHaveBeenCalledWith('/', { + expect(mock).toHaveBeenCalledWith('/snap.manifest.json', { updateAndWriteManifest: true, sourceCode: expect.any(String), }); @@ -260,7 +260,7 @@ describe('snaps', () => { }); expect(mock).toHaveBeenCalledTimes(1); - expect(mock).toHaveBeenCalledWith('/', { + expect(mock).toHaveBeenCalledWith('/snap.manifest.json', { updateAndWriteManifest: false, sourceCode: expect.any(String), }); diff --git a/packages/snaps-utils/src/manifest/manifest.test.ts b/packages/snaps-utils/src/manifest/manifest.test.ts index 0e2d0b3375..5ebc44410b 100644 --- a/packages/snaps-utils/src/manifest/manifest.test.ts +++ b/packages/snaps-utils/src/manifest/manifest.test.ts @@ -103,7 +103,7 @@ describe('checkManifest', () => { }); it('returns the status and warnings after processing', async () => { - const { updated, reports } = await checkManifest(BASE_PATH); + const { updated, reports } = await checkManifest(MANIFEST_PATH); expect(reports).toHaveLength(0); expect(updated).toBe(false); }); @@ -119,7 +119,7 @@ describe('checkManifest', () => { ), ); - const { files, updated, reports } = await checkManifest(BASE_PATH); + const { files, updated, reports } = await checkManifest(MANIFEST_PATH); const unfixed = reports.filter((report) => !report.wasFixed); const fixed = reports.filter((report) => report.wasFixed); @@ -147,7 +147,7 @@ describe('checkManifest', () => { ), ); - const { files, updated, reports } = await checkManifest(BASE_PATH); + const { files, updated, reports } = await checkManifest(MANIFEST_PATH); const unfixed = reports.filter((report) => !report.wasFixed); const fixed = reports.filter((report) => report.wasFixed); @@ -180,7 +180,7 @@ describe('checkManifest', () => { await fs.writeFile(MANIFEST_PATH, JSON.stringify(manifest)); - const { files, updated, reports } = await checkManifest(BASE_PATH); + const { files, updated, reports } = await checkManifest(MANIFEST_PATH); const unfixed = reports.filter((report) => !report.wasFixed); const fixed = reports.filter((report) => report.wasFixed); @@ -228,7 +228,7 @@ describe('checkManifest', () => { await fs.writeFile(PACKAGE_JSON_PATH, JSON.stringify(packageJson)); - const { updated, reports } = await checkManifest(BASE_PATH); + const { updated, reports } = await checkManifest(MANIFEST_PATH); const warnings = reports.filter(({ severity }) => severity === 'warning'); @@ -247,7 +247,7 @@ describe('checkManifest', () => { await fs.writeFile(MANIFEST_PATH, JSON.stringify(manifest)); - const { reports } = await checkManifest(BASE_PATH); + const { reports } = await checkManifest(MANIFEST_PATH); const warnings = reports.filter(({ severity }) => severity === 'warning'); expect(warnings).toHaveLength(1); expect(warnings[0].message).toMatch( @@ -255,7 +255,7 @@ describe('checkManifest', () => { ); }); - it('returns a warning if manifest has with a non 1:1 ratio', async () => { + it('returns a warning if manifest has icon with a non 1:1 ratio', async () => { const manifest = getSnapManifest({ platformVersion: getPlatformVersion(), }); @@ -266,7 +266,7 @@ describe('checkManifest', () => { ); await fs.writeFile(MANIFEST_PATH, JSON.stringify(manifest)); - const { reports } = await checkManifest(BASE_PATH); + const { reports } = await checkManifest(MANIFEST_PATH); const warnings = reports.filter(({ severity }) => severity === 'warning'); expect(warnings).toHaveLength(1); expect(warnings[0].message).toMatch( @@ -283,7 +283,7 @@ describe('checkManifest', () => { await fs.writeFile(MANIFEST_PATH, JSON.stringify(manifest)); - const { reports } = await checkManifest(BASE_PATH, { + const { reports } = await checkManifest(MANIFEST_PATH, { updateAndWriteManifest: false, }); @@ -315,7 +315,7 @@ describe('checkManifest', () => { ), ); - await expect(checkManifest(BASE_PATH)).rejects.toThrow( + await expect(checkManifest(MANIFEST_PATH)).rejects.toThrow( "Failed to read snap files: ENOENT: no such file or directory, open '/snap/foo.json'", ); }); @@ -342,7 +342,7 @@ describe('checkManifest', () => { JSON.stringify(localizationFile), ); - const { reports } = await checkManifest(BASE_PATH); + const { reports } = await checkManifest(MANIFEST_PATH); expect(reports.map(({ message }) => message)).toStrictEqual([ 'Failed to validate localization file "/snap/locales/en.json": At path: messages — Expected a value of type record, but received: "foo".', ]); @@ -370,7 +370,7 @@ describe('checkManifest', () => { JSON.stringify(localizationFile), ); - const { reports } = await checkManifest(BASE_PATH); + const { reports } = await checkManifest(MANIFEST_PATH); expect(reports.map(({ message }) => message)).toStrictEqual([ 'Failed to localize Snap manifest: Failed to translate "{{ name }}": No translation found for "name" in "en" file.', ]); @@ -388,7 +388,7 @@ describe('checkManifest', () => { await fs.mkdir(join(BASE_PATH, 'locales')); await fs.writeFile(join(BASE_PATH, '/locales/en.json'), ','); - await expect(checkManifest(BASE_PATH)).rejects.toThrow( + await expect(checkManifest(MANIFEST_PATH)).rejects.toThrow( 'Failed to parse localization file "/snap/locales/en.json" as JSON.', ); }); @@ -401,7 +401,7 @@ describe('checkManifest', () => { throw new Error('foo'); }); - await expect(checkManifest(BASE_PATH)).rejects.toThrow( + await expect(checkManifest(MANIFEST_PATH)).rejects.toThrow( 'Failed to update "snap.manifest.json": foo', ); }); diff --git a/packages/snaps-webpack-plugin/src/plugin.test.ts b/packages/snaps-webpack-plugin/src/plugin.test.ts index 3062638e1c..f7269f49c2 100644 --- a/packages/snaps-webpack-plugin/src/plugin.test.ts +++ b/packages/snaps-webpack-plugin/src/plugin.test.ts @@ -276,7 +276,7 @@ describe('SnapsWebpackPlugin', () => { }); expect(mock).toHaveBeenCalledTimes(1); - expect(mock).toHaveBeenCalledWith('/', { + expect(mock).toHaveBeenCalledWith('/snap.manifest.json', { exports: undefined, handlerEndowments, updateAndWriteManifest: true, @@ -320,7 +320,7 @@ describe('SnapsWebpackPlugin', () => { }); expect(checkManifestMock).toHaveBeenCalledTimes(1); - expect(checkManifestMock).toHaveBeenCalledWith('/', { + expect(checkManifestMock).toHaveBeenCalledWith('/snap.manifest.json', { exports: ['foo'], handlerEndowments, updateAndWriteManifest: true, @@ -347,7 +347,7 @@ describe('SnapsWebpackPlugin', () => { }); expect(mock).toHaveBeenCalledTimes(1); - expect(mock).toHaveBeenCalledWith('/', { + expect(mock).toHaveBeenCalledWith('/snap.manifest.json', { exports: undefined, handlerEndowments, updateAndWriteManifest: false, @@ -377,7 +377,7 @@ describe('SnapsWebpackPlugin', () => { }); expect(mock).toHaveBeenCalledTimes(1); - expect(mock).toHaveBeenCalledWith('/', { + expect(mock).toHaveBeenCalledWith('/snap.manifest.json', { exports: undefined, handlerEndowments, updateAndWriteManifest: false, From fbaddc36532ac48783b1491d7abe23f0e1e5a6ce Mon Sep 17 00:00:00 2001 From: Maarten Zuidhoorn Date: Fri, 9 Jan 2026 12:54:49 +0100 Subject: [PATCH 5/8] Add more tests --- .../src/commands/watch/index.test.ts | 17 +++++++- packages/snaps-cli/src/webpack/server.test.ts | 40 +++++++++++++++++-- packages/snaps-cli/src/webpack/server.ts | 4 ++ 3 files changed, 57 insertions(+), 4 deletions(-) diff --git a/packages/snaps-cli/src/commands/watch/index.test.ts b/packages/snaps-cli/src/commands/watch/index.test.ts index c2a225939f..355f39bb61 100644 --- a/packages/snaps-cli/src/commands/watch/index.test.ts +++ b/packages/snaps-cli/src/commands/watch/index.test.ts @@ -5,9 +5,10 @@ import type { YargsArgs } from '../../types/yargs'; jest.mock('./watch'); -const getMockArgv = () => { +const getMockArgv = (manifest?: string | undefined) => { return { context: { config: getMockConfig() }, + manifest, } as unknown as YargsArgs; }; @@ -16,4 +17,18 @@ describe('watch command', () => { await command.handler(getMockArgv()); expect(watchHandler).toHaveBeenCalled(); }); + + it('calls the `watchHandler` function with a custom manifest path', async () => { + await command.handler(getMockArgv('/custom.json')); + expect(watchHandler).toHaveBeenCalledWith( + expect.objectContaining({ + manifest: expect.objectContaining({ + path: '/custom.json', + }), + }), + { + port: undefined, + }, + ); + }); }); diff --git a/packages/snaps-cli/src/webpack/server.test.ts b/packages/snaps-cli/src/webpack/server.test.ts index 1814f60582..aae2985c65 100644 --- a/packages/snaps-cli/src/webpack/server.test.ts +++ b/packages/snaps-cli/src/webpack/server.test.ts @@ -139,7 +139,8 @@ describe('getAllowedPaths', () => { describe('getServer', () => { beforeEach(async () => { - await fs.mkdir('/foo', { recursive: true }); + await fs.mkdir('/foo/dist', { recursive: true }); + await fs.writeFile('/foo/dist/bundle.js', 'console.log("Hello, world!");'); await fs.writeFile( '/foo/snap.manifest.json', JSON.stringify(getSnapManifest()), @@ -207,6 +208,9 @@ describe('getServer', () => { root: '/foo', port: 0, }, + output: { + path: '/foo/dist', + }, manifest: { path: '/foo/snap.manifest.json', }, @@ -216,11 +220,41 @@ describe('getServer', () => { const { port, close } = await server.listen(); const response = await fetch( - `http://localhost:${port}/snap.manifest.json?_=1731493314736`, + `http://localhost:${port}/dist/bundle.js?_=1731493314736`, ); expect(response.status).toBe(200); - expect(await response.text()).toBe(JSON.stringify(getSnapManifest())); + expect(await response.text()).toBe('console.log("Hello, world!");'); + + await close(); + }); + + it('responds with a custom manifest file', async () => { + const config = getMockConfig({ + input: 'src/index.js', + server: { + root: '/foo', + port: 0, + }, + manifest: { + path: '/foo/snap.manifest.dev.json', + }, + }); + + const server = getServer(config); + const { port, close } = await server.listen(); + + // Create a custom manifest file in the /foo/dist directory + const customManifest = getSnapManifest({ proposedName: 'Dev Snap' }); + await fs.writeFile( + '/foo/snap.manifest.dev.json', + JSON.stringify(customManifest), + ); + + const response = await fetch(`http://localhost:${port}/snap.manifest.json`); + + expect(response.status).toBe(200); + expect(await response.text()).toBe(JSON.stringify(customManifest)); await close(); }); diff --git a/packages/snaps-cli/src/webpack/server.ts b/packages/snaps-cli/src/webpack/server.ts index 635de2fe59..2cc806e78a 100644 --- a/packages/snaps-cli/src/webpack/server.ts +++ b/packages/snaps-cli/src/webpack/server.ts @@ -152,6 +152,10 @@ export function getServer( 'Access-Control-Allow-Origin': '*', }, }, + + // This is complicated to test, since this middleware is only called if + // the file exists in the first place. + /* istanbul ignore next */ (error) => { if (error) { next(error); From 7f17dbf7bc44b308abbe38c6dba03be05cbf66bf Mon Sep 17 00:00:00 2001 From: Maarten Zuidhoorn Date: Fri, 9 Jan 2026 13:04:27 +0100 Subject: [PATCH 6/8] Fix test on Windows --- packages/snaps-cli/src/commands/watch/index.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/snaps-cli/src/commands/watch/index.test.ts b/packages/snaps-cli/src/commands/watch/index.test.ts index 355f39bb61..2fd9e2c5ec 100644 --- a/packages/snaps-cli/src/commands/watch/index.test.ts +++ b/packages/snaps-cli/src/commands/watch/index.test.ts @@ -23,7 +23,7 @@ describe('watch command', () => { expect(watchHandler).toHaveBeenCalledWith( expect.objectContaining({ manifest: expect.objectContaining({ - path: '/custom.json', + path: expect.stringContaining('custom.json'), }), }), { From 5fb414f4312c4ee22da238b55b744ced005ceae7 Mon Sep 17 00:00:00 2001 From: Maarten Zuidhoorn Date: Fri, 9 Jan 2026 13:58:47 +0100 Subject: [PATCH 7/8] Attempt to fix Windows compatibility --- .../src/commands/manifest/implementation.test.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/snaps-cli/src/commands/manifest/implementation.test.ts b/packages/snaps-cli/src/commands/manifest/implementation.test.ts index cee6b4d91e..89c02d9b42 100644 --- a/packages/snaps-cli/src/commands/manifest/implementation.test.ts +++ b/packages/snaps-cli/src/commands/manifest/implementation.test.ts @@ -8,6 +8,7 @@ import { import type { SemVerVersion } from '@metamask/utils'; import normalFs from 'fs'; import ora from 'ora'; +import { join } from 'path'; import { manifest } from './implementation'; import type * as webpack from '../../webpack'; @@ -74,7 +75,7 @@ describe('manifest', () => { const spinner = ora(); const result = await manifest( - '/snap/snap.manifest.json', + join('/snap', 'snap.manifest.json'), false, undefined, spinner, @@ -97,7 +98,7 @@ describe('manifest', () => { const spinner = ora(); const result = await manifest( - '/snap/snap.manifest.json', + join('/snap', 'snap.manifest.json'), false, undefined, spinner, @@ -132,7 +133,7 @@ describe('manifest', () => { const spinner = ora(); const result = await manifest( - '/snap/snap.manifest.json', + join('/snap', 'snap.manifest.json'), true, undefined, spinner, @@ -155,7 +156,7 @@ describe('manifest', () => { const log = jest.spyOn(console, 'log').mockImplementation(); await fs.writeFile( - '/snap/snap.manifest.json', + join('/snap', 'snap.manifest.json'), JSON.stringify( getSnapManifest({ shasum: 'G/W5b2JZVv+epgNX9pkN63X6Lye9EJVJ4NLSgAw/afd=', From ef52e02b0eac4d560d67608bcfc2a59fd8e6a7ea Mon Sep 17 00:00:00 2001 From: Maarten Zuidhoorn Date: Fri, 9 Jan 2026 14:58:20 +0100 Subject: [PATCH 8/8] Add temporary debug log --- packages/snaps-cli/src/commands/manifest/implementation.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/snaps-cli/src/commands/manifest/implementation.ts b/packages/snaps-cli/src/commands/manifest/implementation.ts index 608e864390..dc07f27938 100644 --- a/packages/snaps-cli/src/commands/manifest/implementation.ts +++ b/packages/snaps-cli/src/commands/manifest/implementation.ts @@ -4,6 +4,7 @@ import { writeManifest } from '@metamask/snaps-webpack-plugin'; import { assert } from '@metamask/utils'; import { red, yellow, green } from 'chalk'; import type { Ora } from 'ora'; +import { dirname } from 'path'; import { error, info, warn } from '../../utils'; @@ -25,6 +26,11 @@ export async function manifest( exports?: string[], spinner?: Ora, ): Promise { + /* eslint-disable no-console */ + console.log('Checking manifest at path:', path); + console.log('Dirname:', dirname(path)); + /* eslint-enable no-console */ + const { reports, updated } = await checkManifest(path, { exports, handlerEndowments,