Merge branch 'next' into valentin/fix-prod-mode

This commit is contained in:
Valentin Palkovic 2024-07-03 10:58:53 +02:00 committed by GitHub
commit a61d7563d0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
69 changed files with 1696 additions and 739 deletions

View File

@ -67,7 +67,7 @@
"prep": "node --loader ../../../scripts/node_modules/esbuild-register/loader.js -r ../../../scripts/node_modules/esbuild-register/register.js ../../../scripts/prepare/addon-bundle.ts"
},
"dependencies": {
"@storybook/csf": "0.1.10",
"@storybook/csf": "0.1.11",
"@storybook/global": "^5.0.0",
"ts-dedent": "^2.0.0"
},

View File

@ -244,7 +244,7 @@
"prep": "bun ./scripts/prep.ts"
},
"dependencies": {
"@storybook/csf": "0.1.10",
"@storybook/csf": "0.1.11",
"@types/express": "^4.17.21",
"@types/node": "^18.0.0",
"browser-assert": "^1.2.1",

View File

@ -33,6 +33,7 @@ import { StoryStore } from '../../store';
import { StoryRender } from './render/StoryRender';
import type { CsfDocsRender } from './render/CsfDocsRender';
import type { MdxDocsRender } from './render/MdxDocsRender';
import { mountDestructured } from './render/mount-utils';
import type { Args, Globals, Renderer, StoryId } from '@storybook/core/types';
import type {
ModuleImportFn,
@ -291,7 +292,11 @@ export class Preview<TRenderer extends Renderer> {
await Promise.all(
this.storyRenders
.filter((r) => r.id === storyId && !r.renderOptions.forceInitialArgs)
.map((r) => r.rerender())
.map((r) =>
// We only run the play function, with in a force remount.
// But when mount is destructured, the rendering happens inside of the play function.
r.story && mountDestructured(r.story.playFunction) ? r.remount() : r.rerender()
)
);
this.channel.emit(STORY_ARGS_UPDATED, {

View File

@ -908,6 +908,41 @@ describe('PreviewWeb', () => {
expect(mockChannel.emit).toHaveBeenCalledWith(STORY_RENDERED, 'component-one--a');
});
describe('if play function destructures mount', () => {
it('passes forceRemount to renderToCanvas', async () => {
document.location.search = '?id=component-one--a';
const newImportFn = vi.fn(async (path) => {
if (path === './src/ComponentOne.stories.js') {
return {
...componentOneExports,
a: { ...componentOneExports.a, play: ({ mount }: any) => mount() },
};
}
return importFn(path);
});
await createAndRenderPreview({ importFn: newImportFn });
mockChannel.emit.mockClear();
projectAnnotations.renderToCanvas.mockClear();
emitter.emit(UPDATE_STORY_ARGS, {
storyId: 'component-one--a',
updatedArgs: { new: 'arg' },
});
await waitForRender();
expect(projectAnnotations.renderToCanvas).toHaveBeenCalledWith(
expect.objectContaining({
forceRemount: true,
storyContext: expect.objectContaining({
initialArgs: { foo: 'a', one: 1 },
args: { foo: 'a', new: 'arg', one: 'mapped-1' },
}),
}),
'story-element'
);
});
});
describe('while story is still rendering', () => {
it('runs loaders again after renderToCanvas is done', async () => {
// Arrange - set up a gate to control when the loaders run
@ -3626,6 +3661,7 @@ describe('PreviewWeb', () => {
"dev",
"test",
],
"testingLibraryRender": undefined,
"title": "Component One",
},
"component-one--b": {
@ -3674,6 +3710,7 @@ describe('PreviewWeb', () => {
"dev",
"test",
],
"testingLibraryRender": undefined,
"title": "Component One",
},
"component-one--e": {
@ -3700,6 +3737,7 @@ describe('PreviewWeb', () => {
"dev",
"test",
],
"testingLibraryRender": undefined,
"title": "Component One",
},
"component-two--c": {
@ -3736,6 +3774,7 @@ describe('PreviewWeb', () => {
"dev",
"test",
],
"testingLibraryRender": undefined,
"title": "Component Two",
},
}

View File

@ -463,17 +463,9 @@ export class PreviewWithSelection<TRenderer extends Renderer> extends Preview<TR
this.channel.emit(STORY_THREW_EXCEPTION, { name, message, stack });
this.channel.emit(STORY_RENDER_PHASE_CHANGED, { newPhase: 'errored', storyId });
// Ignored exceptions exist for control flow purposes, and are typically handled elsewhere.
//
// FIXME: Should be '=== IGNORED_EXCEPTION', but currently the object
// is coming from two different bundles (index.js vs index.mjs)
//
// https://github.com/storybookjs/storybook/issues/19321
if (!error.message?.startsWith('ignoredException')) {
this.view.showErrorDisplay(error);
logger.error(`Error rendering story '${storyId}':`);
logger.error(error);
}
this.view.showErrorDisplay(error);
logger.error(`Error rendering story '${storyId}':`);
logger.error(error);
}
// renderError is used by the various app layers to inform the user they have done something

View File

@ -1,7 +1,7 @@
// @vitest-environment happy-dom
import { describe, it, expect, vi } from 'vitest';
import { Channel } from '@storybook/core/channels';
import type { Renderer, StoryIndexEntry } from '@storybook/core/types';
import type { PreparedStory, Renderer, StoryContext, StoryIndexEntry } from '@storybook/core/types';
import type { StoryStore } from '../../store';
import { PREPARE_ABORTED } from './Render';
@ -26,29 +26,45 @@ const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
window.location = { reload: vi.fn() } as any;
const buildStory = (overrides: Partial<PreparedStory> = {}): PreparedStory =>
({
id: 'id',
title: 'title',
name: 'name',
tags: [],
applyLoaders: vi.fn(),
applyBeforeEach: vi.fn(),
unboundStoryFn: vi.fn(),
playFunction: vi.fn(),
mount: vi.fn((context: StoryContext) => {
return async () => {
await context.renderToCanvas();
return context.canvas;
};
}),
...overrides,
}) as any;
const buildStore = (overrides: Partial<StoryStore<Renderer>> = {}): StoryStore<Renderer> =>
({
getStoryContext: () => ({}),
addCleanupCallbacks: vi.fn(),
cleanupStory: vi.fn(),
...overrides,
}) as any;
describe('StoryRender', () => {
it('does run play function if passed autoplay=true', async () => {
const story = {
id: 'id',
title: 'title',
name: 'name',
tags: [],
applyLoaders: vi.fn(),
applyBeforeEach: vi.fn(),
unboundStoryFn: vi.fn(),
playFunction: vi.fn(),
prepareContext: vi.fn(),
};
const story = buildStory();
const render = new StoryRender(
new Channel({}),
{ getStoryContext: () => ({}), addCleanupCallbacks: vi.fn() } as any,
buildStore(),
vi.fn() as any,
{} as any,
entry.id,
'story',
{ autoplay: true },
story as any
story
);
await render.renderToElement({} as any);
@ -56,27 +72,16 @@ describe('StoryRender', () => {
});
it('does not run play function if passed autoplay=false', async () => {
const story = {
id: 'id',
title: 'title',
name: 'name',
tags: [],
applyLoaders: vi.fn(),
applyBeforeEach: vi.fn(),
unboundStoryFn: vi.fn(),
playFunction: vi.fn(),
prepareContext: vi.fn(),
};
const story = buildStory();
const render = new StoryRender(
new Channel({}),
{ getStoryContext: () => ({}), addCleanupCallbacks: vi.fn() } as any,
buildStore(),
vi.fn() as any,
{} as any,
entry.id,
'story',
{ autoplay: false },
story as any
story
);
await render.renderToElement({} as any);
@ -86,32 +91,20 @@ describe('StoryRender', () => {
it('only rerenders once when triggered multiple times while pending', async () => {
// Arrange - setup StoryRender and async gate blocking applyLoaders
const [loaderGate, openLoaderGate] = createGate();
const story = {
id: 'id',
title: 'title',
name: 'name',
tags: [],
applyLoaders: vi.fn(() => loaderGate),
applyBeforeEach: vi.fn(),
unboundStoryFn: vi.fn(),
playFunction: vi.fn(),
prepareContext: vi.fn(),
};
const store = {
getStoryContext: () => ({}),
cleanupStory: vi.fn(),
addCleanupCallbacks: vi.fn(),
};
const renderToScreen = vi.fn();
const story = buildStory({
applyLoaders: vi.fn(() => loaderGate as any),
});
const render = new StoryRender(
new Channel({}),
store as any,
buildStore(),
renderToScreen,
{} as any,
entry.id,
'story',
{ autoplay: true },
story as any
story
);
// Arrange - render (blocked by loaders)
render.renderToElement({} as any);
@ -133,27 +126,129 @@ describe('StoryRender', () => {
// Assert - loaded and rendered twice, played once
await vi.waitFor(async () => {
console.log(render.phase);
expect(story.applyLoaders).toHaveBeenCalledTimes(2);
expect(renderToScreen).toHaveBeenCalledTimes(2);
expect(story.playFunction).toHaveBeenCalledOnce();
});
});
it('calls mount if play function does not destructure mount', async () => {
const actualMount = vi.fn(async (context) => {
await context.renderToCanvas();
return {};
});
const story = buildStory({
mount: (context) => () => actualMount(context) as any,
playFunction: () => {},
});
const render = new StoryRender(
new Channel({}),
buildStore(),
vi.fn() as any,
{} as any,
entry.id,
'story',
{ autoplay: true },
story
);
await render.renderToElement({} as any);
expect(actualMount).toHaveBeenCalled();
});
it('does not call mount if play function destructures mount', async () => {
const actualMount = vi.fn(async (context) => {
await context.renderToCanvas();
return context.canvas;
});
const story = buildStory({
mount: (context) => () => actualMount(context) as any,
playFunction: ({ mount }) => {},
});
const render = new StoryRender(
new Channel({}),
buildStore(),
vi.fn() as any,
{} as any,
entry.id,
'story',
{ autoplay: true },
story
);
await render.renderToElement({} as any);
expect(actualMount).not.toHaveBeenCalled();
});
it('errors if play function calls mount without destructuring', async () => {
const actualMount = vi.fn(async (context) => {
await context.renderToCanvas();
return {};
});
const story = buildStory({
mount: (context) => () => actualMount(context) as any,
playFunction: async (context) => {
await context.mount();
},
});
const view = { showException: vi.fn() };
const render = new StoryRender(
new Channel({}),
buildStore(),
vi.fn() as any,
view as any,
entry.id,
'story',
{ autoplay: true },
story
);
await render.renderToElement({} as any);
expect(view.showException).toHaveBeenCalled();
});
it('enters rendering phase during play if play function calls mount', async () => {
const actualMount = vi.fn(async (context) => {
await context.renderToCanvas();
return {};
});
const story = buildStory({
mount: (context) => () => actualMount(context) as any,
playFunction: ({ mount }) => {
expect(render.phase).toBe('playing');
mount();
},
});
const render = new StoryRender(
new Channel({}),
buildStore(),
vi.fn(() => {
expect(render.phase).toBe('rendering');
}) as any,
{} as any,
entry.id,
'story',
{ autoplay: true },
story
);
await render.renderToElement({} as any);
expect(actualMount).toHaveBeenCalled();
});
describe('teardown', () => {
it('throws PREPARE_ABORTED if torndown during prepare', async () => {
const [importGate, openImportGate] = createGate();
const mockStore = {
const mockStore = buildStore({
loadStory: vi.fn(async () => {
await importGate;
return {};
}),
cleanupStory: vi.fn(),
};
}) as any,
});
const render = new StoryRender(
new Channel({}),
mockStore as unknown as StoryStore<Renderer>,
mockStore,
vi.fn(),
{} as any,
entry.id,
@ -172,31 +267,19 @@ describe('StoryRender', () => {
it('reloads the page when tearing down during loading', async () => {
// Arrange - setup StoryRender and async gate blocking applyLoaders
const [loaderGate] = createGate();
const story = {
id: 'id',
title: 'title',
name: 'name',
tags: [],
applyLoaders: vi.fn(() => loaderGate),
applyBeforeEach: vi.fn(),
unboundStoryFn: vi.fn(),
playFunction: vi.fn(),
prepareContext: vi.fn(),
};
const store = {
getStoryContext: () => ({}),
cleanupStory: vi.fn(),
addCleanupCallbacks: vi.fn(),
};
const story = buildStory({
applyLoaders: vi.fn(() => loaderGate as any),
});
const store = buildStore();
const render = new StoryRender(
new Channel({}),
store as any,
store,
vi.fn() as any,
{} as any,
entry.id,
'story',
{ autoplay: true },
story as any
story
);
// Act - render (blocked by loaders), teardown
@ -215,33 +298,19 @@ describe('StoryRender', () => {
it('reloads the page when tearing down during rendering', async () => {
// Arrange - setup StoryRender and async gate blocking renderToScreen
const [renderGate] = createGate();
const story = {
id: 'id',
title: 'title',
name: 'name',
tags: [],
applyLoaders: vi.fn(),
applyBeforeEach: vi.fn(),
unboundStoryFn: vi.fn(),
playFunction: vi.fn(),
prepareContext: vi.fn(),
};
const store = {
getStoryContext: () => ({}),
cleanupStory: vi.fn(),
addCleanupCallbacks: vi.fn(),
};
const story = buildStory();
const store = buildStore();
const renderToScreen = vi.fn(() => renderGate);
const render = new StoryRender(
new Channel({}),
store as any,
store,
renderToScreen as any,
{} as any,
entry.id,
'story',
{ autoplay: true },
story as any
story
);
// Act - render (blocked by renderToScreen), teardown
@ -261,32 +330,20 @@ describe('StoryRender', () => {
it('reloads the page when tearing down during playing', async () => {
// Arrange - setup StoryRender and async gate blocking playing
const [playGate] = createGate();
const story = {
id: 'id',
title: 'title',
name: 'name',
tags: [],
applyLoaders: vi.fn(),
applyBeforeEach: vi.fn(),
unboundStoryFn: vi.fn(),
playFunction: vi.fn(() => playGate),
prepareContext: vi.fn(),
};
const store = {
getStoryContext: () => ({}),
cleanupStory: vi.fn(),
addCleanupCallbacks: vi.fn(),
};
const story = buildStory({
playFunction: vi.fn(() => playGate as any),
});
const store = buildStore();
const render = new StoryRender(
new Channel({}),
store as any,
store,
vi.fn() as any,
{} as any,
entry.id,
'story',
{ autoplay: true },
story as any
story
);
// Act - render (blocked by playFn), teardown
@ -307,31 +364,20 @@ describe('StoryRender', () => {
it('reloads the page when remounting during loading', async () => {
// Arrange - setup StoryRender and async gate blocking applyLoaders
const [loaderGate] = createGate();
const story = {
id: 'id',
title: 'title',
name: 'name',
tags: [],
applyLoaders: vi.fn(() => loaderGate),
applyBeforeEach: vi.fn(),
unboundStoryFn: vi.fn(),
playFunction: vi.fn(),
prepareContext: vi.fn(),
};
const store = {
getStoryContext: () => ({}),
cleanupStory: vi.fn(),
addCleanupCallbacks: vi.fn(),
};
const story = buildStory({
applyLoaders: vi.fn(() => loaderGate as any),
});
const store = buildStore();
const render = new StoryRender(
new Channel({}),
store as any,
store,
vi.fn() as any,
{} as any,
entry.id,
'story',
{ autoplay: true },
story as any
story
);
// Act - render, blocked by loaders

View File

@ -8,8 +8,11 @@ import {
import type { StoryStore } from '../../store';
import type { Render, RenderType } from './Render';
import { PREPARE_ABORTED } from './Render';
import { mountDestructured } from './mount-utils';
import { MountMustBeDestructuredError } from '@storybook/core-events/preview-errors';
import type {
Canvas,
PreparedStory,
RenderContext,
RenderContextCallbacks,
@ -179,6 +182,10 @@ export class StoryRender<TRenderer extends Renderer> implements Render<TRenderer
// abort controller may be torn down (above) before we actually check the signal.
const abortSignal = (this.abortController as AbortController).signal;
let mounted = false;
const isMountDestructured = playFunction && mountDestructured(playFunction);
try {
const context: StoryContext<TRenderer> = {
...this.storyContext(),
@ -188,9 +195,23 @@ export class StoryRender<TRenderer extends Renderer> implements Render<TRenderer
loaded: {},
step: (label, play) => runStep(label, play, context),
context: null!,
canvas: {},
canvas: {} as Canvas,
mount: null!,
renderToCanvas: async () => {
await this.runPhase(abortSignal, 'rendering', async () => {
const teardown = await this.renderToScreen(renderContext, canvasElement);
this.teardownRender = teardown || (() => {});
mounted = true;
});
if (isMountDestructured) {
// put the phase back to playing if mount is used inside a play function
await this.runPhase(abortSignal, 'playing', async () => {});
}
},
};
context.context = context;
context.mount = this.story.mount(context);
const renderContext: RenderContext<TRenderer> = {
componentId,
@ -227,10 +248,9 @@ export class StoryRender<TRenderer extends Renderer> implements Render<TRenderer
if (abortSignal.aborted) return;
await this.runPhase(abortSignal, 'rendering', async () => {
const teardown = await this.renderToScreen(renderContext, canvasElement);
this.teardownRender = teardown || (() => {});
});
if (!mounted && !isMountDestructured) {
await context.mount();
}
this.notYetRendered = false;
if (abortSignal.aborted) return;
@ -248,6 +268,11 @@ export class StoryRender<TRenderer extends Renderer> implements Render<TRenderer
window.addEventListener('unhandledrejection', onError);
this.disableKeyListeners = true;
try {
if (!isMountDestructured) {
context.mount = async () => {
throw new MountMustBeDestructuredError({ playFunction: playFunction.toString() });
};
}
await this.runPhase(abortSignal, 'playing', async () => {
await playFunction(context);
});

View File

@ -0,0 +1,57 @@
import { expect, test } from 'vitest';
import { getUsedProps } from './mount-utils';
const StoryWithContext = {
play: async (context: any) => {
console.log(context);
},
};
const StoryWitCanvasElement = {
play: async ({ canvasElement }: any) => {
console.log(canvasElement);
},
};
const MountStory = {
play: async ({ mount }: any) => {
await mount();
},
};
const LongDefinition = {
play: async ({
mount,
veryLongDefinitionnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnn,
over,
multiple,
lines,
}: any) => {
await mount();
},
};
test('Detect destructure', () => {
expect(getUsedProps(StoryWithContext.play)).toMatchInlineSnapshot(`[]`);
expect(getUsedProps(StoryWitCanvasElement.play)).toMatchInlineSnapshot(`
[
"canvasElement",
]
`);
expect(getUsedProps(MountStory.play)).toMatchInlineSnapshot(`
[
"mount",
]
`);
expect(getUsedProps(LongDefinition.play)).toMatchInlineSnapshot(`
[
"mount",
"veryLongDefinitionnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnn",
"over",
"multiple",
"lines",
]
`);
});

View File

@ -0,0 +1,45 @@
// Inspired by Vitest fixture implementation:
// https://github.com/vitest-dev/vitest/blob/200a4349a2f85686bc7005dce686d9d1b48b84d2/packages/runner/src/fixture.ts
import { type PreparedStory, type Renderer } from '@storybook/types';
export function mountDestructured<TRenderer extends Renderer>(
playFunction: PreparedStory<TRenderer>['playFunction']
) {
return playFunction && getUsedProps(playFunction).includes('mount');
}
export function getUsedProps(fn: Function) {
const match = fn.toString().match(/[^(]*\(([^)]*)/);
if (!match) return [];
const args = splitByComma(match[1]);
if (!args.length) return [];
const first = args[0];
if (!(first.startsWith('{') && first.endsWith('}'))) return [];
const props = splitByComma(first.slice(1, -1).replace(/\s/g, '')).map((prop) => {
return prop.replace(/:.*|=.*/g, '');
});
return props;
}
function splitByComma(s: string) {
const result = [];
const stack = [];
let start = 0;
for (let i = 0; i < s.length; i++) {
if (s[i] === '{' || s[i] === '[') {
stack.push(s[i] === '{' ? '}' : ']');
} else if (s[i] === stack[stack.length - 1]) {
stack.pop();
} else if (!stack.length && s[i] === ',') {
const token = s.substring(start, i).trim();
if (token) result.push(token);
start = i + 1;
}
}
const lastToken = s.substring(start).trim();
if (lastToken) result.push(lastToken);
return result;
}

View File

@ -311,12 +311,14 @@ describe('StoryStore', () => {
"fileName": "./src/ComponentOne-new.stories.js",
},
"playFunction": undefined,
"renderToCanvas": undefined,
"story": "A",
"subcomponents": undefined,
"tags": [
"dev",
"test",
],
"testingLibraryRender": undefined,
"title": "Component One",
},
}
@ -477,12 +479,14 @@ describe('StoryStore', () => {
"fileName": "./src/ComponentOne.stories.js",
},
"playFunction": undefined,
"renderToCanvas": undefined,
"story": "A",
"subcomponents": undefined,
"tags": [
"dev",
"test",
],
"testingLibraryRender": undefined,
"title": "Component One",
},
"component-one--b": {
@ -516,12 +520,14 @@ describe('StoryStore', () => {
"fileName": "./src/ComponentOne.stories.js",
},
"playFunction": undefined,
"renderToCanvas": undefined,
"story": "B",
"subcomponents": undefined,
"tags": [
"dev",
"test",
],
"testingLibraryRender": undefined,
"title": "Component One",
},
"component-two--c": {
@ -555,12 +561,14 @@ describe('StoryStore', () => {
"fileName": "./src/ComponentTwo.stories.js",
},
"playFunction": undefined,
"renderToCanvas": undefined,
"story": "C",
"subcomponents": undefined,
"tags": [
"dev",
"test",
],
"testingLibraryRender": undefined,
"title": "Component Two",
},
}
@ -656,6 +664,7 @@ describe('StoryStore', () => {
"foo": "a",
},
},
"mount": [Function],
"name": "A",
"originalStoryFn": [MockFunction spy],
"parameters": {
@ -663,6 +672,7 @@ describe('StoryStore', () => {
"fileName": "./src/ComponentOne.stories.js",
},
"playFunction": undefined,
"renderToCanvas": undefined,
"runStep": [Function],
"story": "A",
"storyFn": [Function],
@ -671,6 +681,7 @@ describe('StoryStore', () => {
"dev",
"test",
],
"testingLibraryRender": undefined,
"title": "Component One",
"unboundStoryFn": [Function],
"undecoratedStoryFn": [Function],
@ -704,6 +715,7 @@ describe('StoryStore', () => {
"foo": "b",
},
},
"mount": [Function],
"name": "B",
"originalStoryFn": [MockFunction spy],
"parameters": {
@ -711,6 +723,7 @@ describe('StoryStore', () => {
"fileName": "./src/ComponentOne.stories.js",
},
"playFunction": undefined,
"renderToCanvas": undefined,
"runStep": [Function],
"story": "B",
"storyFn": [Function],
@ -719,6 +732,7 @@ describe('StoryStore', () => {
"dev",
"test",
],
"testingLibraryRender": undefined,
"title": "Component One",
"unboundStoryFn": [Function],
"undecoratedStoryFn": [Function],
@ -752,6 +766,7 @@ describe('StoryStore', () => {
"foo": "c",
},
},
"mount": [Function],
"name": "C",
"originalStoryFn": [MockFunction spy],
"parameters": {
@ -759,6 +774,7 @@ describe('StoryStore', () => {
"fileName": "./src/ComponentTwo.stories.js",
},
"playFunction": undefined,
"renderToCanvas": undefined,
"runStep": [Function],
"story": "C",
"storyFn": [Function],
@ -767,6 +783,7 @@ describe('StoryStore', () => {
"dev",
"test",
],
"testingLibraryRender": undefined,
"title": "Component Two",
"unboundStoryFn": [Function],
"undecoratedStoryFn": [Function],
@ -823,12 +840,14 @@ describe('StoryStore', () => {
"fileName": "./src/ComponentOne.stories.js",
},
"playFunction": undefined,
"renderToCanvas": undefined,
"story": "A",
"subcomponents": undefined,
"tags": [
"dev",
"test",
],
"testingLibraryRender": undefined,
"title": "Component One",
},
"component-one--b": {
@ -862,12 +881,14 @@ describe('StoryStore', () => {
"fileName": "./src/ComponentOne.stories.js",
},
"playFunction": undefined,
"renderToCanvas": undefined,
"story": "B",
"subcomponents": undefined,
"tags": [
"dev",
"test",
],
"testingLibraryRender": undefined,
"title": "Component One",
},
"component-two--c": {
@ -901,12 +922,14 @@ describe('StoryStore', () => {
"fileName": "./src/ComponentTwo.stories.js",
},
"playFunction": undefined,
"renderToCanvas": undefined,
"story": "C",
"subcomponents": undefined,
"tags": [
"dev",
"test",
],
"testingLibraryRender": undefined,
"title": "Component Two",
},
},

View File

@ -374,6 +374,7 @@ export class StoryStore<TRenderer extends Renderer> {
loaded: {},
step: (label, play) => story.runStep(label, play, context),
context: null!,
mount: null!,
canvas: {},
viewMode: 'story',
} as StoryContext<TRenderer>;

View File

@ -67,5 +67,7 @@ export function composeConfigs<TRenderer extends Renderer>(
applyDecorators: getSingletonField(moduleExportList, 'applyDecorators'),
runStep: composeStepRunners<TRenderer>(stepRunners),
tags: getArrayField(moduleExportList, 'tags'),
mount: getSingletonField(moduleExportList, 'mount'),
testingLibraryRender: getSingletonField(moduleExportList, 'testingLibraryRender'),
};
}

View File

@ -85,8 +85,12 @@ describe('composeStory', () => {
spy(context);
};
const composedStory = composeStory(Story, meta);
await composedStory.play!({ canvasElement: null });
const composedStory = composeStory(Story, meta, {
mount: (context) => async () => {
return context.canvas;
},
});
await composedStory.play({ canvasElement: null });
expect(spy).toHaveBeenCalledWith(
expect.objectContaining({
args: {
@ -259,48 +263,6 @@ describe('composeStory', () => {
expect(spyFn).toHaveBeenNthCalledWith(2, 'from beforeEach');
});
it('should warn when previous cleanups are still around when rendering a story', async () => {
const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const cleanupSpy = vi.fn();
const beforeEachSpy = vi.fn(() => {
return () => {
cleanupSpy();
};
});
const PreviousStory: Story = {
render: () => 'first',
beforeEach: beforeEachSpy,
};
const CurrentStory: Story = {
render: () => 'second',
args: {
firstArg: false,
secondArg: true,
},
};
const firstComposedStory = composeStory(PreviousStory, {});
await firstComposedStory.load();
firstComposedStory();
expect(beforeEachSpy).toHaveBeenCalled();
expect(cleanupSpy).not.toHaveBeenCalled();
expect(consoleWarnSpy).not.toHaveBeenCalled();
const secondComposedStory = composeStory(CurrentStory, {});
secondComposedStory();
expect(cleanupSpy).not.toHaveBeenCalled();
expect(consoleWarnSpy).toHaveBeenCalledOnce();
expect(consoleWarnSpy.mock.calls[0][0]).toMatchInlineSnapshot(
`
"Some stories were not cleaned up before rendering 'Unnamed Story (firstArg, secondArg)'.
You should load the story with \`await Story.load()\` before rendering it."
`
);
});
it('should throw an error if Story is undefined', () => {
expect(() => {
// @ts-expect-error (invalid input)

View File

@ -3,17 +3,21 @@
import { type CleanupCallback, isExportStory } from '@storybook/csf';
import { dedent } from 'ts-dedent';
import type {
Renderer,
Args,
Canvas,
ComponentAnnotations,
ComposedStoryFn,
ComposeStoryFn,
LegacyStoryAnnotationsOrFn,
NamedOrDefaultProjectAnnotations,
ComposeStoryFn,
Parameters,
PreparedStory,
ProjectAnnotations,
RenderContext,
Renderer,
Store_CSFExports,
StoryContext,
Parameters,
StrictArgTypes,
ProjectAnnotations,
} from '@storybook/core/types';
import { HooksContext } from '../../../addons';
@ -23,7 +27,8 @@ import { normalizeStory } from './normalizeStory';
import { normalizeComponentAnnotations } from './normalizeComponentAnnotations';
import { getValuesFromArgTypes } from './getValuesFromArgTypes';
import { normalizeProjectAnnotations } from './normalizeProjectAnnotations';
import type { ComposedStoryFn } from '@storybook/core/types';
import { mountDestructured } from '../../../modules/preview-web/render/mount-utils';
import { MountMustBeDestructuredError } from '@storybook/core/preview-errors';
let globalProjectAnnotations: ProjectAnnotations<any> = {};
@ -48,7 +53,7 @@ export function setProjectAnnotations<TRenderer extends Renderer = Renderer>(
globalProjectAnnotations = composeConfigs(annotations.map(extractAnnotation));
}
const cleanups: { storyName: string; callback: CleanupCallback }[] = [];
const cleanups: CleanupCallback[] = [];
export function composeStory<TRenderer extends Renderer = Renderer, TArgs extends Args = Args>(
storyAnnotations: LegacyStoryAnnotationsOrFn<TRenderer>,
@ -81,8 +86,25 @@ export function composeStory<TRenderer extends Renderer = Renderer, TArgs extend
normalizedComponentAnnotations
);
// TODO: Remove this in 9.0
// We can only use the renderToCanvas definition of the default config when testingLibraryRender is set
// This makes sure, that when the user doesn't do this, and doesn't provide its own renderToCanvas definition,
// we fall back to the < 8.1 behavior of the play function.
const fallback =
defaultConfig &&
!globalProjectAnnotations?.testingLibraryRender &&
!projectAnnotations?.testingLibraryRender;
const normalizedProjectAnnotations = normalizeProjectAnnotations<TRenderer>(
composeConfigs([defaultConfig ?? {}, globalProjectAnnotations, projectAnnotations ?? {}])
composeConfigs([
{
...defaultConfig,
renderToCanvas: fallback ? undefined : defaultConfig?.renderToCanvas,
},
globalProjectAnnotations,
projectAnnotations ?? {},
])
);
const story = prepareStory<TRenderer>(
@ -93,85 +115,114 @@ export function composeStory<TRenderer extends Renderer = Renderer, TArgs extend
const globalsFromGlobalTypes = getValuesFromArgTypes(normalizedProjectAnnotations.globalTypes);
const context: StoryContext<TRenderer> = {
hooks: new HooksContext(),
globals: {
...globalsFromGlobalTypes,
...normalizedProjectAnnotations.initialGlobals,
},
args: { ...story.initialArgs },
viewMode: 'story',
loaded: {},
abortSignal: new AbortController().signal,
step: (label, play) => story.runStep(label, play, context),
canvasElement: globalThis?.document?.body,
context: null!,
canvas: {},
...story,
const initializeContext = () => {
const context: StoryContext<TRenderer> = prepareContext({
hooks: new HooksContext(),
globals: {
...globalsFromGlobalTypes,
...normalizedProjectAnnotations.initialGlobals,
},
args: { ...story.initialArgs },
viewMode: 'story',
loaded: {},
abortSignal: new AbortController().signal,
step: (label, play) => story.runStep(label, play, context),
canvasElement: globalThis?.document?.body,
canvas: {} as Canvas,
...story,
context: null!,
mount: null!,
});
context.context = context;
if (story.renderToCanvas) {
context.renderToCanvas = async () => {
// Consolidate this renderContext with Context in SB 9.0
const unmount = await story.renderToCanvas?.(
{
componentId: story.componentId,
title: story.title,
id: story.id,
name: story.name,
tags: story.tags,
showError: (error) => {},
showException: (error) => {},
forceRemount: true,
storyContext: context,
storyFn: () => story.unboundStoryFn(context),
unboundStoryFn: story.unboundStoryFn,
} as RenderContext<TRenderer>,
context.canvasElement
);
if (unmount) {
cleanups.push(unmount);
}
};
}
context.mount = story.mount(context);
return context;
};
context.context = context;
let loadedContext: StoryContext<TRenderer> | undefined;
const playFunction = story.playFunction
? async (extraContext?: Partial<StoryContext<TRenderer, Partial<TArgs>>>) => {
Object.assign(context, extraContext);
return story.playFunction!(context);
}
: undefined;
let previousCleanupsDone = false;
// TODO: Remove in 9.0
const backwardsCompatiblePlay = async (
extraContext?: Partial<StoryContext<TRenderer, Partial<TArgs>>>
) => {
const context = initializeContext();
if (loadedContext) {
context.loaded = loadedContext.loaded;
}
Object.assign(context, extraContext);
return story.playFunction!(context);
};
const newPlay = (extraContext?: Partial<StoryContext<TRenderer, Partial<TArgs>>>) => {
const context = initializeContext();
Object.assign(context, extraContext);
return playStory(story, context);
};
const playFunction =
!story.renderToCanvas && story.playFunction
? backwardsCompatiblePlay
: !story.renderToCanvas && !story.playFunction
? undefined
: newPlay;
const composedStory: ComposedStoryFn<TRenderer, Partial<TArgs>> = Object.assign(
function storyFn(extraArgs?: Partial<TArgs>) {
const context = initializeContext();
if (loadedContext) {
context.loaded = loadedContext.loaded;
}
context.args = {
...context.initialArgs,
...extraArgs,
};
if (cleanups.length > 0 && !previousCleanupsDone) {
let humanReadableIdentifier = storyName;
if (story.title !== DEFAULT_STORY_TITLE) {
// prefix with title unless it's the generic ComposedStory title
humanReadableIdentifier = `${story.title} - ${humanReadableIdentifier}`;
}
if (storyName === DEFAULT_STORY_NAME && Object.keys(context.args).length > 0) {
// suffix with args if it's an unnamed story and there are args
humanReadableIdentifier = `${humanReadableIdentifier} (${Object.keys(context.args).join(
', '
)})`;
}
console.warn(
dedent`Some stories were not cleaned up before rendering '${humanReadableIdentifier}'.
You should load the story with \`await Story.load()\` before rendering it.`
);
// TODO: Add a link to the docs when they are ready
// eg. "See https://storybook.js.org/docs/api/portable-stories-${process.env.JEST_WORKER_ID !== undefined ? 'jest' : 'vitest'}#3-load for more information."
}
return story.unboundStoryFn(prepareContext(context));
return story.unboundStoryFn(context);
},
{
id: story.id,
storyName,
load: async () => {
// First run any registered cleanup function
for (const { callback } of [...cleanups].reverse()) await callback();
for (const callback of [...cleanups].reverse()) await callback();
cleanups.length = 0;
previousCleanupsDone = true;
const context = initializeContext();
context.loaded = await story.applyLoaders(context);
cleanups.push(
...(await story.applyBeforeEach(context))
.filter(Boolean)
.map((callback) => ({ storyName, callback }))
);
cleanups.push(...(await story.applyBeforeEach(context)).filter(Boolean));
loadedContext = context;
},
args: story.initialArgs as Partial<TArgs>,
parameters: story.parameters as Parameters,
argTypes: story.argTypes as StrictArgTypes<TArgs>,
play: playFunction,
play: playFunction!,
tags: story.tags,
}
);
@ -207,9 +258,12 @@ export function composeStories<TModule extends Store_CSFExports>(
type WrappedStoryRef = { __pw_type: 'jsx' | 'importRef' };
type UnwrappedJSXStoryRef = {
__pw_type: 'jsx';
type: ComposedStoryFn;
type: UnwrappedImportStoryRef;
};
type UnwrappedImportStoryRef = ComposedStoryFn & {
playPromise?: Promise<void>;
renderingEnded?: PromiseWithResolvers<void>;
};
type UnwrappedImportStoryRef = ComposedStoryFn;
declare global {
function __pwUnwrapObject(
@ -268,3 +322,37 @@ export function createPlaywrightTest<TFixture extends { extend: any }>(
},
});
}
// TODO At some point this function should live in prepareStory and become the core of StoryRender.render as well.
// Will make a follow up PR for that
async function playStory<TRenderer extends Renderer>(
story: PreparedStory<TRenderer>,
context: StoryContext<TRenderer>
) {
for (const callback of [...cleanups].reverse()) await callback();
cleanups.length = 0;
context.loaded = await story.applyLoaders(context);
if (context.abortSignal.aborted) return;
cleanups.push(...(await story.applyBeforeEach(context)).filter(Boolean));
const playFunction = story.playFunction;
const isMountDestructured = playFunction && mountDestructured(playFunction);
if (!isMountDestructured) {
await context.mount();
}
if (context.abortSignal.aborted) return;
if (playFunction) {
if (!isMountDestructured) {
context.mount = async () => {
throw new MountMustBeDestructuredError({ playFunction: playFunction.toString() });
};
}
await playFunction(context);
}
}

View File

@ -54,6 +54,7 @@ const addExtraContext = (
hooks: new HooksContext(),
viewMode: 'story' as const,
loaded: {},
mount: vi.fn(),
abortSignal: new AbortController().signal,
canvasElement: {},
step: vi.fn(),
@ -763,6 +764,9 @@ describe('prepareMeta', () => {
undecoratedStoryFn,
playFunction,
runStep,
mount,
renderToCanvas,
testingLibraryRender,
...preparedStory
} = prepareStory({ id, name, moduleExport }, meta, { render });

View File

@ -26,6 +26,7 @@ import type {
NormalizedProjectAnnotations,
NormalizedStoryAnnotations,
} from '@storybook/core/types';
import { mountDestructured } from '../../preview-web/render/mount-utils';
// Combine all the metadata about a story (both direct and inherited from the component/global scope)
// into a "render-able" story function, with all decorators applied, parameters passed as context etc
@ -82,7 +83,7 @@ export function prepareStory<TRenderer extends Renderer>(
};
const undecoratedStoryFn = (context: StoryContext<TRenderer>) =>
(render as ArgsStoryFn<TRenderer>)(context.args, context);
(context.originalStoryFn as ArgsStoryFn<TRenderer>)(context.args, context);
// Currently it is only possible to set these globally
const { applyDecorators = defaultDecorateStory, runStep } = projectAnnotations;
@ -100,26 +101,59 @@ export function prepareStory<TRenderer extends Renderer>(
storyAnnotations?.render ||
componentAnnotations.render ||
projectAnnotations.render;
if (!render) throw new Error(`No render function available for storyId '${id}'`);
const decoratedStoryFn = applyHooks<TRenderer>(applyDecorators)(undecoratedStoryFn, decorators);
const unboundStoryFn = (context: StoryContext<TRenderer>) => decoratedStoryFn(context);
const playFunction = storyAnnotations?.play ?? componentAnnotations?.play;
const mountUsed = mountDestructured(playFunction);
if (!render && !mountUsed) {
// TODO Make this a named error
throw new Error(`No render function available for storyId '${id}'`);
}
let { tags } = partialAnnotations;
if (mountUsed) {
// Don't show stories where mount is used in docs.
// As the play function is not running in docs, and when mount is used, the mounting is happening in play itself.
tags = tags.filter((tag) => tag !== 'autodocs');
}
const defaultMount = (context: StoryContext) => {
return async () => {
await context.renderToCanvas();
return context.canvas;
};
};
const mount =
storyAnnotations.mount ??
componentAnnotations.mount ??
projectAnnotations.mount ??
defaultMount;
const testingLibraryRender = projectAnnotations.testingLibraryRender;
return {
...partialAnnotations,
tags,
moduleExport,
id,
name,
story: name,
originalStoryFn: render,
originalStoryFn: render!,
undecoratedStoryFn,
unboundStoryFn,
applyLoaders,
applyBeforeEach,
playFunction,
runStep,
mount,
testingLibraryRender,
renderToCanvas: projectAnnotations.renderToCanvas,
};
}
export function prepareMeta<TRenderer extends Renderer>(

View File

@ -238,6 +238,63 @@ export class StoryStoreAccessedBeforeInitializationError extends StorybookError
}
}
export class MountMustBeDestructuredError extends StorybookError {
readonly category = Category.PREVIEW_API;
readonly code = 12;
constructor(public data: { playFunction: string }) {
super();
}
template() {
return dedent`
To use mount in the play function, you must use object destructuring, e.g. play: ({ mount }) => {}.
Instead received:
${this.data.playFunction}
`;
}
}
export class TestingLibraryMustBeConfiguredError extends StorybookError {
readonly category = Category.PREVIEW_API;
readonly code = 13;
template() {
return dedent`
You must configure testingLibraryRender to use play in portable stories.
import { render } from '@testing-library/[renderer]';
setProjectAnnotations({
testingLibraryRender: render,
});
For other testing renderers, you can configure renderToCanvas:
import { render } from 'your-renderer';
setProjectAnnotations({
renderToCanvas: ({ storyFn }) => {
const Story = storyFn();
// Svelte
render(Story.Component, Story.props);
// Vue
render(Story);
// or for React
render(<Story/>);
},
});
`;
}
}
export class NextJsSharpError extends StorybookError {
readonly category = Category.FRAMEWORK_NEXTJS;

View File

@ -44,7 +44,7 @@ export type ComposedStoryFn<
> = PartialArgsStoryFn<TRenderer, TArgs> & {
args: TArgs;
id: StoryId;
play?: (context?: Partial<StoryContext<TRenderer, Partial<TArgs>>>) => Promise<void>;
play: (context?: Partial<StoryContext<TRenderer, Partial<TArgs>>>) => Promise<void>;
load: () => Promise<void>;
storyName: string;
parameters: Parameters;

View File

@ -11,6 +11,7 @@ export type {
ArgTypes,
ArgTypesEnhancer,
BaseAnnotations,
Canvas,
ComponentAnnotations,
ComponentId,
ComponentTitle,

View File

@ -5,6 +5,7 @@ import type {
LoaderFunction,
CleanupCallback,
StepRunner,
Canvas,
} from '@storybook/csf';
import type {
@ -43,6 +44,7 @@ export type RenderToCanvas<TRenderer extends Renderer> = (
export interface ProjectAnnotations<TRenderer extends Renderer>
extends CsfProjectAnnotations<TRenderer> {
testingLibraryRender?: (...args: never[]) => { unmount: () => void };
renderToCanvas?: RenderToCanvas<TRenderer>;
/* @deprecated use renderToCanvas */
renderToDOM?: RenderToCanvas<TRenderer>;
@ -106,6 +108,9 @@ export type PreparedStory<TRenderer extends Renderer = Renderer> =
applyBeforeEach: (context: StoryContext<TRenderer>) => Promise<CleanupCallback[]>;
playFunction?: (context: StoryContext<TRenderer>) => Promise<void> | void;
runStep: StepRunner<TRenderer>;
mount: (context: StoryContext<TRenderer>) => () => Promise<Canvas>;
testingLibraryRender?: (...args: never[]) => unknown;
renderToCanvas?: ProjectAnnotations<TRenderer>['renderToCanvas'];
};
export type PreparedMeta<TRenderer extends Renderer = Renderer> = Omit<
@ -119,6 +124,7 @@ export type BoundStory<TRenderer extends Renderer = Renderer> = PreparedStory<TR
storyFn: PartialStoryFn<TRenderer>;
};
// TODO Consolidate this with context for 9.0
export declare type RenderContext<TRenderer extends Renderer = Renderer> = StoryIdentifier & {
showMain: () => void;
showError: (error: { title: string; description: string }) => void;

View File

@ -27,6 +27,7 @@ const defaultContext: Addon_StoryContext<AngularRenderer> = {
step: undefined,
context: undefined,
canvas: undefined,
mount: undefined,
};
defaultContext.context = defaultContext;

View File

@ -44,7 +44,7 @@
"prep": "node --loader ../../../scripts/node_modules/esbuild-register/loader.js -r ../../../scripts/node_modules/esbuild-register/register.js ../../../scripts/prepare/bundle.ts"
},
"dependencies": {
"@storybook/csf": "0.1.10",
"@storybook/csf": "0.1.11",
"@storybook/global": "^5.0.0",
"@storybook/icons": "^1.2.5",
"@types/lodash": "^4.14.167",

View File

@ -95,7 +95,10 @@ type BaseTemplates = Template & {
const baseTemplates = {
'cra/default-js': {
name: 'Create React App Latest (Webpack | JavaScript)',
script: 'npx create-react-app {{beforeDir}}',
script: `
npx create-react-app {{beforeDir}} && cd {{beforeDir}} && \
jq '.browserslist.production[0] = ">0.9%"' package.json > tmp.json && mv tmp.json package.json
`,
expected: {
// TODO: change this to @storybook/cra once that package is created
framework: '@storybook/react-webpack5',
@ -120,7 +123,10 @@ const baseTemplates = {
},
'cra/default-ts': {
name: 'Create React App Latest (Webpack | TypeScript)',
script: 'npx create-react-app {{beforeDir}} --template typescript',
script: `
npx create-react-app {{beforeDir}} --template typescript && cd {{beforeDir}} && \
jq '.browserslist.production[0] = ">0.9%"' package.json > tmp.json && mv tmp.json package.json
`,
// Re-enable once https://github.com/storybookjs/storybook/issues/19351 is fixed.
skipTasks: ['smoke-test', 'bench'],
expected: {

View File

@ -58,7 +58,7 @@
"@babel/preset-env": "^7.24.4",
"@babel/types": "^7.24.0",
"@storybook/core": "workspace:*",
"@storybook/csf": "0.1.10",
"@storybook/csf": "0.1.11",
"@types/cross-spawn": "^6.0.2",
"cross-spawn": "^7.0.3",
"globby": "^14.0.1",

View File

@ -45,7 +45,7 @@
"prep": "node --loader ../../../scripts/node_modules/esbuild-register/loader.js -r ../../../scripts/node_modules/esbuild-register/register.js ../../../scripts/prepare/bundle.ts"
},
"dependencies": {
"@storybook/csf": "0.1.10",
"@storybook/csf": "0.1.11",
"estraverse": "^5.2.0",
"lodash": "^4.17.21",
"prettier": "^3.1.1"

View File

@ -44,7 +44,7 @@
"prep": "node --loader ../../../scripts/node_modules/esbuild-register/loader.js -r ../../../scripts/node_modules/esbuild-register/register.js ../../../scripts/prepare/bundle.ts"
},
"dependencies": {
"@storybook/csf": "0.1.10",
"@storybook/csf": "0.1.11",
"@storybook/instrumenter": "workspace:*",
"@testing-library/dom": "10.1.0",
"@testing-library/jest-dom": "6.4.5",

View File

@ -0,0 +1,65 @@
import { expect, mocked, getByRole, spyOn, userEvent, fn } from '@storybook/test';
const meta = {
component: globalThis.Components.Button,
loaders() {
spyOn(console, 'log').mockName('console.log');
console.log('1 - [from loaders]');
},
beforeEach() {
console.log('2 - [from meta beforeEach]');
},
};
export default meta;
export const MountInPlay = {
beforeEach() {
console.log('3 - [from story beforeEach]');
},
decorators: (storyFn) => {
console.log('5 - [from decorator]');
return storyFn();
},
args: {
label: 'Button',
onClick: () => {
console.log('7 - [from onClick]');
},
},
async play({ mount, canvasElement }) {
console.log('4 - [before mount]');
await mount();
console.log('6 - [after mount]');
await userEvent.click(getByRole(canvasElement, 'button'));
await expect(mocked(console.log).mock.calls).toEqual([
['1 - [from loaders]'],
['2 - [from meta beforeEach]'],
['3 - [from story beforeEach]'],
['4 - [before mount]'],
['5 - [from decorator]'],
['6 - [after mount]'],
['7 - [from onClick]'],
]);
},
};
export const MountShouldBeDestructured = {
parameters: { chromatic: { disable: true } },
args: {
label: 'Button',
onClick: fn(),
},
async play(context) {
let error;
// TODO use expect.toThrow once this issue is fixed
// https://github.com/storybookjs/storybook/issues/28406
try {
await context.mount();
} catch (e) {
error = e;
}
await expect(error?.name).toContain('MountMustBeDestructuredError');
},
};

View File

@ -118,7 +118,7 @@
"@storybook/codemod": "workspace:*",
"@storybook/core": "workspace:*",
"@storybook/core-webpack": "workspace:*",
"@storybook/csf": "0.1.10",
"@storybook/csf": "0.1.11",
"@storybook/csf-plugin": "workspace:*",
"@storybook/ember": "workspace:*",
"@storybook/eslint-config-storybook": "^4.0.0",

View File

@ -6,6 +6,7 @@ import type { ButtonProps } from './Button';
import { Button } from './Button';
import type { HandlerFunction } from '@storybook/addon-actions';
import { action } from '@storybook/addon-actions';
import { mocked } from '@storybook/test';
const meta = {
title: 'Example/Button',
@ -127,6 +128,30 @@ export const LoaderStory: CSF3Story<{ mockFn: (val: string) => string }> = {
},
};
export const MountInPlayFunction: CSF3Story<{ mockFn: (val: string) => string }> = {
args: {
mockFn: fn(),
},
play: async ({ args, mount, context }) => {
// 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 WithActionArg: CSF3Story<{ someActionArg: HandlerFunction }> = {
args: {
someActionArg: action('some-action-arg'),

View File

@ -28,14 +28,14 @@ export interface ButtonProps {
* Primary UI component for user interaction
*/
export const Button: React.FC<ButtonProps> = (props) => {
const { primary = false, size = 'medium', backgroundColor, children, ...otherProps } = props;
const { primary = false, size = 'medium', backgroundColor, children, onClick } = props;
const mode = primary ? 'storybook-button--primary' : 'storybook-button--secondary';
return (
<button
type="button"
className={['storybook-button', `storybook-button--${size}`, mode].join(' ')}
style={{ backgroundColor }}
{...otherProps}
onClick={onClick}
>
{children}
</button>

View File

@ -0,0 +1,120 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`Legacy Portable Stories API > 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[`Legacy Portable Stories API > Renders CSF2StoryWithParamsAndDecorator story 1`] = `
<body>
<div>
<button
class="storybook-button storybook-button--medium storybook-button--secondary"
type="button"
>
foo
</button>
</div>
</body>
`;
exports[`Legacy Portable Stories API > Renders CSF3Button story 1`] = `
<body>
<div>
<button
class="storybook-button storybook-button--medium storybook-button--secondary"
type="button"
>
foo
</button>
</div>
</body>
`;
exports[`Legacy Portable Stories API > 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[`Legacy Portable Stories API > Renders CSF3InputFieldFilled story 1`] = `
<body>
<div>
<input
data-testid="input"
/>
</div>
</body>
`;
exports[`Legacy Portable Stories API > Renders CSF3Primary story 1`] = `
<body>
<div>
<button
class="storybook-button storybook-button--large storybook-button--primary"
type="button"
>
foo
</button>
</div>
</body>
`;
exports[`Legacy Portable Stories API > 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[`Legacy Portable Stories API > Renders WithActionArg story 1`] = `
<body>
<div>
<button />
</div>
</body>
`;
exports[`Legacy Portable Stories API > Renders WithActionArgType story 1`] = `
<body>
<div>
<div>
nothing
</div>
</div>
</body>
`;

View File

@ -101,6 +101,25 @@ exports[`Renders LoaderStory story 1`] = `
</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>

View File

@ -0,0 +1,209 @@
// @vitest-environment happy-dom
/* eslint-disable import/namespace */
import React from 'react';
import { vi, it, expect, afterEach, describe } from 'vitest';
import { render, screen, cleanup } from '@testing-library/react';
import { addons } from '@storybook/preview-api';
import * as addonActionsPreview from '@storybook/addon-actions/preview';
import type { Meta } from '@storybook/react';
import { expectTypeOf } from 'expect-type';
import { setProjectAnnotations, composeStories, composeStory } from '..';
import type { Button } from './Button';
import * as stories from './Button.stories';
// TODO: Potentially remove this in Storybook 9.0 once we fully move users to the new portable stories API
describe('Legacy Portable Stories API', () => {
// example with composeStories, returns an object with all stories composed with args/decorators
const { CSF3Primary, LoaderStory } = composeStories(stories);
// example with composeStory, returns a single story composed with args/decorators
const Secondary = composeStory(stories.CSF2Secondary, stories.default);
describe('renders', () => {
afterEach(() => {
cleanup();
});
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 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.play!();
});
});
describe('projectAnnotations', () => {
afterEach(() => {
cleanup();
});
it('renders with default projectAnnotations', () => {
setProjectAnnotations([
{
parameters: { injected: true },
globalTypes: {
locale: { defaultValue: 'en' },
},
},
]);
const WithEnglishText = composeStory(stories.CSF2StoryWithLocale, stories.default);
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(stories.CSF2StoryWithLocale, stories.default, {
initialGlobals: { locale: 'pt' },
});
const { getByText } = render(<WithPortugueseText />);
const buttonElement = getByText('Olá!');
expect(buttonElement).not.toBeNull();
});
it('explicit action are spies when the test loader is loaded', async () => {
const Story = composeStory(stories.WithActionArg, stories.default);
await Story.load();
expect(vi.mocked(Story.args.someActionArg!).mock).toBeDefined();
const { container } = render(<Story />);
expect(Story.args.someActionArg).toHaveBeenCalledOnce();
expect(Story.args.someActionArg).toHaveBeenCalledWith('in render');
await Story.play!({ canvasElement: container });
expect(Story.args.someActionArg).toHaveBeenCalledTimes(2);
expect(Story.args.someActionArg).toHaveBeenCalledWith('on click');
});
it('has action arg from argTypes when addon-actions annotations are added', () => {
//@ts-expect-error our tsconfig.jsn#moduleResulution is set to 'node', which doesn't support this import
const Story = composeStory(stories.WithActionArgType, stories.default, addonActionsPreview);
expect(Story.args.someActionArg).toHaveProperty('isAction', true);
});
});
describe('CSF3', () => {
afterEach(() => {
cleanup();
});
it('renders with inferred globalRender', () => {
const Primary = composeStory(stories.CSF3Button, stories.default);
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(stories.CSF3ButtonWithRender, stories.default);
render(<Primary />);
expect(screen.getByTestId('custom-render')).not.toBeNull();
});
it('renders with play function without canvas element', async () => {
const CSF3InputFieldFilled = composeStory(stories.CSF3InputFieldFilled, stories.default);
render(<CSF3InputFieldFilled />);
await CSF3InputFieldFilled.play!();
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(stories.CSF3InputFieldFilled, stories.default);
const { container } = render(<CSF3InputFieldFilled />);
await CSF3InputFieldFilled.play!({ canvasElement: container });
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(stories.CSF3Primary, stories.default, {
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({
...stories,
default: stories.default as Meta<typeof Button>,
}).toMatchTypeOf<ComposeStoriesParam>();
expectTypeOf({
...stories,
default: stories.default satisfies Meta<typeof Button>,
}).toMatchTypeOf<ComposeStoriesParam>();
});
});
// Batch snapshot testing
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();
if (_storyName === 'CSF2StoryWithLocale' || _storyName === 'MountInPlayFunction') {
return;
}
await Story.load();
const { baseElement } = await render(<Story />);
await Story.play?.();
expect(baseElement).toMatchSnapshot();
});
});

View File

@ -14,16 +14,18 @@ import { setProjectAnnotations, composeStories, composeStory } from '..';
import type { Button } from './Button';
import * as stories from './Button.stories';
setProjectAnnotations([{ testingLibraryRender: render }]);
// example with composeStories, returns an object with all stories composed with args/decorators
const { CSF3Primary, LoaderStory } = composeStories(stories);
const { CSF3Primary, LoaderStory, MountInPlayFunction } = composeStories(stories);
afterEach(() => {
cleanup();
});
// example with composeStory, returns a single story composed with args/decorators
const Secondary = composeStory(stories.CSF2Secondary, stories.default);
describe('renders', () => {
afterEach(() => {
cleanup();
});
it('renders primary button', () => {
render(<CSF3Primary>Hello world</CSF3Primary>);
const buttonElement = screen.getByText(/Hello world/i);
@ -50,6 +52,13 @@ describe('renders', () => {
expect(buttonElement).not.toBeNull();
});
it('should render component mounted in play function', async () => {
await MountInPlayFunction.play();
expect(screen.getByTestId('spy-data').textContent).toEqual('mockFn return value');
expect(screen.getByTestId('loaded-data').textContent).toEqual('loaded data');
});
it('should call and compose loaders data', async () => {
await LoaderStory.load();
const { getByTestId } = render(<LoaderStory />);
@ -68,6 +77,7 @@ describe('projectAnnotations', () => {
it('renders with default projectAnnotations', () => {
setProjectAnnotations([
{
testingLibraryRender: render,
parameters: { injected: true },
globalTypes: {
locale: { defaultValue: 'en' },
@ -90,20 +100,6 @@ describe('projectAnnotations', () => {
expect(buttonElement).not.toBeNull();
});
it('explicit action are spies when the test loader is loaded', async () => {
const Story = composeStory(stories.WithActionArg, stories.default);
await Story.load();
expect(vi.mocked(Story.args.someActionArg!).mock).toBeDefined();
const { container } = render(<Story />);
expect(Story.args.someActionArg).toHaveBeenCalledOnce();
expect(Story.args.someActionArg).toHaveBeenCalledWith('in render');
await Story.play!({ canvasElement: container });
expect(Story.args.someActionArg).toHaveBeenCalledTimes(2);
expect(Story.args.someActionArg).toHaveBeenCalledWith('on click');
});
it('has action arg from argTypes when addon-actions annotations are added', () => {
//@ts-expect-error our tsconfig.jsn#moduleResulution is set to 'node', which doesn't support this import
const Story = composeStory(stories.WithActionArgType, stories.default, addonActionsPreview);
@ -112,10 +108,6 @@ describe('projectAnnotations', () => {
});
describe('CSF3', () => {
afterEach(() => {
cleanup();
});
it('renders with inferred globalRender', () => {
const Primary = composeStory(stories.CSF3Button, stories.default);
@ -133,10 +125,7 @@ describe('CSF3', () => {
it('renders with play function without canvas element', async () => {
const CSF3InputFieldFilled = composeStory(stories.CSF3InputFieldFilled, stories.default);
render(<CSF3InputFieldFilled />);
await CSF3InputFieldFilled.play!();
await CSF3InputFieldFilled.play();
const input = screen.getByTestId('input') as HTMLInputElement;
expect(input.value).toEqual('Hello world!');
@ -145,12 +134,16 @@ describe('CSF3', () => {
it('renders with play function with canvas element', async () => {
const CSF3InputFieldFilled = composeStory(stories.CSF3InputFieldFilled, stories.default);
const { container } = render(<CSF3InputFieldFilled />);
const div = document.createElement('div');
console.log(div.tagName);
document.body.appendChild(div);
await CSF3InputFieldFilled.play!({ canvasElement: container });
await CSF3InputFieldFilled.play({ canvasElement: div });
const input = screen.getByTestId('input') as HTMLInputElement;
expect(input.value).toEqual('Hello world!');
document.body.removeChild(div);
});
});
@ -191,16 +184,7 @@ 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();
if (_storyName === 'CSF2StoryWithLocale') {
return;
}
await Story.load();
const { baseElement } = await render(<Story />);
await Story.play?.();
expect(baseElement).toMatchSnapshot();
if (_storyName === 'CSF2StoryWithLocale') return;
await Story.play();
expect(document.body).toMatchSnapshot();
});

View File

@ -1,3 +1,4 @@
export const parameters: {} = { renderer: 'react' };
export { render } from './render';
export { renderToCanvas } from './renderToCanvas';
export { mount } from './mount';

View File

@ -0,0 +1,11 @@
import { type StoryContext, type ReactRenderer } from './public-types';
import type { BaseAnnotations } from '@storybook/types';
export const mount: BaseAnnotations<ReactRenderer>['mount'] =
(context: StoryContext) => async (ui) => {
if (ui != null) {
context.originalStoryFn = () => ui;
}
await context.renderToCanvas();
return context.canvas;
};

View File

@ -15,6 +15,8 @@ import type {
import * as reactProjectAnnotations from './entry-preview';
import type { Meta } from './public-types';
import type { ReactRenderer } from './types';
import { TestingLibraryMustBeConfiguredError } from 'storybook/internal/preview-errors';
import React from 'react';
/** Function that sets the globalConfig of your storybook. The global config is the preview module of your .storybook folder.
*
@ -40,8 +42,16 @@ export function setProjectAnnotations(
}
// This will not be necessary once we have auto preset loading
export const INTERNAL_DEFAULT_PROJECT_ANNOTATIONS: ProjectAnnotations<ReactRenderer> =
reactProjectAnnotations;
export const INTERNAL_DEFAULT_PROJECT_ANNOTATIONS: ProjectAnnotations<ReactRenderer> = {
...reactProjectAnnotations,
renderToCanvas: ({
storyContext: { context, unboundStoryFn: Story, testingLibraryRender: render, canvasElement },
}) => {
if (render == null) throw new TestingLibraryMustBeConfiguredError();
const { unmount } = render(<Story {...context} />, { baseElement: context.canvasElement });
return unmount;
},
};
/**
* Function that will receive a story along with meta (e.g. a default export from a .stories file)

View File

@ -15,6 +15,7 @@ import { fn } from '@storybook/test';
import type { Decorator, Meta, StoryObj } from './public-types';
import type { ReactRenderer } from './types';
import type { Canvas } from '@storybook/csf';
type ReactStory<TArgs, TRequiredArgs> = StoryAnnotations<ReactRenderer, TArgs, TRequiredArgs>;
@ -315,7 +316,9 @@ it('Infer mock function given to args in meta.', () => {
type Story = StoryObj<typeof meta>;
const Basic: Story = {
play: async ({ args }) => {
play: async ({ args, mount }) => {
const canvas = await mount(<TestButton {...args} />);
expectTypeOf(canvas).toEqualTypeOf<Canvas>();
expectTypeOf(args.onClick).toEqualTypeOf<Mock<[], void>>();
expectTypeOf(args.onRender).toEqualTypeOf<() => JSX.Element>();
},

View File

@ -1,11 +1,12 @@
import type { ComponentType } from 'react';
import type { WebRenderer } from 'storybook/internal/types';
import type { Canvas, WebRenderer } from 'storybook/internal/types';
export type { RenderContext, StoryContext } from 'storybook/internal/types';
export interface ReactRenderer extends WebRenderer {
component: ComponentType<this['T']>;
storyResult: StoryFnReactReturnType;
mount: (ui?: JSX.Element) => Promise<Canvas>;
}
export interface ShowErrorArgs {

View File

@ -0,0 +1,20 @@
import type { FC } from 'react';
import React from 'react';
import type { StoryObj } from '@storybook/react';
const Button: FC<{ label?: string; disabled?: boolean }> = (props) => {
return <button disabled={props.disabled}>{props.label}</button>;
};
export default {
component: Button,
};
export const Basic: StoryObj<typeof Button> = {
args: {
disabled: true,
},
async play({ mount, args }) {
await mount(<Button {...args} label={'set in play'} />);
},
};

View File

@ -46,7 +46,7 @@
"prep": "node --loader ../../../scripts/node_modules/esbuild-register/loader.js -r ../../../scripts/node_modules/esbuild-register/register.js ../../../scripts/prepare/bundle.ts"
},
"dependencies": {
"@storybook/csf": "0.1.10",
"@storybook/csf": "0.1.11",
"@storybook/global": "^5.0.0",
"@types/fs-extra": "^11.0.1",
"fs-extra": "^11.1.0",

View File

@ -2,54 +2,29 @@
exports[`Renders CSF2Secondary story 1`] = `
<body>
<div>
<button
class="storybook-button storybook-button--medium storybook-button--secondary"
style=""
type="button"
>
label coming from story args!
<!---->
</button>
<button
class="storybook-button storybook-button--medium storybook-button--secondary"
style=""
type="button"
>
label coming from story args!
<!---->
<!---->
<!---->
</div>
</button>
</body>
`;
exports[`Renders CSF2StoryWithParamsAndDecorator story 1`] = `
<body>
<div>
<div
data-testid="local-decorator"
style="margin: 3em;"
>
<button
class="storybook-button storybook-button--medium storybook-button--secondary"
style=""
type="button"
>
foo
<!---->
</button>
<!---->
<!---->
</div>
<!---->
<!---->
<!---->
<!---->
<!---->
</div>
</body>
`;
exports[`Renders CSF3Button story 1`] = `
<body>
<div>
<div
data-testid="local-decorator"
style="margin: 3em;"
>
<button
class="storybook-button storybook-button--medium storybook-button--secondary"
style=""
@ -60,59 +35,139 @@ exports[`Renders CSF3Button story 1`] = `
</button>
<!---->
<!---->
<!---->
</div>
<!---->
<!---->
</body>
`;
exports[`Renders CSF3Button story 1`] = `
<body>
<button
class="storybook-button storybook-button--medium storybook-button--secondary"
style=""
type="button"
>
foo
<!---->
</button>
</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"
style=""
type="button"
>
foo
<!---->
</button>
<p
data-testid="custom-render"
>
I am a custom render function
</p>
<button
class="storybook-button storybook-button--medium storybook-button--secondary"
style=""
type="button"
>
foo
<!---->
</div>
</button>
<!---->
<!---->
<!---->
</div>
</body>
`;
exports[`Renders CSF3InputFieldFilled story 1`] = `
<body>
<div>
<input
data-testid="input"
formaction="http://localhost:3000/"
formmethod=""
/>
<!---->
<!---->
<!---->
</div>
<input
data-testid="input"
formaction="http://localhost:3000/"
formmethod=""
/>
</body>
`;
exports[`Renders CSF3Primary story 1`] = `
<body>
<button
class="storybook-button storybook-button--large storybook-button--primary"
style=""
type="button"
>
foo
<!---->
</button>
</body>
`;
exports[`Renders LoaderStory story 1`] = `
<body>
<div>
<div
data-testid="loaded-data"
>
loaded data
</div>
<div
data-testid="spy-data"
>
mockFn return value
</div>
</div>
</body>
`;
exports[`Renders NewStory story 1`] = `
<body>
<div
data-testid="local-decorator"
style="margin: 3em;"
>
<button
class="storybook-button storybook-button--large storybook-button--primary"
style=""
@ -123,60 +178,9 @@ exports[`Renders CSF3Primary story 1`] = `
</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 NewStory story 1`] = `
<body>
<div>
<div
data-testid="local-decorator"
style="margin: 3em;"
>
<button
class="storybook-button storybook-button--large storybook-button--primary"
style=""
type="button"
>
foo
<!---->
</button>
<!---->
<!---->
</div>
<!---->
<!---->
<!---->
<!---->
<!---->
</div>
<!---->
<!---->
</body>
`;

View File

@ -11,16 +11,18 @@ import * as stories from './Button.stories';
import type Button from './Button.svelte';
import { composeStories, composeStory, setProjectAnnotations } from '../../portable-stories';
setProjectAnnotations({ testingLibraryRender: render });
// example with composeStories, returns an object with all stories composed with args/decorators
const { CSF3Primary, LoaderStory } = composeStories(stories);
afterEach(() => {
cleanup();
});
// example with composeStory, returns a single story composed with args/decorators
const Secondary = composeStory(stories.CSF2Secondary, stories.default);
describe('renders', () => {
afterEach(() => {
cleanup();
});
it('renders primary button with custom props via composeStory', () => {
// We unfortunately can't do the following:
// render(CSF3Primary.Component, { ...CSF3Primary.props, label: 'Hello world' });
@ -73,10 +75,6 @@ describe('renders', () => {
});
describe('projectAnnotations', () => {
afterEach(() => {
cleanup();
});
it('renders with default projectAnnotations', () => {
setProjectAnnotations([
{
@ -84,6 +82,7 @@ describe('projectAnnotations', () => {
globalTypes: {
locale: { defaultValue: 'en' },
},
testingLibraryRender: render,
},
]);
const WithEnglishText = composeStory(stories.CSF2StoryWithLocale, stories.default);
@ -104,10 +103,6 @@ describe('projectAnnotations', () => {
});
describe('CSF3', () => {
afterEach(() => {
cleanup();
});
it('renders with inferred globalRender', () => {
const Primary = composeStory(stories.CSF3Button, stories.default);
render(Primary.Component, Primary.props);
@ -125,9 +120,7 @@ describe('CSF3', () => {
it('renders with play function without canvas element', async () => {
const CSF3InputFieldFilled = composeStory(stories.CSF3InputFieldFilled, stories.default);
render(CSF3InputFieldFilled.Component, CSF3InputFieldFilled.props);
await CSF3InputFieldFilled.play!();
await CSF3InputFieldFilled.play();
const input = screen.getByTestId('input') as HTMLInputElement;
expect(input.value).toEqual('Hello world!');
@ -136,12 +129,15 @@ describe('CSF3', () => {
it('renders with play function with canvas element', async () => {
const CSF3InputFieldFilled = composeStory(stories.CSF3InputFieldFilled, stories.default);
const { container } = render(CSF3InputFieldFilled.Component, CSF3InputFieldFilled.props);
const div = document.createElement('div');
document.body.appendChild(div);
await CSF3InputFieldFilled.play!({ canvasElement: container });
await CSF3InputFieldFilled.play({ canvasElement: div });
const input = screen.getByTestId('input') as HTMLInputElement;
expect(input.value).toEqual('Hello world!');
document.body.removeChild(div);
});
});
@ -174,16 +170,7 @@ 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();
if (_storyName === 'CSF2StoryWithLocale') {
return;
}
await Story.load();
const { container } = await render(Story.Component, Story.props);
await Story.play?.({ canvasElement: container });
expect(container).toMatchSnapshot();
if (_storyName === 'CSF2StoryWithLocale') return;
await Story.play();
expect(document.body).toMatchSnapshot();
});

View File

@ -2,7 +2,6 @@
import SlotDecorator from './SlotDecorator.svelte';
import { dedent } from 'ts-dedent';
export let svelteVersion;
export let name;
export let title;
export let storyFn;
@ -56,4 +55,4 @@
}
</script>
<SlotDecorator {svelteVersion} {Component} {props} on={{ ...eventsFromArgTypes, ...on }} />
<SlotDecorator {Component} {props} on={{ ...eventsFromArgTypes, ...on }} />

View File

@ -1,15 +1,17 @@
<script>
import { onMount } from 'svelte';
import { VERSION } from 'svelte/compiler';
export let svelteVersion;
export let decorator = undefined;
export let Component;
export let props = {};
export let on = undefined;
let instance;
let decoratorInstance;
const svelteVersion = VERSION[0];
if (on && svelteVersion < 5) {
// Attach Svelte event listeners in Svelte v4
// In Svelte v5 this is not possible anymore as instances are no longer classes with $on() properties, so it will be a no-op

View File

@ -2,3 +2,4 @@ export const parameters: {} = { renderer: 'svelte' };
export { render, renderToCanvas } from './render';
export { decorateStory as applyDecorators } from './decorators';
export { mount } from './mount';

View File

@ -0,0 +1,15 @@
import { type StoryContext, type SvelteRenderer } from './public-types';
import { type BaseAnnotations } from '@storybook/types';
export const mount: BaseAnnotations<SvelteRenderer>['mount'] = (context: StoryContext) => {
return async (Component, options) => {
if (Component) {
context.originalStoryFn = () => ({
Component,
props: options && 'props' in options ? options?.props : options,
});
}
await context.renderToCanvas();
return context.canvas;
};
};

View File

@ -19,6 +19,7 @@ import PreviewRender from '@storybook/svelte/internal/PreviewRender.svelte';
// @ts-expect-error Don't know why TS doesn't pick up the types export here
import { createSvelte5Props } from '@storybook/svelte/internal/createSvelte5Props';
import { IS_SVELTE_V4 } from './utils';
import { TestingLibraryMustBeConfiguredError } from 'storybook/internal/preview-errors';
type ComposedStory<TArgs extends Args = any> = ComposedStoryFn<SvelteRenderer, TArgs> & {
Component: typeof PreviewRender;
@ -57,8 +58,15 @@ export function setProjectAnnotations(
}
// This will not be necessary once we have auto preset loading
export const INTERNAL_DEFAULT_PROJECT_ANNOTATIONS: ProjectAnnotations<SvelteRenderer> =
svelteProjectAnnotations;
export const INTERNAL_DEFAULT_PROJECT_ANNOTATIONS: ProjectAnnotations<SvelteRenderer> = {
...svelteProjectAnnotations,
renderToCanvas: ({ storyFn, storyContext: { testingLibraryRender: render, canvasElement } }) => {
if (render == null) throw new TestingLibraryMustBeConfiguredError();
const { Component, props } = storyFn();
const { unmount } = render(Component, { props, target: canvasElement });
return unmount;
},
};
/**
* Function that will receive a story along with meta (e.g. a default export from a .stories file)

View File

@ -231,3 +231,13 @@ describe('Story args can be inferred', () => {
expectTypeOf(Basic).toEqualTypeOf<Expected>();
});
});
it('mount accepts a Component and props', () => {
const Basic: StoryObj<Button> = {
async play({ mount }) {
const canvas = await mount(Button, { label: 'label', disabled: true });
expectTypeOf(canvas).toEqualTypeOf<Canvas>();
},
};
expectTypeOf(Basic).toEqualTypeOf<StoryObj<Button>>();
});

View File

@ -1,4 +1,8 @@
import type { StoryContext as StoryContextBase, WebRenderer } from 'storybook/internal/types';
import type {
Canvas,
StoryContext as StoryContextBase,
WebRenderer,
} from 'storybook/internal/types';
import type { ComponentConstructorOptions, ComponentEvents, SvelteComponent } from 'svelte';
export type StoryContext = StoryContextBase<SvelteRenderer>;
@ -37,6 +41,12 @@ export interface SvelteRenderer<C extends SvelteComponent = SvelteComponent> ext
storyResult: this['T'] extends Record<string, any>
? SvelteStoryResult<this['T'], ComponentEvents<C>>
: SvelteStoryResult;
mount: (
Component?: ComponentType,
// TODO add proper typesafety
options?: Record<string, any> & { props: Record<string, any> }
) => Promise<Canvas>;
}
export interface SvelteStoryResult<

View File

@ -0,0 +1,44 @@
<script>
import { createEventDispatcher } from 'svelte';
/**
* Is this the principal call to action on the page?
*/
export let primary = false;
/**
* What background color to use
*/
export let backgroundColor = undefined;
/**
* How large should the button be?
*/
export let size = 'medium';
/**
* Button contents
*/
export let label = '';
$: mode = primary ? 'storybook-button--primary' : 'storybook-button--secondary';
$: style = backgroundColor ? `background-color: ${backgroundColor}` : '';
const dispatch = createEventDispatcher();
/**
* Optional click handler
*/
export let onClick = (event) => {
dispatch('click', event);
};
</script>
<button
type="button"
class={['storybook-button', `storybook-button--${size}`, mode].join(' ')}
{style}
on:click={onClick}
>
{label}
</button>

View File

@ -0,0 +1,15 @@
import Button from './Button.svelte';
import type { StoryObj } from '@storybook/svelte';
export default {
component: Button,
};
export const Basic: StoryObj = {
args: {
disabled: true,
},
async play({ mount, args }) {
await mount(Button, { props: { ...args, label: 'set in play' } });
},
};

View File

@ -11,6 +11,8 @@ import * as stories from './Button.stories';
import type Button from './Button.vue';
import { composeStories, composeStory, setProjectAnnotations } from '../../portable-stories';
setProjectAnnotations({ testingLibraryRender: render });
// example with composeStories, returns an object with all stories composed with args/decorators
const { CSF3Primary, LoaderStory } = composeStories(stories);
@ -58,6 +60,7 @@ describe('projectAnnotations', () => {
it('renders with default projectAnnotations', () => {
setProjectAnnotations([
{
testingLibraryRender: render,
parameters: { injected: true },
globalTypes: {
locale: { defaultValue: 'en' },
@ -100,9 +103,7 @@ describe('CSF3', () => {
it('renders with play function', async () => {
const CSF3InputFieldFilled = composeStory(stories.CSF3InputFieldFilled, stories.default);
render(CSF3InputFieldFilled);
await CSF3InputFieldFilled.play!();
await CSF3InputFieldFilled.play();
const input = screen.getByTestId('input') as HTMLInputElement;
expect(input.value).toEqual('Hello world!');
@ -143,14 +144,8 @@ describe('ComposeStories types', () => {
// Batch snapshot testing
const testCases = Object.values(composeStories(stories)).map((Story) => [Story.storyName, Story]);
it.each(testCases)('Renders %s story', async (_storyName, Story) => {
if (typeof Story === 'string' || _storyName === 'CSF2StoryWithLocale') {
return;
}
await Story.load();
const { baseElement } = await render(Story);
await Story.play?.();
if (typeof Story === 'string' || _storyName === 'CSF2StoryWithLocale') return;
await Story.play();
await new Promise((resolve) => setTimeout(resolve, 0));
expect(baseElement).toMatchSnapshot();
expect(document.body).toMatchSnapshot();
});

View File

@ -1,3 +1,4 @@
export const parameters: {} = { renderer: 'vue3' };
export { render, renderToCanvas } from './render';
export { decorateStory as applyDecorators } from './decorateStory';
export { mount } from './mount';

View File

@ -0,0 +1,13 @@
import { type StoryContext, type VueRenderer } from './public-types';
import { h } from 'vue';
import { type BaseAnnotations } from '@storybook/types';
export const mount: BaseAnnotations<VueRenderer>['mount'] = (context: StoryContext) => {
return async (Component, options) => {
if (Component) {
context.originalStoryFn = () => () => h(Component, options?.props, options?.slots);
}
await context.renderToCanvas();
return context.canvas;
};
};

View File

@ -11,6 +11,7 @@ import type {
Store_CSFExports,
StoriesWithPartialProps,
} from 'storybook/internal/types';
import { TestingLibraryMustBeConfiguredError } from 'storybook/internal/preview-errors';
import { h } from 'vue';
import * as defaultProjectAnnotations from './entry-preview';
@ -48,6 +49,16 @@ export function setProjectAnnotations(
originalSetProjectAnnotations<VueRenderer>(projectAnnotations);
}
// This will not be necessary once we have auto preset loading
export const vueProjectAnnotations: ProjectAnnotations<VueRenderer> = {
...defaultProjectAnnotations,
renderToCanvas: ({ storyFn, storyContext: { testingLibraryRender: render, canvasElement } }) => {
if (render == null) throw new TestingLibraryMustBeConfiguredError();
const { unmount } = render(storyFn(), { baseElement: canvasElement });
return unmount;
},
};
/**
* Function that will receive a story along with meta (e.g. a default export from a .stories file)
* and optionally projectAnnotations e.g. (import * from '../.storybook/preview)
@ -85,7 +96,7 @@ export function composeStory<TArgs extends Args = Args>(
story as StoryAnnotationsOrFn<VueRenderer, Args>,
componentAnnotations,
projectAnnotations,
defaultProjectAnnotations,
vueProjectAnnotations,
exportsName
);

View File

@ -1,7 +1,7 @@
// this file tests Typescript types that's why there are no assertions
import { describe, it } from 'vitest';
import { satisfies } from 'storybook/internal/common';
import type { ComponentAnnotations, StoryAnnotations } from 'storybook/internal/types';
import type { Canvas, ComponentAnnotations, StoryAnnotations } from 'storybook/internal/types';
import { expectTypeOf } from 'expect-type';
import type { SetOptional } from 'type-fest';
import { h } from 'vue';
@ -196,3 +196,13 @@ it('Infer type of slots', () => {
type Expected = StoryAnnotations<VueRenderer, Props, Props>;
expectTypeOf(Basic).toEqualTypeOf<Expected>();
});
it('mount accepts a Component', () => {
const Basic: StoryObj<typeof Button> = {
async play({ mount }) {
const canvas = await mount(Button, { label: 'label', disabled: true });
expectTypeOf(canvas).toEqualTypeOf<Canvas>();
},
};
expectTypeOf(Basic).toEqualTypeOf<StoryObj<typeof Button>>();
});

View File

@ -1,4 +1,8 @@
import { type StoryContext as StoryContextBase, type WebRenderer } from 'storybook/internal/types';
import {
type Canvas,
type StoryContext as StoryContextBase,
type WebRenderer,
} from 'storybook/internal/types';
import type { App, ConcreteComponent } from 'vue';
export type { RenderContext } from 'storybook/internal/types';
@ -21,4 +25,10 @@ export interface VueRenderer extends WebRenderer {
// Try not omitting, and check the type errros in the test file, if you want to learn more.
component: Omit<ConcreteComponent<this['T']>, 'props'>;
storyResult: StoryFnVueReturnType;
mount: (
Component?: StoryFnVueReturnType,
// TODO add proper typesafety
options?: { props?: Record<string, any>; slots?: Record<string, any> }
) => Promise<Canvas>;
}

View File

@ -0,0 +1,20 @@
import type { StoryObj } from '@storybook/vue3';
import { defineComponent } from 'vue';
const Button = defineComponent({
template: '<button :disabled="disabled">{{label}}</button>',
props: ['disabled', 'label'],
});
export default {
component: Button,
};
export const Basic: StoryObj<typeof Button> = {
args: {
disabled: true,
},
async play({ mount, args }) {
await mount(Button, { props: { ...args, label: 'set in play' } });
},
};

View File

@ -501,7 +501,7 @@ __metadata:
languageName: node
linkType: hard
"@babel/helper-create-class-features-plugin@npm:^7.18.6, @babel/helper-create-class-features-plugin@npm:^7.21.0, @babel/helper-create-class-features-plugin@npm:^7.24.1, @babel/helper-create-class-features-plugin@npm:^7.24.4":
"@babel/helper-create-class-features-plugin@npm:^7.18.6, @babel/helper-create-class-features-plugin@npm:^7.21.0, @babel/helper-create-class-features-plugin@npm:^7.24.0, @babel/helper-create-class-features-plugin@npm:^7.24.1, @babel/helper-create-class-features-plugin@npm:^7.24.4":
version: 7.24.4
resolution: "@babel/helper-create-class-features-plugin@npm:7.24.4"
dependencies:
@ -692,9 +692,9 @@ __metadata:
linkType: hard
"@babel/helper-string-parser@npm:^7.23.4":
version: 7.24.1
resolution: "@babel/helper-string-parser@npm:7.24.1"
checksum: 10c0/2f9bfcf8d2f9f083785df0501dbab92770111ece2f90d120352fda6dd2a7d47db11b807d111e6f32aa1ba6d763fe2dc6603d153068d672a5d0ad33ca802632b2
version: 7.23.4
resolution: "@babel/helper-string-parser@npm:7.23.4"
checksum: 10c0/f348d5637ad70b6b54b026d6544bd9040f78d24e7ec245a0fc42293968181f6ae9879c22d89744730d246ce8ec53588f716f102addd4df8bbc79b73ea10004ac
languageName: node
linkType: hard
@ -705,7 +705,7 @@ __metadata:
languageName: node
linkType: hard
"@babel/helper-validator-option@npm:^7.23.5":
"@babel/helper-validator-option@npm:^7.22.15, @babel/helper-validator-option@npm:^7.23.5":
version: 7.23.5
resolution: "@babel/helper-validator-option@npm:7.23.5"
checksum: 10c0/af45d5c0defb292ba6fd38979e8f13d7da63f9623d8ab9ededc394f67eb45857d2601278d151ae9affb6e03d5d608485806cd45af08b4468a0515cf506510e94
@ -816,15 +816,15 @@ __metadata:
linkType: hard
"@babel/plugin-proposal-decorators@npm:^7.13.5, @babel/plugin-proposal-decorators@npm:^7.22.7":
version: 7.24.1
resolution: "@babel/plugin-proposal-decorators@npm:7.24.1"
version: 7.24.0
resolution: "@babel/plugin-proposal-decorators@npm:7.24.0"
dependencies:
"@babel/helper-create-class-features-plugin": "npm:^7.24.1"
"@babel/helper-create-class-features-plugin": "npm:^7.24.0"
"@babel/helper-plugin-utils": "npm:^7.24.0"
"@babel/plugin-syntax-decorators": "npm:^7.24.1"
"@babel/plugin-syntax-decorators": "npm:^7.24.0"
peerDependencies:
"@babel/core": ^7.0.0-0
checksum: 10c0/ffe49522ada6581f1c760b777dbd913afcd204e11e6907c4f2c293ce6d30961449ac19d9960250d8743a1f60e21cb667e51a3af15992dfe7627105e039c46a9b
checksum: 10c0/6bf16cb2b5b2f1b63b5ea964853cd3b3419c8285296b5bf64a64127c9d5c1b2e6829e84bd92734e4b71df67686d8f36fb01bb8a45fc52bcece7503b73bc42ec7
languageName: node
linkType: hard
@ -932,14 +932,14 @@ __metadata:
languageName: node
linkType: hard
"@babel/plugin-syntax-decorators@npm:^7.24.1":
version: 7.24.1
resolution: "@babel/plugin-syntax-decorators@npm:7.24.1"
"@babel/plugin-syntax-decorators@npm:^7.24.0":
version: 7.24.0
resolution: "@babel/plugin-syntax-decorators@npm:7.24.0"
dependencies:
"@babel/helper-plugin-utils": "npm:^7.24.0"
peerDependencies:
"@babel/core": ^7.0.0-0
checksum: 10c0/14028a746f86efbdd47e4961456bb53d656e9e3461890f66b1b01032151d15fda5ba99fcaa60232a229a33aa9e73b11c2597b706d5074c520155757e372cd17b
checksum: 10c0/6c11801e062772d4e1b0b418a4732574128b1dfc13193a2909fa93937346746aaa7046f88f6026ff3c80777c967d0fe2e4bb19a1d3fb399e8349c81741e4f471
languageName: node
linkType: hard
@ -965,14 +965,14 @@ __metadata:
languageName: node
linkType: hard
"@babel/plugin-syntax-flow@npm:^7.24.1":
version: 7.24.1
resolution: "@babel/plugin-syntax-flow@npm:7.24.1"
"@babel/plugin-syntax-flow@npm:^7.22.5":
version: 7.22.5
resolution: "@babel/plugin-syntax-flow@npm:7.22.5"
dependencies:
"@babel/helper-plugin-utils": "npm:^7.24.0"
"@babel/helper-plugin-utils": "npm:^7.22.5"
peerDependencies:
"@babel/core": ^7.0.0-0
checksum: 10c0/618de04360a96111408abdaafaba2efbaef0d90faad029d50e0281eaad5d7c7bd2ce4420bbac0ee27ad84c2b7bbc3e48f782064f81ed5bc40c398637991004c7
checksum: 10c0/07afc7df02141597968532bfbfa3f6c0ad21a2bdd885d0e5e035dcf60fdf35f0995631c9750b464e1a6f2feea14160a82787f914e88e8f7115dc99f09853e43e
languageName: node
linkType: hard
@ -1354,15 +1354,15 @@ __metadata:
languageName: node
linkType: hard
"@babel/plugin-transform-flow-strip-types@npm:^7.24.1":
version: 7.24.1
resolution: "@babel/plugin-transform-flow-strip-types@npm:7.24.1"
"@babel/plugin-transform-flow-strip-types@npm:^7.22.5":
version: 7.22.5
resolution: "@babel/plugin-transform-flow-strip-types@npm:7.22.5"
dependencies:
"@babel/helper-plugin-utils": "npm:^7.24.0"
"@babel/plugin-syntax-flow": "npm:^7.24.1"
"@babel/helper-plugin-utils": "npm:^7.22.5"
"@babel/plugin-syntax-flow": "npm:^7.22.5"
peerDependencies:
"@babel/core": ^7.0.0-0
checksum: 10c0/e6aa9cbad0441867598d390d4df65bc8c6b797574673e4eedbdae0cc528e81e00f4b2cd38f7d138b0f04bcdd2540384a9812d5d76af5abfa06aee1c7fc20ca58
checksum: 10c0/5949a8e5214e3fc65d31dab0551423cea9d9eef35faa5d0004707ba7347baf96166aa400907ce7498f754db4e1e9d039ca434a508546b0dc9fdae9a42e814c1a
languageName: node
linkType: hard
@ -1536,13 +1536,13 @@ __metadata:
linkType: hard
"@babel/plugin-transform-object-assign@npm:^7.8.3":
version: 7.24.1
resolution: "@babel/plugin-transform-object-assign@npm:7.24.1"
version: 7.22.5
resolution: "@babel/plugin-transform-object-assign@npm:7.22.5"
dependencies:
"@babel/helper-plugin-utils": "npm:^7.24.0"
"@babel/helper-plugin-utils": "npm:^7.22.5"
peerDependencies:
"@babel/core": ^7.0.0-0
checksum: 10c0/eb30beac71a5930ecdfc8740b184f22dd2043b1ac6f9f6818fb2e10ddfbdd6536b4ddb0d00af2c9f4a375823f52a566915eb598bea0633484aa5ff5db4e547fd
checksum: 10c0/c80ca956ccc45c68a6f35e8aea80e08c0a653e4baf243727d4258f242d312d71be20e3fad35a1f2cd9d58b30dcbb5cdf5f8d6c6614a3f8c6079d90f9b1dadee6
languageName: node
linkType: hard
@ -1668,24 +1668,24 @@ __metadata:
linkType: hard
"@babel/plugin-transform-react-jsx-self@npm:^7.18.6":
version: 7.24.1
resolution: "@babel/plugin-transform-react-jsx-self@npm:7.24.1"
version: 7.22.5
resolution: "@babel/plugin-transform-react-jsx-self@npm:7.22.5"
dependencies:
"@babel/helper-plugin-utils": "npm:^7.24.0"
"@babel/helper-plugin-utils": "npm:^7.22.5"
peerDependencies:
"@babel/core": ^7.0.0-0
checksum: 10c0/ea362ff94b535c753f560eb1f5e063dc72bbbca17ed58837a949a7b289d5eacc7b0a28296d1932c94429b168d6040cdee5484a59b9e3c021f169e0ee137e6a27
checksum: 10c0/263091bdede1f448cb2c59b84eb69972c15d3f022c929a75337bd20d8b65551ac38cd26dad1946eaa93289643506b10ddaea3445a28cb8fca5a773a22a0df90b
languageName: node
linkType: hard
"@babel/plugin-transform-react-jsx-source@npm:^7.19.6":
version: 7.24.1
resolution: "@babel/plugin-transform-react-jsx-source@npm:7.24.1"
version: 7.22.5
resolution: "@babel/plugin-transform-react-jsx-source@npm:7.22.5"
dependencies:
"@babel/helper-plugin-utils": "npm:^7.24.0"
"@babel/helper-plugin-utils": "npm:^7.22.5"
peerDependencies:
"@babel/core": ^7.0.0-0
checksum: 10c0/ea8e3263c0dc51fbc97c156cc647150a757cc56de10781287353d0ce9b2dcd6b6d93d573c0142d7daf5d6fb554c74fa1971ae60764924ea711161d8458739b63
checksum: 10c0/defc9debb76b4295e3617ef7795a0533dbbecef6f51bf5ba4bfc162df892a84fd39e14d5f1b9a5aad7b09b97074fef4c6756f9d2036eef5a9874acabe198f75a
languageName: node
linkType: hard
@ -2080,15 +2080,15 @@ __metadata:
linkType: hard
"@babel/preset-flow@npm:^7.13.13, @babel/preset-flow@npm:^7.22.15":
version: 7.24.1
resolution: "@babel/preset-flow@npm:7.24.1"
version: 7.22.15
resolution: "@babel/preset-flow@npm:7.22.15"
dependencies:
"@babel/helper-plugin-utils": "npm:^7.24.0"
"@babel/helper-validator-option": "npm:^7.23.5"
"@babel/plugin-transform-flow-strip-types": "npm:^7.24.1"
"@babel/helper-plugin-utils": "npm:^7.22.5"
"@babel/helper-validator-option": "npm:^7.22.15"
"@babel/plugin-transform-flow-strip-types": "npm:^7.22.5"
peerDependencies:
"@babel/core": ^7.0.0-0
checksum: 10c0/e2209158d68a456b8f9d6cd6c810e692f3ab8ca28edba99afcecaacd657ace7cc905e566f84d6da06e537836a2f830bc6ddf4cb34006d57303ff9a40a94fa433
checksum: 10c0/7eef0c84ec1889d6c4f7a67d7d1a81703420eed123a8c23f25af148eead77907f0bd701f3e729fdb37d3ddb2a373bf43938b36a9ba17f546111ddb9521466b92
languageName: node
linkType: hard
@ -2137,17 +2137,17 @@ __metadata:
linkType: hard
"@babel/register@npm:^7.13.16, @babel/register@npm:^7.22.15":
version: 7.23.7
resolution: "@babel/register@npm:7.23.7"
version: 7.22.15
resolution: "@babel/register@npm:7.22.15"
dependencies:
clone-deep: "npm:^4.0.1"
find-cache-dir: "npm:^2.0.0"
make-dir: "npm:^2.1.0"
pirates: "npm:^4.0.6"
pirates: "npm:^4.0.5"
source-map-support: "npm:^0.5.16"
peerDependencies:
"@babel/core": ^7.0.0-0
checksum: 10c0/b2466e41a4394e725b57e139ba45c3f61b88546d3cb443e84ce46cb34071b60c6cdb706a14c58a1443db530691a54f51da1f0c97f6c1aecbb838a2fb7eb5dbb9
checksum: 10c0/895cc773c3b3eae909478ea2a9735ef6edd634b04b4aaaad2ce576fd591c2b3c70ff8c90423e769a291bee072186e7e4801480c1907e31ba3053c6cdba5571cb
languageName: node
linkType: hard
@ -2159,12 +2159,12 @@ __metadata:
linkType: hard
"@babel/runtime-corejs3@npm:^7.10.2":
version: 7.24.4
resolution: "@babel/runtime-corejs3@npm:7.24.4"
version: 7.23.1
resolution: "@babel/runtime-corejs3@npm:7.23.1"
dependencies:
core-js-pure: "npm:^3.30.2"
regenerator-runtime: "npm:^0.14.0"
checksum: 10c0/121bec9a0b505e2995c4b71cf480167e006e8ee423f77bccc38975bfbfbfdb191192ff03557c18fad6de8f2b85c12c49aaa4b92d1d5fe0c0e136da664129be1e
checksum: 10c0/6e2c2b11779ff56c88b1f3a8742498640f7271ad4fcf9cfd24052bbb236a5e7c4c7c8d81cda751da3b4effa678736303deb78441c5752e63bfb90d6453fd870f
languageName: node
linkType: hard
@ -2802,11 +2802,11 @@ __metadata:
linkType: hard
"@figspec/components@npm:^1.0.1":
version: 1.0.3
resolution: "@figspec/components@npm:1.0.3"
version: 1.0.2
resolution: "@figspec/components@npm:1.0.2"
dependencies:
lit: "npm:^2.1.3"
checksum: 10c0/78f5ee600ea1d15af7848b9fc601063acd537f49deb08aa16b95d3452c3a5783352e142c970f9a7375817099f2f54c3f9b42911f21c44ce6dab72eab20e6e20a
checksum: 10c0/8e889140d6577f6bdf31a6b460539127f1614e42c8d08b545b8dd500dbb606edae87e7619933fc6039370fc7552a4b68458e23957e0d7c28ca90eecf7b06cdce
languageName: node
linkType: hard
@ -5148,8 +5148,8 @@ __metadata:
linkType: soft
"@storybook/addon-designs@npm:^7.0.4":
version: 7.0.9
resolution: "@storybook/addon-designs@npm:7.0.9"
version: 7.0.7
resolution: "@storybook/addon-designs@npm:7.0.7"
dependencies:
"@figspec/react": "npm:^1.0.0"
peerDependencies:
@ -5166,7 +5166,7 @@ __metadata:
optional: true
react-dom:
optional: true
checksum: 10c0/e27528e64a9bb21cd194d6940d852d88828ca6b2e473b9fa69ac8d4446322935bf73286d98d22bdd6316937db176a1295a6befd1ba4a8021105b5eebb4099950
checksum: 10c0/650cb4254a2e12b5c80cc999fb9048efc6ce9bf0d9a29b78a5b5e4fc1e9a67d0b1e5f58ee3fa14780efcb3c896967017d56b11dde989ea1931db409e3ca534f1
languageName: node
linkType: hard
@ -5270,7 +5270,7 @@ __metadata:
version: 0.0.0-use.local
resolution: "@storybook/addon-links@workspace:addons/links"
dependencies:
"@storybook/csf": "npm:0.1.10"
"@storybook/csf": "npm:0.1.11"
"@storybook/global": "npm:^5.0.0"
fs-extra: "npm:^11.1.0"
ts-dedent: "npm:^2.0.0"
@ -5493,7 +5493,7 @@ __metadata:
resolution: "@storybook/blocks@workspace:lib/blocks"
dependencies:
"@storybook/addon-actions": "workspace:*"
"@storybook/csf": "npm:0.1.10"
"@storybook/csf": "npm:0.1.11"
"@storybook/global": "npm:^5.0.0"
"@storybook/icons": "npm:^1.2.5"
"@storybook/test": "workspace:*"
@ -5645,7 +5645,7 @@ __metadata:
"@babel/preset-env": "npm:^7.24.4"
"@babel/types": "npm:^7.24.0"
"@storybook/core": "workspace:*"
"@storybook/csf": "npm:0.1.10"
"@storybook/csf": "npm:0.1.11"
"@types/cross-spawn": "npm:^6.0.2"
"@types/jscodeshift": "npm:^0.11.10"
ansi-regex: "npm:^5.0.1"
@ -5739,7 +5739,7 @@ __metadata:
"@radix-ui/react-dialog": "npm:^1.0.5"
"@radix-ui/react-scroll-area": "npm:^1.0.5"
"@radix-ui/react-slot": "npm:^1.0.2"
"@storybook/csf": "npm:0.1.10"
"@storybook/csf": "npm:0.1.11"
"@storybook/docs-mdx": "npm:3.1.0-next.0"
"@storybook/global": "npm:^5.0.0"
"@storybook/icons": "npm:^1.2.5"
@ -5875,12 +5875,12 @@ __metadata:
languageName: unknown
linkType: soft
"@storybook/csf@npm:0.1.10":
version: 0.1.10
resolution: "@storybook/csf@npm:0.1.10"
"@storybook/csf@npm:0.1.11":
version: 0.1.11
resolution: "@storybook/csf@npm:0.1.11"
dependencies:
type-fest: "npm:^2.19.0"
checksum: 10c0/c5bd17b92aeb8be5918cfad238bfef4c08553f8c60b6284e1cabb8646aeb6f8d6ab4343a77954a5c9924ca717cf306c239c0b061915918137136aa0c9b4be5ab
checksum: 10c0/c5329fc13e7d762049b5c91df1bc1c0e510a1a898c401b72b68f1ff64139a85ab64a92f8e681d2fcb226c0a4a55d0f23b569b2bdb517e0f067bd05ea46228356
languageName: node
linkType: hard
@ -6472,7 +6472,7 @@ __metadata:
"@storybook/codemod": "workspace:*"
"@storybook/core": "workspace:*"
"@storybook/core-webpack": "workspace:*"
"@storybook/csf": "npm:0.1.10"
"@storybook/csf": "npm:0.1.11"
"@storybook/csf-plugin": "workspace:*"
"@storybook/ember": "workspace:*"
"@storybook/eslint-config-storybook": "npm:^4.0.0"
@ -6607,7 +6607,7 @@ __metadata:
version: 0.0.0-use.local
resolution: "@storybook/server@workspace:renderers/server"
dependencies:
"@storybook/csf": "npm:0.1.10"
"@storybook/csf": "npm:0.1.11"
"@storybook/global": "npm:^5.0.0"
"@types/fs-extra": "npm:^11.0.1"
fs-extra: "npm:^11.1.0"
@ -6623,7 +6623,7 @@ __metadata:
version: 0.0.0-use.local
resolution: "@storybook/source-loader@workspace:lib/source-loader"
dependencies:
"@storybook/csf": "npm:0.1.10"
"@storybook/csf": "npm:0.1.11"
estraverse: "npm:^5.2.0"
lodash: "npm:^4.17.21"
prettier: "npm:^3.1.1"
@ -6725,7 +6725,7 @@ __metadata:
version: 0.0.0-use.local
resolution: "@storybook/test@workspace:lib/test"
dependencies:
"@storybook/csf": "npm:0.1.10"
"@storybook/csf": "npm:0.1.11"
"@storybook/instrumenter": "workspace:*"
"@testing-library/dom": "npm:10.1.0"
"@testing-library/jest-dom": "npm:6.4.5"
@ -22132,7 +22132,7 @@ __metadata:
languageName: node
linkType: hard
"pirates@npm:^4.0.6":
"pirates@npm:^4.0.5":
version: 4.0.6
resolution: "pirates@npm:4.0.6"
checksum: 10c0/00d5fa51f8dded94d7429700fb91a0c1ead00ae2c7fd27089f0c5b63e6eca36197fe46384631872690a66f390c5e27198e99006ab77ae472692ab9c2ca903f36

View File

@ -38,7 +38,7 @@ const sbInit = async (
flags?: string[],
debug?: boolean
) => {
const sbCliBinaryPath = join(__dirname, `../../code/lib/cli/bin/index.js`);
const sbCliBinaryPath = join(__dirname, `../../code/lib/cli/bin/index.cjs`);
console.log(`🎁 Installing storybook`);
const env = { STORYBOOK_DISABLE_TELEMETRY: 'true', ...envVars };
const fullFlags = ['--yes', ...(flags || [])];

View File

@ -4,11 +4,12 @@ import { ReactRenderer } from '@storybook/react';
import { setProjectAnnotations } from '@storybook/nextjs';
import * as addonInteractions from '@storybook/addon-interactions/preview';
import * as addonActions from '@storybook/addon-essentials/actions/preview';
import { render } from '@testing-library/react';
/**
* For some weird reason, Jest in Nextjs throws the following error:
* Cannot find module '.storybook/preview' from 'jest.setup.ts
*
*
* when using import sbAnnotations from './.storybook/preview';
*/
const sbAnnotations = require('./.storybook/preview');
@ -17,4 +18,5 @@ setProjectAnnotations([
sbAnnotations,
addonInteractions as ProjectAnnotations<ReactRenderer>, // instruments actions as spies
addonActions as ProjectAnnotations<ReactRenderer>, // creates actions from argTypes
{ testingLibraryRender: render },
]);

View File

@ -1,7 +1,7 @@
import { describe, it, expect } from '@jest/globals'
import { describe, it, expect } from '@jest/globals';
import { render } from '@testing-library/react';
import { composeStories } from '@storybook/nextjs';
import { composeStories, setProjectAnnotations } from '@storybook/nextjs';
import * as imageStories from './Image.stories';
import * as navigationStories from './Navigation.stories';
import * as linkStories from './Link.stories';
@ -20,14 +20,12 @@ const runTests = (name: string, storiesModule: any) => {
const composedStories = composeStories(storiesModule);
Object.entries(composedStories).forEach(([name, Story]: [any, any]) => {
it(`renders ${name}`, async () => {
await Story.load();
const { baseElement } = render(<Story />);
await Story.play?.();
expect(baseElement).toMatchSnapshot();
expect(document.body).toMatchSnapshot();
});
});
})
}
});
};
// example with composeStory, returns a single story composed with args/decorators
describe('renders', () => {
@ -42,4 +40,4 @@ describe('renders', () => {
runTests('fontStories', fontStories);
runTests('headStories', headStories);
runTests('getImagePropsStories', getImagePropsStories);
});
});

View File

@ -4,9 +4,11 @@ import { ReactRenderer, setProjectAnnotations } from '@storybook/react';
import sbAnnotations from './.storybook/preview';
import * as addonInteractions from '@storybook/addon-interactions/preview';
import * as addonActions from '@storybook/addon-essentials/actions/preview';
import { render } from '@testing-library/react';
setProjectAnnotations([
sbAnnotations,
addonInteractions as ProjectAnnotations<ReactRenderer>, // instruments actions as spies
addonActions as ProjectAnnotations<ReactRenderer>, // creates actions from argTypes
{ testingLibraryRender: render },
]);

View File

@ -2,48 +2,33 @@
import * as stories from './Button.stories';
import { composeStories } from '@storybook/react';
const { CSF3Primary, WithLoader, CSF3InputFieldFilled, Modal } = composeStories(stories)
const { CSF3Primary, WithLoader, Modal } = composeStories(stories);
describe('<Button />', () => {
it('renders with loaders and play function', () => {
cy.then(async() => {
cy.then(async () => {
await WithLoader.load();
});
cy.mount(<WithLoader />);
cy.then(async() => {
await WithLoader.play!({ canvasElement: document.querySelector('[data-cy-root]') as HTMLElement });
cy.then(async () => {
cy.get('[data-testid="loaded-data"]').should('contain.text', 'loaded data');
cy.get('[data-testid="mock-data"]').should('contain.text', 'mockFn return value');
});
})
});
it('renders primary button', async () => {
cy.mount(<CSF3Primary />)
cy.mount(<CSF3Primary />);
cy.get('[data-decorator]').should('exist');
})
});
it('renders primary button with custom args', async () => {
cy.mount(<CSF3Primary>bar</CSF3Primary>)
cy.mount(<CSF3Primary>bar</CSF3Primary>);
cy.get('button').should('contain.text', 'bar');
})
it('renders with play function', () => {
cy.mount(<CSF3InputFieldFilled />);
cy.then(async() => {
await CSF3InputFieldFilled.play!({ canvasElement: document.querySelector('[data-cy-root]') as HTMLElement });
cy.get('[data-testid="input"]').should('contain.value', 'Hello world!');
});
})
});
it('renders modal story', () => {
cy.mount(<Modal />);
cy.then(async() => {
await Modal.play!({ canvasElement: document.querySelector('[data-cy-root]') as HTMLElement });
cy.get('[role="dialog"]').should('exist');
});
})
})
});
});

View File

@ -4,16 +4,16 @@ import { addons } from 'storybook/internal/preview-api';
import { setProjectAnnotations, composeStories, composeStory } from '@storybook/react';
import * as stories from './Button.stories';
afterEach(() => {
cleanup();
});
// example with composeStories, returns an object with all stories composed with args/decorators
const { CSF3Primary } = composeStories(stories);
// // example with composeStory, returns a single story composed with args/decorators
const Secondary = composeStory(stories.CSF2Secondary, stories.default);
describe('renders', () => {
afterEach(() => {
cleanup();
});
it('renders primary button', () => {
render(<CSF3Primary>Hello world</CSF3Primary>);
const buttonElement = screen.getByText(/Hello world/i);
@ -46,10 +46,6 @@ describe('renders', () => {
});
describe('projectAnnotations', () => {
afterEach(() => {
cleanup();
});
it('renders with default projectAnnotations', () => {
const WithEnglishText = composeStory(stories.CSF2StoryWithLocale, stories.default);
const { getByText } = render(<WithEnglishText />);
@ -67,7 +63,7 @@ describe('projectAnnotations', () => {
});
it('renders with custom projectAnnotations via setProjectAnnotations', () => {
setProjectAnnotations([{ parameters: { injected: true } }]);
setProjectAnnotations([{ parameters: { injected: true }, testingLibraryRender: render }]);
const Story = composeStory(stories.CSF2StoryWithLocale, stories.default);
expect(Story.parameters?.injected).toBe(true);
});
@ -96,9 +92,7 @@ describe('CSF3', () => {
it('renders with play function', async () => {
const CSF3InputFieldFilled = composeStory(stories.CSF3InputFieldFilled, stories.default);
const { container } = render(<CSF3InputFieldFilled />);
await CSF3InputFieldFilled.play({ canvasElement: container });
await CSF3InputFieldFilled.play();
const input = screen.getByTestId('input') as HTMLInputElement;
expect(input.value).toEqual('Hello world!');
@ -123,9 +117,6 @@ it('should pass with decorators that need addons channel', () => {
// Batch snapshot testing
const testCases = Object.values(composeStories(stories)).map((Story) => [Story.storyName, Story]);
it.each(testCases)('Renders %s story', async (_storyName, Story) => {
cleanup();
await Story.load();
const { baseElement } = await render(<Story />);
await Story.play?.();
expect(baseElement).toMatchSnapshot();
await Story.play();
expect(document.body).toMatchSnapshot();
});

View File

@ -6,6 +6,8 @@ import * as stories from './Button.stories';
// import type Button from './Button.svelte';
import { composeStories, composeStory, setProjectAnnotations } from '@storybook/svelte';
setProjectAnnotations({ testingLibraryRender: render });
// example with composeStories, returns an object with all stories composed with args/decorators
const { CSF3Primary, LoaderStory } = composeStories(stories);
@ -69,6 +71,7 @@ describe('projectAnnotations', () => {
globalTypes: {
locale: { defaultValue: 'en' },
},
testingLibraryRender: render,
},
]);
const WithEnglishText = composeStory(stories.CSF2StoryWithLocale, stories.default);
@ -111,9 +114,7 @@ describe('CSF3', () => {
it('renders with play function without canvas element', async () => {
const CSF3InputFieldFilled = composeStory(stories.CSF3InputFieldFilled, stories.default);
render(CSF3InputFieldFilled.Component, CSF3InputFieldFilled.props);
await CSF3InputFieldFilled.play!();
await CSF3InputFieldFilled.play();
const input = screen.getByTestId('input') as HTMLInputElement;
expect(input.value).toEqual('Hello world!');
@ -122,12 +123,15 @@ describe('CSF3', () => {
it('renders with play function with canvas element', async () => {
const CSF3InputFieldFilled = composeStory(stories.CSF3InputFieldFilled, stories.default);
const { container } = render(CSF3InputFieldFilled.Component, CSF3InputFieldFilled.props);
const div = document.createElement('div');
document.body.appendChild(div);
await CSF3InputFieldFilled.play!({ canvasElement: container });
await CSF3InputFieldFilled.play({ canvasElement: div });
const input = screen.getByTestId('input') as HTMLInputElement;
expect(input.value).toEqual('Hello world!');
document.body.removeChild(div);
});
});
@ -136,17 +140,7 @@ 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();
if (_storyName === 'CSF2StoryWithLocale') {
return;
}
await Story.load();
const { container } = await render(Story.Component, Story.props);
await Story.play?.({ canvasElement: container });
expect(container).toMatchSnapshot();
if (_storyName === 'CSF2StoryWithLocale') return;
await Story.play();
expect(document.body).toMatchSnapshot();
});

View File

@ -2,48 +2,22 @@
exports[`Renders CSF2Secondary story 1`] = `
<body>
<div>
<button
class="storybook-button storybook-button--medium storybook-button--secondary"
style=""
type="button"
>
label coming from story args!
</button>
</div>
<button
class="storybook-button storybook-button--medium storybook-button--secondary"
style=""
type="button"
>
label coming from story args!
</button>
</body>
`;
exports[`Renders CSF2StoryWithParamsAndDecorator story 1`] = `
<body>
<div>
<div
style="margin: 3em;"
>
<button
class="storybook-button storybook-button--medium storybook-button--secondary"
style=""
type="button"
>
foo
</button>
</div>
</div>
</body>
`;
exports[`Renders CSF3Button story 1`] = `
<body>
<div>
<div
style="margin: 3em;"
>
<button
class="storybook-button storybook-button--medium storybook-button--secondary"
style=""
@ -53,51 +27,90 @@ exports[`Renders CSF3Button story 1`] = `
</button>
</div>
</body>
`;
exports[`Renders CSF3Button story 1`] = `
<body>
<button
class="storybook-button storybook-button--medium storybook-button--secondary"
style=""
type="button"
>
foo
</button>
</body>
`;
exports[`Renders CSF3ButtonWithRender story 1`] = `
<body>
<div>
<div>
<p
data-testid="custom-render"
>
I am a custom render function
</p>
<p
data-testid="custom-render"
>
I am a custom render function
</p>
<button
class="storybook-button storybook-button--medium storybook-button--secondary"
style=""
type="button"
>
foo
<button
class="storybook-button storybook-button--medium storybook-button--secondary"
style=""
type="button"
>
foo
</button>
</div>
</button>
</div>
</body>
`;
exports[`Renders CSF3InputFieldFilled story 1`] = `
<body>
<div>
<input
data-testid="input"
/>
</div>
<input
data-testid="input"
/>
</body>
`;
exports[`Renders CSF3Primary story 1`] = `
<body>
<button
class="storybook-button storybook-button--large storybook-button--primary"
style=""
type="button"
>
foo
</button>
</body>
`;
exports[`Renders LoaderStory story 1`] = `
<body>
<div>
<div
data-testid="loaded-data"
>
loaded data
</div>
<div
data-testid="spy-data"
>
mockFn return value
</div>
</div>
</body>
`;
exports[`Renders NewStory story 1`] = `
<body>
<div
style="margin: 3em;"
>
<button
class="storybook-button storybook-button--large storybook-button--primary"
style=""
@ -107,53 +120,8 @@ exports[`Renders CSF3Primary story 1`] = `
</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 NewStory story 1`] = `
<body>
<div>
<div
style="margin: 3em;"
>
<button
class="storybook-button storybook-button--large storybook-button--primary"
style=""
type="button"
>
foo
</button>
</div>
</div>
</body>
`;

View File

@ -3,7 +3,7 @@
"compilerOptions": {
"target": "ESNext",
"useDefineForClassFields": true,
"module": "ESNext",
"module": "Preserve",
"resolveJsonModule": true,
/**
* Typecheck JS in `.svelte` and `.js` files by default.