mirror of
https://github.com/storybookjs/storybook.git
synced 2025-04-09 00:19:13 +08:00
Merge remote-tracking branch 'origin/kasper/csf-factories' into kasper/csf-factories
This commit is contained in:
commit
1d2ee2ff01
@ -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();
|
||||
}
|
||||
|
@ -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';
|
||||
|
@ -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 {
|
||||
|
@ -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');
|
||||
|
@ -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;
|
||||
|
288
code/renderers/react/src/__test__/Button.csf4.stories.tsx
Normal file
288
code/renderers/react/src/__test__/Button.csf4.stories.tsx
Normal 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();
|
||||
},
|
||||
});
|
@ -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>
|
||||
`;
|
@ -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();
|
||||
});
|
@ -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;
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user