Skip to content

Commit

Permalink
Merge pull request #28255 from storybookjs/beforeAll-hook
Browse files Browse the repository at this point in the history
Core: Add support for `beforeAll` hook
  • Loading branch information
ghengeveld authored Jul 3, 2024
2 parents 640415d + df3ab6f commit 48e454f
Show file tree
Hide file tree
Showing 15 changed files with 310 additions and 26 deletions.
Empty file.
40 changes: 30 additions & 10 deletions code/core/src/preview-api/modules/preview-web/Preview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,22 @@ import {
UPDATE_GLOBALS,
UPDATE_STORY_ARGS,
} from '@storybook/core/core-events';
import type { CleanupCallback } from '@storybook/csf';
import type { Channel } from '@storybook/core/channels';
import type {
Renderer,
Args,
Globals,
ModuleImportFn,
RenderContextCallbacks,
RenderToCanvas,
PreparedStory,
StoryIndex,
ProjectAnnotations,
StoryId,
StoryRenderOptions,
SetGlobalsPayload,
} from '@storybook/core/types';
import {
CalledPreviewMethodBeforeInitializationError,
MissingRenderToCanvasError,
Expand All @@ -34,16 +49,6 @@ 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,
PreparedStory,
ProjectAnnotations,
RenderToCanvas,
} from '@storybook/core/types';
import type { RenderContextCallbacks, StoryRenderOptions } from '@storybook/core/types';
import type { StoryIndex } from '@storybook/core/types';
import type { SetGlobalsPayload } from '@storybook/core/types';

const { fetch } = global;

