diff --git a/README.md b/README.md index fc352f8..b494895 100644 --- a/README.md +++ b/README.md @@ -20,27 +20,23 @@ npx sv add @msw/sveltekit ## What you get - `msw` added as a dev dependency. -- `src/msw/handlers.ts` or `src/msw/handlers.js` with shared request handlers. -- Optional browser setup in `src/msw/browser` and `src/hooks.client`. -- Optional Node setup in `src/msw/node` and `src/hooks.server`. -- Browser worker configuration in `package.json`. +- `src/mocks/handlers.ts` or `src/mocks/handlers.js` with shared request handlers. +- The `msw()` plugin from `msw/vite` added to your Vite config. +- Optional browser and server setup in the corresponding `src/hooks.client` and `src/hooks.server` `init` hooks. +- Types for `virtual:msw` referenced in `src/app.d.ts`. + +The hooks load your handlers and enable the `virtual:msw` network during development only. The Vite plugin selects the browser or Node implementation automatically and serves the browser worker, so there is no need to run `msw init` or copy a worker into `static`. + +The add-on installs the latest stable MSW release. The Vite integration requires MSW 3.0.0 or later. You can override the dependency with the `MSW_VERSION` environment variable. ## Options ### `environments` -Choose where MSW should run. This is a multiselect option. +Choose where MSW should run during development: `browser`, `node` (the SvelteKit server), or both. This is a multiselect option. Default: `browser,node` ```shell npx sv add @msw/sveltekit="environments:browser,node" ``` - -## Browser worker - -If you enable browser mocking, generate the service worker after installing dependencies: - -```shell -npx msw init static --save -``` diff --git a/jsconfig.json b/jsconfig.json index 81fdfe4..9ffb8a8 100644 --- a/jsconfig.json +++ b/jsconfig.json @@ -5,5 +5,6 @@ "checkJs": true, "module": "NodeNext", "moduleResolution": "NodeNext" - } + }, + "include": ["src/**/*.js", "tests/**/*.js", "*.js"] } diff --git a/src/index.js b/src/index.js index 15d7143..640b164 100644 --- a/src/index.js +++ b/src/index.js @@ -1,7 +1,7 @@ import { defineAddon, defineAddonOptions } from 'sv' -import { color, downloadJson, transforms, defineEnv } from './sv-utils.js' +import { color, downloadJson, transforms } from './sv-utils.js' -const options = defineAddonOptions() +const addonOptions = defineAddonOptions() .add('environments', { question: 'Where do you want to use MSW?', type: 'multiselect', @@ -16,7 +16,7 @@ const options = defineAddonOptions() { value: 'node', label: 'Node', - hint: 'Intercept the network in Node.js (e.g. server, tests).', + hint: 'Intercept the network in the SvelteKit server.', }, ], }) @@ -26,20 +26,10 @@ const FILES = { handlers: `import { http, HttpResponse } from 'msw/http'; export const handlers = [ - http.get('/api/hello', () => { + http.get('*/api/hello', () => { return HttpResponse.text('Hello world!'); }) ]; -`, - browser: `import { setupWorker } from 'msw/browser'; -import { handlers } from './handlers'; - -export const worker = setupWorker(...handlers); -`, - node: `import { setupServer } from 'msw/node'; -import { handlers } from './handlers'; - -export const server = setupServer(...handlers); `, } @@ -48,7 +38,7 @@ export default defineAddon({ alias: 'msw', shortDescription: 'add Mock Service Worker to your SvelteKit app', homepage: 'https://mswjs.io', - options, + options: addonOptions, setup: ({ isKit, unsupported }) => { if (!isKit) { @@ -56,50 +46,29 @@ export default defineAddon({ } }, - run: async ({ - directory, - file, - language, - options, - sv, - cwd, - dependencyVersion, - }) => { + run: async ({ directory, file, language, options, sv }) => { const mswVersion = await getMswVersion() const extension = language === 'ts' ? 'ts' : 'js' - const mocksDirectory = `${directory.src}/msw` - - const env = defineEnv({ sv, cwd, dependencyVersion }) + const mocksDirectory = `${directory.src}/mocks` sv.devDependency('msw', mswVersion) + sv.file(file.viteConfig, addVitePlugin()) sv.file(`${mocksDirectory}/handlers.${extension}`, seedFile(FILES.handlers)) + sv.file(`${directory.src}/app.d.ts`, addVirtualModuleTypes()) if (options.environments.includes('browser')) { - sv.file(`${mocksDirectory}/browser.${extension}`, seedFile(FILES.browser)) - sv.file(file.package, addMswWorkerDirectory()) - sv.file(`${directory.src}/hooks.client.${extension}`, addClientHook(env)) + sv.file(`${directory.src}/hooks.client.${extension}`, addInitHook()) } if (options.environments.includes('node')) { - sv.file(`${mocksDirectory}/node.${extension}`, seedFile(FILES.node)) - sv.file(`${directory.src}/hooks.server.${extension}`, addServerHook(env)) + sv.file(`${directory.src}/hooks.server.${extension}`, addInitHook()) } }, - nextSteps: ({ options }) => { - const steps = [ - 'Edit your request handlers in ' + color.path('src/msw/handlers'), - ] - - if (options.environments.includes('browser')) { - steps.push( - `Generate the browser worker with ${color.command('npx msw init static --save')}`, - ) - } - - return steps - }, + nextSteps: () => [ + 'Edit your request handlers in ' + color.path('src/mocks/handlers'), + ], }) async function getMswVersion() { @@ -125,53 +94,84 @@ function seedFile(content) { }) } -function addMswWorkerDirectory() { - return transforms.json(({ data }) => { - data.msw ??= {} - data.msw.workerDirectory = ['static'] +function addVitePlugin() { + return transforms.script(({ ast, js }) => { + const { alias } = js.imports.find(ast, { from: 'msw/vite', name: 'msw' }) + if (!alias) { + js.imports.addNamed(ast, { from: 'msw/vite', imports: ['msw'] }) + } + js.vite.addPlugin(ast, { code: `${alias ?? 'msw'}()` }) }) } -/** - * @param {ReturnType} env - */ -function addClientHook(env) { - return transforms.script(({ ast, content, js }) => { - env.importEnv(ast, js, ['dev']) - js.imports.addNamed(ast, { - from: './msw/browser', - imports: ['worker'], - }) - - if (content.includes('worker.start(')) { - return +function addVirtualModuleTypes() { + return transforms.text(({ content }) => { + if (/\/\/\/\s*\n${content}` }) } -/** - * @param {ReturnType} env - */ -function addServerHook(env) { - return transforms.script(({ ast, js }) => { - env.importEnv(ast, js, ['dev']) - js.imports.addNamed(ast, { - from: './msw/node', - imports: { server: 'msw_server' }, +function addInitHook() { + return transforms.script(({ ast, js, comments, content }) => { + let setup = js.common.parseStatement(`if (import.meta.env.DEV) { + const { network } = await import('virtual:msw'); + const { handlers } = await import('./mocks/handlers'); + + network.configure({ handlers }); + await network.enable(); +}`) + const exported = js.exports.createNamed(ast, { + name: 'init', + // falling back to an empty init that we will populate with the AST changes below + fallback: js.common.parseFromString('const init = async () => {};'), }) - js.common.appendStatement(ast, { - statement: js.common.parseStatement(`if (dev) { - msw_server.listen({ onUnhandledFrame: 'bypass' }); -}`), - }) + const declaration = exported.declaration + const init = + declaration?.type === 'VariableDeclaration' + ? declaration.declarations[0].init + : declaration + + if ( + !init || + (init.type !== 'FunctionDeclaration' && + init.type !== 'FunctionExpression' && + init.type !== 'ArrowFunctionExpression') + ) { + throw new Error('Cannot add MSW to an init hook that is not a function') + } + + // converts non async, non block function bodies into block statements + // so we can have multiple statements + init.async = true + if (init.body.type !== 'BlockStatement') { + init.body = { + type: 'BlockStatement', + body: [{ type: 'ReturnStatement', argument: init.body }], + } + if (init.type === 'ArrowFunctionExpression') init.expression = false + } + + const existingSetup = init.body.body.find((statement) => + js.common.areNodesEqual(statement, setup), + ) + if (existingSetup) { + setup = existingSetup + } else { + init.body.body.unshift(setup) + } + + // let's add a few comments to show why we use import.meta.env.DEV + for (const line of [ + 'Use import.meta.env.DEV so Vite drops MSW imports before dependency discovery.', + "SvelteKit's dev is folded later and can leave unused MSW assets in production.", + ]) { + if (!content.includes(line)) { + comments.add(setup, { type: 'Line', value: ` ${line}` }) + } + } }) } diff --git a/tests/addon.test.js b/tests/addon.test.js index 866c902..4d993b2 100644 --- a/tests/addon.test.js +++ b/tests/addon.test.js @@ -1,5 +1,6 @@ import fs from 'node:fs' import path from 'node:path' +import assert from 'node:assert/strict' import { expect } from 'vitest' import addon from '../src/index.js' import { setupTest } from './setup/suite.js' @@ -16,6 +17,14 @@ const { test, testCases } = setupTest( type: 'sveltekit-3', options: { [addon.id]: { environments: ['browser', 'node'] } }, }, + { + type: 'browser-only', + options: { [addon.id]: { environments: ['browser'] } }, + }, + { + type: 'node-only', + options: { [addon.id]: { environments: ['node'] } }, + }, ], filter: (testCase) => testCase.variant.includes('kit'), browser: false, @@ -31,76 +40,94 @@ const { test, testCases } = setupTest( package_json_path, JSON.stringify(package_json, null, '\t'), ) + // Older cached templates have a separate config; newer ones already use Vite. + fs.rmSync(path.resolve(cwd, 'svelte.config.js'), { force: true }) + const viteConfigPath = fs + .readdirSync(cwd) + .find((file) => /^vite\.config\.[jt]s$/.test(file)) + assert(viteConfigPath, 'Expected a Vite config') + fs.writeFileSync( + path.resolve(cwd, viteConfigPath), + ` +import adapter from '@sveltejs/adapter-auto'; +import { sveltekit } from '@sveltejs/kit/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ plugins: [sveltekit({ adapter: adapter() })] }); +`, + ) }, }, ) test.concurrent.for(testCases)( '@msw/msw-add $kind.type $variant', - async (testCase, ctx) => { + (testCase, ctx) => { const cwd = ctx.cwd(testCase) const extension = testCase.variant.includes('ts') ? 'ts' : 'js' - const appModule = - testCase.kind.type === 'sveltekit-3' ? '$app/env' : '$app/environment' const package_json = JSON.parse( fs.readFileSync(path.resolve(cwd, 'package.json'), 'utf8'), ) expect(package_json.devDependencies.msw).toBe(process.env.MSW_VERSION) - expect(package_json.msw.workerDirectory).toEqual(['static']) + expect(package_json.msw).toBeUndefined() - const handlers = fs.readFileSync( - path.resolve(cwd, `src/msw/handlers.${extension}`), + const viteConfigPath = fs + .readdirSync(cwd) + .find((file) => /^vite\.config\.[jt]s$/.test(file)) + assert(viteConfigPath, 'Expected a Vite config') + const viteConfig = fs.readFileSync( + path.resolve(cwd, viteConfigPath), 'utf8', ) - expect(handlers).toContain("import { http, HttpResponse } from 'msw/http';") - expect(handlers).toContain("http.get('/api/hello'") + expect(viteConfig).toContain("import { msw } from 'msw/vite';") + expect(viteConfig).toContain('msw()') + expect(viteConfig).toContain('sveltekit(') - const browser = fs.readFileSync( - path.resolve(cwd, `src/msw/browser.${extension}`), - 'utf8', - ) - expect(browser).toContain("import { setupWorker } from 'msw/browser';") - expect(browser).toContain('setupWorker(...handlers)') + const appTypes = fs.readFileSync(path.resolve(cwd, 'src/app.d.ts'), 'utf8') + expect(appTypes).toContain('/// ') - const node = fs.readFileSync( - path.resolve(cwd, `src/msw/node.${extension}`), + const handlers = fs.readFileSync( + path.resolve(cwd, `src/mocks/handlers.${extension}`), 'utf8', ) - expect(node).toContain("import { setupServer } from 'msw/node';") - expect(node).toContain('setupServer(...handlers)') + expect(handlers).toContain("import { http, HttpResponse } from 'msw/http';") + expect(handlers).toContain("http.get('*/api/hello'") - const hooksClient = fs.readFileSync( - path.resolve(cwd, `src/hooks.client.${extension}`), - 'utf8', - ) - expectDevImport(hooksClient, appModule) - expect(hooksClient).toContain("import { worker } from './msw/browser';") - expect(hooksClient).toContain('export async function init()') - expect(hooksClient).toContain('worker.start()') + for (const file of ['browser', 'node']) { + expect( + fs.existsSync(path.resolve(cwd, `src/mocks/${file}.${extension}`)), + ).toBe(false) + } + expect( + fs.existsSync(path.resolve(cwd, 'static/mockServiceWorker.js')), + ).toBe(false) - const hooks_server = fs.readFileSync( - path.resolve(cwd, `src/hooks.server.${extension}`), - 'utf8', - ) - expectDevImport(hooks_server, appModule) - expect(hooks_server).toContain( - "import { server as msw_server } from './msw/node';", - ) - expect(hooks_server).toContain('if (dev)') - expect(hooks_server).toContain( - "msw_server.listen({ onUnhandledFrame: 'bypass' })", - ) + const environments = testCase.kind.options[addon.id].environments + assert(Array.isArray(environments), 'Expected an environments array') + for (const [environment, target] of [ + ['browser', 'client'], + ['node', 'server'], + ]) { + const hookPath = path.resolve(cwd, `src/hooks.${target}.${extension}`) + if (!environments.includes(environment)) { + expect(fs.existsSync(hookPath)).toBe(false) + continue + } + + const hook = fs.readFileSync(hookPath, 'utf8') + expect(hook).toContain('export const init = async () =>') + expect(hook).toContain('if (import.meta.env.DEV)') + expect(hook).toContain( + '// Use import.meta.env.DEV so Vite drops MSW imports before dependency discovery.', + ) + expect(hook).toContain( + "// SvelteKit's dev is folded later and can leave unused MSW assets in production.", + ) + expect(hook).toContain("await import('virtual:msw')") + expect(hook).toContain("await import('./mocks/handlers')") + expect(hook).toContain('network.configure({ handlers })') + expect(hook).toContain('await network.enable()') + } }, ) - -/** - * @param {string} content - * @param {string} module - */ -function expectDevImport(content, module) { - expect(content).toContain(`import { dev } from '${module}';`) - expect(content).not.toContain( - module === '$app/env' ? "from '$app/environment'" : "from '$app/env'", - ) -} diff --git a/tests/existing-files/addon.test.js b/tests/existing-files/addon.test.js index a4ac2dd..b7d842d 100644 --- a/tests/existing-files/addon.test.js +++ b/tests/existing-files/addon.test.js @@ -1,6 +1,8 @@ import fs from 'node:fs' import path from 'node:path' +import assert from 'node:assert/strict' import { expect } from 'vitest' +import { add } from 'sv' import addon from '../../src/index.js' import { setupTest } from '../setup/suite.js' @@ -17,34 +19,47 @@ const { test, testCases } = setupTest( browser: false, preAdd: ({ addonTestCase, cwd }) => { const extension = addonTestCase.variant.includes('ts') ? 'ts' : 'js' - const msw_directory = path.resolve(cwd, 'src/msw') + const mocks_directory = path.resolve(cwd, 'src/mocks') - fs.mkdirSync(msw_directory, { recursive: true }) + fs.mkdirSync(mocks_directory, { recursive: true }) fs.writeFileSync( - path.resolve(msw_directory, `handlers.${extension}`), + path.resolve(mocks_directory, `handlers.${extension}`), "export const handlers = ['existing handlers'];\n", 'utf8', ) fs.writeFileSync( - path.resolve(msw_directory, `browser.${extension}`), + path.resolve(mocks_directory, `browser.${extension}`), "export const worker = 'existing worker';\n", 'utf8', ) fs.writeFileSync( - path.resolve(msw_directory, `node.${extension}`), + path.resolve(mocks_directory, `node.${extension}`), "export const server = 'existing server';\n", 'utf8', ) fs.writeFileSync( path.resolve(cwd, `src/hooks.client.${extension}`), - 'export const existing_client_hook = true;\n', + 'export function init() { console.log("existing client init"); }\n', 'utf8', ) fs.writeFileSync( path.resolve(cwd, `src/hooks.server.${extension}`), - 'export const existing_server_hook = true;\n', + 'export const init = () => console.log("existing server init");\n', 'utf8', ) + const viteConfigPath = fs + .readdirSync(cwd) + .find((file) => /^vite\.config\.[jt]s$/.test(file)) + assert(viteConfigPath, 'Expected a Vite config') + const viteConfig = fs.readFileSync( + path.resolve(cwd, viteConfigPath), + 'utf8', + ) + fs.writeFileSync( + path.resolve(cwd, viteConfigPath), + "import { msw as mockServiceWorker } from 'msw/vite';\n" + + viteConfig.replace('plugins: [', 'plugins: [mockServiceWorker(), '), + ) }, }, ) @@ -56,19 +71,19 @@ test.concurrent.for(testCases)( const extension = testCase.variant.includes('ts') ? 'ts' : 'js' const handlers = fs.readFileSync( - path.resolve(cwd, `src/msw/handlers.${extension}`), + path.resolve(cwd, `src/mocks/handlers.${extension}`), 'utf8', ) expect(handlers).toBe("export const handlers = ['existing handlers'];\n") const browser = fs.readFileSync( - path.resolve(cwd, `src/msw/browser.${extension}`), + path.resolve(cwd, `src/mocks/browser.${extension}`), 'utf8', ) expect(browser).toBe("export const worker = 'existing worker';\n") const node = fs.readFileSync( - path.resolve(cwd, `src/msw/node.${extension}`), + path.resolve(cwd, `src/mocks/node.${extension}`), 'utf8', ) expect(node).toBe("export const server = 'existing server';\n") @@ -77,17 +92,53 @@ test.concurrent.for(testCases)( path.resolve(cwd, `src/hooks.client.${extension}`), 'utf8', ) - expect(hooks_client).toContain('existing_client_hook') + expect(hooks_client).toContain('existing client init') expect(hooks_client).toContain('export async function init()') - expect(hooks_client).toContain('worker.start()') + expect(hooks_client).toContain('await network.enable()') + expect(hooks_client).toContain( + '// Use import.meta.env.DEV so Vite drops MSW imports before dependency discovery.', + ) + expect(hooks_client.indexOf('await network.enable()')).toBeLessThan( + hooks_client.indexOf('existing client init'), + ) const hooks_server = fs.readFileSync( path.resolve(cwd, `src/hooks.server.${extension}`), 'utf8', ) - expect(hooks_server).toContain('existing_server_hook') + expect(hooks_server).toContain('existing server init') + expect(hooks_server).toContain('export const init = async () =>') + expect(hooks_server).toContain('return console.log(') + expect(hooks_server).toContain('await network.enable()') expect(hooks_server).toContain( - "msw_server.listen({ onUnhandledFrame: 'bypass' })", + '// Use import.meta.env.DEV so Vite drops MSW imports before dependency discovery.', + ) + + // Re-applying the add-on must not duplicate hooks, plugins, or type references. + const viteConfigPath = fs + .readdirSync(cwd) + .find((file) => /^vite\.config\.[jt]s$/.test(file)) + assert(viteConfigPath, 'Expected a Vite config') + const files = [ + `src/hooks.client.${extension}`, + `src/hooks.server.${extension}`, + 'src/app.d.ts', + viteConfigPath, + ] + const before = files.map((file) => + fs.readFileSync(path.resolve(cwd, file), 'utf8'), + ) + expect(before[3]).toContain('mockServiceWorker()') + expect(before[3]).not.toContain('msw()') + await add({ + cwd, + addons: { [addon.id]: addon }, + options: testCase.kind.options, + packageManager: 'pnpm', + }) + const after = files.map((file) => + fs.readFileSync(path.resolve(cwd, file), 'utf8'), ) + expect(after).toEqual(before) }, )