diff --git a/code/addons/test/src/vitest-plugin/index.ts b/code/addons/test/src/vitest-plugin/index.ts index def3eab2b796..d1884a273027 100644 --- a/code/addons/test/src/vitest-plugin/index.ts +++ b/code/addons/test/src/vitest-plugin/index.ts @@ -1,5 +1,7 @@ /* eslint-disable no-underscore-dangle */ import type { Plugin } from 'vitest/config'; +import { mergeConfig } from 'vitest/config'; +import type { ViteUserConfig } from 'vitest/config'; import { getInterpretedFile, @@ -10,7 +12,7 @@ import { import { StoryIndexGenerator, mapStaticDir } from 'storybook/internal/core-server'; import { readConfig, vitestTransform } from 'storybook/internal/csf-tools'; import { MainFileMissingError } from 'storybook/internal/server-errors'; -import type { DocsOptions, StoriesEntry } from 'storybook/internal/types'; +import type { Presets } from 'storybook/internal/types'; import { join, resolve } from 'pathe'; import picocolors from 'picocolors'; @@ -21,9 +23,11 @@ import { dedent } from 'ts-dedent'; import { TestManager } from '../node/test-manager'; import type { InternalOptions, UserOptions } from './types'; +const WORKING_DIR = process.cwd(); + const defaultOptions: UserOptions = { storybookScript: undefined, - configDir: undefined, + configDir: resolve(join(WORKING_DIR, '.storybook')), storybookUrl: 'http://localhost:6006', }; @@ -37,10 +41,32 @@ const extractTagsFromPreview = async (configDir: string) => { return previewConfig.getFieldValue(['tags']) ?? []; }; -export const storybookTest = (options?: UserOptions): Plugin => { +const getStoryGlobsAndFiles = async ( + presets: Presets, + directories: { configDir: string; workingDir: string } +) => { + const stories = await presets.apply('stories', []); + const docs = await presets.apply('docs', {}); + const indexers = await presets.apply('experimental_indexers', []); + const generator = new StoryIndexGenerator(normalizeStories(stories, directories), { + ...directories, + indexers, + docs, + }); + await generator.initialize(); + return { + storiesGlobs: stories, + storiesFiles: generator.storyFileNames(), + }; +}; + +export const storybookTest = async (options?: UserOptions): Promise => { const finalOptions = { ...defaultOptions, ...options, + configDir: options?.configDir + ? resolve(WORKING_DIR, options.configDir) + : defaultOptions.configDir, tags: { include: options?.tags?.include ?? ['test'], exclude: options?.tags?.exclude ?? [], @@ -52,88 +78,172 @@ export const storybookTest = (options?: UserOptions): Plugin => { finalOptions.debug = true; } - const storybookUrl = finalOptions.storybookUrl || defaultOptions.storybookUrl; - // To be accessed by the global setup file - process.env.__STORYBOOK_URL__ = storybookUrl; + process.env.__STORYBOOK_URL__ = finalOptions.storybookUrl; process.env.__STORYBOOK_SCRIPT__ = finalOptions.storybookScript; - if (!finalOptions.configDir) { - finalOptions.configDir = resolve(join(process.cwd(), '.storybook')); - } else { - finalOptions.configDir = resolve(process.cwd(), finalOptions.configDir); - } + const directories = { + configDir: finalOptions.configDir, + workingDir: WORKING_DIR, + }; - let previewLevelTags: string[]; - let storiesGlobs: StoriesEntry[]; - let storiesFiles: string[]; - const statics: ReturnType[] = []; + const presets = await loadAllPresets({ + configDir: finalOptions.configDir, + corePresets: [], + overridePresets: [], + packageJson: {}, + }); + + const [ + { storiesGlobs, storiesFiles }, + framework, + storybookEnv, + viteConfigFromStorybook, + staticDirs, + previewLevelTags, + ] = await Promise.all([ + getStoryGlobsAndFiles(presets, directories), + presets.apply('framework', undefined), + presets.apply('env', {}), + presets.apply('viteFinal', {}), + presets.apply('staticDirs', []), + extractTagsFromPreview(finalOptions.configDir), + ]); return { name: 'vite-plugin-storybook-test', enforce: 'pre', - async config(config) { - const configDir = finalOptions.configDir; + async config(inputConfig_DoNotMutate) { + // ! We're not mutating the input config, instead we're returning a new partial config + // ! see https://vite.dev/guide/api-plugin.html#config try { - await validateConfigurationFiles(configDir); + await validateConfigurationFiles(finalOptions.configDir); } catch (err) { throw new MainFileMissingError({ - location: configDir, + location: finalOptions.configDir, source: 'vitest', }); } - const presets = await loadAllPresets({ - configDir, - corePresets: [], - overridePresets: [], - packageJson: {}, - }); - - const workingDir = process.cwd(); - const directories = { - configDir, - workingDir, - }; - storiesGlobs = await presets.apply('stories'); - const indexers = await presets.apply('experimental_indexers', []); - const docsOptions = await presets.apply('docs', {}); - const normalizedStories = normalizeStories(await storiesGlobs, directories); - - const generator = new StoryIndexGenerator(normalizedStories, { - ...directories, - indexers: indexers, - docs: docsOptions, - workingDir, - }); - - await generator.initialize(); - - storiesFiles = generator.storyFileNames(); - - previewLevelTags = await extractTagsFromPreview(configDir); - - const framework = await presets.apply('framework', undefined); const frameworkName = typeof framework === 'string' ? framework : framework.name; - const storybookEnv = await presets.apply('env', {}); - const staticDirs = await presets.apply('staticDirs', []); - - for (const staticDir of staticDirs) { - try { - statics.push(mapStaticDir(staticDir, configDir)); - } catch (e) { - console.warn(e); - } - } // If we end up needing to know if we are running in browser mode later // const isRunningInBrowserMode = config.plugins.find((plugin: Plugin) => // plugin.name?.startsWith('vitest:browser') // ) - config.test ??= {}; + const baseConfig: Omit = { + test: { + setupFiles: [ + '@storybook/experimental-addon-test/internal/setup-file', + // if the existing setupFiles is a string, we have to include it otherwise we're overwriting it + typeof inputConfig_DoNotMutate.test?.setupFiles === 'string' && + inputConfig_DoNotMutate.test?.setupFiles, + ].filter(Boolean), + + ...(finalOptions.storybookScript + ? { + globalSetup: ['@storybook/experimental-addon-test/internal/global-setup'], + } + : {}), + + env: { + ...storybookEnv, + // To be accessed by the setup file + __STORYBOOK_URL__: finalOptions.storybookUrl, + // We signal the test runner that we are not running it via Storybook + // We are overriding the environment variable to 'true' if vitest runs via @storybook/addon-test's backend + VITEST_STORYBOOK: 'false', + __VITEST_INCLUDE_TAGS__: finalOptions.tags.include.join(','), + __VITEST_EXCLUDE_TAGS__: finalOptions.tags.exclude.join(','), + __VITEST_SKIP_TAGS__: finalOptions.tags.skip.join(','), + }, - if (config.test.include?.length > 0) { + include: storiesFiles + .filter((path) => !path.endsWith('.mdx')) + .map((path) => convertPathToPattern(path)), + + // if the existing deps.inline is true, we keep it as-is, because it will inline everything + ...(inputConfig_DoNotMutate.test?.server?.deps?.inline !== true + ? { + server: { + deps: { + inline: ['@storybook/experimental-addon-test'], + }, + }, + } + : {}), + + browser: { + ...inputConfig_DoNotMutate.test?.browser, + commands: { + getInitialGlobals: () => { + const envConfig = JSON.parse(process.env.VITEST_STORYBOOK_CONFIG ?? '{}'); + + const isA11yEnabled = process.env.VITEST_STORYBOOK + ? (envConfig.a11y ?? false) + : true; + + return { + a11y: { + manual: !isA11yEnabled, + }, + }; + }, + }, + // if there is a test.browser config AND test.browser.screenshotFailures is not explicitly set, we set it to false + ...(inputConfig_DoNotMutate.test?.browser && + inputConfig_DoNotMutate.test.browser.screenshotFailures === undefined + ? { + screenshotFailures: false, + } + : {}), + }, + }, + + envPrefix: Array.from( + new Set([...(inputConfig_DoNotMutate.envPrefix || []), 'STORYBOOK_', 'VITE_']) + ), + + resolve: { + conditions: [ + 'storybook', + 'stories', + 'test', + // copying straight from https://github.com/vitejs/vite/blob/main/packages/vite/src/node/constants.ts#L60 + // to avoid having to maintain Vite as a dependency just for this + 'module', + 'browser', + 'development|production', + ], + }, + + optimizeDeps: { + include: [ + '@storybook/experimental-addon-test/**', + ...(frameworkName?.includes('react') || frameworkName?.includes('nextjs') + ? ['react-dom/test-utils'] + : []), + ], + }, + + define: { + // polyfilling process.env.VITEST_STORYBOOK to 'false' in the browser + 'process.env.VITEST_STORYBOOK': JSON.stringify('false'), + ...(frameworkName?.includes('vue3') + ? { __VUE_PROD_HYDRATION_MISMATCH_DETAILS__: 'false' } + : {}), + }, + }; + + // Merge config from storybook with the plugin config + const config: Omit = mergeConfig( + baseConfig, + viteConfigFromStorybook + ); + + // alert the user of problems + if (inputConfig_DoNotMutate.test.include?.length > 0) { console.warn( picocolors.yellow(dedent` Warning: Starting in Storybook 8.5.0-alpha.18, the "test.include" option in Vitest is discouraged in favor of just using the "stories" field in your Storybook configuration. @@ -145,111 +255,25 @@ export const storybookTest = (options?: UserOptions): Plugin => { ); } - config.test.include = storiesFiles - .filter((path) => !path.endsWith('.mdx')) - .map((path) => convertPathToPattern(path)); - - config.test.env ??= {}; - config.test.env = { - ...storybookEnv, - ...config.test.env, - // To be accessed by the setup file - __STORYBOOK_URL__: storybookUrl, - // We signal the test runner that we are not running it via Storybook - // We are overriding the environment variable to 'true' if vitest runs via @storybook/addon-test's backend - VITEST_STORYBOOK: 'false', - __VITEST_INCLUDE_TAGS__: finalOptions.tags.include.join(','), - __VITEST_EXCLUDE_TAGS__: finalOptions.tags.exclude.join(','), - __VITEST_SKIP_TAGS__: finalOptions.tags.skip.join(','), - }; - - config.envPrefix = Array.from(new Set([...(config.envPrefix || []), 'STORYBOOK_', 'VITE_'])); - - if (config.test.browser) { - config.define ??= { - ...config.define, - // polyfilling process.env.VITEST_STORYBOOK to 'false' in the browser - 'process.env.VITEST_STORYBOOK': JSON.stringify('false'), - }; - - config.test.browser.screenshotFailures ??= false; - - config.test.browser.commands ??= { - getInitialGlobals: () => { - const envConfig = JSON.parse(process.env.VITEST_STORYBOOK_CONFIG ?? '{}'); - - const isA11yEnabled = process.env.VITEST_STORYBOOK ? (envConfig.a11y ?? false) : true; - - return { - a11y: { - manual: !isA11yEnabled, - }, - }; - }, - }; - } - - // copying straight from https://github.com/vitejs/vite/blob/main/packages/vite/src/node/constants.ts#L60 - // to avoid having to maintain Vite as a dependency just for this - const viteDefaultClientConditions = ['module', 'browser', 'development|production']; - - config.resolve ??= {}; - config.resolve.conditions ??= []; - config.resolve.conditions.push( - 'storybook', - 'stories', - 'test', - ...viteDefaultClientConditions - ); - - config.test.setupFiles ??= []; - if (typeof config.test.setupFiles === 'string') { - config.test.setupFiles = [config.test.setupFiles]; - } - config.test.setupFiles.push('@storybook/experimental-addon-test/internal/setup-file'); - - // when a Storybook script is provided, we spawn Storybook for the user when in watch mode - if (finalOptions.storybookScript) { - config.test.globalSetup = config.test.globalSetup ?? []; - if (typeof config.test.globalSetup === 'string') { - config.test.globalSetup = [config.test.globalSetup]; + // return the new config, it will be deep-merged by vite + return config; + }, + async configureServer(server) { + for (const staticDir of staticDirs) { + try { + const { staticPath, targetEndpoint } = mapStaticDir(staticDir, directories.configDir); + server.middlewares.use( + targetEndpoint, + sirv(staticPath, { + dev: true, + etag: true, + extensions: [], + }) + ); + } catch (e) { + console.warn(e); } - config.test.globalSetup.push('@storybook/experimental-addon-test/internal/global-setup'); - } - - config.test.server ??= {}; - config.test.server.deps ??= {}; - config.test.server.deps.inline ??= []; - if (Array.isArray(config.test.server.deps.inline)) { - config.test.server.deps.inline.push('@storybook/experimental-addon-test'); } - - config.optimizeDeps ??= {}; - config.optimizeDeps = { - ...config.optimizeDeps, - include: [...(config.optimizeDeps.include ?? []), '@storybook/experimental-addon-test/**'], - }; - - if (frameworkName?.includes('react') || frameworkName?.includes('nextjs')) { - config.optimizeDeps.include.push('react-dom/test-utils'); - } - - if (frameworkName?.includes('vue3')) { - config.define ??= {}; - config.define.__VUE_PROD_HYDRATION_MISMATCH_DETAILS__ = 'false'; - } - }, - configureServer(server) { - statics.map(({ staticPath, targetEndpoint }) => { - server.middlewares.use( - targetEndpoint, - sirv(staticPath, { - dev: true, - etag: true, - extensions: [], - }) - ); - }); }, async transform(code, id) { if (process.env.VITEST !== 'true') {