Expand All @@ -69,6 +74,8 @@ export class Preview<TRenderer extends Renderer> {
// project annotations. Once the index loads, it is stored on the store and this will get unset.
private projectAnnotationsBeforeInitialization?: ProjectAnnotations<TRenderer>;

private beforeAllCleanup?: CleanupCallback | void;

protected storeInitializationPromise: Promise<void>;

protected resolveStoreInitializationPromise!: () => void;
Expand Down Expand Up @@ -120,6 +127,7 @@ export class Preview<TRenderer extends Renderer> {

try {
const projectAnnotations = await this.getProjectAnnotationsOrRenderError();
await this.runBeforeAllHook(projectAnnotations);
await this.initializeWithProjectAnnotations(projectAnnotations);
} catch (err) {
this.rejectStoreInitializationPromise(err as Error);
Expand Down Expand Up @@ -168,6 +176,16 @@ export class Preview<TRenderer extends Renderer> {
}
}

async runBeforeAllHook(projectAnnotations: ProjectAnnotations<TRenderer>) {
try {
await this.beforeAllCleanup?.();
this.beforeAllCleanup = await projectAnnotations.beforeAll?.();
} catch (err) {
this.renderPreviewEntryError('Error in beforeAll hook:', err as Error);
throw err;
}
}

async getStoryIndexFromServer() {
const result = await fetch(STORY_INDEX_PATH);
if (result.status === 200) {
Expand Down Expand Up @@ -223,6 +241,8 @@ export class Preview<TRenderer extends Renderer> {
this.getProjectAnnotations = getProjectAnnotations;

const projectAnnotations = await this.getProjectAnnotationsOrRenderError();
await this.runBeforeAllHook(projectAnnotations);

if (!this.storyStoreValue) {
await this.initializeWithProjectAnnotations(projectAnnotations);
return;
Expand Down
71 changes: 71 additions & 0 deletions code/core/src/preview-api/modules/store/csf/beforeAll.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { composeBeforeAllHooks } from './beforeAll';

const calls: string[] = [];

beforeEach(() => {
calls.length = 0;
});

const basicHook = (label: string) => () => {
calls.push(label);
};
const asyncHook = (label: string, delay: number) => async () => {
await new Promise((resolve) => setTimeout(resolve, delay));
calls.push(label);
};
const cleanupHook = (label: string) => () => {
calls.push(label);
return () => {
calls.push(label + ' cleanup');
};
};
const asyncCleanupHook = (label: string, delay: number) => () => {
calls.push(label);
return async () => {
await new Promise((resolve) => setTimeout(resolve, delay));
calls.push(label + ' cleanup');
};
};

describe('composeBeforeAllHooks', () => {
it('should return a composed hook function', async () => {
await composeBeforeAllHooks([basicHook('one'), basicHook('two'), basicHook('three')])();
expect(calls).toEqual(['one', 'two', 'three']);
});

it('should execute cleanups in reverse order', async () => {
const cleanup = await composeBeforeAllHooks([
cleanupHook('one'),
cleanupHook('two'),
cleanupHook('three'),
])();
expect(calls).toEqual(['one', 'two', 'three']);

await cleanup?.();
expect(calls).toEqual(['one', 'two', 'three', 'three cleanup', 'two cleanup', 'one cleanup']);
});

it('should execute async hooks in sequence', async () => {
await composeBeforeAllHooks([
asyncHook('one', 10),
asyncHook('two', 100),
asyncHook('three', 10),
])();
expect(calls).toEqual(['one', 'two', 'three']);
});

it('should execute async cleanups in reverse order', async () => {
const hooks = [
asyncCleanupHook('one', 10),
asyncCleanupHook('two', 100),
asyncCleanupHook('three', 10),
];

const cleanup = await composeBeforeAllHooks(hooks)();
expect(calls).toEqual(['one', 'two', 'three']);

await cleanup?.();
expect(calls).toEqual(['one', 'two', 'three', 'three cleanup', 'two cleanup', 'one cleanup']);
});
});
17 changes: 17 additions & 0 deletions code/core/src/preview-api/modules/store/csf/beforeAll.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { type BeforeAll, type CleanupCallback } from '@storybook/csf';

// Execute all the hooks in sequence, and return a function that will execute cleanups in reverse order
export const composeBeforeAllHooks = (hooks: BeforeAll[]): BeforeAll => {
return async () => {
const cleanups: CleanupCallback[] = [];
for (const hook of hooks) {
const cleanup = await hook();
if (cleanup) cleanups.unshift(cleanup);
}
return async () => {
for (const cleanup of cleanups) {
await cleanup();
}
};
};
};
28 changes: 28 additions & 0 deletions code/core/src/preview-api/modules/store/csf/composeConfigs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ describe('composeConfigs', () => {
initialGlobals: {},
globalTypes: {},
loaders: [],
beforeAll: expect.any(Function),
beforeEach: [],
runStep: expect.any(Function),
tags: [],
Expand Down Expand Up @@ -49,6 +50,7 @@ describe('composeConfigs', () => {
initialGlobals: {},
globalTypes: {},
loaders: [],
beforeAll: expect.any(Function),
beforeEach: [],
runStep: expect.any(Function),
tags: [],
Expand Down Expand Up @@ -80,6 +82,7 @@ describe('composeConfigs', () => {
initialGlobals: {},
globalTypes: {},
loaders: [],
beforeAll: expect.any(Function),
beforeEach: [],
runStep: expect.any(Function),
tags: [],
Expand Down Expand Up @@ -117,6 +120,7 @@ describe('composeConfigs', () => {
initialGlobals: {},
globalTypes: { x: '2', y: '1', z: '2', obj: { a: '2', c: '2' } },
loaders: [],
beforeAll: expect.any(Function),
beforeEach: [],
runStep: expect.any(Function),
tags: [],
Expand Down Expand Up @@ -157,6 +161,7 @@ describe('composeConfigs', () => {
initialGlobals: {},
globalTypes: { x: '2', y: '1', z: '2', obj: { a: '2', c: '2' } },
loaders: [],
beforeAll: expect.any(Function),
beforeEach: [],
runStep: expect.any(Function),
tags: [],
Expand Down Expand Up @@ -188,6 +193,7 @@ describe('composeConfigs', () => {
initialGlobals: {},
globalTypes: {},
loaders: ['1', '2', '3', '4'],
beforeAll: expect.any(Function),
beforeEach: [],
runStep: expect.any(Function),
tags: [],
Expand Down Expand Up @@ -219,6 +225,7 @@ describe('composeConfigs', () => {
initialGlobals: {},
globalTypes: {},
loaders: ['1', '2', '3'],
beforeAll: expect.any(Function),
beforeEach: [],
runStep: expect.any(Function),
tags: [],
Expand Down Expand Up @@ -246,6 +253,7 @@ describe('composeConfigs', () => {
initialGlobals: {},
globalTypes: {},
loaders: [],
beforeAll: expect.any(Function),
beforeEach: [],
runStep: expect.any(Function),
tags: [],
Expand Down Expand Up @@ -274,6 +282,7 @@ describe('composeConfigs', () => {
initialGlobals: {},
globalTypes: {},
loaders: [],
beforeAll: expect.any(Function),
beforeEach: [],
runStep: expect.any(Function),
tags: [],
Expand Down Expand Up @@ -305,6 +314,7 @@ describe('composeConfigs', () => {
initialGlobals: {},
globalTypes: {},
loaders: [],
beforeAll: expect.any(Function),
beforeEach: [],
render: 'render-2',
renderToCanvas: 'renderToCanvas-2',
Expand All @@ -314,6 +324,24 @@ describe('composeConfigs', () => {
});
});

it('composes beforeAll hooks', async () => {
const one = vi.fn();
const two = vi.fn();
const three = vi.fn();

const { beforeAll } = composeConfigs([
{ beforeAll: one },
{ beforeAll: two },
{ beforeAll: three },
]);

await beforeAll?.();

expect(one).toHaveBeenCalled();
expect(two).toHaveBeenCalled();
expect(three).toHaveBeenCalled();
});

it('composes step runners', () => {
const fn = vi.fn();

Expand Down
7 changes: 5 additions & 2 deletions code/core/src/preview-api/modules/store/csf/composeConfigs.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import type { ModuleExports, ProjectAnnotations } from '@storybook/core/types';
import type { Renderer } from '@storybook/core/types';
import { global } from '@storybook/global';

import { combineParameters } from '../parameters';
import { composeStepRunners } from './stepRunners';
import { normalizeArrays } from './normalizeArrays';
import type { ModuleExports, ProjectAnnotations } from '@storybook/core/types';
import type { Renderer } from '@storybook/core/types';
import { composeBeforeAllHooks } from './beforeAll';

export function getField<TFieldType = any>(
moduleExportList: ModuleExports[],
Expand Down Expand Up @@ -43,6 +44,7 @@ export function composeConfigs<TRenderer extends Renderer>(
): ProjectAnnotations<TRenderer> {
const allArgTypeEnhancers = getArrayField(moduleExportList, 'argTypesEnhancers');
const stepRunners = getField(moduleExportList, 'runStep');
const beforeAllHooks = getArrayField(moduleExportList, 'beforeAll');

return {
parameters: combineParameters(...getField(moduleExportList, 'parameters')),
Expand All @@ -60,6 +62,7 @@ export function composeConfigs<TRenderer extends Renderer>(
initialGlobals: getObjectField(moduleExportList, 'initialGlobals'),
globalTypes: getObjectField(moduleExportList, 'globalTypes'),
loaders: getArrayField(moduleExportList, 'loaders'),
beforeAll: composeBeforeAllHooks(beforeAllHooks),
beforeEach: getArrayField(moduleExportList, 'beforeEach'),
render: getSingletonField(moduleExportList, 'render'),
renderToCanvas: getSingletonField(moduleExportList, 'renderToCanvas'),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,43 @@ describe('composeStory', () => {
tags: ['metaTag'],
};

it('should return composed beforeAll as part of project annotations', async () => {
const after = vi.fn();
const before = vi.fn((n) => () => after(n));
const finalAnnotations = setProjectAnnotations([
{ beforeAll: () => before(1) },
{ beforeAll: () => before(2) },
{ beforeAll: () => before(3) },
]);

const cleanup = await finalAnnotations.beforeAll?.();
expect(before.mock.calls).toEqual([[1], [2], [3]]);

await cleanup?.();
expect(after.mock.calls).toEqual([[3], [2], [1]]);
});

it('should return composed project annotations via setProjectAnnotations', () => {
const firstAnnotations = {
parameters: { foo: 'bar' },
tags: ['autodocs'],
};

const secondAnnotations = {
args: {
foo: 'bar',
},
};
const finalAnnotations = setProjectAnnotations([firstAnnotations, secondAnnotations]);
expect(finalAnnotations).toEqual(
expect.objectContaining({
parameters: { foo: 'bar' },
args: { foo: 'bar' },
tags: ['autodocs'],
})
);
});

it('should compose project annotations in all module formats', () => {
setProjectAnnotations([defaultExportAnnotations, namedExportAnnotations]);

Expand Down
10 changes: 7 additions & 3 deletions code/core/src/preview-api/modules/store/csf/portable-stories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,10 @@ const DEFAULT_STORY_NAME = 'Unnamed Story';
function extractAnnotation<TRenderer extends Renderer = Renderer>(
annotation: NamedOrDefaultProjectAnnotations<TRenderer>
) {
if (!annotation) return {};
// support imports such as
// import * as annotations from '.storybook/preview'
// import annotations from '.storybook/preview'
// in both cases: 1 - the file has a default export; 2 - named exports only
return 'default' in annotation ? annotation.default : annotation;
}
Expand All @@ -48,9 +50,11 @@ export function setProjectAnnotations<TRenderer extends Renderer = Renderer>(
projectAnnotations:
| NamedOrDefaultProjectAnnotations<TRenderer>
| NamedOrDefaultProjectAnnotations<TRenderer>[]
) {
): ProjectAnnotations<TRenderer> {
const annotations = Array.isArray(projectAnnotations) ? projectAnnotations : [projectAnnotations];
globalProjectAnnotations = composeConfigs(annotations.map(extractAnnotation));

return globalProjectAnnotations;
}

const cleanups: CleanupCallback[] = [];
Expand Down Expand Up @@ -287,10 +291,10 @@ export function createPlaywrightTest<TFixture extends { extend: any }>(
throw new Error(dedent`
Portable stories in Playwright CT only work when referencing JSX elements.
Please use JSX format for your components such as:
instead of:
await mount(MyComponent, { props: { foo: 'bar' } })
do:
await mount(<MyComponent foo="bar"/>)
Expand Down
Loading

0 comments on commit 48e454f

Please sign in to comment.