diff --git a/code/addons/test/src/vitest-plugin/test-utils.ts b/code/addons/test/src/vitest-plugin/test-utils.ts index a00ff6d7f6f..abd6fd0bbdd 100644 --- a/code/addons/test/src/vitest-plugin/test-utils.ts +++ b/code/addons/test/src/vitest-plugin/test-utils.ts @@ -27,11 +27,12 @@ export const testStory = ( return async (context: TestContext & TaskContext & { story: ComposedStoryFn }) => { const composedStory = composeStory( story, - meta, + 'isCSFFactory' in story ? (meta as any).annotations : meta, { initialGlobals: (await getInitialGlobals?.()) ?? {} }, undefined, exportName ); + if (composedStory === undefined || skipTags?.some((tag) => composedStory.tags.includes(tag))) { context.skip(); } diff --git a/code/core/src/components/components/Button/Button.stories.tsx b/code/core/src/components/components/Button/Button.stories.tsx index 7fb5dd4039a..2a88929db21 100644 --- a/code/core/src/components/components/Button/Button.stories.tsx +++ b/code/core/src/components/components/Button/Button.stories.tsx @@ -2,7 +2,6 @@ import type { ReactNode } from 'react'; import React from 'react'; import { FaceHappyIcon } from '@storybook/icons'; -import type { Meta, StoryObj } from '@storybook/react'; import { config } from '../../../../../.storybook/preview'; import { Button } from './Button'; diff --git a/code/core/src/csf-tools/CsfFile.ts b/code/core/src/csf-tools/CsfFile.ts index bc311164ba2..5e6eaac9fa2 100644 --- a/code/core/src/csf-tools/CsfFile.ts +++ b/code/core/src/csf-tools/CsfFile.ts @@ -679,7 +679,7 @@ export class CsfFile { if (t.isImportDeclaration(configParent)) { if (isValidPreviewPath(configParent.source.value)) { const metaNode = node.arguments[0] as t.ObjectExpression; - self._metaVariableName = callee.object.name; + self._metaVariableName = callee.property.name; self._metaIsFactory = true; self._parseMeta(metaNode, self._ast.program); } else { 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 5b030ac19c7..b475380df2e 100644 --- a/code/core/src/csf-tools/vitest-plugin/transformer.test.ts +++ b/code/core/src/csf-tools/vitest-plugin/transformer.test.ts @@ -58,166 +58,140 @@ describe('transformer', () => { }); }); - describe('default exports (meta)', () => { - it('should add title to inline default export if not present', async () => { - const code = ` - export default { - component: Button, - }; - export const Story = {}; - `; - - const result = await transform({ code }); - - expect(getStoryTitle).toHaveBeenCalled(); - - expect(result.code).toMatchInlineSnapshot(` - import { test as _test, expect as _expect } from "vitest"; - import { testStory as _testStory } from "@storybook/experimental-addon-test/internal/test-utils"; - const _meta = { - component: Button, - title: "automatic/calculated/title" - }; - export default _meta; - export const Story = {}; - const _isRunningFromThisFile = import.meta.url.includes(globalThis.__vitest_worker__.filepath ?? _expect.getState().testPath); - if (_isRunningFromThisFile) { - _test("Story", _testStory("Story", Story, _meta, [])); - } - `); - }); - - it('should overwrite title to inline default export if already present', async () => { - const code = ` - export default { - title: 'Button', - component: Button, - }; - export const Story = {}; - `; - - const result = await transform({ code }); - - expect(getStoryTitle).toHaveBeenCalled(); - - expect(result.code).toMatchInlineSnapshot(` - import { test as _test, expect as _expect } from "vitest"; - import { testStory as _testStory } from "@storybook/experimental-addon-test/internal/test-utils"; - const _meta = { - title: "automatic/calculated/title", - component: Button - }; - export default _meta; - export const Story = {}; - const _isRunningFromThisFile = import.meta.url.includes(globalThis.__vitest_worker__.filepath ?? _expect.getState().testPath); - if (_isRunningFromThisFile) { - _test("Story", _testStory("Story", Story, _meta, [])); - } - `); - }); - - it('should add title to const declared default export if not present', async () => { - const code = ` - const meta = { - component: Button, - }; - export default meta; - - export const Story = {}; - `; - - const result = await transform({ code }); - - expect(getStoryTitle).toHaveBeenCalled(); - - expect(result.code).toMatchInlineSnapshot(` - import { test as _test, expect as _expect } from "vitest"; - import { testStory as _testStory } from "@storybook/experimental-addon-test/internal/test-utils"; - const meta = { - component: Button, - title: "automatic/calculated/title" - }; - export default meta; - export const Story = {}; - const _isRunningFromThisFile = import.meta.url.includes(globalThis.__vitest_worker__.filepath ?? _expect.getState().testPath); - if (_isRunningFromThisFile) { - _test("Story", _testStory("Story", Story, meta, [])); - } - `); - }); - - it('should overwrite title to const declared default export if already present', async () => { - const code = ` - const meta = { - title: 'Button', - component: Button, - }; - export default meta; - - export const Story = {}; - `; - - const result = await transform({ code }); - - expect(getStoryTitle).toHaveBeenCalled(); - - expect(result.code).toMatchInlineSnapshot(` - import { test as _test, expect as _expect } from "vitest"; - import { testStory as _testStory } from "@storybook/experimental-addon-test/internal/test-utils"; - const meta = { - title: "automatic/calculated/title", - component: Button - }; - export default meta; - export const Story = {}; - const _isRunningFromThisFile = import.meta.url.includes(globalThis.__vitest_worker__.filepath ?? _expect.getState().testPath); - if (_isRunningFromThisFile) { - _test("Story", _testStory("Story", Story, meta, [])); - } - `); - }); - }); - - 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', - }, - }; - `; - - 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-test/internal/test-utils"; - const _meta = { - component: Button, - title: "automatic/calculated/title" - }; - export default _meta; - export const Primary = { - args: { - label: 'Primary Button' - } - }; - const _isRunningFromThisFile = import.meta.url.includes(globalThis.__vitest_worker__.filepath ?? _expect.getState().testPath); - if (_isRunningFromThisFile) { - _test("Primary", _testStory("Primary", Primary, _meta, [])); - } - `); - }); - - describe("use the story's name as test title", () => { - it('should support CSF v3 via name property', async () => { + describe('CSF v1/v2/v3', () => { + describe('default exports (meta)', () => { + it('should add title to inline default export if not present', async () => { const code = ` - export default { component: Button } - export const Primary = { name: "custom name" };`; + export default { + component: Button, + }; + export const Story = {}; + `; + + const result = await transform({ code }); + + expect(getStoryTitle).toHaveBeenCalled(); + + expect(result.code).toMatchInlineSnapshot(` + import { test as _test, expect as _expect } from "vitest"; + import { testStory as _testStory } from "@storybook/experimental-addon-test/internal/test-utils"; + const _meta = { + component: Button, + title: "automatic/calculated/title" + }; + export default _meta; + export const Story = {}; + const _isRunningFromThisFile = import.meta.url.includes(globalThis.__vitest_worker__.filepath ?? _expect.getState().testPath); + if (_isRunningFromThisFile) { + _test("Story", _testStory("Story", Story, _meta, [])); + } + `); + }); + + it('should overwrite title to inline default export if already present', async () => { + const code = ` + export default { + title: 'Button', + component: Button, + }; + export const Story = {}; + `; + + const result = await transform({ code }); + + expect(getStoryTitle).toHaveBeenCalled(); + + expect(result.code).toMatchInlineSnapshot(` + import { test as _test, expect as _expect } from "vitest"; + import { testStory as _testStory } from "@storybook/experimental-addon-test/internal/test-utils"; + const _meta = { + title: "automatic/calculated/title", + component: Button + }; + export default _meta; + export const Story = {}; + const _isRunningFromThisFile = import.meta.url.includes(globalThis.__vitest_worker__.filepath ?? _expect.getState().testPath); + if (_isRunningFromThisFile) { + _test("Story", _testStory("Story", Story, _meta, [])); + } + `); + }); + + it('should add title to const declared default export if not present', async () => { + const code = ` + const meta = { + component: Button, + }; + export default meta; + + export const Story = {}; + `; + + const result = await transform({ code }); + + expect(getStoryTitle).toHaveBeenCalled(); + + expect(result.code).toMatchInlineSnapshot(` + import { test as _test, expect as _expect } from "vitest"; + import { testStory as _testStory } from "@storybook/experimental-addon-test/internal/test-utils"; + const meta = { + component: Button, + title: "automatic/calculated/title" + }; + export default meta; + export const Story = {}; + const _isRunningFromThisFile = import.meta.url.includes(globalThis.__vitest_worker__.filepath ?? _expect.getState().testPath); + if (_isRunningFromThisFile) { + _test("Story", _testStory("Story", Story, meta, [])); + } + `); + }); + + it('should overwrite title to const declared default export if already present', async () => { + const code = ` + const meta = { + title: 'Button', + component: Button, + }; + export default meta; + + export const Story = {}; + `; + + const result = await transform({ code }); + + expect(getStoryTitle).toHaveBeenCalled(); + + expect(result.code).toMatchInlineSnapshot(` + import { test as _test, expect as _expect } from "vitest"; + import { testStory as _testStory } from "@storybook/experimental-addon-test/internal/test-utils"; + const meta = { + title: "automatic/calculated/title", + component: Button + }; + export default meta; + export const Story = {}; + const _isRunningFromThisFile = import.meta.url.includes(globalThis.__vitest_worker__.filepath ?? _expect.getState().testPath); + if (_isRunningFromThisFile) { + _test("Story", _testStory("Story", Story, meta, [])); + } + `); + }); + }); + + 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', + }, + }; + `; + const result = await transform({ code }); expect(result.code).toMatchInlineSnapshot(` @@ -229,109 +203,508 @@ describe('transformer', () => { }; export default _meta; export const Primary = { - name: "custom name" + args: { + label: 'Primary Button' + } }; const _isRunningFromThisFile = import.meta.url.includes(globalThis.__vitest_worker__.filepath ?? _expect.getState().testPath); if (_isRunningFromThisFile) { - _test("custom name", _testStory("Primary", Primary, _meta, [])); + _test("Primary", _testStory("Primary", Primary, _meta, [])); } `); }); - it('should support CSF v1/v2 via storyName property', async () => { + describe("use the story's name as test title", () => { + it('should support CSF v3 via name property', async () => { + const code = ` + export default { component: Button } + export const Primary = { name: "custom name" };`; + 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-test/internal/test-utils"; + const _meta = { + component: Button, + title: "automatic/calculated/title" + }; + export default _meta; + export const Primary = { + name: "custom name" + }; + const _isRunningFromThisFile = import.meta.url.includes(globalThis.__vitest_worker__.filepath ?? _expect.getState().testPath); + if (_isRunningFromThisFile) { + _test("custom name", _testStory("Primary", Primary, _meta, [])); + } + `); + }); + + it('should support CSF v1/v2 via storyName property', async () => { + const code = ` + export default { component: Button } + export const Story = () => {} + Story.storyName = 'custom name';`; + const result = await transform({ code: code }); + expect(result.code).toMatchInlineSnapshot(` + import { test as _test, expect as _expect } from "vitest"; + import { testStory as _testStory } from "@storybook/experimental-addon-test/internal/test-utils"; + const _meta = { + component: Button, + title: "automatic/calculated/title" + }; + export default _meta; + export const Story = () => {}; + Story.storyName = 'custom name'; + const _isRunningFromThisFile = import.meta.url.includes(globalThis.__vitest_worker__.filepath ?? _expect.getState().testPath); + if (_isRunningFromThisFile) { + _test("custom name", _testStory("Story", Story, _meta, [])); + } + `); + }); + }); + + it('should add test statement to const declared exported stories', async () => { const code = ` - export default { component: Button } - export const Story = () => {} - Story.storyName = 'custom name';`; - const result = await transform({ code: code }); + export default {}; + const Primary = { + args: { + label: 'Primary Button', + }, + }; + + 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-test/internal/test-utils"; const _meta = { - component: Button, title: "automatic/calculated/title" }; export default _meta; - export const Story = () => {}; - Story.storyName = 'custom name'; + const Primary = { + args: { + label: 'Primary Button' + } + }; + export { Primary }; const _isRunningFromThisFile = import.meta.url.includes(globalThis.__vitest_worker__.filepath ?? _expect.getState().testPath); if (_isRunningFromThisFile) { - _test("custom name", _testStory("Story", Story, _meta, [])); + _test("Primary", _testStory("Primary", Primary, _meta, [])); + } + `); + }); + + it('should add test statement to const declared renamed exported stories', async () => { + const code = ` + export default {}; + const Primary = { + args: { + label: 'Primary Button', + }, + }; + + export { Primary as PrimaryStory }; + `; + + 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-test/internal/test-utils"; + const _meta = { + title: "automatic/calculated/title" + }; + export default _meta; + const Primary = { + args: { + label: 'Primary Button' + } + }; + export { Primary as PrimaryStory }; + const _isRunningFromThisFile = import.meta.url.includes(globalThis.__vitest_worker__.filepath ?? _expect.getState().testPath); + if (_isRunningFromThisFile) { + _test("PrimaryStory", _testStory("PrimaryStory", 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-test/internal/test-utils"; + const _meta = { + title: "automatic/calculated/title" + }; + export default _meta; + const Primary = { + args: { + label: 'Primary Button' + } + }; + export const Secondary = {}; + export { Primary }; + const _isRunningFromThisFile = import.meta.url.includes(globalThis.__vitest_worker__.filepath ?? _expect.getState().testPath); + 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 Story = {}; + export const nonStory = 123 + `; + + 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-test/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(globalThis.__vitest_worker__.filepath ?? _expect.getState().testPath); + 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-test/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(globalThis.__vitest_worker__.filepath ?? _expect.getState().testPath); + 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-test/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(globalThis.__vitest_worker__.filepath ?? _expect.getState().testPath); + 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-test/internal/test-utils"; + const _meta = { + title: "automatic/calculated/title" + }; + export default _meta; + export const Skipped = { + tags: ['skip-me'] + }; + const _isRunningFromThisFile = import.meta.url.includes(globalThis.__vitest_worker__.filepath ?? _expect.getState().testPath); + if (_isRunningFromThisFile) { + _test("Skipped", _testStory("Skipped", Skipped, _meta, ["skip-me"])); } `); }); }); - it('should add test statement to const declared exported stories', async () => { - const code = ` - export default {}; - const Primary = { + describe('source map calculation', () => { + it('should remap the location of an inline named export to its relative testStory function', async () => { + const originalCode = ` + const meta = { + title: 'Button', + component: Button, + } + export default meta; + export const Primary = {}; + `; + + const { code: transformedCode, map } = await transform({ + code: originalCode, + }); + + expect(transformedCode).toMatchInlineSnapshot(` + import { test as _test, expect as _expect } from "vitest"; + import { testStory as _testStory } from "@storybook/experimental-addon-test/internal/test-utils"; + const meta = { + title: "automatic/calculated/title", + component: Button + }; + export default meta; + export const Primary = {}; + const _isRunningFromThisFile = import.meta.url.includes(globalThis.__vitest_worker__.filepath ?? _expect.getState().testPath); + if (_isRunningFromThisFile) { + _test("Primary", _testStory("Primary", Primary, meta, [])); + } + `); + + const consumer = await new SourceMapConsumer(map as unknown as RawSourceMap); + + // Locate `__test("Primary"...` in the transformed code + const testPrimaryLine = + transformedCode.split('\n').findIndex((line) => line.includes('_test("Primary"')) + 1; + const testPrimaryColumn = transformedCode + .split('\n') + [testPrimaryLine - 1].indexOf('_test("Primary"'); + + // Get the original position from the source map for `__test("Primary"...` + const originalPosition = consumer.originalPositionFor({ + line: testPrimaryLine, + column: testPrimaryColumn, + }); + + // Locate `export const Primary` in the original code + const originalPrimaryLine = + originalCode.split('\n').findIndex((line) => line.includes('export const Primary')) + 1; + const originalPrimaryColumn = originalCode + .split('\n') + [originalPrimaryLine - 1].indexOf('export const Primary'); + + // The original locations of the transformed code should match with the ones of the original code + expect(originalPosition.line, 'original line location').toBe(originalPrimaryLine); + expect(originalPosition.column, 'original column location').toBe(originalPrimaryColumn); + }); + }); + }); + + describe('CSF Factories', () => { + describe('default exports (meta)', () => { + it('should add title to inline default export if not present', async () => { + const code = ` + import { config } from '#.storybook/preview'; + const meta = config.meta({ component: Button }); + export const Story = meta.story({}); + `; + + const result = await transform({ code }); + + expect(getStoryTitle).toHaveBeenCalled(); + + expect(result.code).toMatchInlineSnapshot(` + import { test as _test, expect as _expect } from "vitest"; + import { testStory as _testStory } from "@storybook/experimental-addon-test/internal/test-utils"; + import { config } from '#.storybook/preview'; + const meta = config.meta({ + component: Button, + title: "automatic/calculated/title" + }); + export const Story = meta.story({}); + const _isRunningFromThisFile = import.meta.url.includes(globalThis.__vitest_worker__.filepath ?? _expect.getState().testPath); + if (_isRunningFromThisFile) { + _test("Story", _testStory("Story", Story, meta, [])); + } + `); + }); + }); + + describe('named exports (stories)', () => { + it("should use the story's name as test title", async () => { + const code = ` + import { config } from '#.storybook/preview'; + const meta = config.meta({ component: Button }); + export const Primary = meta.story({ name: "custom name" });`; + 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-test/internal/test-utils"; + import { config } from '#.storybook/preview'; + const meta = config.meta({ + component: Button, + title: "automatic/calculated/title" + }); + export const Primary = meta.story({ + name: "custom name" + }); + const _isRunningFromThisFile = import.meta.url.includes(globalThis.__vitest_worker__.filepath ?? _expect.getState().testPath); + if (_isRunningFromThisFile) { + _test("custom name", _testStory("Primary", Primary, meta, [])); + } + `); + }); + + it('should add test statement to const declared exported stories', async () => { + const code = ` + import { config } from '#.storybook/preview'; + const meta = config.meta({ component: Button }); + const Primary = meta.story({ args: { label: 'Primary Button', - }, - }; + } + }); export { Primary }; `; - const result = await transform({ code }); + 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-test/internal/test-utils"; - const _meta = { - title: "automatic/calculated/title" - }; - export default _meta; - const Primary = { - args: { - label: 'Primary Button' + expect(result.code).toMatchInlineSnapshot(` + import { test as _test, expect as _expect } from "vitest"; + import { testStory as _testStory } from "@storybook/experimental-addon-test/internal/test-utils"; + import { config } from '#.storybook/preview'; + const meta = config.meta({ + component: Button, + title: "automatic/calculated/title" + }); + const Primary = meta.story({ + args: { + label: 'Primary Button' + } + }); + export { Primary }; + const _isRunningFromThisFile = import.meta.url.includes(globalThis.__vitest_worker__.filepath ?? _expect.getState().testPath); + if (_isRunningFromThisFile) { + _test("Primary", _testStory("Primary", Primary, meta, [])); } - }; - export { Primary }; - const _isRunningFromThisFile = import.meta.url.includes(globalThis.__vitest_worker__.filepath ?? _expect.getState().testPath); - if (_isRunningFromThisFile) { - _test("Primary", _testStory("Primary", Primary, _meta, [])); - } - `); - }); + `); + }); - it('should add test statement to const declared renamed exported stories', async () => { - const code = ` - export default {}; - const Primary = { + it('should add test statement to const declared renamed exported stories', async () => { + const code = ` + import { config } from '#.storybook/preview'; + const meta = config.meta({ component: Button }); + const Primary = meta.story({ args: { label: 'Primary Button', - }, - }; + } + }); export { Primary as PrimaryStory }; `; - const result = await transform({ code }); + 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-test/internal/test-utils"; - const _meta = { - title: "automatic/calculated/title" - }; - export default _meta; - const Primary = { - args: { - label: 'Primary Button' + expect(result.code).toMatchInlineSnapshot(` + import { test as _test, expect as _expect } from "vitest"; + import { testStory as _testStory } from "@storybook/experimental-addon-test/internal/test-utils"; + import { config } from '#.storybook/preview'; + const meta = config.meta({ + component: Button, + title: "automatic/calculated/title" + }); + const Primary = meta.story({ + args: { + label: 'Primary Button' + } + }); + export { Primary as PrimaryStory }; + const _isRunningFromThisFile = import.meta.url.includes(globalThis.__vitest_worker__.filepath ?? _expect.getState().testPath); + if (_isRunningFromThisFile) { + _test("PrimaryStory", _testStory("PrimaryStory", Primary, meta, [])); } - }; - export { Primary as PrimaryStory }; - const _isRunningFromThisFile = import.meta.url.includes(globalThis.__vitest_worker__.filepath ?? _expect.getState().testPath); - if (_isRunningFromThisFile) { - _test("PrimaryStory", _testStory("PrimaryStory", Primary, _meta, [])); - } - `); - }); + `); + }); - it('should add tests for multiple stories', async () => { - const code = ` + it('should add tests for multiple stories', async () => { + const code = ` export default {}; const Primary = { args: { @@ -344,8 +717,8 @@ describe('transformer', () => { export { Primary }; `; - const result = await transform({ code }); - expect(result.code).toMatchInlineSnapshot(` + 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-test/internal/test-utils"; const _meta = { @@ -365,10 +738,10 @@ describe('transformer', () => { _test("Primary", _testStory("Primary", Primary, _meta, [])); } `); - }); + }); - it('should exclude exports via excludeStories', async () => { - const code = ` + it('should exclude exports via excludeStories', async () => { + const code = ` export default { title: 'Button', component: Button, @@ -378,9 +751,9 @@ describe('transformer', () => { export const nonStory = 123 `; - const result = await transform({ code }); + const result = await transform({ code }); - expect(result.code).toMatchInlineSnapshot(` + expect(result.code).toMatchInlineSnapshot(` import { test as _test, expect as _expect } from "vitest"; import { testStory as _testStory } from "@storybook/experimental-addon-test/internal/test-utils"; const _meta = { @@ -396,10 +769,10 @@ describe('transformer', () => { _test("Story", _testStory("Story", Story, _meta, [])); } `); - }); + }); - it('should return a describe with skip if there are no valid stories', async () => { - const code = ` + it('should return a describe with skip if there are no valid stories', async () => { + const code = ` export default { title: 'Button', component: Button, @@ -407,9 +780,9 @@ describe('transformer', () => { } export const Story = {} `; - const result = await transform({ code }); + const result = await transform({ code }); - expect(result.code).toMatchInlineSnapshot(` + expect(result.code).toMatchInlineSnapshot(` import { test as _test, describe as _describe } from "vitest"; const _meta = { title: "automatic/calculated/title", @@ -420,156 +793,156 @@ describe('transformer', () => { 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-test/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(globalThis.__vitest_worker__.filepath ?? _expect.getState().testPath); - if (_isRunningFromThisFile) { - _test("Included", _testStory("Included", Included, _meta, [])); - } - `); }); - it('should exclude stories from tags.exclude', async () => { - const code = ` - export default {}; - export const Included = {}; + describe('tags filtering mechanism', () => { + it('should only include stories from tags.include', async () => { + const code = ` + import { config } from '#.storybook/preview'; + const meta = config.meta({}); + export const Included = meta.story({ tags: ['include-me'] }); - export const NotIncluded = { tags: ['exclude-me'] } + export const NotIncluded = meta.story({}); `; - const result = await transform({ - code, - tagsFilter: { include: ['test'], exclude: ['exclude-me'], skip: [] }, + 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-test/internal/test-utils"; + import { config } from '#.storybook/preview'; + const meta = config.meta({ + title: "automatic/calculated/title" + }); + export const Included = meta.story({ + tags: ['include-me'] + }); + export const NotIncluded = meta.story({}); + const _isRunningFromThisFile = import.meta.url.includes(globalThis.__vitest_worker__.filepath ?? _expect.getState().testPath); + if (_isRunningFromThisFile) { + _test("Included", _testStory("Included", Included, meta, [])); + } + `); }); - expect(result.code).toMatchInlineSnapshot(` - import { test as _test, expect as _expect } from "vitest"; - import { testStory as _testStory } from "@storybook/experimental-addon-test/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(globalThis.__vitest_worker__.filepath ?? _expect.getState().testPath); - if (_isRunningFromThisFile) { - _test("Included", _testStory("Included", Included, _meta, [])); - } - `); + it('should exclude stories from tags.exclude', async () => { + const code = ` + import { config } from '#.storybook/preview'; + const meta = config.meta({}); + export const Included = meta.story({}); + + export const NotIncluded = meta.story({ 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-test/internal/test-utils"; + import { config } from '#.storybook/preview'; + const meta = config.meta({ + title: "automatic/calculated/title" + }); + export const Included = meta.story({}); + export const NotIncluded = meta.story({ + tags: ['exclude-me'] + }); + const _isRunningFromThisFile = import.meta.url.includes(globalThis.__vitest_worker__.filepath ?? _expect.getState().testPath); + if (_isRunningFromThisFile) { + _test("Included", _testStory("Included", Included, meta, [])); + } + `); + }); + + it('should pass skip tags to testStory call using tags.skip', async () => { + const code = ` + import { config } from '#.storybook/preview'; + const meta = config.meta({}); + export const Skipped = meta.story({ 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-test/internal/test-utils"; + import { config } from '#.storybook/preview'; + const meta = config.meta({ + title: "automatic/calculated/title" + }); + export const Skipped = meta.story({ + tags: ['skip-me'] + }); + const _isRunningFromThisFile = import.meta.url.includes(globalThis.__vitest_worker__.filepath ?? _expect.getState().testPath); + if (_isRunningFromThisFile) { + _test("Skipped", _testStory("Skipped", Skipped, meta, ["skip-me"])); + } + `); + }); }); - it('should pass skip tags to testStory call using tags.skip', async () => { - const code = ` - export default {}; - export const Skipped = { tags: ['skip-me'] }; + describe('source map calculation', () => { + it('should remap the location of an inline named export to its relative testStory function', async () => { + const originalCode = ` + import { config } from '#.storybook/preview'; + const meta = config.meta({}); + export const Primary = meta.story({}); `; - const result = await transform({ - code, - tagsFilter: { include: ['test'], exclude: [], skip: ['skip-me'] }, + const { code: transformedCode, map } = await transform({ + code: originalCode, + }); + + expect(transformedCode).toMatchInlineSnapshot(` + import { test as _test, expect as _expect } from "vitest"; + import { testStory as _testStory } from "@storybook/experimental-addon-test/internal/test-utils"; + import { config } from '#.storybook/preview'; + const meta = config.meta({ + title: "automatic/calculated/title" + }); + export const Primary = meta.story({}); + const _isRunningFromThisFile = import.meta.url.includes(globalThis.__vitest_worker__.filepath ?? _expect.getState().testPath); + if (_isRunningFromThisFile) { + _test("Primary", _testStory("Primary", Primary, meta, [])); + } + `); + + const consumer = await new SourceMapConsumer(map as unknown as RawSourceMap); + + // Locate `__test("Primary"...` in the transformed code + const testPrimaryLine = + transformedCode.split('\n').findIndex((line) => line.includes('_test("Primary"')) + 1; + const testPrimaryColumn = transformedCode + .split('\n') + [testPrimaryLine - 1].indexOf('_test("Primary"'); + + // Get the original position from the source map for `__test("Primary"...` + const originalPosition = consumer.originalPositionFor({ + line: testPrimaryLine, + column: testPrimaryColumn, + }); + + // Locate `export const Primary` in the original code + const originalPrimaryLine = + originalCode.split('\n').findIndex((line) => line.includes('export const Primary')) + 1; + const originalPrimaryColumn = originalCode + .split('\n') + [originalPrimaryLine - 1].indexOf('export const Primary'); + + // The original locations of the transformed code should match with the ones of the original code + expect(originalPosition.line, 'original line location').toBe(originalPrimaryLine); + expect(originalPosition.column, 'original column location').toBe(originalPrimaryColumn); }); - - expect(result.code).toMatchInlineSnapshot(` - import { test as _test, expect as _expect } from "vitest"; - import { testStory as _testStory } from "@storybook/experimental-addon-test/internal/test-utils"; - const _meta = { - title: "automatic/calculated/title" - }; - export default _meta; - export const Skipped = { - tags: ['skip-me'] - }; - const _isRunningFromThisFile = import.meta.url.includes(globalThis.__vitest_worker__.filepath ?? _expect.getState().testPath); - if (_isRunningFromThisFile) { - _test("Skipped", _testStory("Skipped", Skipped, _meta, ["skip-me"])); - } - `); - }); - }); - - describe('source map calculation', () => { - it('should remap the location of an inline named export to its relative testStory function', async () => { - const originalCode = ` - const meta = { - title: 'Button', - component: Button, - } - export default meta; - export const Primary = {}; - `; - - const { code: transformedCode, map } = await transform({ - code: originalCode, - }); - - expect(transformedCode).toMatchInlineSnapshot(` - import { test as _test, expect as _expect } from "vitest"; - import { testStory as _testStory } from "@storybook/experimental-addon-test/internal/test-utils"; - const meta = { - title: "automatic/calculated/title", - component: Button - }; - export default meta; - export const Primary = {}; - const _isRunningFromThisFile = import.meta.url.includes(globalThis.__vitest_worker__.filepath ?? _expect.getState().testPath); - if (_isRunningFromThisFile) { - _test("Primary", _testStory("Primary", Primary, meta, [])); - } - `); - - const consumer = await new SourceMapConsumer(map as unknown as RawSourceMap); - - // Locate `__test("Primary"...` in the transformed code - const testPrimaryLine = - transformedCode.split('\n').findIndex((line) => line.includes('_test("Primary"')) + 1; - const testPrimaryColumn = transformedCode - .split('\n') - [testPrimaryLine - 1].indexOf('_test("Primary"'); - - // Get the original position from the source map for `__test("Primary"...` - const originalPosition = consumer.originalPositionFor({ - line: testPrimaryLine, - column: testPrimaryColumn, - }); - - // Locate `export const Primary` in the original code - const originalPrimaryLine = - originalCode.split('\n').findIndex((line) => line.includes('export const Primary')) + 1; - const originalPrimaryColumn = originalCode - .split('\n') - [originalPrimaryLine - 1].indexOf('export const Primary'); - - // The original locations of the transformed code should match with the ones of the original code - expect(originalPosition.line, 'original line location').toBe(originalPrimaryLine); - expect(originalPosition.column, 'original column location').toBe(originalPrimaryColumn); }); }); diff --git a/code/core/src/preview-api/modules/store/csf/portable-stories.ts b/code/core/src/preview-api/modules/store/csf/portable-stories.ts index 774137dcf85..c843826609e 100644 --- a/code/core/src/preview-api/modules/store/csf/portable-stories.ts +++ b/code/core/src/preview-api/modules/store/csf/portable-stories.ts @@ -275,7 +275,13 @@ export function composeStories( globalConfig: ProjectAnnotations, composeStoryFn: ComposeStoryFn = defaultComposeStory ) { - const { default: meta, __esModule, __namedExportsOrder, ...stories } = storiesImport; + const { default: metaExport, __esModule, __namedExportsOrder, ...stories } = storiesImport; + let meta = metaExport; + const firstStory = Object.values(stories)[0] as any; + if (!meta && 'isCSFFactory' in firstStory) { + meta = firstStory.meta.annotations; + } + const composedStories = Object.entries(stories).reduce((storiesMap, [exportsName, story]) => { if (!isExportStory(exportsName, meta)) { return storiesMap; diff --git a/code/renderers/react/src/__test__/Button.csf4.stories.tsx b/code/renderers/react/src/__test__/Button.csf4.stories.tsx new file mode 100644 index 00000000000..358961add9a --- /dev/null +++ b/code/renderers/react/src/__test__/Button.csf4.stories.tsx @@ -0,0 +1,288 @@ +import React, { useEffect, useState } from 'react'; +import { createPortal } from 'react-dom'; + +import { expect, fn, mocked, userEvent, within } from '@storybook/test'; + +import { action } from '@storybook/addon-actions'; + +import { defineConfig } from '../preview'; +import { Button } from './Button'; + +// eslint-disable-next-line storybook/default-exports +const config = defineConfig({ args: { children: 'TODO: THIS IS NOT WORKING YET' } }); + +const meta = config.meta({ + id: 'button-component', + title: 'Example/CSF4/Button', + component: Button, + argTypes: { + backgroundColor: { control: 'color' }, + }, + args: {}, +}); + +export const CSF2Secondary = meta.story({ + render: (args) => { + return + + ); + }, + name: 'WithLocale', +}); + +export const CSF2StoryWithParamsAndDecorator = meta.story({ + render: (args: any) => { + return + + ); + }, + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement); + await step('Step label', async () => { + const inputEl = canvas.getByTestId('input'); + const buttonEl = canvas.getByRole('button'); + await userEvent.click(buttonEl); + await userEvent.type(inputEl, 'Hello world!'); + + await expect(inputEl).toHaveValue('Hello world!'); + await expect(buttonEl).toHaveTextContent('I am clicked'); + }); + }, +}); + +export const CSF3InputFieldFilled = meta.story({ + render: () => { + return ; + }, + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement); + await step('Step label', async () => { + const inputEl = canvas.getByTestId('input'); + await userEvent.type(inputEl, 'Hello world!'); + await expect(inputEl).toHaveValue('Hello world!'); + }); + }, +}); + +const mockFn = fn(); +export const LoaderStory = meta.story({ + args: { + mockFn, + }, + loaders: [ + async () => { + mockFn.mockReturnValueOnce('mockFn return value'); + return { + value: 'loaded data', + }; + }, + ], + render: (args: any & { mockFn: (val: string) => string }, { loaded }) => { + const data = args.mockFn('render'); + return ( +
+
{loaded.value}
+
{String(data)}
+
+ ); + }, + play: async () => { + expect(mockFn).toHaveBeenCalledWith('render'); + }, +}); + +export const MountInPlayFunction = meta.story({ + args: { + mockFn: fn(), + }, + play: async ({ args, mount, context }: any) => { + // equivalent of loaders + const loadedData = await Promise.resolve('loaded data'); + mocked(args.mockFn).mockReturnValueOnce('mockFn return value'); + // equivalent of render + const data = args.mockFn('render'); + // TODO refactor this in the mount args PR + context.originalStoryFn = () => ( +
+
{loadedData}
+
{String(data)}
+
+ ); + await mount(); + + // equivalent of play + expect(args.mockFn).toHaveBeenCalledWith('render'); + }, +}); + +export const MountInPlayFunctionThrow = meta.story({ + play: async () => { + throw new Error('Error thrown in play'); + }, +}); + +export const WithActionArg = meta.story({ + args: { + someActionArg: action('some-action-arg'), + }, + render: (args: any) => { + args.someActionArg('in render'); + return ( + + , + modalContainer + ) + : null; + + return ( + <> + + {modalContent} + + ); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const openModalButton = await canvas.getByRole('button', { name: /open modal/i }); + await userEvent.click(openModalButton); + await expect(within(document.body).getByRole('dialog')).toBeInTheDocument(); + }, +}); diff --git a/code/renderers/react/src/__test__/__snapshots__/portable-stories-factory.test.tsx.snap b/code/renderers/react/src/__test__/__snapshots__/portable-stories-factory.test.tsx.snap new file mode 100644 index 00000000000..3f00ff74628 --- /dev/null +++ b/code/renderers/react/src/__test__/__snapshots__/portable-stories-factory.test.tsx.snap @@ -0,0 +1,185 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Renders CSF2Secondary story 1`] = ` + +
+ +
+ +`; + +exports[`Renders CSF2StoryWithParamsAndDecorator story 1`] = ` + +
+ +
+ +`; + +exports[`Renders CSF3Button story 1`] = ` + +
+ +
+ +`; + +exports[`Renders CSF3ButtonWithRender story 1`] = ` + +
+
+

+ I am a custom render function +

+ +
+
+ +`; + +exports[`Renders CSF3InputFieldFilled story 1`] = ` + +
+ +
+ +`; + +exports[`Renders CSF3Primary story 1`] = ` + +
+ +
+ +`; + +exports[`Renders HooksStory story 1`] = ` + +
+ +
+ +
+ +`; + +exports[`Renders LoaderStory story 1`] = ` + +
+
+
+ loaded data +
+
+ mockFn return value +
+
+
+ +`; + +exports[`Renders Modal story 1`] = ` + +
+ +
+ + +`; + +exports[`Renders MountInPlayFunction story 1`] = ` + +
+
+
+ loaded data +
+
+ mockFn return value +
+
+
+ +`; + +exports[`Renders WithActionArg story 1`] = ` + +
+
+ +`; + +exports[`Renders WithActionArgType story 1`] = ` + +
+
+ nothing +
+
+ +`; diff --git a/code/renderers/react/src/__test__/portable-stories-factory.test.tsx b/code/renderers/react/src/__test__/portable-stories-factory.test.tsx new file mode 100644 index 00000000000..7ad6669c202 --- /dev/null +++ b/code/renderers/react/src/__test__/portable-stories-factory.test.tsx @@ -0,0 +1,247 @@ +// @vitest-environment happy-dom + +/* eslint-disable import/namespace */ +import { cleanup, render, screen } from '@testing-library/react'; +import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; + +import React from 'react'; + +import { addons } from 'storybook/internal/preview-api'; + +import type { ProjectAnnotations } from '@storybook/csf'; +import type { Meta, ReactRenderer } from '@storybook/react'; + +import * as addonActionsPreview from '@storybook/addon-actions/preview'; + +import { expectTypeOf } from 'expect-type'; + +import { composeStories, composeStory, setProjectAnnotations } from '..'; +import type { Button } from './Button'; +import * as ButtonStories from './Button.csf4.stories'; +import * as ComponentWithErrorStories from './ComponentWithError.stories'; + +const HooksStory = composeStory( + ButtonStories.HooksStory, + ButtonStories.CSF3Primary.meta.annotations +); + +const projectAnnotations = setProjectAnnotations([]); + +// example with composeStories, returns an object with all stories composed with args/decorators +const { CSF3Primary, LoaderStory, MountInPlayFunction, MountInPlayFunctionThrow } = + composeStories(ButtonStories); +const { ThrowsError } = composeStories(ComponentWithErrorStories); + +beforeAll(async () => { + await projectAnnotations.beforeAll?.(); +}); + +afterEach(() => { + cleanup(); +}); + +// example with composeStory, returns a single story composed with args/decorators +const Secondary = composeStory( + ButtonStories.CSF2Secondary, + ButtonStories.CSF3Primary.meta.annotations +); +describe('renders', () => { + it('renders primary button', () => { + render(Hello world); + const buttonElement = screen.getByText(/Hello world/i); + expect(buttonElement).not.toBeNull(); + }); + + it('reuses args from composed story', () => { + render(); + const buttonElement = screen.getByRole('button'); + expect(buttonElement.textContent).toEqual(Secondary.args.children); + }); + + it('onclick handler is called', async () => { + const onClickSpy = vi.fn(); + render(); + const buttonElement = screen.getByRole('button'); + buttonElement.click(); + expect(onClickSpy).toHaveBeenCalled(); + }); + + it('reuses args from composeStories', () => { + const { getByText } = render(); + const buttonElement = getByText(/foo/i); + expect(buttonElement).not.toBeNull(); + }); + + it('should throw error when rendering a component with a render error', async () => { + await expect(() => ThrowsError.run()).rejects.toThrowError('Error in render'); + }); + + it('should render component mounted in play function', async () => { + await MountInPlayFunction.run(); + + expect(screen.getByTestId('spy-data').textContent).toEqual('mockFn return value'); + expect(screen.getByTestId('loaded-data').textContent).toEqual('loaded data'); + }); + + it('should throw an error in play function', () => { + expect(() => MountInPlayFunctionThrow.run()).rejects.toThrowError('Error thrown in play'); + }); + + it('should call and compose loaders data', async () => { + await LoaderStory.load(); + const { getByTestId } = render(); + expect(getByTestId('spy-data').textContent).toEqual('mockFn return value'); + expect(getByTestId('loaded-data').textContent).toEqual('loaded data'); + // spy assertions happen in the play function and should work + await LoaderStory.run!(); + }); +}); + +describe('projectAnnotations', () => { + it('renders with default projectAnnotations', () => { + setProjectAnnotations([ + { + parameters: { injected: true }, + globalTypes: { + locale: { defaultValue: 'en' }, + }, + }, + ]); + const WithEnglishText = composeStory( + ButtonStories.CSF2StoryWithLocale, + ButtonStories.CSF3Primary.meta.annotations + ); + const { getByText } = render(); + const buttonElement = getByText('Hello!'); + expect(buttonElement).not.toBeNull(); + expect(WithEnglishText.parameters?.injected).toBe(true); + }); + + it('renders with custom projectAnnotations via composeStory params', () => { + const WithPortugueseText = composeStory( + ButtonStories.CSF2StoryWithLocale, + ButtonStories.CSF3Primary.meta.annotations, + { + initialGlobals: { locale: 'pt' }, + } + ); + const { getByText } = render(); + const buttonElement = getByText('Olá!'); + expect(buttonElement).not.toBeNull(); + }); + + it('has action arg from argTypes when addon-actions annotations are added', () => { + const Story = composeStory( + ButtonStories.WithActionArgType, + ButtonStories.CSF3Primary.meta.annotations, + addonActionsPreview as ProjectAnnotations + ); + expect(Story.args.someActionArg).toHaveProperty('isAction', true); + }); +}); + +describe('CSF3', () => { + it('renders with inferred globalRender', () => { + const Primary = composeStory( + ButtonStories.CSF3Button, + ButtonStories.CSF3Primary.meta.annotations + ); + + render(Hello world); + const buttonElement = screen.getByText(/Hello world/i); + expect(buttonElement).not.toBeNull(); + }); + + it('renders with custom render function', () => { + const Primary = composeStory( + ButtonStories.CSF3ButtonWithRender, + ButtonStories.CSF3Primary.meta.annotations + ); + + render(); + expect(screen.getByTestId('custom-render')).not.toBeNull(); + }); + + it('renders with play function without canvas element', async () => { + const CSF3InputFieldFilled = composeStory( + ButtonStories.CSF3InputFieldFilled, + ButtonStories.CSF3Primary.meta.annotations + ); + await CSF3InputFieldFilled.run(); + + const input = screen.getByTestId('input') as HTMLInputElement; + expect(input.value).toEqual('Hello world!'); + }); + + it('renders with play function with canvas element', async () => { + const CSF3InputFieldFilled = composeStory( + ButtonStories.CSF3InputFieldFilled, + ButtonStories.CSF3Primary.meta.annotations + ); + + const div = document.createElement('div'); + document.body.appendChild(div); + + await CSF3InputFieldFilled.run({ canvasElement: div }); + + const input = screen.getByTestId('input') as HTMLInputElement; + expect(input.value).toEqual('Hello world!'); + + document.body.removeChild(div); + }); + + it('renders with hooks', async () => { + await HooksStory.run(); + + const input = screen.getByTestId('input') as HTMLInputElement; + expect(input.value).toEqual('Hello world!'); + }); +}); + +// common in addons that need to communicate between manager and preview +it('should pass with decorators that need addons channel', () => { + const PrimaryWithChannels = composeStory( + ButtonStories.CSF3Primary, + ButtonStories.CSF3Primary.meta.annotations, + { + decorators: [ + (StoryFn: any) => { + addons.getChannel(); + return StoryFn(); + }, + ], + } + ); + render(Hello world); + const buttonElement = screen.getByText(/Hello world/i); + expect(buttonElement).not.toBeNull(); +}); + +describe('ComposeStories types', () => { + // this file tests Typescript types that's why there are no assertions + it('Should support typescript operators', () => { + type ComposeStoriesParam = Parameters[0]; + + expectTypeOf({ + ...ButtonStories, + default: ButtonStories.CSF3Primary.meta.annotations as Meta, + }).toMatchTypeOf(); + + expectTypeOf({ + ...ButtonStories, + default: ButtonStories.CSF3Primary.meta.annotations satisfies Meta, + }).toMatchTypeOf(); + }); +}); + +const testCases = Object.values(composeStories(ButtonStories)).map( + (Story) => [Story.storyName, Story] as [string, typeof Story] +); +it.each(testCases)('Renders %s story', async (_storyName, Story) => { + if (_storyName === 'CSF2StoryWithLocale' || _storyName === 'MountInPlayFunctionThrow') { + return; + } + + await Story.run(); + expect(document.body).toMatchSnapshot(); +}); diff --git a/code/renderers/react/src/preview.tsx b/code/renderers/react/src/preview.tsx index 655f246598d..8cd9c5cec2d 100644 --- a/code/renderers/react/src/preview.tsx +++ b/code/renderers/react/src/preview.tsx @@ -59,7 +59,9 @@ class Story { public annotations: StoryAnnotations, public meta: Meta, public config: PreviewConfig - ) {} + ) { + Object.assign(this, annotations); + } readonly isCSFFactory = true; }