Merge remote-tracking branch 'origin/kasper/csf-factories' into kasper/csf-factories

This commit is contained in:
Kasper Peulen 2025-01-06 14:49:53 +01:00
commit 1d2ee2ff01
9 changed files with 1477 additions and 376 deletions

View File

@ -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();
}

View File

@ -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';

View File

@ -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 {

View File

@ -58,6 +58,7 @@ describe('transformer', () => {
});
});
describe('CSF v1/v2/v3', () => {
describe('default exports (meta)', () => {
it('should add title to inline default export if not present', async () => {
const code = `
@ -572,6 +573,378 @@ describe('transformer', () => {
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 });
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, []));
}
`);
});
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 });
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, []));
}
`);
});
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 = `
import { config } from '#.storybook/preview';
const meta = config.meta({});
export const Included = meta.story({ tags: ['include-me'] });
export const NotIncluded = meta.story({});
`;
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, []));
}
`);
});
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"]));
}
`);
});
});
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 { 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);
});
});
});
describe('error handling', () => {
const warnSpy = vi.spyOn(console, 'warn');

View File

@ -275,7 +275,13 @@ export function composeStories<TModule extends Store_CSFExports>(
globalConfig: ProjectAnnotations<Renderer>,
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;

View File

@ -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 <Button {...args} />;
},
args: {
children: 'Children coming from story args!',
primary: false,
},
});
const getCaptionForLocale = (locale: string) => {
switch (locale) {
case 'es':
return 'Hola!';
case 'fr':
return 'Bonjour!';
case 'kr':
return '안녕하세요!';
case 'pt':
return 'Olá!';
case 'en':
return 'Hello!';
default:
return undefined;
}
};
export const CSF2StoryWithLocale = meta.story({
render: (args, { globals: { locale } }) => {
const caption = getCaptionForLocale(locale);
return (
<>
<p>locale: {locale}</p>
<Button>{caption}</Button>
</>
);
},
name: 'WithLocale',
});
export const CSF2StoryWithParamsAndDecorator = meta.story({
render: (args: any) => {
return <Button {...args} />;
},
args: {
children: 'foo',
},
parameters: {
layout: 'centered',
},
decorators: (StoryFn) => <StoryFn />,
});
export const CSF3Primary = meta.story({
args: {
children: 'foo',
size: 'large',
primary: true,
},
});
export const CSF3Button = meta.story({
args: { children: 'foo' },
});
export const CSF3ButtonWithRender = meta.story({
...CSF3Button,
render: (args: any) => (
<div>
<p data-testid="custom-render">I am a custom render function</p>
<Button {...args} />
</div>
),
});
export const HooksStory = meta.story({
render: function Component() {
const [isClicked, setClicked] = useState(false);
return (
<>
<input data-testid="input" />
<br />
<button onClick={() => setClicked(!isClicked)}>
I am {isClicked ? 'clicked' : 'not clicked'}
</button>
</>
);
},
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 <input data-testid="input" />;
},
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 (
<div>
<div data-testid="loaded-data">{loaded.value}</div>
<div data-testid="spy-data">{String(data)}</div>
</div>
);
},
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 = () => (
<div>
<div data-testid="loaded-data">{loadedData}</div>
<div data-testid="spy-data">{String(data)}</div>
</div>
);
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 (
<button
onClick={() => {
args.someActionArg('on click');
}}
/>
);
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const buttonEl = await canvas.getByRole('button');
await buttonEl.click();
},
});
export const WithActionArgType = meta.story({
argTypes: {
someActionArg: {
action: true,
},
},
render: () => {
return <div>nothing</div>;
},
});
export const Modal = meta.story({
render: function Component() {
const [isModalOpen, setIsModalOpen] = useState(false);
const [modalContainer] = useState(() => {
const div = document.createElement('div');
div.id = 'modal-root';
return div;
});
useEffect(() => {
document.body.appendChild(modalContainer);
return () => {
document.body.removeChild(modalContainer);
};
}, [modalContainer]);
const handleOpenModal = () => setIsModalOpen(true);
const handleCloseModal = () => setIsModalOpen(false);
const modalContent = isModalOpen
? createPortal(
<div
role="dialog"
style={{
position: 'fixed',
top: '20%',
left: '50%',
transform: 'translate(-50%, -20%)',
backgroundColor: 'white',
padding: '20px',
zIndex: 1000,
border: '2px solid black',
borderRadius: '5px',
}}
>
<div style={{ marginBottom: '10px' }}>
<p>This is a modal!</p>
</div>
<button onClick={handleCloseModal}>Close</button>
</div>,
modalContainer
)
: null;
return (
<>
<button id="openModalButton" onClick={handleOpenModal}>
Open Modal
</button>
{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();
},
});

View File

@ -0,0 +1,185 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`Renders CSF2Secondary story 1`] = `
<body>
<div>
<button
class="storybook-button storybook-button--medium storybook-button--secondary"
type="button"
>
Children coming from story args!
</button>
</div>
</body>
`;
exports[`Renders CSF2StoryWithParamsAndDecorator story 1`] = `
<body>
<div>
<button
class="storybook-button storybook-button--medium storybook-button--secondary"
type="button"
>
foo
</button>
</div>
</body>
`;
exports[`Renders CSF3Button story 1`] = `
<body>
<div>
<button
class="storybook-button storybook-button--medium storybook-button--secondary"
type="button"
>
foo
</button>
</div>
</body>
`;
exports[`Renders CSF3ButtonWithRender story 1`] = `
<body>
<div>
<div>
<p
data-testid="custom-render"
>
I am a custom render function
</p>
<button
class="storybook-button storybook-button--medium storybook-button--secondary"
type="button"
>
foo
</button>
</div>
</div>
</body>
`;
exports[`Renders CSF3InputFieldFilled story 1`] = `
<body>
<div>
<input
data-testid="input"
/>
</div>
</body>
`;
exports[`Renders CSF3Primary story 1`] = `
<body>
<div>
<button
class="storybook-button storybook-button--large storybook-button--primary"
type="button"
>
foo
</button>
</div>
</body>
`;
exports[`Renders HooksStory story 1`] = `
<body>
<div>
<input
data-testid="input"
/>
<br />
<button>
I am
clicked
</button>
</div>
</body>
`;
exports[`Renders LoaderStory story 1`] = `
<body>
<div>
<div>
<div
data-testid="loaded-data"
>
loaded data
</div>
<div
data-testid="spy-data"
>
mockFn return value
</div>
</div>
</div>
</body>
`;
exports[`Renders Modal story 1`] = `
<body>
<div>
<button
id="openModalButton"
>
Open Modal
</button>
</div>
<div
id="modal-root"
>
<div
role="dialog"
style="position: fixed; top: 20%; left: 50%; transform: translate(-50%, -20%); background-color: white; padding: 20px; z-index: 1000; border: 2px solid black; border-radius: 5px;"
>
<div
style="margin-bottom: 10px;"
>
<p>
This is a modal!
</p>
</div>
<button>
Close
</button>
</div>
</div>
</body>
`;
exports[`Renders MountInPlayFunction story 1`] = `
<body>
<div>
<div>
<div
data-testid="loaded-data"
>
loaded data
</div>
<div
data-testid="spy-data"
>
mockFn return value
</div>
</div>
</div>
</body>
`;
exports[`Renders WithActionArg story 1`] = `
<body>
<div>
<button />
</div>
</body>
`;
exports[`Renders WithActionArgType story 1`] = `
<body>
<div>
<div>
nothing
</div>
</div>
</body>
`;

View File

@ -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(<CSF3Primary>Hello world</CSF3Primary>);
const buttonElement = screen.getByText(/Hello world/i);
expect(buttonElement).not.toBeNull();
});
it('reuses args from composed story', () => {
render(<Secondary />);
const buttonElement = screen.getByRole('button');
expect(buttonElement.textContent).toEqual(Secondary.args.children);
});
it('onclick handler is called', async () => {
const onClickSpy = vi.fn();
render(<Secondary onClick={onClickSpy} />);
const buttonElement = screen.getByRole('button');
buttonElement.click();
expect(onClickSpy).toHaveBeenCalled();
});
it('reuses args from composeStories', () => {
const { getByText } = render(<CSF3Primary />);
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(<LoaderStory />);
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(<WithEnglishText />);
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(<WithPortugueseText />);
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<ReactRenderer>
);
expect(Story.args.someActionArg).toHaveProperty('isAction', true);
});
});
describe('CSF3', () => {
it('renders with inferred globalRender', () => {
const Primary = composeStory(
ButtonStories.CSF3Button,
ButtonStories.CSF3Primary.meta.annotations
);
render(<Primary>Hello world</Primary>);
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(<Primary />);
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(<PrimaryWithChannels>Hello world</PrimaryWithChannels>);
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<typeof composeStories>[0];
expectTypeOf({
...ButtonStories,
default: ButtonStories.CSF3Primary.meta.annotations as Meta<typeof Button>,
}).toMatchTypeOf<ComposeStoriesParam>();
expectTypeOf({
...ButtonStories,
default: ButtonStories.CSF3Primary.meta.annotations satisfies Meta<typeof Button>,
}).toMatchTypeOf<ComposeStoriesParam>();
});
});
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();
});

View File

@ -59,7 +59,9 @@ class Story<TRenderer extends Renderer, TArgs extends Args> {
public annotations: StoryAnnotations<TRenderer, TArgs>,
public meta: Meta<TRenderer, TArgs>,
public config: PreviewConfig<TRenderer>
) {}
) {
Object.assign(this, annotations);
}
readonly isCSFFactory = true;
}