From da79150a0a5d0af7f76c6f6594732f17faf4a615 Mon Sep 17 00:00:00 2001 From: yannbf Date: Wed, 7 Jan 2026 21:37:47 +0100 Subject: [PATCH 1/3] Addon Vitest: Add component to test transformer --- code/addons/vitest/src/vitest-plugin/index.ts | 75 +++- code/core/src/csf-tools/index.ts | 1 + .../component-transformer.test.ts | 215 ++++++++++ .../vitest-plugin/component-transformer.ts | 373 ++++++++++++++++++ .../csf-tools/vitest-plugin/transformer.ts | 122 +++--- 5 files changed, 727 insertions(+), 59 deletions(-) create mode 100644 code/core/src/csf-tools/vitest-plugin/component-transformer.test.ts create mode 100644 code/core/src/csf-tools/vitest-plugin/component-transformer.ts diff --git a/code/addons/vitest/src/vitest-plugin/index.ts b/code/addons/vitest/src/vitest-plugin/index.ts index 9e65cd3b747e..a058f054e5c0 100644 --- a/code/addons/vitest/src/vitest-plugin/index.ts +++ b/code/addons/vitest/src/vitest-plugin/index.ts @@ -18,7 +18,7 @@ import { experimental_loadStorybook, mapStaticDir, } from 'storybook/internal/core-server'; -import { readConfig, vitestTransform } from 'storybook/internal/csf-tools'; +import { componentTransform, readConfig, vitestTransform } from 'storybook/internal/csf-tools'; import { MainFileMissingError } from 'storybook/internal/server-errors'; import { telemetry } from 'storybook/internal/telemetry'; import { oneWayHash } from 'storybook/internal/telemetry'; @@ -26,6 +26,7 @@ import type { Presets } from 'storybook/internal/types'; import { match } from 'micromatch'; import { join, normalize, relative, resolve, sep } from 'pathe'; +import path from 'pathe'; import picocolors from 'picocolors'; import sirv from 'sirv'; import { dedent } from 'ts-dedent'; @@ -98,11 +99,73 @@ const mdxStubPlugin: Plugin = { }, }; +// Transforming components and extracting args can be expensive because of docgen +// so we pass the paths via env variable and use as filter to only transform the files we need +const getComponentTestPaths = (vitestRoot: string): string[] => { + const envPaths = process.env.STORYBOOK_COMPONENT_PATHS; + + if (!envPaths) { + return []; + } + + return ( + envPaths + .split(';') + .filter(Boolean) + // TODO: check whether this is actually needed + .map((p) => path.relative(vitestRoot, path.resolve(process.cwd(), p))) + ); +}; + +const createComponentTestTransformPlugin = (presets: Presets, configDir: string): Plugin => { + let vitestRoot: string; + let storybookComponentTestPaths: string[] = []; + + return { + name: 'storybook:component-test-transform-plugin', + enforce: 'pre', + async config(config) { + vitestRoot = config.test?.dir || config.test?.root || config.root || process.cwd(); + storybookComponentTestPaths = getComponentTestPaths(vitestRoot); + }, + async transform(code, id) { + if (!optionalEnvToBoolean(process.env.VITEST) || storybookComponentTestPaths.length === 0) { + return code; + } + + const resolvedId = path.resolve(id); + const matches = storybookComponentTestPaths.some( + (testPath) => + resolvedId === testPath || + resolvedId.startsWith(testPath + path.sep) || + resolvedId.endsWith(testPath) + ); + + // We only transform paths included in STORYBOOK_COMPONENT_PATHS + if (!matches) { + return code; + } + + const result = await componentTransform({ + code, + fileName: id, + getComponentArgTypes: async ({ componentName, fileName }) => + presets.apply('experimental_getArgTypesData', null, { + componentFilePath: fileName, + componentExportName: componentName, + configDir, + }), + }); + + return result.code; + }, + }; +}; + export const storybookTest = async (options?: UserOptions): Promise => { if (!optionalEnvToBoolean(process.env.VITEST)) { return []; } - const finalOptions = { ...defaultOptions, ...options, @@ -203,7 +266,7 @@ export const storybookTest = async (options?: UserOptions): Promise => // ! see https://vite.dev/guide/api-plugin.html#config try { await validateConfigurationFiles(finalOptions.configDir); - } catch (err) { + } catch { throw new MainFileMissingError({ location: finalOptions.configDir, source: 'vitest', @@ -279,7 +342,7 @@ export const storybookTest = async (options?: UserOptions): Promise => __VITEST_SKIP_TAGS__: finalOptions.tags.skip.join(','), }, - include: includeStories, + include: [...includeStories, ...getComponentTestPaths(finalOptions.vitestRoot)], exclude: [ ...(nonMutableInputConfig.test?.exclude ?? []), join(relative(finalOptions.vitestRoot, process.cwd()), '**/*.mdx').replaceAll(sep, '/'), @@ -438,6 +501,10 @@ export const storybookTest = async (options?: UserOptions): Promise => }, }; + if (optionalEnvToBoolean(process.env.STORYBOOK_COMPONENT_PATHS)) { + plugins.push(createComponentTestTransformPlugin(presets, finalOptions.configDir)); + } + plugins.push(storybookTestPlugin); // When running tests via the Storybook UI, we need diff --git a/code/core/src/csf-tools/index.ts b/code/core/src/csf-tools/index.ts index a2dcddb3cc0e..fdd3838071fa 100644 --- a/code/core/src/csf-tools/index.ts +++ b/code/core/src/csf-tools/index.ts @@ -4,3 +4,4 @@ export * from './getStorySortParameter'; export * from './enrichCsf'; export { babelParse } from 'storybook/internal/babel'; export { vitestTransform } from './vitest-plugin/transformer'; +export { componentTransform } from './vitest-plugin/component-transformer'; diff --git a/code/core/src/csf-tools/vitest-plugin/component-transformer.test.ts b/code/core/src/csf-tools/vitest-plugin/component-transformer.test.ts new file mode 100644 index 000000000000..35620672d2a6 --- /dev/null +++ b/code/core/src/csf-tools/vitest-plugin/component-transformer.test.ts @@ -0,0 +1,215 @@ +import { describe, expect, it } from 'vitest'; + +import { componentTransform } from './component-transformer'; + +const transform = async ({ + code, + fileName = 'src/components/Badge.tsx', +}: { + code: string; + fileName?: string; +}) => { + return componentTransform({ code, fileName }); +}; + +describe('component transformer', () => { + it('adds a vitest test for a named component export', async () => { + const code = ` + import { Body } from '../typography'; + + export const Badge = ({ text }: { text: string }) => ( +
+ {text} +
+ ); + `; + + const result = await transform({ code }); + + expect(result.code).toContain('import { test as _test, expect as _expect } from "vitest";'); + expect(result.code).toContain('import { testStory as _testStory, convertToFilePath }'); + expect(result.code).toContain('meta: {'); + expect(result.code).toContain('component: Badge'); + expect(result.code).toContain('_test("Badge", _testStory({'); + + expect(result.code).toMatchInlineSnapshot(` + "import { testStory as _testStory, convertToFilePath } from "@storybook/addon-vitest/internal/test-utils"; + import { test as _test, expect as _expect } from "vitest"; + import { Body } from '../typography'; + export const Badge = ({ + text + }: { + text: string; + }) =>
+ {text} +
; + const _isRunningFromThisFile = convertToFilePath(import.meta.url).includes(globalThis.__vitest_worker__.filepath ?? _expect.getState().testPath); + if (_isRunningFromThisFile) { + _test("Badge", _testStory({ + exportName: "Badge", + story: { + args: {} + }, + meta: { + title: "generated/tests/Badge", + component: Badge + }, + skipTags: [], + storyId: "generated-Badge", + componentPath: "src/components/Badge.tsx", + componentName: "Badge" + })); + }" + `); + }); + + it('wraps a default inline component export by hoisting it to a const first', async () => { + const code = ` + export default () =>
; + `; + + const result = await transform({ code, fileName: 'src/components/Spinner.tsx' }); + + expect(result.code).toContain('const _Spinner = () =>
;'); + expect(result.code).toContain('export default _Spinner;'); + expect(result.code).toContain('_test("Spinner", _testStory({'); + + expect(result.code).toMatchInlineSnapshot(` + "import { testStory as _testStory, convertToFilePath } from "@storybook/addon-vitest/internal/test-utils"; + import { test as _test, expect as _expect } from "vitest"; + const _Spinner = () =>
; + export default _Spinner; + const _isRunningFromThisFile = convertToFilePath(import.meta.url).includes(globalThis.__vitest_worker__.filepath ?? _expect.getState().testPath); + if (_isRunningFromThisFile) { + _test("Spinner", _testStory({ + exportName: "Spinner", + story: { + args: {} + }, + meta: { + title: "generated/tests/Spinner", + component: _Spinner + }, + skipTags: [], + storyId: "generated-Spinner", + componentPath: "src/components/Spinner.tsx", + componentName: "_Spinner" + })); + }" + `); + }); + + it('generates tests for every exported component', async () => { + const code = ` + export const Label = () =>
; + export const Tag = () => ; + export default () =>
; + + const Input = () => ; + const Checkbox = () => ; + export { + Input, + Checkbox as CheckboxInput + }; + `; + + const result = await transform({ code, fileName: 'src/components/Badge.tsx' }); + + expect(result.code).toContain('_test("Badge", _testStory({'); + expect(result.code).toContain('_test("Tag", _testStory({'); + expect(result.code).toMatchInlineSnapshot(` + "import { testStory as _testStory, convertToFilePath } from "@storybook/addon-vitest/internal/test-utils"; + import { test as _test, expect as _expect } from "vitest"; + export const Label = () =>
; + export const Tag = () => ; + const _Badge = () =>
; + export default _Badge; + const Input = () => ; + const Checkbox = () => ; + export { Input, Checkbox as CheckboxInput }; + const _isRunningFromThisFile = convertToFilePath(import.meta.url).includes(globalThis.__vitest_worker__.filepath ?? _expect.getState().testPath); + if (_isRunningFromThisFile) { + _test("Label", _testStory({ + exportName: "Label", + story: { + args: {} + }, + meta: { + title: "generated/tests/Label", + component: Label + }, + skipTags: [], + storyId: "generated-Label", + componentPath: "src/components/Badge.tsx", + componentName: "Label" + })); + _test("Tag", _testStory({ + exportName: "Tag", + story: { + args: {} + }, + meta: { + title: "generated/tests/Tag", + component: Tag + }, + skipTags: [], + storyId: "generated-Tag", + componentPath: "src/components/Badge.tsx", + componentName: "Tag" + })); + _test("Badge", _testStory({ + exportName: "Badge", + story: { + args: {} + }, + meta: { + title: "generated/tests/Badge", + component: _Badge + }, + skipTags: [], + storyId: "generated-Badge", + componentPath: "src/components/Badge.tsx", + componentName: "_Badge" + })); + _test("Input", _testStory({ + exportName: "Input", + story: { + args: {} + }, + meta: { + title: "generated/tests/Input", + component: Input + }, + skipTags: [], + storyId: "generated-Input", + componentPath: "src/components/Badge.tsx", + componentName: "Input" + })); + _test("CheckboxInput", _testStory({ + exportName: "CheckboxInput", + story: { + args: {} + }, + meta: { + title: "generated/tests/CheckboxInput", + component: Checkbox + }, + skipTags: [], + storyId: "generated-CheckboxInput", + componentPath: "src/components/Badge.tsx", + componentName: "Checkbox" + })); + }" + `); + }); + + it('leaves non-component exports untouched', async () => { + const code = ` + export const VALUES = [1, 2, 3]; + `; + + const result = await transform({ code, fileName: 'src/constants.ts' }); + + expect(result.code).toBe(code); + }); +}); diff --git a/code/core/src/csf-tools/vitest-plugin/component-transformer.ts b/code/core/src/csf-tools/vitest-plugin/component-transformer.ts new file mode 100644 index 000000000000..8aee4f808d35 --- /dev/null +++ b/code/core/src/csf-tools/vitest-plugin/component-transformer.ts @@ -0,0 +1,373 @@ +import path from 'node:path'; + +import { + BabelFileClass, + type NodePath, + babelParse, + generate, + types as t, + traverse, +} from 'storybook/internal/babel'; +import type { ArgTypes } from 'storybook/internal/csf'; + +import { generateDummyPropsFromArgTypes } from '../../core-server/utils/get-dummy-props-for-args'; +import { createTestGuardDeclaration } from './transformer'; + +const VITEST_IMPORT_SOURCE = 'vitest'; +const TEST_UTILS_IMPORT_SOURCE = '@storybook/addon-vitest/internal/test-utils'; + +type ComponentExport = { + exportedName: string; + localIdentifier: t.Identifier; +}; + +const sanitizeIdentifier = (value: string) => { + const sanitized = value.replace(/[^a-zA-Z0-9_$]+/g, ''); + return sanitized || 'Component'; +}; + +const createComponentNameFromFileName = (fileName: string) => { + if (!fileName) { + return 'Component'; + } + + const basename = path.basename(fileName, path.extname(fileName)); + return sanitizeIdentifier(basename); +}; + +const containsJsxNode = (valuePath: NodePath | null) => { + if (!valuePath?.node) { + return false; + } + + let found = false; + valuePath.traverse({ + JSXElement(path) { + found = true; + path.stop(); + }, + JSXFragment(path) { + found = true; + path.stop(); + }, + }); + return found; +}; + +const unwrapExpression = (node: t.Node | null): t.Node | null => { + if (!node) { + return null; + } + + if (t.isTSAsExpression(node) || t.isTSSatisfiesExpression(node)) { + return unwrapExpression(node.expression); + } + + return node; +}; + +const dedupeImports = (program: t.Program, source: string, specifiers: t.ImportSpecifier[]) => { + const existing = program.body.find( + (node) => t.isImportDeclaration(node) && node.source.value === source + ) as t.ImportDeclaration | undefined; + + if (existing) { + specifiers.forEach((specifier) => { + if ( + existing.specifiers.every( + (existingSpecifier) => + !t.isImportSpecifier(existingSpecifier) || + existingSpecifier.local.name !== specifier.local.name + ) + ) { + existing.specifiers.push(specifier); + } + }); + return; + } + + program.body.unshift(t.importDeclaration(specifiers, t.stringLiteral(source))); +}; + +// Traverses the AST to find all exported components that contain JSX. Handles named exports, +// default exports, and various declaration types. +const collectComponentExports = (program: t.Program, fileName: string) => { + const components: ComponentExport[] = []; + + // Helper to add a component to the collection if it contains JSX + const addComponent = ( + exportedName: string, + localIdentifier: t.Identifier, + valuePath: NodePath | null + ) => { + if (!valuePath || !valuePath.node) { + return; + } + + const target = unwrapExpression(valuePath.node); + if (!target) { + return; + } + + if (!containsJsxNode(valuePath)) { + return; + } + + components.push({ exportedName, localIdentifier }); + }; + + traverse(program, { + ExportNamedDeclaration(path) { + const { node } = path; + if (node.source) { + return; + } + + const declarationPath = path.get('declaration'); + + if (declarationPath.isVariableDeclaration()) { + declarationPath.get('declarations').forEach((declPath) => { + if (!declPath.isVariableDeclarator()) { + return; + } + const id = declPath.node.id; + if (!t.isIdentifier(id)) { + return; + } + const initPath = declPath.get('init'); + addComponent(id.name, id, initPath); + }); + } else if (declarationPath.isFunctionDeclaration() && declarationPath.node.id) { + const declarationId = declarationPath.node.id; + if (t.isIdentifier(declarationId)) { + addComponent(declarationId.name, declarationId, declarationPath); + } + } else if (declarationPath.isClassDeclaration() && declarationPath.node.id) { + const declarationId = declarationPath.node.id; + if (t.isIdentifier(declarationId)) { + addComponent(declarationId.name, declarationId, declarationPath); + } + } + + path.get('specifiers').forEach((specifierPath) => { + if (!specifierPath.isExportSpecifier()) { + return; + } + const { local, exported } = specifierPath.node; + if (!t.isIdentifier(local) || !t.isIdentifier(exported)) { + return; + } + const binding = specifierPath.scope.getBinding(local.name); + if (!binding) { + return; + } + + const bindingPath = binding.path; + const localIdentifier = binding.identifier; + if (!t.isIdentifier(localIdentifier)) { + return; + } + if (bindingPath.isVariableDeclarator()) { + addComponent(exported.name, localIdentifier, bindingPath.get('init')); + } else if (bindingPath.isFunctionDeclaration() || bindingPath.isClassDeclaration()) { + const bindingNodeId = bindingPath.node.id; + if (t.isIdentifier(bindingNodeId)) { + addComponent(bindingNodeId.name, localIdentifier, bindingPath); + } + } + }); + }, + ExportDefaultDeclaration(path) { + const { node } = path; + const declaration = node.declaration; + + if ( + t.isFunctionExpression(declaration) || + t.isArrowFunctionExpression(declaration) || + t.isClassExpression(declaration) + ) { + const identifierName = createComponentNameFromFileName(fileName); + const identifier = path.scope.generateUidIdentifier(identifierName); + const variableDeclaration = t.variableDeclaration('const', [ + t.variableDeclarator(identifier, declaration), + ]); + variableDeclaration.loc = node.loc; + path.insertBefore(variableDeclaration); + node.declaration = identifier; + + const insertedVarPath = path.getPrevSibling(); + let initPath: NodePath | null = null; + if (insertedVarPath?.isVariableDeclaration()) { + const declarationPath = insertedVarPath.get('declarations')[0]; + if (declarationPath?.isVariableDeclarator()) { + initPath = declarationPath.get('init'); + } + } + + addComponent(identifierName, identifier, initPath); + return; + } + + if (t.isIdentifier(declaration)) { + const binding = path.scope.getBinding(declaration.name); + if (!binding) { + return; + } + + const bindingIdentifier = binding.identifier; + if (!t.isIdentifier(bindingIdentifier)) { + return; + } + + if (binding.path.isVariableDeclarator()) { + addComponent( + createComponentNameFromFileName(fileName), + bindingIdentifier, + binding.path.get('init') + ); + } else if (binding.path.isFunctionDeclaration() || binding.path.isClassDeclaration()) { + const bindingNodeId = binding.path.node.id; + if (t.isIdentifier(bindingNodeId)) { + addComponent(bindingNodeId.name, bindingIdentifier, binding.path); + } + } + return; + } + + if (t.isFunctionDeclaration(declaration) && declaration.id) { + addComponent( + declaration.id.name, + declaration.id, + path.get('declaration') as NodePath + ); + return; + } + + if (t.isClassDeclaration(declaration) && declaration.id) { + addComponent( + declaration.id.name, + declaration.id, + path.get('declaration') as NodePath + ); + } + }, + }); + + return components; +}; + +/** + * Transforms a component file directly into a Vitest test file. Uses a getComponentArgTypes + * function to retrieve component argTypes for required prop generation. Uses portable stories to + * construct a test based on the default state of a component (basic render + required args) + */ +export const componentTransform = async ({ + code, + fileName, + getComponentArgTypes, +}: { + code: string; + fileName: string; + getComponentArgTypes?: (options: { + componentName: string; + fileName: string; + }) => Promise; +}): Promise | { code: string; map: null }> => { + const ast = babelParse(code); + const file = new BabelFileClass({ filename: fileName, highlightCode: false }, { code, ast }); + + const components = collectComponentExports(ast.program, fileName); + if (!components.length) { + return { code, map: null }; + } + + const vitestTestId = file.path.scope.generateUidIdentifier('test'); + const vitestExpectId = file.path.scope.generateUidIdentifier('expect'); + const testStoryId = file.path.scope.generateUidIdentifier('testStory'); + const convertToFilePathId = t.identifier('convertToFilePath'); + + dedupeImports(ast.program, VITEST_IMPORT_SOURCE, [ + t.importSpecifier(vitestTestId, t.identifier('test')), + t.importSpecifier(vitestExpectId, t.identifier('expect')), + ]); + dedupeImports(ast.program, TEST_UTILS_IMPORT_SOURCE, [ + t.importSpecifier(testStoryId, t.identifier('testStory')), + t.importSpecifier(convertToFilePathId, t.identifier('convertToFilePath')), + ]); + + const testStatements: t.ExpressionStatement[] = []; + + // Helper to convert a props object to an AST object expression + const buildArgsExpression = (args?: Record) => { + if (!args || Object.keys(args).length === 0) { + return t.objectExpression([]); + } + + const properties = Object.entries(args).map(([key, value]) => + t.objectProperty(t.identifier(key), t.valueToNode(value)) + ); + return t.objectExpression(properties); + }; + + // For each discovered component, generate a test case + for (const component of components) { + const argTypes = getComponentArgTypes + ? await getComponentArgTypes({ componentName: component.exportedName, fileName }) + : undefined; + const generatedArgs = argTypes ? generateDummyPropsFromArgTypes(argTypes).required : undefined; + + // Each component export is passed as component in an inline meta + // this allows for multiple component metas in a single test file + const meta = t.objectExpression([ + t.objectProperty( + t.identifier('title'), + t.stringLiteral(`generated/tests/${component.exportedName}`) + ), + t.objectProperty(t.identifier('component'), component.localIdentifier), + ]); + + // The actual testStory function + const testStoryArgs = t.objectExpression([ + t.objectProperty(t.identifier('exportName'), t.stringLiteral(component.exportedName)), + // This is where the story annotation for a particular component is defined, inline + t.objectProperty( + t.identifier('story'), + t.objectExpression([ + t.objectProperty(t.identifier('args'), buildArgsExpression(generatedArgs)), + ]) + ), + t.objectProperty(t.identifier('meta'), meta), + t.objectProperty(t.identifier('skipTags'), t.arrayExpression([])), + t.objectProperty( + t.identifier('storyId'), + t.stringLiteral(`generated-${component.exportedName}`) + ), + t.objectProperty(t.identifier('componentPath'), t.stringLiteral(fileName)), + t.objectProperty( + t.identifier('componentName'), + t.stringLiteral(component.localIdentifier.name) + ), + ]); + + const testCall = t.expressionStatement( + t.callExpression(vitestTestId, [ + t.stringLiteral(component.exportedName), + t.callExpression(testStoryId, [testStoryArgs]), + ]) + ); + + testStatements.push(testCall); + } + + // Wrap the code in a guard to avoid side effects when running tests + const { declaration: guardDeclaration, identifier: guardIdentifier } = createTestGuardDeclaration( + file.path.scope, + vitestExpectId, + convertToFilePathId + ); + + ast.program.body.push(guardDeclaration); + ast.program.body.push(t.ifStatement(guardIdentifier, t.blockStatement(testStatements))); + + return generate(ast, { sourceMaps: true, sourceFileName: fileName }, code); +}; diff --git a/code/core/src/csf-tools/vitest-plugin/transformer.ts b/code/core/src/csf-tools/vitest-plugin/transformer.ts index bdbe2d04b03d..30bfdd605f0f 100644 --- a/code/core/src/csf-tools/vitest-plugin/transformer.ts +++ b/code/core/src/csf-tools/vitest-plugin/transformer.ts @@ -43,6 +43,68 @@ const DOUBLE_SPACES = ' '; const getLiteralWithZeroWidthSpace = (testTitle: string) => t.stringLiteral(`${testTitle}${DOUBLE_SPACES}`); +/** + * In Storybook users might be importing stories from other story files. As a side effect, tests can + * get re-triggered. To avoid this, we add a guard to only run tests if the current file is the one + * running the test. + * + * Const isRunningFromThisFile = import.meta.url.includes(expect.getState().testPath ?? + * globalThis.**vitest_worker**.filepath) if(isRunningFromThisFile) { ... } + */ +export function createTestGuardDeclaration( + scope: { generateUidIdentifier: (name: string) => t.Identifier }, + expectId: t.Identifier, + convertToFilePathId: t.Identifier +): { declaration: t.VariableDeclaration; identifier: t.Identifier } { + const isRunningFromThisFileId = scope.generateUidIdentifier('isRunningFromThisFile'); + + // expect.getState().testPath + const testPathProperty = t.memberExpression( + t.callExpression(t.memberExpression(expectId, t.identifier('getState')), []), + t.identifier('testPath') + ); + + // There is a bug in Vitest where expect.getState().testPath is undefined when called outside of a test function so we add this fallback in the meantime + // https://github.com/vitest-dev/vitest/issues/6367 + // globalThis.__vitest_worker__.filepath + const filePathProperty = t.memberExpression( + t.memberExpression(t.identifier('globalThis'), t.identifier('__vitest_worker__')), + t.identifier('filepath') + ); + + // Combine testPath and filepath using the ?? operator + const nullishCoalescingExpression = t.logicalExpression( + '??', + // TODO: switch order of testPathProperty and filePathProperty when the bug is fixed + // https://github.com/vitest-dev/vitest/issues/6367 (or probably just use testPathProperty) + filePathProperty, + testPathProperty + ); + + // Create the final expression: import.meta.url.includes(...) + const includesCall = t.callExpression( + t.memberExpression( + t.callExpression(convertToFilePathId, [ + t.memberExpression( + t.memberExpression(t.identifier('import'), t.identifier('meta')), + t.identifier('url') + ), + ]), + t.identifier('includes') + ), + [nullishCoalescingExpression] + ); + + const isRunningFromThisFileDeclaration = t.variableDeclaration('const', [ + t.variableDeclarator(isRunningFromThisFileId, includesCall), + ]); + + return { + declaration: isRunningFromThisFileDeclaration, + identifier: isRunningFromThisFileId, + }; +} + export async function vitestTransform({ code, fileName, @@ -165,63 +227,13 @@ export async function vitestTransform({ componentNameLiteral = t.stringLiteral(parsed._componentImportSpecifier.local.name); } - /** - * In Storybook users might be importing stories from other story files. As a side effect, tests - * can get re-triggered. To avoid this, we add a guard to only run tests if the current file is - * the one running the test. - * - * Const isRunningFromThisFile = import.meta.url.includes(expect.getState().testPath ?? - * globalThis.**vitest_worker**.filepath) if(isRunningFromThisFile) { ... } - */ - function getTestGuardDeclaration() { - const isRunningFromThisFileId = - parsed._file.path.scope.generateUidIdentifier('isRunningFromThisFile'); - - // expect.getState().testPath - const testPathProperty = t.memberExpression( - t.callExpression(t.memberExpression(vitestExpectId, t.identifier('getState')), []), - t.identifier('testPath') - ); - - // There is a bug in Vitest where expect.getState().testPath is undefined when called outside of a test function so we add this fallback in the meantime - // https://github.com/vitest-dev/vitest/issues/6367 - // globalThis.__vitest_worker__.filepath - const filePathProperty = t.memberExpression( - t.memberExpression(t.identifier('globalThis'), t.identifier('__vitest_worker__')), - t.identifier('filepath') + const { declaration: isRunningFromThisFileDeclaration, identifier: isRunningFromThisFileId } = + createTestGuardDeclaration( + parsed._file.path.scope, + vitestExpectId, + t.identifier('convertToFilePath') ); - // Combine testPath and filepath using the ?? operator - const nullishCoalescingExpression = t.logicalExpression( - '??', - // TODO: switch order of testPathProperty and filePathProperty when the bug is fixed - // https://github.com/vitest-dev/vitest/issues/6367 (or probably just use testPathProperty) - filePathProperty, - testPathProperty - ); - - // Create the final expression: import.meta.url.includes(...) - const includesCall = t.callExpression( - t.memberExpression( - t.callExpression(t.identifier('convertToFilePath'), [ - t.memberExpression( - t.memberExpression(t.identifier('import'), t.identifier('meta')), - t.identifier('url') - ), - ]), - t.identifier('includes') - ), - [nullishCoalescingExpression] - ); - - const isRunningFromThisFileDeclaration = t.variableDeclaration('const', [ - t.variableDeclarator(isRunningFromThisFileId, includesCall), - ]); - return { isRunningFromThisFileDeclaration, isRunningFromThisFileId }; - } - - const { isRunningFromThisFileDeclaration, isRunningFromThisFileId } = getTestGuardDeclaration(); - ast.program.body.push(isRunningFromThisFileDeclaration); const getTestStatementForStory = ({ From c4f70c3433b4b6aa67ad1f09f9d07d1a4e8f973a Mon Sep 17 00:00:00 2001 From: yannbf Date: Fri, 9 Jan 2026 12:07:38 +0100 Subject: [PATCH 2/3] Add function placeholder handling in component transformer tests --- .../component-transformer.test.ts | 101 +++++++++++++++++- .../vitest-plugin/component-transformer.ts | 70 ++++++++++-- 2 files changed, 163 insertions(+), 8 deletions(-) diff --git a/code/core/src/csf-tools/vitest-plugin/component-transformer.test.ts b/code/core/src/csf-tools/vitest-plugin/component-transformer.test.ts index 35620672d2a6..f22da7a908ab 100644 --- a/code/core/src/csf-tools/vitest-plugin/component-transformer.test.ts +++ b/code/core/src/csf-tools/vitest-plugin/component-transformer.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { componentTransform } from './component-transformer'; @@ -212,4 +212,103 @@ describe('component transformer', () => { expect(result.code).toBe(code); }); + + it('does not add fn import when no function placeholders exist', async () => { + const code = ` + import { Body } from '../typography'; + + export const Badge = ({ text }: { text: string }) => ( +
+ {text} +
+ ); + `; + + const mockGetComponentArgTypes = vi.fn().mockResolvedValue({ + rating: { name: 'rating', type: { name: 'number' } }, + photoUrl: { name: 'photoUrl', type: { name: 'string', required: true } }, + }); + + const result = await componentTransform({ + code, + fileName: 'src/components/Badge.tsx', + getComponentArgTypes: mockGetComponentArgTypes, + }); + + expect(result.code).not.toContain('import { fn as _fn } from "storybook/test"'); + }); + + it('generates test with args from getComponentArgTypes', async () => { + const code = ` + import { Body } from '../typography'; + + export const Badge = ({ text }: { text: string }) => ( +
+ {text} +
+ ); + `; + + const mockGetComponentArgTypes = vi.fn().mockResolvedValue({ + rating: { name: 'rating', type: { name: 'number' } }, + photoUrl: { name: 'photoUrl', type: { name: 'string', required: true } }, + onClick: { name: 'onClick', type: { name: 'function', required: true } }, + someObject: { + name: 'someObject', + type: { + name: 'object', + value: { + category: { name: 'string' }, + onClick: { name: 'function' }, + }, + required: true, + }, + }, + }); + + const result = await componentTransform({ + code, + fileName: 'src/components/Badge.tsx', + getComponentArgTypes: mockGetComponentArgTypes, + }); + + expect(result.code).toContain('import { fn as _fn } from "storybook/test"'); + expect(result.code).toMatchInlineSnapshot(` + "import { fn as _fn } from "storybook/test"; + import { testStory as _testStory, convertToFilePath } from "@storybook/addon-vitest/internal/test-utils"; + import { test as _test, expect as _expect } from "vitest"; + import { Body } from '../typography'; + export const Badge = ({ + text + }: { + text: string; + }) =>
+ {text} +
; + const _isRunningFromThisFile = convertToFilePath(import.meta.url).includes(globalThis.__vitest_worker__.filepath ?? _expect.getState().testPath); + if (_isRunningFromThisFile) { + _test("Badge", _testStory({ + exportName: "Badge", + story: { + args: { + photoUrl: "https://placehold.co/600x400?text=Storybook", + onClick: _fn(), + someObject: { + category: "category", + onClick: _fn() + } + } + }, + meta: { + title: "generated/tests/Badge", + component: Badge + }, + skipTags: [], + storyId: "generated-Badge", + componentPath: "src/components/Badge.tsx", + componentName: "Badge" + })); + }" + `); + }); }); diff --git a/code/core/src/csf-tools/vitest-plugin/component-transformer.ts b/code/core/src/csf-tools/vitest-plugin/component-transformer.ts index 8aee4f808d35..eb2fa1499c31 100644 --- a/code/core/src/csf-tools/vitest-plugin/component-transformer.ts +++ b/code/core/src/csf-tools/vitest-plugin/component-transformer.ts @@ -10,11 +10,15 @@ import { } from 'storybook/internal/babel'; import type { ArgTypes } from 'storybook/internal/csf'; -import { generateDummyPropsFromArgTypes } from '../../core-server/utils/get-dummy-props-for-args'; +import { + STORYBOOK_FN_PLACEHOLDER, + generateDummyPropsFromArgTypes, +} from '../../core-server/utils/get-dummy-props-for-args'; import { createTestGuardDeclaration } from './transformer'; const VITEST_IMPORT_SOURCE = 'vitest'; const TEST_UTILS_IMPORT_SOURCE = '@storybook/addon-vitest/internal/test-utils'; +const STORYBOOK_TEST_IMPORT_SOURCE = 'storybook/test'; type ComponentExport = { exportedName: string; @@ -285,6 +289,7 @@ export const componentTransform = async ({ const vitestExpectId = file.path.scope.generateUidIdentifier('expect'); const testStoryId = file.path.scope.generateUidIdentifier('testStory'); const convertToFilePathId = t.identifier('convertToFilePath'); + const fnId = file.path.scope.generateUidIdentifier('fn'); dedupeImports(ast.program, VITEST_IMPORT_SOURCE, [ t.importSpecifier(vitestTestId, t.identifier('test')), @@ -297,25 +302,67 @@ export const componentTransform = async ({ const testStatements: t.ExpressionStatement[] = []; + // Detect whether argTypes contains fn placeholders that need replacing with an actual function expression. Done ahead of time for performance reasons. + const hasFunctionPlaceholder = (value: unknown): boolean => { + return JSON.stringify(value).includes(STORYBOOK_FN_PLACEHOLDER); + }; + + /** + * When argTypes relate to handlers like onClick, they will have a string value like + * [[STORYBOOK_FN_PLACEHOLDER]] In those cases we need to replace them with an actual fn() call + * from storybook/test + */ + const valueToNodeRecursive = (value: unknown, replaceFnCalls: boolean): t.Expression => { + // When there are no function placeholders, no need to recurse - just use valueToNode + if (!replaceFnCalls) { + return t.valueToNode(value) as t.Expression; + } + + if (value === STORYBOOK_FN_PLACEHOLDER) { + return t.callExpression(fnId, []); + } + + if (typeof value === 'object' && value !== null) { + if (Array.isArray(value)) { + return t.arrayExpression(value.map((val) => valueToNodeRecursive(val, replaceFnCalls))); + } + + // For objects, create a new object with recursively processed values + const properties = Object.entries(value).map(([key, val]) => + t.objectProperty(t.identifier(key), valueToNodeRecursive(val, replaceFnCalls)) + ); + return t.objectExpression(properties); + } + + return t.valueToNode(value) as t.Expression; + }; + // Helper to convert a props object to an AST object expression - const buildArgsExpression = (args?: Record) => { + const buildArgsExpression = (args?: Record, useFnImport = false) => { if (!args || Object.keys(args).length === 0) { return t.objectExpression([]); } - const properties = Object.entries(args).map(([key, value]) => - t.objectProperty(t.identifier(key), t.valueToNode(value)) - ); + const properties = Object.entries(args).map(([key, value]) => { + return t.objectProperty(t.identifier(key), valueToNodeRecursive(value, useFnImport)); + }); return t.objectExpression(properties); }; - // For each discovered component, generate a test case + // Check if any component has function placeholders and add import if needed + let hasAnyFunctionPlaceholders = false; + + // Each collected component becomes a test case for (const component of components) { const argTypes = getComponentArgTypes ? await getComponentArgTypes({ componentName: component.exportedName, fileName }) : undefined; const generatedArgs = argTypes ? generateDummyPropsFromArgTypes(argTypes).required : undefined; + if (!hasAnyFunctionPlaceholders && generatedArgs && hasFunctionPlaceholder(generatedArgs)) { + hasAnyFunctionPlaceholders = true; + } + // Each component export is passed as component in an inline meta // this allows for multiple component metas in a single test file const meta = t.objectExpression([ @@ -333,7 +380,10 @@ export const componentTransform = async ({ t.objectProperty( t.identifier('story'), t.objectExpression([ - t.objectProperty(t.identifier('args'), buildArgsExpression(generatedArgs)), + t.objectProperty( + t.identifier('args'), + buildArgsExpression(generatedArgs, hasAnyFunctionPlaceholders) + ), ]) ), t.objectProperty(t.identifier('meta'), meta), @@ -359,6 +409,12 @@ export const componentTransform = async ({ testStatements.push(testCall); } + if (hasAnyFunctionPlaceholders) { + dedupeImports(ast.program, STORYBOOK_TEST_IMPORT_SOURCE, [ + t.importSpecifier(fnId, t.identifier('fn')), + ]); + } + // Wrap the code in a guard to avoid side effects when running tests const { declaration: guardDeclaration, identifier: guardIdentifier } = createTestGuardDeclaration( file.path.scope, From cf4298cb4426c9268465770de35b559a2bd6ac84 Mon Sep 17 00:00:00 2001 From: yannbf Date: Fri, 9 Jan 2026 12:48:37 +0100 Subject: [PATCH 3/3] resolve conflicts --- code/addons/vitest/src/vitest-plugin/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/code/addons/vitest/src/vitest-plugin/index.ts b/code/addons/vitest/src/vitest-plugin/index.ts index a058f054e5c0..3d505ef98e08 100644 --- a/code/addons/vitest/src/vitest-plugin/index.ts +++ b/code/addons/vitest/src/vitest-plugin/index.ts @@ -150,7 +150,7 @@ const createComponentTestTransformPlugin = (presets: Presets, configDir: string) code, fileName: id, getComponentArgTypes: async ({ componentName, fileName }) => - presets.apply('experimental_getArgTypesData', null, { + presets.apply('internal_getArgTypesData', null, { componentFilePath: fileName, componentExportName: componentName, configDir,