diff --git a/code/addons/vitest/src/postinstall.test.ts b/code/addons/vitest/src/postinstall.test.ts new file mode 100644 index 000000000000..e738d7abb4e2 --- /dev/null +++ b/code/addons/vitest/src/postinstall.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest'; + +import { isConfigAlreadySetup } from './postinstall'; + +describe('postinstall helpers', () => { + it('detects a fully configured Vitest config with addon plugin', () => { + const config = ` + import { defineConfig } from 'vitest/config'; + import { storybookTest } from '@storybook/addon-vitest/vitest-plugin'; + + export default defineConfig({ + test: { + projects: [ + { + extends: true, + plugins: [storybookTest({ configDir: '.storybook' })], + test: { + setupFiles: ['./.storybook/vitest.setup.ts'], + }, + }, + ], + }, + }); + `; + + expect(isConfigAlreadySetup('/project/vitest.config.ts', config)).toBe(true); + }); + + it('returns false when storybookTest plugin is not used', () => { + const config = ` + import { defineConfig } from 'vitest/config'; + + export default defineConfig({ + test: { + projects: [ + { + extends: true, + test: { + setupFiles: ['./.storybook/vitest.setup.ts'], + }, + }, + ], + }, + }); + `; + + expect(isConfigAlreadySetup('/project/vitest.config.ts', config)).toBe(false); + }); +}); diff --git a/code/addons/vitest/src/postinstall.ts b/code/addons/vitest/src/postinstall.ts index 76a89d325f6f..37abe7af4560 100644 --- a/code/addons/vitest/src/postinstall.ts +++ b/code/addons/vitest/src/postinstall.ts @@ -2,7 +2,7 @@ import { existsSync } from 'node:fs'; import * as fs from 'node:fs/promises'; import { writeFile } from 'node:fs/promises'; -import { babelParse, generate } from 'storybook/internal/babel'; +import { babelParse, generate, traverse } from 'storybook/internal/babel'; import { AddonVitestService } from 'storybook/internal/cli'; import { JsPackageManagerFactory, @@ -15,7 +15,6 @@ import type { StorybookError } from 'storybook/internal/server-errors'; import { AddonVitestPostinstallConfigUpdateError, AddonVitestPostinstallError, - AddonVitestPostinstallExistingSetupFileError, AddonVitestPostinstallFailedAddonA11yError, AddonVitestPostinstallPrerequisiteCheckError, AddonVitestPostinstallWorkspaceUpdateError, @@ -33,6 +32,7 @@ import { loadTemplate, updateConfigFile, updateWorkspaceFile } from './updateVit const ADDON_NAME = '@storybook/addon-vitest' as const; const EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx', '.cts', '.mts', '.cjs', '.mjs']; +const STORYBOOK_TEST_PLUGIN_SOURCE = `${ADDON_NAME}/vitest-plugin`; const addonA11yName = '@storybook/addon-a11y'; @@ -174,17 +174,13 @@ export default async function postInstall(options: PostinstallOptions) { allDeps.typescript || findFile('tsconfig', [...EXTENSIONS, '.json']) ? 'ts' : 'js'; const vitestSetupFile = resolve(options.configDir, `vitest.setup.${fileExtension}`); + const existingSetupFile = + EXTENSIONS.map((ext) => resolve(options.configDir, `vitest.setup${ext}`)).find(existsSync) || + null; - if (existsSync(vitestSetupFile)) { - const errorMessage = dedent` - Found an existing Vitest setup file: - ${vitestSetupFile} - Please refer to the documentation to complete the setup manually: - https://storybook.js.org/docs/next/${DOCUMENTATION_LINK}#manual-setup-advanced - `; - logger.line(); - logger.error(`${errorMessage}\n`); - errors.push(new AddonVitestPostinstallExistingSetupFileError({ filePath: vitestSetupFile })); + if (existingSetupFile) { + logger.step(`Found existing Vitest setup file, reusing:`); + logger.log(`${existingSetupFile}\n`); } else { logger.step(`Creating a Vitest setup file for Storybook:`); logger.log(`${vitestSetupFile}\n`); @@ -243,16 +239,25 @@ export default async function postInstall(options: PostinstallOptions) { // If there's an existing workspace file, we update that file to include the Storybook Addon Vitest plugin. // We assume the existing workspaces include the Vite(st) config, so we won't add it. if (vitestWorkspaceFile) { + const workspaceFileContent = await fs.readFile(vitestWorkspaceFile, 'utf8'); + const alreadyConfigured = isConfigAlreadySetup(vitestWorkspaceFile, workspaceFileContent); + + if (alreadyConfigured) { + logger.step( + CLI_COLORS.success('Vitest for Storybook is already properly configured. Skipping setup.') + ); + return; + } + const workspaceTemplate = await loadTemplate('vitest.workspace.template.ts', { EXTENDS_WORKSPACE: viteConfigFile ? relative(dirname(vitestWorkspaceFile), viteConfigFile) : '', CONFIG_DIR: options.configDir, - SETUP_FILE: relative(dirname(vitestWorkspaceFile), vitestSetupFile), + SETUP_FILE: relative(dirname(vitestWorkspaceFile), existingSetupFile ?? vitestSetupFile), }).then((t) => t.replace(`\n 'ROOT_CONFIG',`, '').replace(/\s+extends: '',/, '')); - const workspaceFile = await fs.readFile(vitestWorkspaceFile, 'utf8'); const source = babelParse(workspaceTemplate); - const target = babelParse(workspaceFile); + const target = babelParse(workspaceFileContent); const updated = updateWorkspaceFile(source, target); if (updated) { @@ -290,10 +295,12 @@ export default async function postInstall(options: PostinstallOptions) { const templateName = getTemplateName(); - if (templateName) { + const alreadyConfigured = isConfigAlreadySetup(rootConfig, configFile); + + if (templateName && !alreadyConfigured) { const configTemplate = await loadTemplate(templateName, { CONFIG_DIR: options.configDir, - SETUP_FILE: relative(dirname(rootConfig), vitestSetupFile), + SETUP_FILE: relative(dirname(rootConfig), existingSetupFile ?? vitestSetupFile), }); const source = babelParse(configTemplate); @@ -301,7 +308,11 @@ export default async function postInstall(options: PostinstallOptions) { updated = updateConfigFile(source, target); } - if (target && updated) { + if (alreadyConfigured) { + logger.step( + CLI_COLORS.success('Vitest for Storybook is already properly configured. Skipping setup.') + ); + } else if (target && updated) { logger.step(`Updating your ${vitestConfigFile ? 'Vitest' : 'Vite'} config file:`); logger.log(` ${rootConfig}`); @@ -412,3 +423,52 @@ export default async function postInstall(options: PostinstallOptions) { throw new AddonVitestPostinstallError({ errors }); } } + +function isStorybookTestPluginSource(value: string) { + return value === STORYBOOK_TEST_PLUGIN_SOURCE; +} + +export function isConfigAlreadySetup(_configPath: string, configContent: string) { + let ast: ReturnType; + try { + ast = babelParse(configContent); + } catch (e) { + return false; + } + + const pluginIdentifiers = new Set(); + + traverse(ast, { + ImportDeclaration(path) { + const source = path.node.source.value; + if (typeof source === 'string' && isStorybookTestPluginSource(source)) { + path.node.specifiers.forEach((specifier) => { + if ('local' in specifier && specifier.local?.name) { + pluginIdentifiers.add(specifier.local.name); + } + }); + } + }, + }); + + let pluginReferenced = false; + + traverse(ast, { + CallExpression(path) { + if (pluginReferenced) { + path.stop(); + return; + } + const callee = path.node.callee; + if ( + callee.type === 'Identifier' && + (pluginIdentifiers.has(callee.name) || callee.name === 'storybookTest') + ) { + pluginReferenced = true; + path.stop(); + } + }, + }); + + return pluginReferenced; +} diff --git a/code/core/src/server-errors.ts b/code/core/src/server-errors.ts index d163f611864d..9da582ca418b 100644 --- a/code/core/src/server-errors.ts +++ b/code/core/src/server-errors.ts @@ -482,22 +482,6 @@ export class AddonVitestPostinstallFailedAddonA11yError extends StorybookError { } } -export class AddonVitestPostinstallExistingSetupFileError extends StorybookError { - constructor(public data: { filePath: string }) { - super({ - name: 'AddonVitestPostinstallExistingSetupFileError', - category: Category.CLI_INIT, - isHandledError: true, - code: 7, - documentation: `https://storybook.js.org/docs/writing-tests/integrations/vitest-addon#manual-setup-advanced`, - message: dedent` - Found an existing Vitest setup file: ${data.filePath} - Please refer to the documentation to complete the setup manually. - `, - }); - } -} - export class AddonVitestPostinstallWorkspaceUpdateError extends StorybookError { constructor(public data: { filePath: string }) { super({