diff --git a/code/addons/vitest/src/vitest-plugin/index.ts b/code/addons/vitest/src/vitest-plugin/index.ts index 4d4981797291..c3e28d4ed781 100644 --- a/code/addons/vitest/src/vitest-plugin/index.ts +++ b/code/addons/vitest/src/vitest-plugin/index.ts @@ -24,7 +24,7 @@ import { oneWayHash } from 'storybook/internal/telemetry'; import type { Presets } from 'storybook/internal/types'; import { match } from 'micromatch'; -import { dirname, join, normalize, relative, resolve, sep } from 'pathe'; +import { join, normalize, relative, resolve, sep } from 'pathe'; import picocolors from 'picocolors'; import sirv from 'sirv'; import { dedent } from 'ts-dedent'; diff --git a/code/builders/builder-vite/build-config.ts b/code/builders/builder-vite/build-config.ts index 60918f8f8eb0..f6bc2c790c0f 100644 --- a/code/builders/builder-vite/build-config.ts +++ b/code/builders/builder-vite/build-config.ts @@ -7,6 +7,11 @@ const config: BuildEntries = { exportEntries: ['.'], entryPoint: './src/index.ts', }, + { + exportEntries: ['./preset'], + entryPoint: './src/preset.ts', + dts: false, + }, ], }, extraOutputs: { diff --git a/code/builders/builder-vite/package.json b/code/builders/builder-vite/package.json index 4d3c1f0cf526..f010719527f2 100644 --- a/code/builders/builder-vite/package.json +++ b/code/builders/builder-vite/package.json @@ -33,7 +33,8 @@ "default": "./dist/index.js" }, "./input/iframe.html": "./input/iframe.html", - "./package.json": "./package.json" + "./package.json": "./package.json", + "./preset": "./dist/preset.js" }, "files": [ "dist/**/*", @@ -41,6 +42,7 @@ "README.md", "*.js", "*.d.ts", + "preset.js", "!src/**/*" ], "scripts": { @@ -49,6 +51,7 @@ }, "dependencies": { "@storybook/csf-plugin": "workspace:*", + "@vitest/mocker": "3.2.4", "ts-dedent": "^2.0.0" }, "devDependencies": { diff --git a/code/builders/builder-vite/preset.js b/code/builders/builder-vite/preset.js new file mode 100644 index 000000000000..4bd63d324002 --- /dev/null +++ b/code/builders/builder-vite/preset.js @@ -0,0 +1 @@ +export * from './dist/preset.js'; diff --git a/code/builders/builder-vite/src/index.ts b/code/builders/builder-vite/src/index.ts index ff91de398583..557b162630d6 100644 --- a/code/builders/builder-vite/src/index.ts +++ b/code/builders/builder-vite/src/index.ts @@ -63,3 +63,5 @@ export const start: ViteBuilder['start'] = async ({ export const build: ViteBuilder['build'] = async ({ options }) => { return viteBuild(options as Options); }; + +export const corePresets = [import.meta.resolve('@storybook/builder-vite/preset')]; diff --git a/code/core/src/core-server/presets/vitePlugins/vite-inject-mocker/plugin.ts b/code/builders/builder-vite/src/plugins/vite-inject-mocker/plugin.ts similarity index 97% rename from code/core/src/core-server/presets/vitePlugins/vite-inject-mocker/plugin.ts rename to code/builders/builder-vite/src/plugins/vite-inject-mocker/plugin.ts index ec468cf21d3e..8937c9a33bce 100644 --- a/code/core/src/core-server/presets/vitePlugins/vite-inject-mocker/plugin.ts +++ b/code/builders/builder-vite/src/plugins/vite-inject-mocker/plugin.ts @@ -2,12 +2,12 @@ import { readFileSync } from 'node:fs'; import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { resolvePackageDir } from 'storybook/internal/common'; + import { exactRegex } from '@rolldown/pluginutils'; import { dedent } from 'ts-dedent'; import type { ResolvedConfig, ViteDevServer } from 'vite'; -import { resolvePackageDir } from '../../../../shared/utils/module'; - const entryPath = '/vite-inject-mocker-entry.js'; const entryCode = dedent` diff --git a/code/core/src/core-server/presets/vitePlugins/vite-mock/plugin.ts b/code/builders/builder-vite/src/plugins/vite-mock/plugin.ts similarity index 96% rename from code/core/src/core-server/presets/vitePlugins/vite-mock/plugin.ts rename to code/builders/builder-vite/src/plugins/vite-mock/plugin.ts index 57d06762d2a9..23861a121259 100644 --- a/code/core/src/core-server/presets/vitePlugins/vite-mock/plugin.ts +++ b/code/builders/builder-vite/src/plugins/vite-mock/plugin.ts @@ -1,18 +1,19 @@ import { readFileSync } from 'node:fs'; +import { + babelParser, + extractMockCalls, + getAutomockCode, + getRealPath, + rewriteSbMockImportCalls, +} from 'storybook/internal/mocking-utils'; import { logger } from 'storybook/internal/node-logger'; import type { CoreConfig } from 'storybook/internal/types'; +import { findMockRedirect } from '@vitest/mocker/redirect'; import { normalize } from 'pathe'; import type { Plugin, ResolvedConfig } from 'vite'; -import { getAutomockCode } from '../../../mocking-utils/automock'; -import { - babelParser, - extractMockCalls, - rewriteSbMockImportCalls, -} from '../../../mocking-utils/extract'; -import { getRealPath } from '../../../mocking-utils/resolve'; import { type MockCall, getCleanId, invalidateAllRelatedModules } from './utils'; export interface MockPluginOptions { @@ -55,7 +56,7 @@ export function viteMockPlugin(options: MockPluginOptions): Plugin[] { }, buildStart() { - mockCalls = extractMockCalls(options, babelParser, viteConfig.root); + mockCalls = extractMockCalls(options, babelParser, viteConfig.root, findMockRedirect); }, configureServer(server) { @@ -64,7 +65,7 @@ export function viteMockPlugin(options: MockPluginOptions): Plugin[] { // Store the old mocks before updating const oldMockCalls = mockCalls; // Re-extract mocks to get the latest list - mockCalls = extractMockCalls(options, babelParser, viteConfig.root); + mockCalls = extractMockCalls(options, babelParser, viteConfig.root, findMockRedirect); // Invalidate the preview file const previewMod = server.moduleGraph.getModuleById(options.previewConfigPath); diff --git a/code/core/src/core-server/presets/vitePlugins/vite-mock/utils.ts b/code/builders/builder-vite/src/plugins/vite-mock/utils.ts similarity index 96% rename from code/core/src/core-server/presets/vitePlugins/vite-mock/utils.ts rename to code/builders/builder-vite/src/plugins/vite-mock/utils.ts index d3d1de0d4bcf..6f582b9570cd 100644 --- a/code/core/src/core-server/presets/vitePlugins/vite-mock/utils.ts +++ b/code/builders/builder-vite/src/plugins/vite-mock/utils.ts @@ -1,4 +1,3 @@ -import { realpathSync } from 'fs'; import type { ViteDevServer } from 'vite'; /** diff --git a/code/builders/builder-vite/src/preset.ts b/code/builders/builder-vite/src/preset.ts new file mode 100644 index 000000000000..b309970c3314 --- /dev/null +++ b/code/builders/builder-vite/src/preset.ts @@ -0,0 +1,34 @@ +import { findConfigFile } from 'storybook/internal/common'; +import type { Options } from 'storybook/internal/types'; + +import type { UserConfig } from 'vite'; + +import { viteInjectMockerRuntime } from './plugins/vite-inject-mocker/plugin'; +import { viteMockPlugin } from './plugins/vite-mock/plugin'; + +// This preset defines currently mocking plugins for Vite +// It is defined as a viteFinal preset so that @storybook/addon-vitest can use it as well and that it doesn't have to be duplicated in addon-vitest. +// The main vite configuration is defined in `./vite-config.ts`. +export async function viteFinal(existing: UserConfig, options: Options) { + const previewConfigPath = findConfigFile('preview', options.configDir); + + // If there's no preview file, there's nothing to mock. + if (!previewConfigPath) { + return existing; + } + + const coreOptions = await options.presets.apply('core'); + + return { + ...existing, + plugins: [ + ...(existing.plugins ?? []), + ...(previewConfigPath + ? [ + viteInjectMockerRuntime({ previewConfigPath }), + viteMockPlugin({ previewConfigPath, coreOptions, configDir: options.configDir }), + ] + : []), + ], + }; +} diff --git a/code/builders/builder-vite/src/vite-config.ts b/code/builders/builder-vite/src/vite-config.ts index 50852eb307c9..9cb4e86042f2 100644 --- a/code/builders/builder-vite/src/vite-config.ts +++ b/code/builders/builder-vite/src/vite-config.ts @@ -58,6 +58,8 @@ export async function commonConfig( const { config: { build: buildProperty = undefined, ...userConfig } = {} } = (await loadConfigFromFile(configEnv, viteConfigPath, projectRoot)) ?? {}; + // This is the main Vite config that is used by Storybook. + // Some shared vite plugins are defined in the `./preset.ts` file so that it can be shared between the @storybook/builder-vite and @storybook/addon-vitest package. const sbConfig: InlineConfig = { configFile: false, cacheDir: resolvePathInStorybookCache('sb-vite', options.cacheKey), diff --git a/code/builders/builder-webpack5/build-config.ts b/code/builders/builder-webpack5/build-config.ts index cd2d4bb30ccf..64d45a5ebec4 100644 --- a/code/builders/builder-webpack5/build-config.ts +++ b/code/builders/builder-webpack5/build-config.ts @@ -22,6 +22,16 @@ const config: BuildEntries = { entryPoint: './src/loaders/export-order-loader.ts', dts: false, }, + { + exportEntries: ['./loaders/storybook-mock-transform-loader'], + entryPoint: './src/loaders/storybook-mock-transform-loader.ts', + dts: false, + }, + { + exportEntries: ['./loaders/webpack-automock-loader'], + entryPoint: './src/loaders/webpack-automock-loader.ts', + dts: false, + }, ], }, extraOutputs: { diff --git a/code/builders/builder-webpack5/package.json b/code/builders/builder-webpack5/package.json index 8bacb3bba793..6d580a1d53fb 100644 --- a/code/builders/builder-webpack5/package.json +++ b/code/builders/builder-webpack5/package.json @@ -32,6 +32,8 @@ "default": "./dist/index.js" }, "./loaders/export-order-loader": "./dist/loaders/export-order-loader.js", + "./loaders/storybook-mock-transform-loader": "./dist/loaders/storybook-mock-transform-loader.js", + "./loaders/webpack-automock-loader": "./dist/loaders/webpack-automock-loader.js", "./package.json": "./package.json", "./presets/custom-webpack-preset": "./dist/presets/custom-webpack-preset.js", "./presets/preview-preset": "./dist/presets/preview-preset.js", @@ -54,6 +56,7 @@ }, "dependencies": { "@storybook/core-webpack": "workspace:*", + "@vitest/mocker": "3.2.4", "case-sensitive-paths-webpack-plugin": "^2.4.0", "cjs-module-lexer": "^1.2.3", "css-loader": "^7.1.2", diff --git a/code/core/src/core-server/presets/webpack/loaders/storybook-mock-transform-loader.ts b/code/builders/builder-webpack5/src/loaders/storybook-mock-transform-loader.ts similarity index 91% rename from code/core/src/core-server/presets/webpack/loaders/storybook-mock-transform-loader.ts rename to code/builders/builder-webpack5/src/loaders/storybook-mock-transform-loader.ts index 2da83caa09e7..ebc0aa6c402b 100644 --- a/code/core/src/core-server/presets/webpack/loaders/storybook-mock-transform-loader.ts +++ b/code/builders/builder-webpack5/src/loaders/storybook-mock-transform-loader.ts @@ -1,9 +1,8 @@ +import { rewriteSbMockImportCalls } from 'storybook/internal/mocking-utils'; import { logger } from 'storybook/internal/node-logger'; import type { LoaderDefinition } from 'webpack'; -import { rewriteSbMockImportCalls } from '../../../mocking-utils/extract'; - /** * A Webpack loader that normalize sb.mock(import(...)) calls to sb.mock(...) * diff --git a/code/core/src/core-server/presets/webpack/loaders/webpack-automock-loader.ts b/code/builders/builder-webpack5/src/loaders/webpack-automock-loader.ts similarity index 91% rename from code/core/src/core-server/presets/webpack/loaders/webpack-automock-loader.ts rename to code/builders/builder-webpack5/src/loaders/webpack-automock-loader.ts index 980be6eb4018..8fa9d7dfb952 100644 --- a/code/core/src/core-server/presets/webpack/loaders/webpack-automock-loader.ts +++ b/code/builders/builder-webpack5/src/loaders/webpack-automock-loader.ts @@ -1,7 +1,6 @@ -import type { LoaderContext } from 'webpack'; +import { babelParser, getAutomockCode } from 'storybook/internal/mocking-utils'; -import { getAutomockCode } from '../../../mocking-utils/automock'; -import { babelParser } from '../../../mocking-utils/extract'; +import type { LoaderContext } from 'webpack'; /** Defines the options that can be passed to the webpack-automock-loader. */ interface AutomockLoaderOptions { diff --git a/code/core/src/core-server/presets/webpack/plugins/webpack-inject-mocker-runtime-plugin.ts b/code/builders/builder-webpack5/src/plugins/webpack-inject-mocker-runtime-plugin.ts similarity index 72% rename from code/core/src/core-server/presets/webpack/plugins/webpack-inject-mocker-runtime-plugin.ts rename to code/builders/builder-webpack5/src/plugins/webpack-inject-mocker-runtime-plugin.ts index efcccf2f4f95..da843d1cd782 100644 --- a/code/core/src/core-server/presets/webpack/plugins/webpack-inject-mocker-runtime-plugin.ts +++ b/code/builders/builder-webpack5/src/plugins/webpack-inject-mocker-runtime-plugin.ts @@ -1,13 +1,10 @@ -import { join } from 'node:path'; +import { getMockerRuntime } from 'storybook/internal/mocking-utils'; -import { buildSync } from 'esbuild'; // HtmlWebpackPlugin is a standard part of Storybook's Webpack setup. // We can assume it's available as a dependency. import type HtmlWebpackPlugin from 'html-webpack-plugin'; import type { Compiler } from 'webpack'; -import { resolvePackageDir } from '../../../../shared/utils/module'; - const PLUGIN_NAME = 'WebpackInjectMockerRuntimePlugin'; /** @@ -55,26 +52,7 @@ export class WebpackInjectMockerRuntimePlugin { PLUGIN_NAME, (data, cb) => { try { - // The runtime template is the same for both dev and build in the final implementation, - // as all mocking logic is handled at build time or by the dev server's transform. - const runtimeTemplatePath = join( - resolvePackageDir('storybook'), - 'assets', - 'server', - 'mocker-runtime.template.js' - ); - // Use esbuild to bundle the runtime script and its dependencies (`@vitest/mocker`, etc.) - // into a single, self-contained string of code. - const bundleResult = buildSync({ - entryPoints: [runtimeTemplatePath], - bundle: true, - write: false, // Return the result in memory instead of writing to disk - format: 'esm', - target: 'es2020', - external: ['msw/browser', 'msw/core/http'], - }); - - const runtimeScriptContent = bundleResult.outputFiles[0].text; + const runtimeScriptContent = getMockerRuntime(); const runtimeAssetName = 'mocker-runtime-injected.js'; // Use the documented `emitAsset` method to add the pre-bundled runtime script diff --git a/code/core/src/core-server/presets/webpack/plugins/webpack-mock-plugin.ts b/code/builders/builder-webpack5/src/plugins/webpack-mock-plugin.ts similarity index 94% rename from code/core/src/core-server/presets/webpack/plugins/webpack-mock-plugin.ts rename to code/builders/builder-webpack5/src/plugins/webpack-mock-plugin.ts index 6bb87d340fc1..7a3f4b8f2dfe 100644 --- a/code/core/src/core-server/presets/webpack/plugins/webpack-mock-plugin.ts +++ b/code/builders/builder-webpack5/src/plugins/webpack-mock-plugin.ts @@ -1,17 +1,16 @@ -import { createRequire } from 'node:module'; import { dirname, isAbsolute } from 'node:path'; import { fileURLToPath } from 'node:url'; -import type { Compiler } from 'webpack'; - -import { babelParser, extractMockCalls } from '../../../mocking-utils/extract'; import { + babelParser, + extractMockCalls, getIsExternal, resolveExternalModule, resolveWithExtensions, -} from '../../../mocking-utils/resolve'; +} from 'storybook/internal/mocking-utils'; -const require = createRequire(import.meta.url); +import { findMockRedirect } from '@vitest/mocker/redirect'; +import type { Compiler } from 'webpack'; // --- Type Definitions --- @@ -131,7 +130,8 @@ export class WebpackMockPlugin { const mocks = extractMockCalls( { previewConfigPath, configDir: dirname(previewConfigPath) }, babelParser, - compiler.context + compiler.context, + findMockRedirect ); // 2. Resolve each mock call to its absolute path and replacement resource. @@ -148,7 +148,7 @@ export class WebpackMockPlugin { } else { // No `__mocks__` file found. Use our custom loader to automock the module. const loaderPath = fileURLToPath( - import.meta.resolve('storybook/webpack/loaders/webpack-automock-loader') + import.meta.resolve('@storybook/builder-webpack5/loaders/webpack-automock-loader') ); replacementResource = `${loaderPath}?spy=${mock.spy}!${absolutePath}`; } diff --git a/code/builders/builder-webpack5/src/presets/custom-webpack-preset.ts b/code/builders/builder-webpack5/src/presets/custom-webpack-preset.ts index 8cb3b7ba3d21..20947f59e5a9 100644 --- a/code/builders/builder-webpack5/src/presets/custom-webpack-preset.ts +++ b/code/builders/builder-webpack5/src/presets/custom-webpack-preset.ts @@ -1,3 +1,6 @@ +import { fileURLToPath } from 'node:url'; + +import { findConfigFile } from 'storybook/internal/common'; import { logger } from 'storybook/internal/node-logger'; import type { Options, PresetProperty } from 'storybook/internal/types'; @@ -6,6 +9,8 @@ import { loadCustomWebpackConfig } from '@storybook/core-webpack'; import webpackModule from 'webpack'; import type { Configuration } from 'webpack'; +import { WebpackInjectMockerRuntimePlugin } from '../plugins/webpack-inject-mocker-runtime-plugin'; +import { WebpackMockPlugin } from '../plugins/webpack-mock-plugin'; import { createDefaultWebpackConfig } from '../preview/base-webpack.config'; export const swc: PresetProperty<'swc'> = (config: Record): Record => { @@ -26,6 +31,38 @@ export const swc: PresetProperty<'swc'> = (config: Record): Record< }; }; +export async function webpackFinal(config: Configuration, options: Options) { + const previewConfigPath = findConfigFile('preview', options.configDir); + + // If there's no preview file, there's nothing to mock. + if (!previewConfigPath) { + return config; + } + + config.plugins = config.plugins || []; + + // 1. Add the loader to normalize sb.mock(import(...)) calls. + config.module!.rules!.push({ + test: /preview\.(t|j)sx?$/, + use: [ + { + loader: fileURLToPath( + import.meta.resolve('@storybook/builder-webpack5/loaders/storybook-mock-transform-loader') + ), + }, + ], + }); + + // 2. Add the plugin to handle module replacement based on sb.mock() calls. + // This plugin scans the preview file and sets up rules to swap modules. + config.plugins.push(new WebpackMockPlugin({ previewConfigPath })); + + // 3. Add the plugin to inject the mocker runtime script into the HTML. + // This ensures the `sb` object is available before any other code runs. + config.plugins.push(new WebpackInjectMockerRuntimePlugin()); + return config; +} + export async function webpack(config: Configuration, options: Options) { const { configDir, configType, presets } = options; diff --git a/code/core/build-config.ts b/code/core/build-config.ts index 20552490d75c..3912a937d994 100644 --- a/code/core/build-config.ts +++ b/code/core/build-config.ts @@ -77,14 +77,8 @@ const config: BuildEntries = { exportEntries: ['./internal/cli'], }, { - entryPoint: './src/core-server/presets/webpack/loaders/webpack-automock-loader.ts', - exportEntries: ['./webpack/loaders/webpack-automock-loader'], - dts: false, - }, - { - entryPoint: './src/core-server/presets/webpack/loaders/storybook-mock-transform-loader.ts', - exportEntries: ['./webpack/loaders/storybook-mock-transform-loader'], - dts: false, + exportEntries: ['./internal/mocking-utils'], + entryPoint: './src/mocking-utils/index.ts', }, ], browser: [ diff --git a/code/core/package.json b/code/core/package.json index 8c9e1f2fe2a5..f165c07cc143 100644 --- a/code/core/package.json +++ b/code/core/package.json @@ -118,6 +118,10 @@ "default": "./dist/manager/globals.js" }, "./internal/manager/globals-runtime": "./dist/manager/globals-runtime.js", + "./internal/mocking-utils": { + "types": "./dist/mocking-utils/index.d.ts", + "default": "./dist/mocking-utils/index.js" + }, "./internal/node-logger": { "types": "./dist/node-logger/index.d.ts", "default": "./dist/node-logger/index.js" @@ -175,9 +179,7 @@ "./viewport": { "types": "./dist/viewport/index.d.ts", "default": "./dist/viewport/index.js" - }, - "./webpack/loaders/storybook-mock-transform-loader": "./dist/core-server/presets/webpack/loaders/storybook-mock-transform-loader.js", - "./webpack/loaders/webpack-automock-loader": "./dist/core-server/presets/webpack/loaders/webpack-automock-loader.js" + } }, "bin": "./dist/bin/dispatcher.js", "files": [ @@ -200,7 +202,6 @@ "@testing-library/jest-dom": "^6.6.3", "@testing-library/user-event": "^14.6.1", "@vitest/expect": "3.2.4", - "@vitest/mocker": "3.2.4", "@vitest/spy": "3.2.4", "esbuild": "^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0 || ^0.25.0", "recast": "^0.23.5", diff --git a/code/core/src/core-server/load.ts b/code/core/src/core-server/load.ts index 0217913fd4d8..ed24fc349d9d 100644 --- a/code/core/src/core-server/load.ts +++ b/code/core/src/core-server/load.ts @@ -1,9 +1,8 @@ import { getProjectRoot, + getStorybookInfo, loadAllPresets, - loadMainConfig, resolveAddonName, - validateFrameworkName, } from 'storybook/internal/common'; import { oneWayHash } from 'storybook/internal/telemetry'; import type { BuilderOptions, CLIOptions, LoadOptions, Options } from 'storybook/internal/types'; @@ -30,19 +29,17 @@ export async function loadStorybook( options.configDir = configDir; options.cacheKey = cacheKey; - const config = await loadMainConfig(options); - const { framework } = config; const corePresets = []; - let frameworkName = typeof framework === 'string' ? framework : framework?.name; - if (!options.ignorePreview) { - validateFrameworkName(frameworkName); - } - if (frameworkName) { - corePresets.push(join(frameworkName, 'preset')); + const { frameworkPackage, builderPackage } = await getStorybookInfo(configDir); + + if (frameworkPackage) { + corePresets.push(join(frameworkPackage, 'preset')); } - frameworkName = frameworkName || 'custom'; + if (builderPackage) { + corePresets.push(join(builderPackage, 'preset')); + } // Load first pass: We need to determine the builder // We need to do this because builders might introduce 'overridePresets' which we need to take into account diff --git a/code/core/src/core-server/presets/common-preset.ts b/code/core/src/core-server/presets/common-preset.ts index 63c11d689faa..976dad85adca 100644 --- a/code/core/src/core-server/presets/common-preset.ts +++ b/code/core/src/core-server/presets/common-preset.ts @@ -1,13 +1,11 @@ import { existsSync } from 'node:fs'; import { readFile } from 'node:fs/promises'; -import { fileURLToPath } from 'node:url'; import type { Channel } from 'storybook/internal/channels'; import { optionalEnvToBoolean } from 'storybook/internal/common'; import { JsPackageManagerFactory, type RemoveAddonOptions, - findConfigFile, getDirectoryFromWorkingDir, getPreviewBodyTemplate, getPreviewHeadTemplate, @@ -288,73 +286,3 @@ export const managerEntries = async (existing: any) => { ...(existing || []), ]; }; - -export const viteFinal = async ( - existing: import('vite').UserConfig, - options: Options -): Promise => { - const previewConfigPath = findConfigFile('preview', options.configDir); - - // If there's no preview file, there's nothing to mock. - if (!previewConfigPath) { - return existing; - } - - const { viteInjectMockerRuntime } = await import('./vitePlugins/vite-inject-mocker/plugin'); - const { viteMockPlugin } = await import('./vitePlugins/vite-mock/plugin'); - const coreOptions = await options.presets.apply('core'); - - return { - ...existing, - plugins: [ - ...(existing.plugins ?? []), - ...(previewConfigPath - ? [ - viteInjectMockerRuntime({ previewConfigPath }), - viteMockPlugin({ previewConfigPath, coreOptions, configDir: options.configDir }), - ] - : []), - ], - }; -}; - -export const webpackFinal = async ( - config: import('webpack').Configuration, - options: Options -): Promise => { - const previewConfigPath = findConfigFile('preview', options.configDir); - - // If there's no preview file, there's nothing to mock. - if (!previewConfigPath) { - return config; - } - - const { WebpackMockPlugin } = await import('./webpack/plugins/webpack-mock-plugin'); - const { WebpackInjectMockerRuntimePlugin } = await import( - './webpack/plugins/webpack-inject-mocker-runtime-plugin' - ); - - config.plugins = config.plugins || []; - - // 1. Add the loader to normalize sb.mock(import(...)) calls. - config.module!.rules!.push({ - test: /preview\.(t|j)sx?$/, - use: [ - { - loader: fileURLToPath( - import.meta.resolve('storybook/webpack/loaders/storybook-mock-transform-loader') - ), - }, - ], - }); - - // 2. Add the plugin to handle module replacement based on sb.mock() calls. - // This plugin scans the preview file and sets up rules to swap modules. - config.plugins.push(new WebpackMockPlugin({ previewConfigPath })); - - // 3. Add the plugin to inject the mocker runtime script into the HTML. - // This ensures the `sb` object is available before any other code runs. - config.plugins.push(new WebpackInjectMockerRuntimePlugin()); - - return config; -}; diff --git a/code/core/src/core-server/presets/vitePlugins/vite-inject-mocker/constants.ts b/code/core/src/core-server/presets/vitePlugins/vite-inject-mocker/constants.ts deleted file mode 100644 index cff1849840b7..000000000000 --- a/code/core/src/core-server/presets/vitePlugins/vite-inject-mocker/constants.ts +++ /dev/null @@ -1 +0,0 @@ -export const __STORYBOOK_GLOBAL_THIS_ACCESSOR__ = '__vitest_mocker__'; diff --git a/code/core/src/core-server/mocking-utils/automock.ts b/code/core/src/mocking-utils/automock.ts similarity index 97% rename from code/core/src/core-server/mocking-utils/automock.ts rename to code/core/src/mocking-utils/automock.ts index b1df00327a51..aac315198c81 100644 --- a/code/core/src/core-server/mocking-utils/automock.ts +++ b/code/core/src/mocking-utils/automock.ts @@ -8,11 +8,12 @@ import type { } from 'estree'; import MagicString from 'magic-string'; -import { __STORYBOOK_GLOBAL_THIS_ACCESSOR__ } from '../presets/vitePlugins/vite-inject-mocker/constants'; import { type Positioned, getArbitraryModuleIdentifier } from './esmWalker'; type ParseFn = (code: string) => Program; +export const __STORYBOOK_GLOBAL_THIS_ACCESSOR__ = '__vitest_mocker__'; + export function getAutomockCode(originalCode: string, isSpy: boolean, parse: ParseFn) { const mocked = automockModule(originalCode, isSpy ? 'autospy' : 'automock', parse, { globalThisAccessor: JSON.stringify(__STORYBOOK_GLOBAL_THIS_ACCESSOR__), @@ -49,7 +50,8 @@ export function automockModule( parse: (code: string) => any, options: any = {} ): MagicString { - const globalThisAccessor = options.globalThisAccessor || '"__vitest_mocker__"'; + const globalThisAccessor = + options.globalThisAccessor || JSON.stringify(__STORYBOOK_GLOBAL_THIS_ACCESSOR__); const ast = parse(code) as Program; const m = new MagicString(code); diff --git a/code/core/src/core-server/mocking-utils/esmWalker.ts b/code/core/src/mocking-utils/esmWalker.ts similarity index 100% rename from code/core/src/core-server/mocking-utils/esmWalker.ts rename to code/core/src/mocking-utils/esmWalker.ts diff --git a/code/core/src/core-server/mocking-utils/extract.test.ts b/code/core/src/mocking-utils/extract.test.ts similarity index 84% rename from code/core/src/core-server/mocking-utils/extract.test.ts rename to code/core/src/mocking-utils/extract.test.ts index 8c0b8a8c4a32..be33590d426c 100644 --- a/code/core/src/core-server/mocking-utils/extract.test.ts +++ b/code/core/src/mocking-utils/extract.test.ts @@ -16,17 +16,22 @@ vi.mock('fs', async () => { vi.mock('./resolve', async () => { return { - resolveMock: vi.fn((path) => { - if (path === './bar/baz.js') { - return { absolutePath: '/abs/path/bar/baz.js', redirectPath: null }; + resolveMock: vi.fn((path, root, importer, findMockRedirect) => { + const result = + path === './bar/baz.js' + ? { absolutePath: '/abs/path/bar/baz.js', redirectPath: null } + : path === './bar/baz.utils' + ? { absolutePath: '/abs/path/bar/baz.utils.ts', redirectPath: null } + : path === './bar/baz.utils.ts' + ? { absolutePath: '/abs/path/bar/baz.utils.ts', redirectPath: null } + : { absolutePath: '/abs/path', redirectPath: null }; + + if (findMockRedirect) { + const redirectPath = findMockRedirect(root, result.absolutePath, null); + return { ...result, redirectPath }; } - if (path === './bar/baz.utils') { - return { absolutePath: '/abs/path/bar/baz.utils.ts', redirectPath: null }; - } - if (path === './bar/baz.utils.ts') { - return { absolutePath: '/abs/path/bar/baz.utils.ts', redirectPath: null }; - } - return { absolutePath: '/abs/path', redirectPath: null }; + + return result; }), }; }); @@ -50,17 +55,21 @@ describe('extractMockCalls', () => { const root = '/project'; const coreOptions = { disableTelemetry: true }; + const findMockRedirect = vi.fn(() => null); + const extractMockCalls = (previewContent: string) => { vi.mocked(readFileSync).mockReturnValue(previewContent); return extractModule.extractMockCalls( { previewConfigPath, configDir, coreOptions }, parser, - root + root, + findMockRedirect ); }; beforeEach(() => { vi.clearAllMocks(); + findMockRedirect.mockReturnValue(null); }); it('returns empty array if readFileSync throws', () => { @@ -84,7 +93,12 @@ describe('extractMockCalls', () => { spy: true, }, ]); - expect(resolveModule.resolveMock).toHaveBeenCalledWith('foo', root, previewConfigPath); + expect(resolveModule.resolveMock).toHaveBeenCalledWith( + 'foo', + root, + previewConfigPath, + findMockRedirect + ); }); it('handles no sb.mock calls in preview file', () => { diff --git a/code/core/src/core-server/mocking-utils/extract.ts b/code/core/src/mocking-utils/extract.ts similarity index 95% rename from code/core/src/core-server/mocking-utils/extract.ts rename to code/core/src/mocking-utils/extract.ts index 8edf075e3146..cc809760c564 100644 --- a/code/core/src/core-server/mocking-utils/extract.ts +++ b/code/core/src/mocking-utils/extract.ts @@ -91,7 +91,12 @@ export function extractMockCalls( jsx?: boolean; } ) => t.Node, - root: string + root: string, + findMockRedirect: ( + root: string, + absolutePath: string, + externalPath: string | null + ) => string | null ): MockCall[] { try { const previewConfigCode = readFileSync(options.previewConfigPath, 'utf-8'); @@ -155,7 +160,12 @@ export function extractMockCalls( node.arguments[1].type === 'ObjectExpression' && hasSpyTrue(node.arguments[1]); - const { absolutePath, redirectPath } = resolveMock(path, root, options.previewConfigPath); + const { absolutePath, redirectPath } = resolveMock( + path, + root, + options.previewConfigPath, + findMockRedirect + ); const pathWithoutExtension = path.replace(/\.[^/.]+$/, ''); const basenameAbsolutePath = basename(absolutePath); diff --git a/code/core/src/mocking-utils/index.ts b/code/core/src/mocking-utils/index.ts new file mode 100644 index 000000000000..5d418381446e --- /dev/null +++ b/code/core/src/mocking-utils/index.ts @@ -0,0 +1,5 @@ +export * from './automock'; +export * from './extract'; +export * from './resolve'; +export * from './esmWalker'; +export * from './runtime'; diff --git a/code/core/src/core-server/mocking-utils/resolve.ts b/code/core/src/mocking-utils/resolve.ts similarity index 95% rename from code/core/src/core-server/mocking-utils/resolve.ts rename to code/core/src/mocking-utils/resolve.ts index 3c52b03eaec5..35c7ff5b56ea 100644 --- a/code/core/src/core-server/mocking-utils/resolve.ts +++ b/code/core/src/mocking-utils/resolve.ts @@ -1,7 +1,6 @@ import { readFileSync, realpathSync } from 'node:fs'; import { createRequire } from 'node:module'; -import { findMockRedirect } from '@vitest/mocker/redirect'; import { dirname, isAbsolute, join, resolve } from 'pathe'; import { exports as resolveExports } from 'resolve.exports'; @@ -72,7 +71,16 @@ export function getIsExternal(path: string, importer: string) { * @param root The project's root directory. * @param importer The absolute path of the file containing the mock call (the preview file). */ -export function resolveMock(path: string, root: string, importer: string) { +export function resolveMock( + path: string, + root: string, + importer: string, + findMockRedirect: ( + root: string, + absolutePath: string, + externalPath: string | null + ) => string | null +) { const isExternal = getIsExternal(path, root); const externalPath = isExternal ? path : null; diff --git a/code/core/src/mocking-utils/runtime.ts b/code/core/src/mocking-utils/runtime.ts new file mode 100644 index 000000000000..eca3c51629f4 --- /dev/null +++ b/code/core/src/mocking-utils/runtime.ts @@ -0,0 +1,28 @@ +import { resolvePackageDir } from 'storybook/internal/common'; + +import { buildSync } from 'esbuild'; +import { join } from 'pathe'; + +const runtimeTemplatePath = join( + resolvePackageDir('storybook'), + 'assets', + 'server', + 'mocker-runtime.template.js' +); + +export function getMockerRuntime() { + // Use esbuild to bundle the runtime script and its dependencies (`@vitest/mocker`, etc.) + // into a single, self-contained string of code. + const bundleResult = buildSync({ + entryPoints: [runtimeTemplatePath], + bundle: true, + write: false, // Return the result in memory instead of writing to disk + format: 'esm', + target: 'es2020', + external: ['msw/browser', 'msw/core/http'], + }); + + const runtimeScriptContent = bundleResult.outputFiles[0].text; + + return runtimeScriptContent; +} diff --git a/code/yarn.lock b/code/yarn.lock index 3b713130c6f9..9065aeee49d4 100644 --- a/code/yarn.lock +++ b/code/yarn.lock @@ -7886,6 +7886,7 @@ __metadata: dependencies: "@storybook/csf-plugin": "workspace:*" "@types/node": "npm:^22.0.0" + "@vitest/mocker": "npm:3.2.4" empathic: "npm:^2.0.0" es-module-lexer: "npm:^1.5.0" glob: "npm:^10.0.0" @@ -7909,6 +7910,7 @@ __metadata: "@types/node": "npm:^22.0.0" "@types/pretty-hrtime": "npm:^1.0.0" "@types/webpack-hot-middleware": "npm:^2.25.6" + "@vitest/mocker": "npm:3.2.4" case-sensitive-paths-webpack-plugin: "npm:^2.4.0" cjs-module-lexer: "npm:^1.2.3" css-loader: "npm:^7.1.2" @@ -26023,7 +26025,6 @@ __metadata: "@types/semver": "npm:^7.5.8" "@types/ws": "npm:^8" "@vitest/expect": "npm:3.2.4" - "@vitest/mocker": "npm:3.2.4" "@vitest/spy": "npm:3.2.4" "@vitest/utils": "npm:^3.2.4" "@yarnpkg/esbuild-plugin-pnp": "npm:^3.0.0-rc.10"