From e554a7f6b36e0ef0b7cabb9dfb1928992b11c1bc Mon Sep 17 00:00:00 2001 From: Yann Braga Date: Tue, 20 Aug 2024 14:09:47 +0200 Subject: [PATCH 1/5] Addon Vitest: Improve transformation logic to avoid duplicate tests This work changes the transformation done in the plugin to: - account for no tests in a file - transform only the stories that should be included (uses tags at compile time) - execute only tests if they are imported in the same --- code/addons/vitest/src/plugin/index.ts | 31 +- code/addons/vitest/src/plugin/test-utils.ts | 32 +- .../vitest/src/plugin/viewports.test.ts | 5 +- .../vitest-plugin/transformer.test.ts | 311 ++++++++++++++---- .../csf-tools/vitest-plugin/transformer.ts | 260 ++++++++++----- 5 files changed, 468 insertions(+), 171 deletions(-) diff --git a/code/addons/vitest/src/plugin/index.ts b/code/addons/vitest/src/plugin/index.ts index c1fbabb8b626..322e1d5bb74b 100644 --- a/code/addons/vitest/src/plugin/index.ts +++ b/code/addons/vitest/src/plugin/index.ts @@ -3,8 +3,12 @@ import { join, resolve } from 'node:path'; import type { Plugin } from 'vitest/config'; -import { loadAllPresets, validateConfigurationFiles } from 'storybook/internal/common'; -import { vitestTransform } from 'storybook/internal/csf-tools'; +import { + getInterpretedFile, + loadAllPresets, + validateConfigurationFiles, +} from 'storybook/internal/common'; +import { readConfig, vitestTransform } from 'storybook/internal/csf-tools'; import { MainFileMissingError } from 'storybook/internal/server-errors'; import type { StoriesEntry } from 'storybook/internal/types'; @@ -16,6 +20,16 @@ const defaultOptions: UserOptions = { storybookUrl: 'http://localhost:6006', }; +const extractTagsFromPreview = async (configDir: string) => { + const previewConfigPath = getInterpretedFile(join(resolve(configDir), 'preview')); + + if (!previewConfigPath) { + return []; + } + const previewConfig = await readConfig(previewConfigPath); + return previewConfig.getFieldValue(['tags']) ?? []; +}; + export const storybookTest = (options?: UserOptions): Plugin => { const finalOptions = { ...defaultOptions, @@ -45,27 +59,33 @@ export const storybookTest = (options?: UserOptions): Plugin => { finalOptions.configDir = resolve(process.cwd(), finalOptions.configDir); } + let previewLevelTags: string[]; + return { name: 'vite-plugin-storybook-test', enforce: 'pre', async buildStart() { + // evaluate main.js and preview.js so we can extract + // stories for autotitle support and tags for tags filtering support + const configDir = finalOptions.configDir; try { - await validateConfigurationFiles(finalOptions.configDir); + await validateConfigurationFiles(configDir); } catch (err) { throw new MainFileMissingError({ - location: finalOptions.configDir, + location: configDir, source: 'vitest', }); } const presets = await loadAllPresets({ - configDir: finalOptions.configDir, + configDir, corePresets: [], overridePresets: [], packageJson: {}, }); stories = await presets.apply('stories', []); + previewLevelTags = await extractTagsFromPreview(configDir); }, async config(config) { // If we end up needing to know if we are running in browser mode later @@ -123,6 +143,7 @@ export const storybookTest = (options?: UserOptions): Plugin => { configDir: finalOptions.configDir, tagsFilter: finalOptions.tags, stories, + previewLevelTags, }); } }, diff --git a/code/addons/vitest/src/plugin/test-utils.ts b/code/addons/vitest/src/plugin/test-utils.ts index a86a178cc429..632ace561619 100644 --- a/code/addons/vitest/src/plugin/test-utils.ts +++ b/code/addons/vitest/src/plugin/test-utils.ts @@ -3,33 +3,29 @@ /* eslint-disable no-underscore-dangle */ import { type RunnerTask, type TaskContext, type TaskMeta, type TestContext } from 'vitest'; -import type { ComposedStoryFn } from 'storybook/internal/types'; +import { composeStory } from 'storybook/internal/preview-api'; +import type { ComponentAnnotations, ComposedStoryFn } from 'storybook/internal/types'; -import type { UserOptions } from './types'; import { setViewport } from './viewports'; -type TagsFilter = Required; - -export const isValidTest = (storyTags: string[], tagsFilter: TagsFilter) => { - const isIncluded = - tagsFilter?.include.length === 0 || tagsFilter?.include.some((tag) => storyTags.includes(tag)); - const isNotExcluded = tagsFilter?.exclude.every((tag) => !storyTags.includes(tag)); - - return isIncluded && isNotExcluded; -}; - -export const testStory = (Story: ComposedStoryFn, tagsFilter: TagsFilter) => { +export const testStory = ( + exportName: string, + story: ComposedStoryFn, + meta: ComponentAnnotations, + skipTags: string[] +) => { + const composedStory = composeStory(story, meta, undefined, undefined, exportName); return async (context: TestContext & TaskContext & { story: ComposedStoryFn }) => { - if (Story === undefined || tagsFilter?.skip.some((tag) => Story.tags.includes(tag))) { + if (composedStory === undefined || skipTags?.some((tag) => composedStory.tags.includes(tag))) { context.skip(); } - context.story = Story; + context.story = composedStory; const _task = context.task as RunnerTask & { meta: TaskMeta & { storyId: string } }; - _task.meta.storyId = Story.id; + _task.meta.storyId = composedStory.id; - await setViewport(Story.parameters.viewport); - await Story.run(); + await setViewport(composedStory.parameters.viewport); + await composedStory.run(); }; }; diff --git a/code/addons/vitest/src/plugin/viewports.test.ts b/code/addons/vitest/src/plugin/viewports.test.ts index 7b99e252ffe9..cd83f5edc518 100644 --- a/code/addons/vitest/src/plugin/viewports.test.ts +++ b/code/addons/vitest/src/plugin/viewports.test.ts @@ -34,7 +34,10 @@ describe('setViewport', () => { }; await setViewport(viewportsParam); - expect(page.viewport).toHaveBeenCalledWith(1200, 900); + expect(page.viewport).toHaveBeenCalledWith( + DEFAULT_VIEWPORT_DIMENSIONS.width, + DEFAULT_VIEWPORT_DIMENSIONS.height + ); }); it('should set the dimensions of viewport from INITIAL_VIEWPORTS', async () => { diff --git a/code/core/src/csf-tools/vitest-plugin/transformer.test.ts b/code/core/src/csf-tools/vitest-plugin/transformer.test.ts index 76ec0458ab9a..a92af8c4cc2f 100644 --- a/code/core/src/csf-tools/vitest-plugin/transformer.test.ts +++ b/code/core/src/csf-tools/vitest-plugin/transformer.test.ts @@ -24,13 +24,21 @@ const transform = async ({ fileName = 'src/components/Button.stories.js', tagsFilter = { include: ['test'], - exclude: [], - skip: [], + exclude: [] as string[], + skip: [] as string[], }, configDir = '.storybook', stories = [], + previewLevelTags = [], }) => { - const transformed = await originalTransform({ code, fileName, configDir, stories, tagsFilter }); + const transformed = await originalTransform({ + code, + fileName, + configDir, + stories, + tagsFilter, + previewLevelTags, + }); if (typeof transformed === 'string') { return { code: transformed, map: null }; } @@ -53,10 +61,10 @@ describe('transformer', () => { describe('default exports (meta)', () => { it('should add title to inline default export if not present', async () => { const code = ` - import { _test } from 'bla'; export default { component: Button, }; + export const Story = {}; `; const result = await transform({ code }); @@ -64,15 +72,18 @@ describe('transformer', () => { expect(getStoryTitle).toHaveBeenCalled(); expect(result.code).toMatchInlineSnapshot(` - import { test as _test2 } from "vitest"; - import { composeStory as _composeStory } from "storybook/internal/preview-api"; - import { testStory as _testStory, isValidTest as _isValidTest } from "@storybook/experimental-addon-vitest/internal/test-utils"; - import { _test } from 'bla'; + import { test as _test, expect as _expect } from "vitest"; + import { testStory as _testStory } from "@storybook/experimental-addon-vitest/internal/test-utils"; const _meta = { component: Button, title: "automatic/calculated/title" }; export default _meta; + export const Story = {}; + const _isRunningFromThisFile = import.meta.url.includes(_expect.getState().testPath ?? globalThis.__vitest_worker__.filepath); + if (_isRunningFromThisFile) { + _test("Story", _testStory("Story", Story, _meta, [])); + } `); }); @@ -82,6 +93,7 @@ describe('transformer', () => { title: 'Button', component: Button, }; + export const Story = {}; `; const result = await transform({ code }); @@ -89,14 +101,18 @@ describe('transformer', () => { expect(getStoryTitle).toHaveBeenCalled(); expect(result.code).toMatchInlineSnapshot(` - import { test as _test } from "vitest"; - import { composeStory as _composeStory } from "storybook/internal/preview-api"; - import { testStory as _testStory, isValidTest as _isValidTest } from "@storybook/experimental-addon-vitest/internal/test-utils"; + import { test as _test, expect as _expect } from "vitest"; + import { testStory as _testStory } from "@storybook/experimental-addon-vitest/internal/test-utils"; const _meta = { title: "automatic/calculated/title", component: Button }; export default _meta; + export const Story = {}; + const _isRunningFromThisFile = import.meta.url.includes(_expect.getState().testPath ?? globalThis.__vitest_worker__.filepath); + if (_isRunningFromThisFile) { + _test("Story", _testStory("Story", Story, _meta, [])); + } `); }); @@ -105,8 +121,9 @@ describe('transformer', () => { const meta = { component: Button, }; - export default meta; + + export const Story = {}; `; const result = await transform({ code }); @@ -114,14 +131,18 @@ describe('transformer', () => { expect(getStoryTitle).toHaveBeenCalled(); expect(result.code).toMatchInlineSnapshot(` - import { test as _test } from "vitest"; - import { composeStory as _composeStory } from "storybook/internal/preview-api"; - import { testStory as _testStory, isValidTest as _isValidTest } from "@storybook/experimental-addon-vitest/internal/test-utils"; + import { test as _test, expect as _expect } from "vitest"; + import { testStory as _testStory } from "@storybook/experimental-addon-vitest/internal/test-utils"; const meta = { component: Button, title: "automatic/calculated/title" }; export default meta; + export const Story = {}; + const _isRunningFromThisFile = import.meta.url.includes(_expect.getState().testPath ?? globalThis.__vitest_worker__.filepath); + if (_isRunningFromThisFile) { + _test("Story", _testStory("Story", Story, meta, [])); + } `); }); @@ -130,9 +151,10 @@ describe('transformer', () => { const meta = { title: 'Button', component: Button, - }; - + }; export default meta; + + export const Story = {}; `; const result = await transform({ code }); @@ -140,14 +162,18 @@ describe('transformer', () => { expect(getStoryTitle).toHaveBeenCalled(); expect(result.code).toMatchInlineSnapshot(` - import { test as _test } from "vitest"; - import { composeStory as _composeStory } from "storybook/internal/preview-api"; - import { testStory as _testStory, isValidTest as _isValidTest } from "@storybook/experimental-addon-vitest/internal/test-utils"; + import { test as _test, expect as _expect } from "vitest"; + import { testStory as _testStory } from "@storybook/experimental-addon-vitest/internal/test-utils"; const meta = { title: "automatic/calculated/title", component: Button }; export default meta; + export const Story = {}; + const _isRunningFromThisFile = import.meta.url.includes(_expect.getState().testPath ?? globalThis.__vitest_worker__.filepath); + if (_isRunningFromThisFile) { + _test("Story", _testStory("Story", Story, meta, [])); + } `); }); }); @@ -155,22 +181,21 @@ describe('transformer', () => { describe('named exports (stories)', () => { it('should add test statement to inline exported stories', async () => { const code = ` - export default { - component: Button, - } - export const Primary = { - args: { - label: 'Primary Button', - }, - }; - `; + export default { + component: Button, + } + export const Primary = { + args: { + label: 'Primary Button', + }, + }; + `; const result = await transform({ code }); expect(result.code).toMatchInlineSnapshot(` - import { test as _test } from "vitest"; - import { composeStory as _composeStory } from "storybook/internal/preview-api"; - import { testStory as _testStory, isValidTest as _isValidTest } from "@storybook/experimental-addon-vitest/internal/test-utils"; + import { test as _test, expect as _expect } from "vitest"; + import { testStory as _testStory } from "@storybook/experimental-addon-vitest/internal/test-utils"; const _meta = { component: Button, title: "automatic/calculated/title" @@ -181,31 +206,65 @@ describe('transformer', () => { label: 'Primary Button' } }; - const _composedPrimary = _composeStory(Primary, _meta, undefined, undefined, "Primary"); - if (_isValidTest(_composedPrimary.tags, {"include":["test"],"exclude":[],"skip":[]})) { - _test("Primary", _testStory(_composedPrimary, {"include":["test"],"exclude":[],"skip":[]})); + const _isRunningFromThisFile = import.meta.url.includes(_expect.getState().testPath ?? globalThis.__vitest_worker__.filepath); + if (_isRunningFromThisFile) { + _test("Primary", _testStory("Primary", Primary, _meta, [])); } `); }); it('should add test statement to const declared exported stories', async () => { const code = ` - export default {}; - const Primary = { - args: { - label: 'Primary Button', - }, - }; + export default {}; + const Primary = { + args: { + label: 'Primary Button', + }, + }; - export { Primary }; - `; + export { Primary }; + `; const result = await transform({ code }); expect(result.code).toMatchInlineSnapshot(` - import { test as _test } from "vitest"; - import { composeStory as _composeStory } from "storybook/internal/preview-api"; - import { testStory as _testStory, isValidTest as _isValidTest } from "@storybook/experimental-addon-vitest/internal/test-utils"; + import { test as _test, expect as _expect } from "vitest"; + import { testStory as _testStory } from "@storybook/experimental-addon-vitest/internal/test-utils"; + const _meta = { + title: "automatic/calculated/title" + }; + export default _meta; + const Primary = { + args: { + label: 'Primary Button' + } + }; + export { Primary }; + const _isRunningFromThisFile = import.meta.url.includes(_expect.getState().testPath ?? globalThis.__vitest_worker__.filepath); + if (_isRunningFromThisFile) { + _test("Primary", _testStory("Primary", Primary, _meta, [])); + } + `); + }); + + it('should add tests for multiple stories', async () => { + const code = ` + export default {}; + const Primary = { + args: { + label: 'Primary Button', + }, + }; + + export const Secondary = {} + + export { Primary }; + `; + + const result = await transform({ code }); + expect(result.code).toMatchInlineSnapshot(` + import { test as _test, expect as _expect } from "vitest"; + import { testStory as _testStory } from "@storybook/experimental-addon-vitest/internal/test-utils"; const _meta = { title: "automatic/calculated/title" }; @@ -215,37 +274,160 @@ describe('transformer', () => { label: 'Primary Button' } }; + export const Secondary = {}; export { Primary }; - const _composedPrimary = _composeStory(Primary, _meta, undefined, undefined, "Primary"); - if (_isValidTest(_composedPrimary.tags, {"include":["test"],"exclude":[],"skip":[]})) { - _test("Primary", _testStory(_composedPrimary, {"include":["test"],"exclude":[],"skip":[]})); + const _isRunningFromThisFile = import.meta.url.includes(_expect.getState().testPath ?? globalThis.__vitest_worker__.filepath); + if (_isRunningFromThisFile) { + _test("Secondary", _testStory("Secondary", Secondary, _meta, [])); + _test("Primary", _testStory("Primary", Primary, _meta, [])); } `); }); it('should exclude exports via excludeStories', async () => { const code = ` - export default { - title: 'Button', - component: Button, - excludeStories: ['nonStory'], - } - export const nonStory = 123 - `; + export default { + title: 'Button', + component: Button, + excludeStories: ['nonStory'], + } + export const Story = {}; + export const nonStory = 123 + `; const result = await transform({ code }); expect(result.code).toMatchInlineSnapshot(` - import { test as _test } from "vitest"; - import { composeStory as _composeStory } from "storybook/internal/preview-api"; - import { testStory as _testStory, isValidTest as _isValidTest } from "@storybook/experimental-addon-vitest/internal/test-utils"; + import { test as _test, expect as _expect } from "vitest"; + import { testStory as _testStory } from "@storybook/experimental-addon-vitest/internal/test-utils"; const _meta = { title: "automatic/calculated/title", component: Button, excludeStories: ['nonStory'] }; export default _meta; + export const Story = {}; export const nonStory = 123; + const _isRunningFromThisFile = import.meta.url.includes(_expect.getState().testPath ?? globalThis.__vitest_worker__.filepath); + if (_isRunningFromThisFile) { + _test("Story", _testStory("Story", Story, _meta, [])); + } + `); + }); + + it('should return a describe with skip if there are no valid stories', async () => { + const code = ` + export default { + title: 'Button', + component: Button, + tags: ['!test'] + } + export const Story = {} + `; + const result = await transform({ code }); + + expect(result.code).toMatchInlineSnapshot(` + import { test as _test, describe as _describe } from "vitest"; + const _meta = { + title: "automatic/calculated/title", + component: Button, + tags: ['!test'] + }; + export default _meta; + export const Story = {}; + _describe.skip("No valid tests found"); + `); + }); + }); + + describe('tags filtering mechanism', () => { + it('should only include stories from tags.include', async () => { + const code = ` + export default {}; + export const Included = { tags: ['include-me'] }; + + export const NotIncluded = {} + `; + + const result = await transform({ + code, + tagsFilter: { include: ['include-me'], exclude: [], skip: [] }, + }); + + expect(result.code).toMatchInlineSnapshot(` + import { test as _test, expect as _expect } from "vitest"; + import { testStory as _testStory } from "@storybook/experimental-addon-vitest/internal/test-utils"; + const _meta = { + title: "automatic/calculated/title" + }; + export default _meta; + export const Included = { + tags: ['include-me'] + }; + export const NotIncluded = {}; + const _isRunningFromThisFile = import.meta.url.includes(_expect.getState().testPath ?? globalThis.__vitest_worker__.filepath); + if (_isRunningFromThisFile) { + _test("Included", _testStory("Included", Included, _meta, [])); + } + `); + }); + + it('should exclude stories from tags.exclude', async () => { + const code = ` + export default {}; + export const Included = {}; + + export const NotIncluded = { tags: ['exclude-me'] } + `; + + const result = await transform({ + code, + tagsFilter: { include: ['test'], exclude: ['exclude-me'], skip: [] }, + }); + + expect(result.code).toMatchInlineSnapshot(` + import { test as _test, expect as _expect } from "vitest"; + import { testStory as _testStory } from "@storybook/experimental-addon-vitest/internal/test-utils"; + const _meta = { + title: "automatic/calculated/title" + }; + export default _meta; + export const Included = {}; + export const NotIncluded = { + tags: ['exclude-me'] + }; + const _isRunningFromThisFile = import.meta.url.includes(_expect.getState().testPath ?? globalThis.__vitest_worker__.filepath); + if (_isRunningFromThisFile) { + _test("Included", _testStory("Included", Included, _meta, [])); + } + `); + }); + + it('should pass skip tags to testStory call using tags.skip', async () => { + const code = ` + export default {}; + export const Skipped = { tags: ['skip-me'] }; + `; + + const result = await transform({ + code, + tagsFilter: { include: ['test'], exclude: [], skip: ['skip-me'] }, + }); + + expect(result.code).toMatchInlineSnapshot(` + import { test as _test, expect as _expect } from "vitest"; + import { testStory as _testStory } from "@storybook/experimental-addon-vitest/internal/test-utils"; + const _meta = { + title: "automatic/calculated/title" + }; + export default _meta; + export const Skipped = { + tags: ['skip-me'] + }; + const _isRunningFromThisFile = import.meta.url.includes(_expect.getState().testPath ?? globalThis.__vitest_worker__.filepath); + if (_isRunningFromThisFile) { + _test("Skipped", _testStory("Skipped", Skipped, _meta, ["skip-me"])); + } `); }); }); @@ -266,18 +448,17 @@ describe('transformer', () => { }); expect(transformedCode).toMatchInlineSnapshot(` - import { test as _test } from "vitest"; - import { composeStory as _composeStory } from "storybook/internal/preview-api"; - import { testStory as _testStory, isValidTest as _isValidTest } from "@storybook/experimental-addon-vitest/internal/test-utils"; + import { test as _test, expect as _expect } from "vitest"; + import { testStory as _testStory } from "@storybook/experimental-addon-vitest/internal/test-utils"; const meta = { title: "automatic/calculated/title", component: Button }; export default meta; export const Primary = {}; - const _composedPrimary = _composeStory(Primary, meta, undefined, undefined, "Primary"); - if (_isValidTest(_composedPrimary.tags, {"include":["test"],"exclude":[],"skip":[]})) { - _test("Primary", _testStory(_composedPrimary, {"include":["test"],"exclude":[],"skip":[]})); + const _isRunningFromThisFile = import.meta.url.includes(_expect.getState().testPath ?? globalThis.__vitest_worker__.filepath); + if (_isRunningFromThisFile) { + _test("Primary", _testStory("Primary", Primary, meta, [])); } `); diff --git a/code/core/src/csf-tools/vitest-plugin/transformer.ts b/code/core/src/csf-tools/vitest-plugin/transformer.ts index aafe709a2399..c3753720b17f 100644 --- a/code/core/src/csf-tools/vitest-plugin/transformer.ts +++ b/code/core/src/csf-tools/vitest-plugin/transformer.ts @@ -2,7 +2,8 @@ /* eslint-disable no-underscore-dangle */ import { getStoryTitle } from '@storybook/core/common'; -import type { StoriesEntry } from '@storybook/core/types'; +import type { StoriesEntry, Tag } from '@storybook/core/types'; +import { combineTags } from '@storybook/csf'; import * as t from '@babel/types'; import { dedent } from 'ts-dedent'; @@ -11,22 +12,34 @@ import { formatCsf, loadCsf } from '../CsfFile'; const logger = console; +type TagsFilter = { + include: string[]; + exclude: string[]; + skip: string[]; +}; + +const isValidTest = (storyTags: string[], tagsFilter: TagsFilter) => { + const isIncluded = + tagsFilter?.include.length === 0 || tagsFilter?.include.some((tag) => storyTags.includes(tag)); + const isNotExcluded = tagsFilter?.exclude.every((tag) => !storyTags.includes(tag)); + + return isIncluded && isNotExcluded; +}; + export async function vitestTransform({ code, fileName, configDir, stories, tagsFilter, + previewLevelTags = [], }: { code: string; fileName: string; configDir: string; - tagsFilter: { - include: string[]; - exclude: string[]; - skip: string[]; - }; + tagsFilter: TagsFilter; stories: StoriesEntry[]; + previewLevelTags: Tag[]; }) { const isStoryFile = /\.stor(y|ies)\./.test(fileName); if (!isStoryFile) { @@ -81,90 +94,173 @@ export async function vitestTransform({ ); } + // Filter out stories based on the passed tags filter + let validStories: (typeof parsed)['_storyStatements'] = {}; + Object.keys(parsed._stories).map((key) => { + const finalTags = combineTags( + 'test', + 'dev', + ...previewLevelTags, + ...(parsed.meta?.tags || []), + ...(parsed._stories[key].tags || []) + ); + + if (isValidTest(finalTags, tagsFilter)) { + validStories[key] = parsed._storyStatements[key]; + } + }); + const vitestTestId = parsed._file.path.scope.generateUidIdentifier('test'); - const composeStoryId = parsed._file.path.scope.generateUidIdentifier('composeStory'); - const testStoryId = parsed._file.path.scope.generateUidIdentifier('testStory'); - const isValidTestId = parsed._file.path.scope.generateUidIdentifier('isValidTest'); - - const tagsFilterId = t.identifier(JSON.stringify(tagsFilter)); - - const getTestStatementForStory = ({ exportName, node }: { exportName: string; node: t.Node }) => { - const composedStoryId = parsed._file.path.scope.generateUidIdentifier(`composed${exportName}`); - - const composeStoryCall = t.variableDeclaration('const', [ - t.variableDeclarator( - composedStoryId, - t.callExpression(composeStoryId, [ - t.identifier(exportName), - t.identifier(metaExportName), - t.identifier('undefined'), - t.identifier('undefined'), - t.stringLiteral(exportName), - ]) - ), - ]); - - // Preserve sourcemaps location - composeStoryCall.loc = node.loc; - - const isValidTestCall = t.ifStatement( - t.callExpression(isValidTestId, [ - t.memberExpression(composedStoryId, t.identifier('tags')), - tagsFilterId, - ]), - t.blockStatement([ - t.expressionStatement( - t.callExpression(vitestTestId, [ - t.stringLiteral(exportName), - t.callExpression(testStoryId, [composedStoryId, tagsFilterId]), - ]) - ), + const vitestDescribeId = parsed._file.path.scope.generateUidIdentifier('describe'); + + // if no valid stories are found, we just add describe.skip() to the file to avoid empty test files + if (Object.keys(validStories).length === 0) { + const describeSkipBlock = t.expressionStatement( + t.callExpression(t.memberExpression(vitestDescribeId, t.identifier('skip')), [ + t.stringLiteral('No valid tests found'), ]) ); - // Preserve sourcemaps location - isValidTestCall.loc = node.loc; - - return [composeStoryCall, isValidTestCall]; - }; - - Object.entries(parsed._storyStatements).forEach(([exportName, node]) => { - if (node === null) { - logger.warn( - dedent` - [Storybook]: Could not transform "${exportName}" story into test at "${fileName}". - Please make sure to define stories in the same file and not re-export stories coming from other files". - ` + + ast.program.body.push(describeSkipBlock); + const imports = [ + t.importDeclaration( + [ + t.importSpecifier(vitestTestId, t.identifier('test')), + t.importSpecifier(vitestDescribeId, t.identifier('describe')), + ], + t.stringLiteral('vitest') + ), + ]; + + ast.program.body.unshift(...imports); + } else { + const vitestExpectId = parsed._file.path.scope.generateUidIdentifier('expect'); + const composeStoryId = parsed._file.path.scope.generateUidIdentifier('composeStory'); + const testStoryId = parsed._file.path.scope.generateUidIdentifier('testStory'); + const skipTagsId = t.identifier(JSON.stringify(tagsFilter.skip)); + + /** + * 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(vitestExpectId: t.Identifier) { + 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') + ); + + // Combine testPath and filepath using the ?? operator + const nullishCoalescingExpression = t.logicalExpression( + '??', + testPathProperty, + filePathProperty ); - return; + + // Create the final expression: import.meta.url.includes(...) + const includesCall = t.callExpression( + t.memberExpression( + 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 }; } - ast.program.body.push( - ...getTestStatementForStory({ - exportName, - node, + const { isRunningFromThisFileDeclaration, isRunningFromThisFileId } = + getTestGuardDeclaration(vitestExpectId); + + ast.program.body.push(isRunningFromThisFileDeclaration); + + const getTestStatementForStory = ({ + exportName, + node, + }: { + exportName: string; + node: t.Node; + }) => { + // Create the _test expression directly using the exportName identifier + const testStoryCall = t.expressionStatement( + t.callExpression(vitestTestId, [ + t.stringLiteral(exportName), + t.callExpression(testStoryId, [ + t.stringLiteral(exportName), + t.identifier(exportName), + t.identifier(metaExportName), + skipTagsId, + ]), + ]) + ); + + // Preserve sourcemaps location + testStoryCall.loc = node.loc; + + // Return just the testStoryCall as composeStoryCall is not needed + return testStoryCall; + }; + + const storyTestStatements = Object.entries(validStories) + .map(([exportName, node]) => { + if (node === null) { + logger.warn( + dedent` + [Storybook]: Could not transform "${exportName}" story into test at "${fileName}". + Please make sure to define stories in the same file and not re-export stories coming from other files". + ` + ); + return; + } + + return getTestStatementForStory({ + exportName, + node, + }); }) - ); - }); + .filter((st) => !!st); + + const testBlock = t.ifStatement(isRunningFromThisFileId, t.blockStatement(storyTestStatements)); - const imports = [ - t.importDeclaration( - [t.importSpecifier(vitestTestId, t.identifier('test'))], - t.stringLiteral('vitest') - ), - t.importDeclaration( - [t.importSpecifier(composeStoryId, t.identifier('composeStory'))], - t.stringLiteral('storybook/internal/preview-api') - ), - t.importDeclaration( - [ - t.importSpecifier(testStoryId, t.identifier('testStory')), - t.importSpecifier(isValidTestId, t.identifier('isValidTest')), - ], - t.stringLiteral('@storybook/experimental-addon-vitest/internal/test-utils') - ), - ]; - - ast.program.body.unshift(...imports); + ast.program.body.push(testBlock); + + const imports = [ + t.importDeclaration( + [ + t.importSpecifier(vitestTestId, t.identifier('test')), + t.importSpecifier(vitestExpectId, t.identifier('expect')), + ], + t.stringLiteral('vitest') + ), + t.importDeclaration( + [t.importSpecifier(testStoryId, t.identifier('testStory'))], + t.stringLiteral('@storybook/experimental-addon-vitest/internal/test-utils') + ), + ]; + + ast.program.body.unshift(...imports); + } return formatCsf(parsed, { sourceMaps: true, sourceFileName: fileName }, code); } From 518d76b92b6472d7a6ceeb8ac456b2ff6fade9e9 Mon Sep 17 00:00:00 2001 From: Yann Braga Date: Tue, 20 Aug 2024 15:45:57 +0200 Subject: [PATCH 2/5] fix types --- code/core/src/csf-tools/vitest-plugin/transformer.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/code/core/src/csf-tools/vitest-plugin/transformer.ts b/code/core/src/csf-tools/vitest-plugin/transformer.ts index c3753720b17f..53bd84e166de 100644 --- a/code/core/src/csf-tools/vitest-plugin/transformer.ts +++ b/code/core/src/csf-tools/vitest-plugin/transformer.ts @@ -239,7 +239,7 @@ export async function vitestTransform({ node, }); }) - .filter((st) => !!st); + .filter((st) => !!st) as t.ExpressionStatement[]; const testBlock = t.ifStatement(isRunningFromThisFileId, t.blockStatement(storyTestStatements)); From 2b4004de37ec3fa74d2713314be3bc75bfae53a7 Mon Sep 17 00:00:00 2001 From: Yann Braga Date: Tue, 20 Aug 2024 16:14:20 +0200 Subject: [PATCH 3/5] fix lint --- code/core/src/csf-tools/vitest-plugin/transformer.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/code/core/src/csf-tools/vitest-plugin/transformer.ts b/code/core/src/csf-tools/vitest-plugin/transformer.ts index 53bd84e166de..d6cf63c8e68d 100644 --- a/code/core/src/csf-tools/vitest-plugin/transformer.ts +++ b/code/core/src/csf-tools/vitest-plugin/transformer.ts @@ -95,7 +95,7 @@ export async function vitestTransform({ } // Filter out stories based on the passed tags filter - let validStories: (typeof parsed)['_storyStatements'] = {}; + const validStories: (typeof parsed)['_storyStatements'] = {}; Object.keys(parsed._stories).map((key) => { const finalTags = combineTags( 'test', @@ -135,7 +135,6 @@ export async function vitestTransform({ ast.program.body.unshift(...imports); } else { const vitestExpectId = parsed._file.path.scope.generateUidIdentifier('expect'); - const composeStoryId = parsed._file.path.scope.generateUidIdentifier('composeStory'); const testStoryId = parsed._file.path.scope.generateUidIdentifier('testStory'); const skipTagsId = t.identifier(JSON.stringify(tagsFilter.skip)); @@ -147,7 +146,7 @@ export async function vitestTransform({ * Const isRunningFromThisFile = import.meta.url.includes(expect.getState().testPath ?? * globalThis.**vitest_worker**.filepath) if(isRunningFromThisFile) { ... } */ - function getTestGuardDeclaration(vitestExpectId: t.Identifier) { + function getTestGuardDeclaration() { const isRunningFromThisFileId = parsed._file.path.scope.generateUidIdentifier('isRunningFromThisFile'); From 82b5534c84f20ae9d4bec3726e5cb4e9f2fd7d7d Mon Sep 17 00:00:00 2001 From: Yann Braga Date: Tue, 20 Aug 2024 16:37:27 +0200 Subject: [PATCH 4/5] fix lint --- code/core/src/csf-tools/vitest-plugin/transformer.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/code/core/src/csf-tools/vitest-plugin/transformer.ts b/code/core/src/csf-tools/vitest-plugin/transformer.ts index d6cf63c8e68d..c2da59285f24 100644 --- a/code/core/src/csf-tools/vitest-plugin/transformer.ts +++ b/code/core/src/csf-tools/vitest-plugin/transformer.ts @@ -190,7 +190,7 @@ export async function vitestTransform({ } const { isRunningFromThisFileDeclaration, isRunningFromThisFileId } = - getTestGuardDeclaration(vitestExpectId); + getTestGuardDeclaration(); ast.program.body.push(isRunningFromThisFileDeclaration); From fad8af0a6a7fa4faa169694b60af794e1a04f7f1 Mon Sep 17 00:00:00 2001 From: Yann Braga Date: Tue, 20 Aug 2024 17:49:46 +0200 Subject: [PATCH 5/5] fix lint --- code/core/src/csf-tools/vitest-plugin/transformer.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/code/core/src/csf-tools/vitest-plugin/transformer.ts b/code/core/src/csf-tools/vitest-plugin/transformer.ts index c2da59285f24..98ce9635f4eb 100644 --- a/code/core/src/csf-tools/vitest-plugin/transformer.ts +++ b/code/core/src/csf-tools/vitest-plugin/transformer.ts @@ -189,8 +189,7 @@ export async function vitestTransform({ return { isRunningFromThisFileDeclaration, isRunningFromThisFileId }; } - const { isRunningFromThisFileDeclaration, isRunningFromThisFileId } = - getTestGuardDeclaration(); + const { isRunningFromThisFileDeclaration, isRunningFromThisFileId } = getTestGuardDeclaration(); ast.program.body.push(isRunningFromThisFileDeclaration);