From 81e9c7732a193e11a480d28a73c0aa4e9281f37d Mon Sep 17 00:00:00 2001 From: Yann Braga Date: Thu, 22 May 2025 19:50:10 +0200 Subject: [PATCH 001/130] wip prototype for test fn plugin --- code/core/src/csf-tools/index.ts | 1 + .../csf-tools/test-syntax/transformer.test.ts | 91 +++++++++ .../src/csf-tools/test-syntax/transformer.ts | 192 ++++++++++++++++++ 3 files changed, 284 insertions(+) create mode 100644 code/core/src/csf-tools/test-syntax/transformer.test.ts create mode 100644 code/core/src/csf-tools/test-syntax/transformer.ts diff --git a/code/core/src/csf-tools/index.ts b/code/core/src/csf-tools/index.ts index a2dcddb3cc0e..9d5f784798ed 100644 --- a/code/core/src/csf-tools/index.ts +++ b/code/core/src/csf-tools/index.ts @@ -3,4 +3,5 @@ export * from './ConfigFile'; export * from './getStorySortParameter'; export * from './enrichCsf'; export { babelParse } from 'storybook/internal/babel'; +export { testTransform } from './test-syntax/transformer'; export { vitestTransform } from './vitest-plugin/transformer'; diff --git a/code/core/src/csf-tools/test-syntax/transformer.test.ts b/code/core/src/csf-tools/test-syntax/transformer.test.ts new file mode 100644 index 000000000000..45abf7c8142f --- /dev/null +++ b/code/core/src/csf-tools/test-syntax/transformer.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { testTransform as originalTransform } from './transformer'; + +vi.mock('storybook/internal/common', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getStoryTitle: vi.fn(() => 'automatic/calculated/title'), + }; +}); + +expect.addSnapshotSerializer({ + serialize: (val: any) => (typeof val === 'string' ? val : val.toString()), + test: (val) => true, +}); + +const transform = async ({ + code = '', + fileName = 'src/components/Button.stories.js', + configDir = '.storybook', + stories = [], +}) => { + const transformed = await originalTransform({ + code, + fileName, + configDir, + stories, + }); + if (typeof transformed === 'string') { + return { code: transformed, map: null }; + } + + return transformed; +}; + +describe('transformer', () => { + describe('test syntax', () => { + it('should add test statement to const declared exported stories', async () => { + const code = ` + import { config } from '#.storybook/preview'; + const meta = config.meta({ component: Button }); + export const Primary = meta.story({ + args: { + label: 'Primary Button', + } + }); + + Primary.test('some test name here', () => { + console.log('test'); + }); + Primary.test('something else here too', () => { + console.log('test'); + }); + `; + + const result = await transform({ code }); + + expect(result.code).toMatchInlineSnapshot(` + import { config } from '#.storybook/preview'; + const meta = config.meta({ + component: Button, + title: "automatic/calculated/title" + }); + export const Primary = meta.story({ + args: { + label: 'Primary Button' + } + }); + export const _test = { + ...Primary, + tags: [...Primary?.tags, "test-fn"], + play: async context => { + await (Primary?.play)(); + console.log('test'); + }, + storyName: "Primary: some test name here" + }; + export const _test2 = { + ...Primary, + tags: [...Primary?.tags, "test-fn"], + play: async context => { + await (Primary?.play)(); + console.log('test'); + }, + storyName: "Primary: something else here too" + }; + `); + }); + }); +}); diff --git a/code/core/src/csf-tools/test-syntax/transformer.ts b/code/core/src/csf-tools/test-syntax/transformer.ts new file mode 100644 index 000000000000..a8f16e71aad2 --- /dev/null +++ b/code/core/src/csf-tools/test-syntax/transformer.ts @@ -0,0 +1,192 @@ +/* eslint-disable local-rules/no-uncategorized-errors */ +import { types as t } from 'storybook/internal/babel'; +import { getStoryTitle } from 'storybook/internal/common'; +import type { StoriesEntry } from 'storybook/internal/types'; + +import { dedent } from 'ts-dedent'; + +import { formatCsf, loadCsf } from '../CsfFile'; + +const logger = console; + +export async function testTransform({ + code, + fileName, + configDir, + stories, +}: { + code: string; + fileName: string; + configDir: string; + stories: StoriesEntry[]; +}): Promise> { + const isStoryFile = /\.stor(y|ies)\./.test(fileName); + if (!isStoryFile) { + return code; + } + + const parsed = loadCsf(code, { + fileName, + transformInlineMeta: true, + makeTitle: (title) => { + const result = + getStoryTitle({ + storyFilePath: fileName, + configDir, + stories, + userTitle: title, + }) || 'unknown'; + + if (result === 'unknown') { + logger.warn( + dedent` + [Storybook]: Could not calculate story title for "${fileName}". + Please make sure that this file matches the globs included in the "stories" field in your Storybook configuration at "${configDir}". + ` + ); + } + return result; + }, + }).parse(); + + const ast = parsed._ast; + + const metaNode = parsed._metaNode as t.ObjectExpression; + + const metaTitleProperty = metaNode.properties.find( + (prop) => t.isObjectProperty(prop) && t.isIdentifier(prop.key) && prop.key.name === 'title' + ); + + const metaTitle = t.stringLiteral(parsed._meta?.title || 'unknown'); + if (!metaTitleProperty) { + metaNode.properties.push(t.objectProperty(t.identifier('title'), metaTitle)); + } else if (t.isObjectProperty(metaTitleProperty)) { + // If the title is present in meta, overwrite it because autotitle can still affect existing titles + metaTitleProperty.value = metaTitle; + } + + if (!metaNode || !parsed._meta) { + throw new Error( + 'Storybook could not detect the meta (default export) object in the story file. \n\nPlease make sure you have a default export with the meta object. If you are using a different export format that is not supported, please file an issue with details about your use case.' + ); + } + + // Generate new story exports from tests attached to stories + const newExports: t.ExportNamedDeclaration[] = []; + let testCounter = 1; + + // Track nodes to remove from the AST + const nodesToRemove: t.Node[] = []; + + // Process each story to find attached tests + Object.entries(parsed._stories).forEach(([storyExportName, storyInfo]) => { + // Find all test calls on this story in the AST + ast.program.body.forEach((node) => { + if (!t.isExpressionStatement(node)) { + return; + } + + const { expression } = node; + + if (!t.isCallExpression(expression)) { + return; + } + + const { callee, arguments: args } = expression; + + // Check if it's a call like StoryName.test() + if ( + t.isMemberExpression(callee) && + t.isIdentifier(callee.object) && + callee.object.name === storyExportName && + t.isIdentifier(callee.property) && + callee.property.name === 'test' && + args.length >= 2 && + t.isStringLiteral(args[0]) && + (t.isFunctionExpression(args[1]) || t.isArrowFunctionExpression(args[1])) + ) { + // Get test name and body + const testName = (args[0] as t.StringLiteral).value; + const testFunction = args[1] as t.FunctionExpression | t.ArrowFunctionExpression; + + // Create unique export name for the test story + const testExportName = `_test${testCounter > 1 ? testCounter : ''}`; + testCounter++; + + // Create a new story object with the test function integrated as play function + const newStoryObject = t.objectExpression([ + t.spreadElement(t.identifier(storyExportName)), + // Add tags property that preserves existing tags and adds 'test-fn' + t.objectProperty( + t.identifier('tags'), + t.arrayExpression([ + // Spread existing tags if they exist + t.spreadElement( + t.optionalMemberExpression( + t.identifier(storyExportName), + t.identifier('tags'), + false, + true + ) + ), + // Add the test-fn tag + t.stringLiteral('test-fn'), + ]) + ), + t.objectProperty( + t.identifier('play'), + t.arrowFunctionExpression( + [t.identifier('context')], + t.blockStatement([ + // Add code to call the original story's play function if it exists + t.expressionStatement( + t.awaitExpression( + t.callExpression( + t.optionalMemberExpression( + t.identifier(storyExportName), + t.identifier('play'), + false, + true + ), + [] + ) + ) + ), + // Then add the test function body + ...(t.isBlockStatement(testFunction.body) + ? testFunction.body.body + : [t.expressionStatement(testFunction.body)]), + ]), + true // async + ) + ), + t.objectProperty( + t.identifier('storyName'), + t.stringLiteral(`${storyInfo.name || storyExportName}: ${testName}`) + ), + ]); + + // Create export statement + const exportDeclaration = t.exportNamedDeclaration( + t.variableDeclaration('const', [ + t.variableDeclarator(t.identifier(testExportName), newStoryObject), + ]), + [] + ); + + newExports.push(exportDeclaration); + + // Mark the original test call for removal + nodesToRemove.push(node); + } + }); + }); + + // Remove the test calls from the AST + ast.program.body = ast.program.body.filter((node) => !nodesToRemove.includes(node)); + + // Add new exports to the AST + ast.program.body.push(...newExports); + + return formatCsf(parsed, { sourceMaps: true, sourceFileName: fileName }, code); +} From 1803fb02dc00900ddf1b735060cf41bfbec1d286 Mon Sep 17 00:00:00 2001 From: Yann Braga Date: Sun, 25 May 2025 12:19:04 +0200 Subject: [PATCH 002/130] continue prototype --- .gitignore | 2 +- code/addons/vitest/src/vitest-plugin/index.ts | 2 + .../builder-vite/src/plugins/index.ts | 1 + .../src/plugins/test-fn-plugin.ts | 24 +++ code/builders/builder-vite/src/vite-config.ts | 3 +- code/builders/builder-webpack5/package.json | 8 +- .../src/loaders/test-fn-loader.ts | 34 ++++ .../src/preview/iframe-webpack.config.ts | 10 ++ .../components/test-fn.stories.tsx | 27 +++ code/core/src/csf-tools/CsfFile.ts | 101 ++++++++++- code/core/src/csf-tools/storyIndexer.test.ts | 60 +++++++ .../csf-tools/test-syntax/transformer.test.ts | 67 ++++---- .../src/csf-tools/test-syntax/transformer.ts | 158 +++++++----------- code/core/src/csf/csf-factories.ts | 6 + code/core/src/shared/preview/csf4.ts | 30 +++- code/core/src/types/modules/indexer.ts | 1 + .../react/template/stories/preview.ts | 4 + .../template/stories/test-fn.stories.tsx | 27 +++ 18 files changed, 421 insertions(+), 144 deletions(-) create mode 100644 code/builders/builder-vite/src/plugins/test-fn-plugin.ts create mode 100644 code/builders/builder-webpack5/src/loaders/test-fn-loader.ts create mode 100644 code/core/src/component-testing/components/test-fn.stories.tsx create mode 100644 code/core/src/csf-tools/storyIndexer.test.ts create mode 100644 code/renderers/react/template/stories/preview.ts create mode 100644 code/renderers/react/template/stories/test-fn.stories.tsx diff --git a/.gitignore b/.gitignore index 9bf3c7eb5332..d9481a41fbb1 100644 --- a/.gitignore +++ b/.gitignore @@ -57,7 +57,7 @@ code/bench-results/ /packs code/.nx/cache code/.nx/workspace-data -code/.vite-inspect +.vite-inspect .nx/cache .nx/workspace-data !**/fixtures/**/yarn.lock diff --git a/code/addons/vitest/src/vitest-plugin/index.ts b/code/addons/vitest/src/vitest-plugin/index.ts index 0cc23041db86..d6f676621c0f 100644 --- a/code/addons/vitest/src/vitest-plugin/index.ts +++ b/code/addons/vitest/src/vitest-plugin/index.ts @@ -27,6 +27,7 @@ import picocolors from 'picocolors'; import sirv from 'sirv'; import { dedent } from 'ts-dedent'; +import { storybookTestFn } from '../../../../builders/builder-vite/src/plugins/test-fn-plugin'; // ! Relative import to prebundle it without needing to depend on the Vite builder import { withoutVitePlugins } from '../../../../builders/builder-vite/src/utils/without-vite-plugins'; import type { InternalOptions, UserOptions } from './types'; @@ -401,6 +402,7 @@ export const storybookTest = async (options?: UserOptions): Promise => }, }; + plugins.push(await storybookTestFn()); plugins.push(storybookTestPlugin); // When running tests via the Storybook UI, we need diff --git a/code/builders/builder-vite/src/plugins/index.ts b/code/builders/builder-vite/src/plugins/index.ts index bc72dc8755d5..97d9b04b8bae 100644 --- a/code/builders/builder-vite/src/plugins/index.ts +++ b/code/builders/builder-vite/src/plugins/index.ts @@ -2,5 +2,6 @@ export * from './inject-export-order-plugin'; export * from './strip-story-hmr-boundaries'; export * from './code-generator-plugin'; export * from './csf-plugin'; +export * from './test-fn-plugin'; export * from './external-globals-plugin'; export * from './webpack-stats-plugin'; diff --git a/code/builders/builder-vite/src/plugins/test-fn-plugin.ts b/code/builders/builder-vite/src/plugins/test-fn-plugin.ts new file mode 100644 index 000000000000..7aa8f2d06bb9 --- /dev/null +++ b/code/builders/builder-vite/src/plugins/test-fn-plugin.ts @@ -0,0 +1,24 @@ +import { testTransform } from 'storybook/internal/csf-tools'; + +import type { Plugin } from 'vite'; + +/** This transforms the test function of a story into another story */ +export async function storybookTestFn(): Promise { + const storiesRegex = /\.stories\.(tsx?|jsx?|svelte|vue)$/; + + return { + name: 'storybook:test-function', + enforce: 'pre', + async transform(src, id) { + if (!storiesRegex.test(id)) { + return undefined; + } + + const result = await testTransform({ + code: src, + fileName: id, + }); + return result; + }, + }; +} diff --git a/code/builders/builder-vite/src/vite-config.ts b/code/builders/builder-vite/src/vite-config.ts index 147f6419dac9..da8d15948181 100644 --- a/code/builders/builder-vite/src/vite-config.ts +++ b/code/builders/builder-vite/src/vite-config.ts @@ -25,6 +25,7 @@ import { pluginWebpackStats, stripStoryHMRBoundary, } from './plugins'; +import { storybookTestFn } from './plugins/test-fn-plugin'; import type { BuilderOptions } from './types'; export type PluginConfigType = 'build' | 'development'; @@ -85,7 +86,6 @@ export async function commonConfig( } export async function pluginConfig(options: Options) { - const frameworkName = await getFrameworkName(options); const build = await options.presets.apply('build'); const externals: Record = globalsNameReferenceMap; @@ -99,6 +99,7 @@ export async function pluginConfig(options: Options) { await csfPlugin(options), await injectExportOrderPlugin(), await stripStoryHMRBoundary(), + await storybookTestFn(), { name: 'storybook:allow-storybook-dir', enforce: 'post', diff --git a/code/builders/builder-webpack5/package.json b/code/builders/builder-webpack5/package.json index 171f66d93eed..a69781b9028d 100644 --- a/code/builders/builder-webpack5/package.json +++ b/code/builders/builder-webpack5/package.json @@ -41,6 +41,11 @@ "node": "./dist/loaders/export-order-loader.js", "require": "./dist/loaders/export-order-loader.js" }, + "./loaders/test-fn-loader": { + "types": "./dist/loaders/test-fn-loader.d.ts", + "node": "./dist/loaders/test-fn-loader.js", + "require": "./dist/loaders/test-fn-loader.js" + }, "./templates/virtualModuleModernEntry.js": "./templates/virtualModuleModernEntry.js", "./templates/preview.ejs": "./templates/preview.ejs", "./templates/virtualModuleEntry.template.js": "./templates/virtualModuleEntry.template.js", @@ -104,7 +109,8 @@ "./src/index.ts", "./src/presets/custom-webpack-preset.ts", "./src/presets/preview-preset.ts", - "./src/loaders/export-order-loader.ts" + "./src/loaders/export-order-loader.ts", + "./src/loaders/test-fn-loader.ts" ], "platform": "node" }, diff --git a/code/builders/builder-webpack5/src/loaders/test-fn-loader.ts b/code/builders/builder-webpack5/src/loaders/test-fn-loader.ts new file mode 100644 index 000000000000..523cafce14b7 --- /dev/null +++ b/code/builders/builder-webpack5/src/loaders/test-fn-loader.ts @@ -0,0 +1,34 @@ +import { testTransform } from 'storybook/internal/csf-tools'; + +import type { LoaderContext } from 'webpack'; + +/** This transforms the test function of a story into another story */ +export default async function loader( + this: LoaderContext, + source: string, + map: any, + meta: any +) { + const callback = this.async(); + const storiesRegex = /\.stories\.(tsx?|jsx?|svelte|vue)$/; + + try { + // Only process story files + if (!storiesRegex.test(this.resourcePath)) { + return callback(null, source, map, meta); + } + + const result = await testTransform({ + code: source, + fileName: this.resourcePath, + }); + + // Handle both string and GeneratorResult types + const transformedCode = typeof result === 'string' ? result : result.code; + + return callback(null, transformedCode, map, meta); + } catch (err) { + // If transformation fails, return original source + return callback(null, source, map, meta); + } +} diff --git a/code/builders/builder-webpack5/src/preview/iframe-webpack.config.ts b/code/builders/builder-webpack5/src/preview/iframe-webpack.config.ts index 545fa159695f..805b4f623f8c 100644 --- a/code/builders/builder-webpack5/src/preview/iframe-webpack.config.ts +++ b/code/builders/builder-webpack5/src/preview/iframe-webpack.config.ts @@ -218,6 +218,16 @@ export default async ( }, ], }, + { + test: /\.stories\.(tsx?|jsx?|svelte|vue)$/, + exclude: /node_modules/, + enforce: 'post', + use: [ + { + loader: require.resolve('@storybook/builder-webpack5/loaders/test-fn-loader'), + }, + ], + }, { test: /\.m?js$/, type: 'javascript/auto', diff --git a/code/core/src/component-testing/components/test-fn.stories.tsx b/code/core/src/component-testing/components/test-fn.stories.tsx new file mode 100644 index 000000000000..4c62680e87f1 --- /dev/null +++ b/code/core/src/component-testing/components/test-fn.stories.tsx @@ -0,0 +1,27 @@ +import React from 'react'; + +import { expect, fn } from 'storybook/test'; + +import preview from '../../../../.storybook/preview'; + +const Button = (args: React.ComponentProps<'button'>) =>