From e1feeca65e1447eed7f2670616fca56ca06c450f Mon Sep 17 00:00:00 2001 From: Yann Braga Date: Wed, 14 Feb 2024 10:30:13 +0100 Subject: [PATCH 1/2] Portable stories: Add support for loaders --- .../store/csf/portable-stories.test.ts | 74 ++++++++++++++++--- .../src/modules/store/csf/portable-stories.ts | 14 ++-- code/lib/types/src/modules/composedStory.ts | 1 + 3 files changed, 75 insertions(+), 14 deletions(-) diff --git a/code/lib/preview-api/src/modules/store/csf/portable-stories.test.ts b/code/lib/preview-api/src/modules/store/csf/portable-stories.test.ts index 2ad7f7500f5b..2dd231550b7b 100644 --- a/code/lib/preview-api/src/modules/store/csf/portable-stories.test.ts +++ b/code/lib/preview-api/src/modules/store/csf/portable-stories.test.ts @@ -1,9 +1,16 @@ import { describe, expect, vi, it } from 'vitest'; +import type { + ComponentAnnotations as Meta, + StoryAnnotationsOrFn as Story, + Store_CSFExports, +} from '@storybook/types'; import { composeStory, composeStories } from './portable-stories'; +type StoriesModule = Store_CSFExports & Record; + // Most integration tests for this functionality are located under renderers/react describe('composeStory', () => { - const meta = { + const meta: Meta = { title: 'Button', parameters: { firstAddon: true, @@ -14,8 +21,57 @@ describe('composeStory', () => { }, }; + it('should call and compose loaders data', async () => { + const loadSpy = vi.fn(); + const args = { story: 'story' }; + const LoaderStory: Story = { + args, + render: (_args, { loaded }) => { + expect(loaded).toEqual({ foo: 'bar' }); + }, + loaders: [ + async (context) => { + loadSpy(); + expect(context.args).toEqual(args); + return { + foo: 'bar', + }; + }, + ], + }; + + const composedStory = composeStory(LoaderStory, {}); + await composedStory.load(); + expect(loadSpy).toHaveBeenCalled(); + composedStory(); + }); + + it('should work with spies set up in loaders', async () => { + const spyFn = vi.fn(); + + const Story: Story = { + args: { + spyFn, + }, + loaders: [ + async () => { + spyFn.mockReturnValue('mockedData'); + }, + ], + render: (args) => { + const data = args.spyFn(); + expect(spyFn).toHaveBeenCalled(); + expect(data).toBe('mockedData'); + }, + }; + + const composedStory = composeStory(Story, {}); + await composedStory.load(); + composedStory(); + }); + it('should return story with composed args and parameters', () => { - const Story = () => {}; + const Story: Story = () => {}; Story.args = { primary: true }; Story.parameters = { parameters: { @@ -32,7 +88,7 @@ describe('composeStory', () => { it('should compose with a play function', async () => { const spy = vi.fn(); - const Story = () => {}; + const Story: Story = () => {}; Story.args = { primary: true, }; @@ -61,7 +117,7 @@ describe('composeStory', () => { describe('Id of the story', () => { it('is exposed correctly when composeStories is used', () => { - const module = { + const module: StoriesModule = { default: { title: 'Example/Button', }, @@ -71,7 +127,7 @@ describe('composeStory', () => { expect(Primary.id).toBe('example-button--csf-3-primary'); }); it('is exposed correctly when composeStory is used and exportsName is passed', () => { - const module = { + const module: StoriesModule = { default: { title: 'Example/Button', }, @@ -92,7 +148,7 @@ describe('composeStories', () => { const defaultAnnotations = { render: () => '' }; it('should call composeStoryFn with stories', () => { const composeStorySpy = vi.fn((v) => v); - const module = { + const module: StoriesModule = { default: { title: 'Button', }, @@ -117,7 +173,7 @@ describe('composeStories', () => { it('should not call composeStoryFn for non-story exports', () => { const composeStorySpy = vi.fn((v) => v); - const module = { + const module: StoriesModule = { default: { title: 'Button', excludeStories: /Data/, @@ -130,7 +186,7 @@ describe('composeStories', () => { describe('non-story exports', () => { it('should filter non-story exports with excludeStories', () => { - const StoryModuleWithNonStoryExports = { + const StoryModuleWithNonStoryExports: StoriesModule = { default: { title: 'Some/Component', excludeStories: /.*Data/, @@ -148,7 +204,7 @@ describe('composeStories', () => { }); it('should filter non-story exports with includeStories', () => { - const StoryModuleWithNonStoryExports = { + const StoryModuleWithNonStoryExports: StoriesModule = { default: { title: 'Some/Component', includeStories: /.*Story/, diff --git a/code/lib/preview-api/src/modules/store/csf/portable-stories.ts b/code/lib/preview-api/src/modules/store/csf/portable-stories.ts index 91e4cdc365e1..bd2dd421ccfe 100644 --- a/code/lib/preview-api/src/modules/store/csf/portable-stories.ts +++ b/code/lib/preview-api/src/modules/store/csf/portable-stories.ts @@ -87,16 +87,20 @@ export function composeStory> = Object.assign( - (extraArgs?: Partial) => { - const finalContext: StoryContext = { - ...context, - args: { ...context.initialArgs, ...extraArgs }, + function storyFn(extraArgs?: Partial) { + context.args = { + ...context.args, + ...extraArgs, }; - return story.unboundStoryFn(prepareContext(finalContext)); + return story.unboundStoryFn(prepareContext(context)); }, { storyName, + load: async () => { + const loadedContext = await story.applyLoaders(context); + context.loaded = loadedContext.loaded; + }, args: story.initialArgs as Partial, parameters: story.parameters as Parameters, argTypes: story.argTypes as StrictArgTypes, diff --git a/code/lib/types/src/modules/composedStory.ts b/code/lib/types/src/modules/composedStory.ts index 5ce61bc678e8..24bf673cabe6 100644 --- a/code/lib/types/src/modules/composedStory.ts +++ b/code/lib/types/src/modules/composedStory.ts @@ -55,6 +55,7 @@ export type ComposedStoryFn< TRenderer extends Renderer = Renderer, TArgs = Args, > = PartialArgsStoryFn & { + load: () => Promise; play: ComposedStoryPlayFn | undefined; args: TArgs; id: StoryId; From e335a31c72e5f985ce2ca4831c1a927fe0d70016 Mon Sep 17 00:00:00 2001 From: Yann Braga Date: Wed, 14 Feb 2024 14:28:18 +0100 Subject: [PATCH 2/2] Portable stories: Add tests for React and Vue renderers --- .../store/csf/portable-stories.test.ts | 8 +-- .../src/modules/store/csf/portable-stories.ts | 2 +- .../react/src/__test__/Button.stories.tsx | 33 +++++++++- .../portable-stories.test.tsx.snap | 19 ++++++ .../src/__test__/portable-stories.test.tsx | 21 +++++-- .../composeStories/Button.stories.ts | 36 ++++++++++- .../portable-stories.test.ts.snap | 19 ++++++ .../composeStories/portable-stories.test.ts | 61 +++++++++++-------- .../src/stories/Button.stories.tsx | 29 ++++++++- 9 files changed, 190 insertions(+), 38 deletions(-) diff --git a/code/lib/preview-api/src/modules/store/csf/portable-stories.test.ts b/code/lib/preview-api/src/modules/store/csf/portable-stories.test.ts index 2dd231550b7b..8e0452cc41fc 100644 --- a/code/lib/preview-api/src/modules/store/csf/portable-stories.test.ts +++ b/code/lib/preview-api/src/modules/store/csf/portable-stories.test.ts @@ -26,9 +26,6 @@ describe('composeStory', () => { const args = { story: 'story' }; const LoaderStory: Story = { args, - render: (_args, { loaded }) => { - expect(loaded).toEqual({ foo: 'bar' }); - }, loaders: [ async (context) => { loadSpy(); @@ -38,6 +35,9 @@ describe('composeStory', () => { }; }, ], + render: (_args, { loaded }) => { + expect(loaded).toEqual({ foo: 'bar' }); + }, }; const composedStory = composeStory(LoaderStory, {}); @@ -60,7 +60,6 @@ describe('composeStory', () => { ], render: (args) => { const data = args.spyFn(); - expect(spyFn).toHaveBeenCalled(); expect(data).toBe('mockedData'); }, }; @@ -68,6 +67,7 @@ describe('composeStory', () => { const composedStory = composeStory(Story, {}); await composedStory.load(); composedStory(); + expect(spyFn).toHaveBeenCalled(); }); it('should return story with composed args and parameters', () => { diff --git a/code/lib/preview-api/src/modules/store/csf/portable-stories.ts b/code/lib/preview-api/src/modules/store/csf/portable-stories.ts index bd2dd421ccfe..de45ab6af59b 100644 --- a/code/lib/preview-api/src/modules/store/csf/portable-stories.ts +++ b/code/lib/preview-api/src/modules/store/csf/portable-stories.ts @@ -89,7 +89,7 @@ export function composeStory> = Object.assign( function storyFn(extraArgs?: Partial) { context.args = { - ...context.args, + ...context.initialArgs, ...extraArgs, }; diff --git a/code/renderers/react/src/__test__/Button.stories.tsx b/code/renderers/react/src/__test__/Button.stories.tsx index 6882b957b136..859a02ea9d1f 100644 --- a/code/renderers/react/src/__test__/Button.stories.tsx +++ b/code/renderers/react/src/__test__/Button.stories.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { within, userEvent } from '@storybook/testing-library'; +import { within, userEvent, fn, expect } from '@storybook/test'; import type { StoryFn as CSF2Story, StoryObj as CSF3Story, Meta } from '..'; import type { ButtonProps } from './Button'; @@ -84,7 +84,36 @@ export const CSF3InputFieldFilled: CSF3Story = { play: async ({ canvasElement, step }) => { const canvas = within(canvasElement); await step('Step label', async () => { - await userEvent.type(canvas.getByTestId('input'), 'Hello world!'); + const inputEl = canvas.getByTestId('input'); + await userEvent.type(inputEl, 'Hello world!'); + await expect(inputEl).toHaveValue('Hello world!'); }); }, }; + +const spyFn = fn(); +export const LoaderStory: CSF3Story<{ spyFn: (val: string) => string }> = { + args: { + spyFn, + }, + loaders: [ + async () => { + spyFn.mockReturnValueOnce('baz'); + return { + value: 'bar', + }; + }, + ], + render: (args, { loaded }) => { + const data = args.spyFn('foo'); + return ( +
+
{loaded.value}
+
{String(data)}
+
+ ); + }, + play: async () => { + expect(spyFn).toHaveBeenCalledWith('foo'); + }, +}; diff --git a/code/renderers/react/src/__test__/__snapshots__/portable-stories.test.tsx.snap b/code/renderers/react/src/__test__/__snapshots__/portable-stories.test.tsx.snap index 2b92b1d68424..018406c3581d 100644 --- a/code/renderers/react/src/__test__/__snapshots__/portable-stories.test.tsx.snap +++ b/code/renderers/react/src/__test__/__snapshots__/portable-stories.test.tsx.snap @@ -94,3 +94,22 @@ exports[`Renders CSF3Primary story 1`] = ` `; + +exports[`Renders LoaderStory story 1`] = ` + +
+
+
+ bar +
+
+ baz +
+
+
+ +`; diff --git a/code/renderers/react/src/__test__/portable-stories.test.tsx b/code/renderers/react/src/__test__/portable-stories.test.tsx index afa0b70142e4..bcac8173dc78 100644 --- a/code/renderers/react/src/__test__/portable-stories.test.tsx +++ b/code/renderers/react/src/__test__/portable-stories.test.tsx @@ -10,7 +10,7 @@ import type { Button } from './Button'; import * as stories from './Button.stories'; // example with composeStories, returns an object with all stories composed with args/decorators -const { CSF3Primary } = composeStories(stories); +const { CSF3Primary, LoaderStory } = composeStories(stories); // example with composeStory, returns a single story composed with args/decorators const Secondary = composeStory(stories.CSF2Secondary, stories.default); @@ -44,6 +44,15 @@ describe('renders', () => { const buttonElement = getByText(/foo/i); expect(buttonElement).not.toBeNull(); }); + + it('should call and compose loaders data', async () => { + await LoaderStory.load(); + const { getByTestId, container } = render(); + expect(getByTestId('spy-data').textContent).toEqual('baz'); + expect(getByTestId('loaded-data').textContent).toEqual('bar'); + // spy assertions happen in the play function and should work + await LoaderStory.play!({ canvasElement: container as HTMLElement }); + }); }); describe('projectAnnotations', () => { @@ -139,9 +148,13 @@ describe('ComposeStories types', () => { }); // Batch snapshot testing -const testCases = Object.values(composeStories(stories)).map((Story) => [Story.storyName, Story]); +const testCases = Object.values(composeStories(stories)).map( + (Story) => [Story.storyName, Story] as [string, typeof Story] +); it.each(testCases)('Renders %s story', async (_storyName, Story) => { cleanup(); - const tree = await render(); - expect(tree.baseElement).toMatchSnapshot(); + await Story.load(); + const { container, baseElement } = await render(); + await Story.play?.({ canvasElement: container }); + expect(baseElement).toMatchSnapshot(); }); diff --git a/code/renderers/vue3/src/__tests__/composeStories/Button.stories.ts b/code/renderers/vue3/src/__tests__/composeStories/Button.stories.ts index 239416df5c35..de8b3edde4c1 100644 --- a/code/renderers/vue3/src/__tests__/composeStories/Button.stories.ts +++ b/code/renderers/vue3/src/__tests__/composeStories/Button.stories.ts @@ -1,4 +1,4 @@ -import { userEvent, within } from '@storybook/testing-library'; +import { userEvent, within, expect, fn } from '@storybook/test'; import type { Meta, StoryFn as CSF2Story, StoryObj } from '../..'; import Button from './Button.vue'; @@ -114,7 +114,39 @@ export const CSF3InputFieldFilled: CSF3Story = { play: async ({ canvasElement, step }) => { const canvas = within(canvasElement); await step('Step label', async () => { - await userEvent.type(canvas.getByTestId('input'), 'Hello world!'); + const inputEl = canvas.getByTestId('input'); + await userEvent.type(inputEl, 'Hello world!'); + await expect(inputEl).toHaveValue('Hello world!'); }); }, }; + +const spyFn = fn(); +export const LoaderStory: StoryObj<{ spyFn: (val: string) => string }> = { + args: { + spyFn, + }, + loaders: [ + async () => { + spyFn.mockReturnValueOnce('baz'); + return { + value: 'bar', + }; + }, + ], + render: (args, { loaded }) => ({ + components: { Button }, + setup() { + return { args, data: args.spyFn('foo'), loaded: loaded.value }; + }, + template: ` +
+
{{loaded}}
+
{{data}}
+
+ `, + }), + play: async () => { + expect(spyFn).toHaveBeenCalledWith('foo'); + }, +}; diff --git a/code/renderers/vue3/src/__tests__/composeStories/__snapshots__/portable-stories.test.ts.snap b/code/renderers/vue3/src/__tests__/composeStories/__snapshots__/portable-stories.test.ts.snap index 75eab08758cf..5ef40a0d82f3 100644 --- a/code/renderers/vue3/src/__tests__/composeStories/__snapshots__/portable-stories.test.ts.snap +++ b/code/renderers/vue3/src/__tests__/composeStories/__snapshots__/portable-stories.test.ts.snap @@ -86,3 +86,22 @@ exports[`Renders CSF3Primary story 1`] = ` `; + +exports[`Renders LoaderStory story 1`] = ` + +
+
+
+ bar +
+
+ baz +
+
+
+ +`; diff --git a/code/renderers/vue3/src/__tests__/composeStories/portable-stories.test.ts b/code/renderers/vue3/src/__tests__/composeStories/portable-stories.test.ts index 4c541e1c4536..9c698a4d8824 100644 --- a/code/renderers/vue3/src/__tests__/composeStories/portable-stories.test.ts +++ b/code/renderers/vue3/src/__tests__/composeStories/portable-stories.test.ts @@ -10,35 +10,46 @@ import type Button from './Button.vue'; import { composeStories, composeStory, setProjectAnnotations } from '../../portable-stories'; // example with composeStories, returns an object with all stories composed with args/decorators -const { CSF3Primary } = composeStories(stories); +const { CSF3Primary, LoaderStory } = composeStories(stories); // example with composeStory, returns a single story composed with args/decorators const Secondary = composeStory(stories.CSF2Secondary, stories.default); -it('renders primary button', () => { - render(CSF3Primary({ label: 'Hello world' })); - const buttonElement = screen.getByText(/Hello world/i); - expect(buttonElement).toBeInTheDocument(); -}); +describe('renders', () => { + it('renders primary button', () => { + render(CSF3Primary({ label: 'Hello world' })); + const buttonElement = screen.getByText(/Hello world/i); + expect(buttonElement).toBeInTheDocument(); + }); -it('reuses args from composed story', () => { - render(Secondary()); - const buttonElement = screen.getByRole('button'); - expect(buttonElement.textContent).toEqual(Secondary.args.label); -}); + it('reuses args from composed story', () => { + render(Secondary()); + const buttonElement = screen.getByRole('button'); + expect(buttonElement.textContent).toEqual(Secondary.args.label); + }); -it('myClickEvent handler is called', async () => { - const myClickEventSpy = vi.fn(); - render(Secondary({ onMyClickEvent: myClickEventSpy })); - const buttonElement = screen.getByRole('button'); - buttonElement.click(); - expect(myClickEventSpy).toHaveBeenCalled(); -}); + it('myClickEvent handler is called', async () => { + const myClickEventSpy = vi.fn(); + render(Secondary({ onMyClickEvent: myClickEventSpy })); + const buttonElement = screen.getByRole('button'); + buttonElement.click(); + expect(myClickEventSpy).toHaveBeenCalled(); + }); + + it('reuses args from composeStories', () => { + const { getByText } = render(CSF3Primary()); + const buttonElement = getByText(/foo/i); + expect(buttonElement).toBeInTheDocument(); + }); -it('reuses args from composeStories', () => { - const { getByText } = render(CSF3Primary()); - const buttonElement = getByText(/foo/i); - expect(buttonElement).toBeInTheDocument(); + it('should call and compose loaders data', async () => { + await LoaderStory.load(); + const { getByTestId, container } = render(LoaderStory()); + expect(getByTestId('spy-data').textContent).toEqual('baz'); + expect(getByTestId('loaded-data').textContent).toEqual('bar'); + // spy assertions happen in the play function and should work + await LoaderStory.play!({ canvasElement: container as HTMLElement }); + }); }); describe('projectAnnotations', () => { @@ -131,8 +142,10 @@ it.each(testCases)('Renders %s story', async (_storyName, Story) => { return; } + await Story.load(); + const { container, baseElement } = await render(Story()); + await Story.play?.({ canvasElement: container as HTMLElement }); await new Promise((resolve) => setTimeout(resolve, 0)); - const tree = await render(Story()); - expect(tree.baseElement).toMatchSnapshot(); + expect(baseElement).toMatchSnapshot(); }); diff --git a/test-storybooks/portable-stories-react/src/stories/Button.stories.tsx b/test-storybooks/portable-stories-react/src/stories/Button.stories.tsx index 83d21d2702f9..fb3793b815c1 100644 --- a/test-storybooks/portable-stories-react/src/stories/Button.stories.tsx +++ b/test-storybooks/portable-stories-react/src/stories/Button.stories.tsx @@ -1,4 +1,4 @@ -import { expect, within, userEvent } from '@storybook/test'; +import { expect, within, userEvent, fn } from '@storybook/test'; import type { StoryFn as CSF2Story, StoryObj as CSF3Story, Meta } from '@storybook/react'; import type { ButtonProps } from './Button'; @@ -99,3 +99,30 @@ export const CSF3InputFieldFilled: CSF3Story = { console.log('end of play function') }, }; + +const spyFn = fn(); +export const LoaderStory: CSF3Story<{ spyFn: (val: string) => string }> = { + args: { + spyFn, + }, + render: (args, { loaded }) => { + const data = args.spyFn('foo'); + return ( +
+
{loaded.value}
+
{String(data)}
+
+ ); + }, + loaders: [ + async () => { + spyFn.mockReturnValueOnce('mocked'); + return { + value: 'bar', + }; + }, + ], + play: async () => { + expect(spyFn).toHaveBeenCalledWith('foo'); + }, +}; \ No newline at end of file