diff --git a/.gitignore b/.gitignore
index 9bf3c7eb5332..d9481a41fbb1 100644
--- a/.gitignore
+++ b/.gitignore
@@ -57,7 +57,7 @@ code/bench-results/
/packs
code/.nx/cache
code/.nx/workspace-data
-code/.vite-inspect
+.vite-inspect
.nx/cache
.nx/workspace-data
!**/fixtures/**/yarn.lock
diff --git a/code/.storybook/main.ts b/code/.storybook/main.ts
index fbc3bbe4eeed..5b1be3191417 100644
--- a/code/.storybook/main.ts
+++ b/code/.storybook/main.ts
@@ -136,6 +136,7 @@ const config = defineMain({
},
features: {
developmentModeForBuild: true,
+ experimentalTestSyntax: true,
},
staticDirs: [{ from: './bench/bundle-analyzer', to: '/bundle-analyzer' }],
viteFinal: async (viteConfig, { configType }) => {
diff --git a/code/.storybook/preview.tsx b/code/.storybook/preview.tsx
index d70b18e543e4..dc335c620322 100644
--- a/code/.storybook/preview.tsx
+++ b/code/.storybook/preview.tsx
@@ -216,7 +216,7 @@ const decorators = [
* This decorator renders the stories side-by-side, stacked or default based on the theme switcher
* in the toolbar
*/
- (StoryFn, { globals, playFunction, args, storyGlobals, parameters }) => {
+ (StoryFn, { globals, playFunction, testFunction, args, storyGlobals, parameters }) => {
let theme = globals.sb_theme;
let showPlayFnNotice = false;
@@ -224,10 +224,13 @@ const decorators = [
// but this is acceptable, I guess
// we need to ensure only a single rendering in chromatic
// a more 'correct' approach would be to set a specific theme global on every story that has a playFunction
- if (playFunction && args.autoplay !== false && !(theme === 'light' || theme === 'dark')) {
+ if (
+ (testFunction || (playFunction && args.autoplay !== false)) &&
+ !(theme === 'light' || theme === 'dark')
+ ) {
theme = 'light';
showPlayFnNotice = true;
- } else if (isChromatic() && !storyGlobals.sb_theme && !playFunction) {
+ } else if (isChromatic() && !storyGlobals.sb_theme && !playFunction && !testFunction) {
theme = 'stacked';
}
@@ -282,8 +285,8 @@ const decorators = [
<>
- Detected play function in Chromatic. Rendering only light theme to avoid
- multiple play functions in the same story.
+ Detected play/test function in Chromatic. Rendering only light theme to avoid
+ multiple play/test functions in the same story.
diff --git a/code/addons/docs/src/blocks/blocks/external/ExternalPreview.ts b/code/addons/docs/src/blocks/blocks/external/ExternalPreview.ts
index 92ea6c8e4c8d..913df2db074d 100644
--- a/code/addons/docs/src/blocks/blocks/external/ExternalPreview.ts
+++ b/code/addons/docs/src/blocks/blocks/external/ExternalPreview.ts
@@ -71,9 +71,11 @@ export class ExternalPreview extends Prev
title,
name,
type: 'story',
+ subtype: 'story',
};
});
+ // TODO: We probably need to do something here about story tests
this.onStoriesChanged({ storyIndex: this.storyIndex });
return csfFile;
diff --git a/code/addons/docs/src/preview.ts b/code/addons/docs/src/preview.ts
index 4a5a57123b12..713352b025ea 100644
--- a/code/addons/docs/src/preview.ts
+++ b/code/addons/docs/src/preview.ts
@@ -1,12 +1,5 @@
import type { PreparedStory } from 'storybook/internal/types';
-import * as tocbot from 'tocbot';
-
-if (!globalThis.__STORYBOOK_UNSAFE_TOCBOT__) {
- // Users that load dynamic content need to have a way to refresh the TOC, so we expose the tocbot instance
- globalThis.__STORYBOOK_UNSAFE_TOCBOT__ = tocbot.default ?? tocbot;
-}
-
const excludeTags = Object.entries(globalThis.TAGS_OPTIONS ?? {}).reduce(
(acc, entry) => {
const [tag, option] = entry;
diff --git a/code/addons/docs/src/typings.d.ts b/code/addons/docs/src/typings.d.ts
index 2deb8921d2ad..468759c8ba22 100644
--- a/code/addons/docs/src/typings.d.ts
+++ b/code/addons/docs/src/typings.d.ts
@@ -7,7 +7,6 @@ declare var __DOCS_CONTEXT__: any;
declare var PREVIEW_URL: any;
declare var LOGLEVEL: 'trace' | 'debug' | 'info' | 'warn' | 'error' | 'silent' | undefined;
declare var TAGS_OPTIONS: import('storybook/internal/types').TagsOptions;
-declare var __STORYBOOK_UNSAFE_TOCBOT__: typeof import('tocbot').default;
declare module '*.md';
declare module '*.md?raw';
diff --git a/code/addons/vitest/src/components/TestProviderRender.stories.tsx b/code/addons/vitest/src/components/TestProviderRender.stories.tsx
index b5c4919ad5a2..bf7c6671f734 100644
--- a/code/addons/vitest/src/components/TestProviderRender.stories.tsx
+++ b/code/addons/vitest/src/components/TestProviderRender.stories.tsx
@@ -310,6 +310,7 @@ export const InSidebarContextMenu: Story = {
entry: {
id: 'story-id-1',
type: 'story',
+ subtype: 'story',
name: 'Example Story',
tags: [],
title: 'Example Story',
diff --git a/code/addons/vitest/src/components/TestProviderRender.tsx b/code/addons/vitest/src/components/TestProviderRender.tsx
index 1c61ff72b65f..555e3362bcc5 100644
--- a/code/addons/vitest/src/components/TestProviderRender.tsx
+++ b/code/addons/vitest/src/components/TestProviderRender.tsx
@@ -242,15 +242,19 @@ export const TestProviderRender: FC = ({
+ onClick={() => {
+ let storyIds;
+ if (entry) {
+ // Don't send underlying child test ids when running on a story
+ // Vitest Manager already handles running the underlying tests
+ storyIds =
+ entry.type === 'story' ? [entry.id] : api.findAllLeafStoryIds(entry.id);
+ }
store.send({
type: 'TRIGGER_RUN',
- payload: {
- storyIds: entry ? api.findAllLeafStoryIds(entry.id) : undefined,
- triggeredBy: entry ? entry.type : 'global',
- },
- })
- }
+ payload: { storyIds, triggeredBy: entry?.type ?? 'global' },
+ });
+ }}
>
diff --git a/code/addons/vitest/src/node/test-manager.test.ts b/code/addons/vitest/src/node/test-manager.test.ts
index 03cdc8c5ae21..ff8cfa4e64df 100644
--- a/code/addons/vitest/src/node/test-manager.test.ts
+++ b/code/addons/vitest/src/node/test-manager.test.ts
@@ -15,6 +15,7 @@ import path from 'pathe';
import { STATUS_TYPE_ID_A11Y, STATUS_TYPE_ID_COMPONENT_TEST, storeOptions } from '../constants';
import type { StoreEvent, StoreState } from '../types';
import { TestManager, type TestManagerOptions } from './test-manager';
+import { DOUBLE_SPACES } from './vitest-manager';
const setTestNamePattern = vi.hoisted(() => vi.fn());
const vitest = vi.hoisted(() => ({
@@ -103,6 +104,7 @@ global.fetch = vi.fn().mockResolvedValue({
entries: {
'story--one': {
type: 'story',
+ subtype: 'story',
id: 'story--one',
name: 'One',
title: 'story/one',
@@ -111,12 +113,32 @@ global.fetch = vi.fn().mockResolvedValue({
},
'another--one': {
type: 'story',
+ subtype: 'story',
id: 'another--one',
name: 'One',
title: 'another/one',
importPath: 'path/to/another/file',
tags: ['test'],
},
+ 'parent--story': {
+ type: 'story',
+ subtype: 'story',
+ id: 'parent--story',
+ name: 'Parent story',
+ title: 'parent/story',
+ importPath: 'path/to/parent/file',
+ tags: ['test'],
+ },
+ 'parent--story:test': {
+ type: 'story',
+ subtype: 'test',
+ id: 'parent--story:test',
+ name: 'Test name',
+ title: 'parent/story',
+ parent: 'parent--story',
+ importPath: 'path/to/parent/file',
+ tags: ['test', 'test-fn'],
+ },
},
} as StoryIndex)
),
@@ -184,10 +206,56 @@ describe('TestManager', () => {
triggeredBy: 'global',
},
});
- expect(setTestNamePattern).toHaveBeenCalledWith(/^One$/);
+ expect(setTestNamePattern).toHaveBeenCalledWith(new RegExp(`^One$`));
expect(vitest.runTestSpecifications).toHaveBeenCalledWith(tests.slice(0, 1), true);
});
+ it('should trigger a single story render test', async () => {
+ vitest.globTestSpecifications.mockImplementation(() => tests);
+ const testManager = await TestManager.start(options);
+
+ await testManager.handleTriggerRunEvent({
+ type: 'TRIGGER_RUN',
+ payload: {
+ storyIds: ['another--one'],
+ triggeredBy: 'global',
+ },
+ });
+ // regex should be exact match of the story name
+ expect(setTestNamePattern).toHaveBeenCalledWith(new RegExp(`^One$`));
+ });
+
+ it('should trigger a single story test', async () => {
+ vitest.globTestSpecifications.mockImplementation(() => tests);
+ const testManager = await TestManager.start(options);
+
+ await testManager.handleTriggerRunEvent({
+ type: 'TRIGGER_RUN',
+ payload: {
+ storyIds: ['parent--story:test'],
+ triggeredBy: 'global',
+ },
+ });
+ // regex should be Parent Story Name + Test Name
+ expect(setTestNamePattern).toHaveBeenCalledWith(
+ new RegExp(`^Parent story${DOUBLE_SPACES} Test name$`)
+ );
+ });
+
+ it('should trigger all tests of a story', async () => {
+ vitest.globTestSpecifications.mockImplementation(() => tests);
+ const testManager = await TestManager.start(options);
+
+ await testManager.handleTriggerRunEvent({
+ type: 'TRIGGER_RUN',
+ payload: {
+ storyIds: ['parent--story'],
+ triggeredBy: 'global',
+ },
+ });
+ expect(setTestNamePattern).toHaveBeenCalledWith(new RegExp(`^Parent story${DOUBLE_SPACES}`));
+ });
+
it('should restart Vitest before a test run if coverage is enabled', async () => {
const testManager = await TestManager.start(options);
expect(createVitest).toHaveBeenCalledTimes(1);
diff --git a/code/addons/vitest/src/node/vitest-manager.ts b/code/addons/vitest/src/node/vitest-manager.ts
index 00363c295487..64bd32ff0aa5 100644
--- a/code/addons/vitest/src/node/vitest-manager.ts
+++ b/code/addons/vitest/src/node/vitest-manager.ts
@@ -16,7 +16,6 @@ import * as find from 'empathic/find';
import path, { dirname, join, normalize } from 'pathe';
import slash from 'slash';
-import { resolvePackageDir } from '../../../../core/src/shared/utils/module';
import { COVERAGE_DIRECTORY } from '../constants';
import { log } from '../logger';
import type { TriggerRunEvent } from '../types';
@@ -30,6 +29,17 @@ const VITEST_WORKSPACE_FILE_EXTENSION = ['ts', 'js', 'json'];
// We have to tell Vitest that it runs as part of Storybook
process.env.VITEST_STORYBOOK = 'true';
+/**
+ * The Storybook vitest plugin adds double space characters so that it's possible to do a regex for
+ * all test run use cases. Otherwise, if there were two unrelated stories like "Primary Button" and
+ * "Primary Button Mobile", once you run tests for "Primary Button" and its children it would also
+ * match "Primary Button Mobile". As it turns out, this limitation is also present in the Vitest
+ * VSCode extension and the issue would occur with normal vitest tests as well, but because we use
+ * double spaces, we circumvent the issue.
+ */
+export const DOUBLE_SPACES = ' ';
+const getTestName = (name: string) => `${name}${DOUBLE_SPACES}`;
+
export class VitestManager {
vitest: Vitest | null = null;
@@ -168,7 +178,7 @@ export class VitestManager {
});
}
- private async fetchStories(requestStoryIds?: string[]) {
+ private async fetchStories(requestStoryIds?: string[]): Promise {
const indexUrl = this.testManager.store.getState().indexUrl;
if (!indexUrl) {
throw new Error(
@@ -264,18 +274,50 @@ export class VitestManager {
await this.cancelCurrentRun();
const testSpecifications = await this.getStorybookTestSpecifications();
- const stories = await this.fetchStories(runPayload?.storyIds);
+ const allStories = await this.fetchStories();
+
+ const filteredStories = runPayload.storyIds
+ ? allStories.filter((story) => runPayload.storyIds?.includes(story.id))
+ : allStories;
const isSingleStoryRun = runPayload.storyIds?.length === 1;
if (isSingleStoryRun) {
- const storyName = stories[0].name;
- const regex = new RegExp(`^${storyName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`);
+ const selectedStory = filteredStories.find((story) => story.id === runPayload.storyIds?.[0]);
+ if (!selectedStory) {
+ throw new Error(`Story ${runPayload.storyIds?.[0]} not found`);
+ }
+
+ const storyName = selectedStory.name;
+ let regex: RegExp;
+
+ const isParentStory = allStories.some((story) => selectedStory.id === story.parent);
+ const hasParentStory = allStories.some((story) => selectedStory.parent === story.id);
+
+ if (isParentStory) {
+ // Use case 1: "Single" story run on a story with tests
+ // -> run all tests of that story, as storyName is a describe block
+ const parentName = getTestName(selectedStory.name);
+ regex = new RegExp(`^${parentName}`);
+ } else if (hasParentStory) {
+ // Use case 2: Single story run on a specific story test
+ // in this case the regex pattern should be the story parentName + space + story.name
+ const parentStory = allStories.find((story) => story.id === selectedStory.parent);
+ if (!parentStory) {
+ throw new Error(`Parent story not found for story ${selectedStory.id}`);
+ }
+
+ const parentName = getTestName(parentStory.name);
+ regex = new RegExp(`^${parentName} ${storyName}$`);
+ } else {
+ // Use case 3: Single story run on a story without tests, should be exact match of story name
+ regex = new RegExp(`^${storyName}$`);
+ }
this.vitest!.setGlobalTestNamePattern(regex);
}
const { filteredTestSpecifications, filteredStoryIds } = this.filterTestSpecifications(
testSpecifications,
- stories
+ filteredStories
);
this.testManager.store.setState((s) => ({
diff --git a/code/addons/vitest/src/vitest-plugin/index.ts b/code/addons/vitest/src/vitest-plugin/index.ts
index 04757b448565..5d1d38517934 100644
--- a/code/addons/vitest/src/vitest-plugin/index.ts
+++ b/code/addons/vitest/src/vitest-plugin/index.ts
@@ -144,6 +144,7 @@ export const storybookTest = async (options?: UserOptions): Promise =>
previewLevelTags,
core,
extraOptimizeDeps,
+ features,
] = await Promise.all([
getStoryGlobsAndFiles(presets, directories),
presets.apply('framework', undefined),
@@ -153,6 +154,7 @@ export const storybookTest = async (options?: UserOptions): Promise =>
extractTagsFromPreview(finalOptions.configDir),
presets.apply('core'),
presets.apply('optimizeViteDeps', []),
+ presets.apply('features', {}),
]);
const pluginsToIgnore = [
@@ -332,6 +334,7 @@ export const storybookTest = async (options?: UserOptions): Promise =>
...(frameworkName?.includes('vue3')
? { __VUE_PROD_HYDRATION_MISMATCH_DETAILS__: 'false' }
: {}),
+ FEATURES: JSON.stringify(features),
},
};
diff --git a/code/addons/vitest/src/vitest-plugin/test-utils.ts b/code/addons/vitest/src/vitest-plugin/test-utils.ts
index 0983d029c7fc..b712eb48eb9a 100644
--- a/code/addons/vitest/src/vitest-plugin/test-utils.ts
+++ b/code/addons/vitest/src/vitest-plugin/test-utils.ts
@@ -1,6 +1,7 @@
import { type RunnerTask, type TaskMeta, type TestContext } from 'vitest';
-import type { ComponentAnnotations, ComposedStoryFn } from 'storybook/internal/types';
+import { type Meta, type Story, getStoryChildren, isStory, toTestId } from 'storybook/internal/csf';
+import type { ComponentAnnotations, ComposedStoryFn, Renderer } from 'storybook/internal/types';
import { server } from '@vitest/browser/context';
import { type Report, composeStory, getCsfFactoryAnnotations } from 'storybook/preview-api';
@@ -32,14 +33,24 @@ export const convertToFilePath = (url: string): string => {
export const testStory = (
exportName: string,
- story: ComposedStoryFn,
- meta: ComponentAnnotations,
- skipTags: string[]
+ story: ComposedStoryFn | Story,
+ meta: ComponentAnnotations | Meta,
+ skipTags: string[],
+ storyId: string,
+ testName?: string
) => {
return async (context: TestContext & { story: ComposedStoryFn }) => {
const annotations = getCsfFactoryAnnotations(story, meta);
+
+ const test =
+ isStory(story) && testName
+ ? getStoryChildren(story).find((child) => child.input.name === testName)
+ : undefined;
+
+ const storyAnnotations = test ? test.input : annotations.story;
+
const composedStory = composeStory(
- annotations.story,
+ storyAnnotations,
annotations.meta!,
{ initialGlobals: (await getInitialGlobals?.()) ?? {} },
annotations.preview ?? globalThis.globalProjectAnnotations,
@@ -55,10 +66,14 @@ export const testStory = (
const _task = context.task as RunnerTask & {
meta: TaskMeta & { storyId: string; reports: Report[] };
};
- _task.meta.storyId = composedStory.id;
+
+ // The id will always be present, calculated by CsfFile
+ // and is needed so that we can add the test to the story in Storybook's UI for the status
+ _task.meta.storyId = storyId;
await setViewport(composedStory.parameters, composedStory.globals);
- await composedStory.run();
+
+ await composedStory.run(undefined);
_task.meta.reports = composedStory.reporting.reports;
};
diff --git a/code/builders/builder-webpack5/src/types.ts b/code/builders/builder-webpack5/src/types.ts
index 5576baa5deea..9a68c77b6ad6 100644
--- a/code/builders/builder-webpack5/src/types.ts
+++ b/code/builders/builder-webpack5/src/types.ts
@@ -16,7 +16,8 @@ export interface TypescriptOptions extends TypeScriptOptionsBase {
checkOptions?: ConstructorParameters[0];
}
-export interface StorybookConfigWebpack extends Omit {
+export interface StorybookConfigWebpack
+ extends Omit {
/**
* Modify or return a custom Webpack config after the Storybook's default configuration has run
* (mostly used by addons).
@@ -28,6 +29,15 @@ export interface StorybookConfigWebpack extends Omit Configuration | Promise;
+
+ features?: StorybookConfig['features'] & {
+ /**
+ * Enable the experimental `.test` function in CSF Next
+ *
+ * @see https://storybook.js.org/docs/10/api/main-config/main-config-features#experimentalTestSyntax
+ */
+ experimentalTestSyntax?: boolean;
+ };
}
export type BuilderOptions = {
diff --git a/code/core/src/common/utils/sync-main-preview-addons.test.ts b/code/core/src/common/utils/sync-main-preview-addons.test.ts
index 505d9d5cc794..5bd3c6eff7d9 100644
--- a/code/core/src/common/utils/sync-main-preview-addons.test.ts
+++ b/code/core/src/common/utils/sync-main-preview-addons.test.ts
@@ -151,4 +151,31 @@ describe('getSyncedStorybookAddons', () => {
expect(transformedCode).toMatch(originalCode);
});
+
+ it('should add an empty addons array if no addons are installed', async () => {
+ const originalCode = dedent`
+ import { definePreview } from "@storybook/react/preview";
+
+ export default definePreview({});
+ `;
+ const preview = loadConfig(originalCode).parse();
+
+ const result = await getSyncedStorybookAddons(
+ {
+ addons: [],
+ stories: [],
+ },
+ preview,
+ configDir
+ );
+ const transformedCode = normalizeLineBreaks(printConfig(result).code);
+
+ expect(transformedCode).toMatchInlineSnapshot(`
+ import { definePreview } from "@storybook/react/preview";
+
+ export default definePreview({
+ addons: []
+ });
+ `);
+ });
});
diff --git a/code/core/src/common/utils/sync-main-preview-addons.ts b/code/core/src/common/utils/sync-main-preview-addons.ts
index 853b367de890..ca14d2c6a6d8 100644
--- a/code/core/src/common/utils/sync-main-preview-addons.ts
+++ b/code/core/src/common/utils/sync-main-preview-addons.ts
@@ -34,6 +34,11 @@ export async function getSyncedStorybookAddons(
if (!isCsfFactory) {
return previewConfig;
}
+ const existingAddons = previewConfig.getFieldNode(['addons']);
+
+ if (!existingAddons) {
+ previewConfig.setFieldNode(['addons'], t.arrayExpression([]));
+ }
const addons = getAddonNames(mainConfig);
if (!addons) {
@@ -41,7 +46,6 @@ export async function getSyncedStorybookAddons(
}
const syncedAddons: string[] = [];
- const existingAddons = previewConfig.getFieldNode(['addons']);
/**
* This goes through all mainConfig.addons, read their package.json and check whether they have an
* exports map called preview, if so add to the array
diff --git a/code/core/src/component-testing/components/test-fn.stories.tsx b/code/core/src/component-testing/components/test-fn.stories.tsx
new file mode 100644
index 000000000000..6b926a961913
--- /dev/null
+++ b/code/core/src/component-testing/components/test-fn.stories.tsx
@@ -0,0 +1,177 @@
+import React from 'react';
+
+import type { StoryContext } from '@storybook/react-vite';
+
+import { expect, fn } from 'storybook/test';
+
+import preview from '../../../../.storybook/preview';
+
+const Button = (args: React.ComponentProps<'button'>) => ;
+
+const meta = preview.meta({
+ component: Button,
+ render: (args, { name }) => (
+
+ {name}
+
+
+
+
+ ),
+ args: {
+ children: 'Default',
+ onClick: fn(),
+ },
+ tags: ['some-tag', 'autodocs'],
+});
+
+export const WithNoTests = meta.story();
+
+export const TestFunctionTypes = meta.story({
+ args: {
+ children: 'Arg from story',
+ },
+});
+
+export const PlayFunction = meta.story({
+ play: async ({ canvas, userEvent }) => {
+ const button = canvas.getByText('Default');
+ await userEvent.click(button);
+ },
+});
+
+TestFunctionTypes.test('simple', async ({ canvas, userEvent, args }) => {
+ const button = canvas.getByText('Arg from story');
+ await userEvent.click(button);
+ await expect(args.onClick).toHaveBeenCalled();
+});
+
+const doTest = async ({
+ canvas,
+ userEvent,
+ args,
+}: StoryContext>) => {
+ const button = canvas.getByText('Arg from story');
+ await userEvent.click(button);
+ await expect(args.onClick).toHaveBeenCalled();
+};
+TestFunctionTypes.test('referring to function in file', doTest);
+
+TestFunctionTypes.test(
+ 'with overrides',
+ {
+ args: {
+ children: 'Arg from test override',
+ },
+ parameters: {
+ viewport: {
+ options: {
+ sized: {
+ name: 'Sized',
+ styles: {
+ width: '380px',
+ height: '500px',
+ },
+ },
+ },
+ },
+ chromatic: { viewports: [380] },
+ },
+ globals: { sb_theme: 'dark', viewport: { value: 'sized' } },
+ },
+ async ({ canvas }) => {
+ const button = canvas.getByText('Arg from test override');
+ await expect(button).toBeInTheDocument();
+ expect(document.body.clientWidth).toBe(380);
+ }
+);
+
+TestFunctionTypes.test(
+ 'with play function',
+ {
+ play: async ({ canvas }) => {
+ const button = canvas.getByText('Arg from story');
+ await expect(button).toBeInTheDocument();
+ },
+ },
+ async ({ canvas }) => {
+ const button = canvas.getByText('Arg from story');
+ await expect(button).toBeEnabled();
+ }
+);
+
+export const ExtendedStorySinglePlayExample = TestFunctionTypes.extend({
+ args: {
+ children: 'Arg from extended story',
+ },
+ play: async ({ canvas }) => {
+ const button = canvas.getByText('Arg from extended story');
+ await expect(button).toBeEnabled();
+ },
+});
+
+export const ExtendedStorySingleTestExample = TestFunctionTypes.extend({
+ args: {
+ children: 'Arg from extended story',
+ },
+});
+
+ExtendedStorySingleTestExample.test(
+ 'this is a very long test name to explain that this story test should guarantee that the args have been extended correctly',
+ async ({ canvas }) => {
+ const button = canvas.getByText('Arg from extended story');
+ await expect(button).toBeEnabled();
+ }
+);
+
+// This is intentionally defined out-of-order
+PlayFunction.test('should be clicked by play function', async ({ args }) => {
+ await expect(args.onClick).toHaveBeenCalled();
+});
+
+export const TestNames = meta.story({
+ args: {
+ children: 'This story is no-op, just focus on the test names',
+ },
+});
+TestNames.test(
+ 'should display an error when login is attempted with an expired session token',
+ () => {}
+);
+
+TestNames.test(
+ 'should display an error when login is attempted with multiple invalid password attempts',
+ () => {}
+);
+
+TestNames.test('should display an error when login is attempted with a revoked API key', () => {});
+
+TestNames.test(
+ 'should display an error when login is attempted after exceeding the maximum session limit',
+ () => {}
+);
+
+TestNames.test(
+ 'should display an error when login is attempted with a disabled user account',
+ () => {}
+);
+
+TestNames.test(
+ 'should display an error when login is attempted with an unsupported authentication provider',
+ () => {}
+);
+
+TestNames.test(
+ 'should display an error when login is attempted after the password reset process is incomplete',
+ () => {}
+);
+
+TestNames.test(
+ 'should display an error when login is attempted with a malformed authentication request',
+ () => {}
+);
+
+TestNames.test(
+ 'should display an error when login is attempted with an unverified email address',
+ () => {}
+);
diff --git a/code/core/src/components/components/tooltip/ListItem.tsx b/code/core/src/components/components/tooltip/ListItem.tsx
index 421d4e27e5bf..63e13f8b1d05 100644
--- a/code/core/src/components/components/tooltip/ListItem.tsx
+++ b/code/core/src/components/components/tooltip/ListItem.tsx
@@ -62,6 +62,7 @@ const Center = styled.span<{ isIndented: boolean }>(
textAlign: 'left',
display: 'flex',
flexDirection: 'column',
+ minWidth: 0, // required for overflow
},
({ isIndented }) => (isIndented ? { marginLeft: 24 } : {})
);
@@ -116,6 +117,7 @@ export interface ItemProps {
const Item = styled.div(
({ theme }) => ({
width: '100%',
+ minWidth: 0, // required for overflow
border: 'none',
borderRadius: theme.appBorderRadius,
background: 'none',
diff --git a/code/core/src/controls/components/ControlsPanel.tsx b/code/core/src/controls/components/ControlsPanel.tsx
index 157e6a77d211..9a7db5842a4d 100644
--- a/code/core/src/controls/components/ControlsPanel.tsx
+++ b/code/core/src/controls/components/ControlsPanel.tsx
@@ -10,6 +10,7 @@ import {
useArgs,
useGlobals,
useParameter,
+ useStorybookApi,
useStorybookState,
} from 'storybook/manager-api';
import { styled } from 'storybook/theming';
@@ -50,6 +51,7 @@ interface ControlsPanelProps {
}
export const ControlsPanel = ({ saveStory, createStory }: ControlsPanelProps) => {
+ const api = useStorybookApi();
const [isLoading, setIsLoading] = useState(true);
const [args, updateArgs, resetArgs, initialArgs] = useArgs();
const [globals] = useGlobals();
@@ -61,6 +63,7 @@ export const ControlsPanel = ({ saveStory, createStory }: ControlsPanelProps) =>
disableSaveFromUI = false,
} = useParameter(PARAM_KEY, {});
const { path, previewInitialized } = useStorybookState();
+ const storyData = api.getCurrentStoryData();
// If the story is prepared, then show the args table
// and reset the loading states
@@ -103,6 +106,8 @@ export const ControlsPanel = ({ saveStory, createStory }: ControlsPanelProps) =>
isLoading={isLoading}
/>
{hasControls &&
+ storyData.type === 'story' &&
+ storyData.subtype !== 'test' &&
hasUpdatedArgs &&
global.CONFIG_TYPE === 'DEVELOPMENT' &&
disableSaveFromUI !== true && }
diff --git a/code/core/src/core-server/build-index.test.ts b/code/core/src/core-server/build-index.test.ts
index 1bd464c538a5..ff288abeab62 100644
--- a/code/core/src/core-server/build-index.test.ts
+++ b/code/core/src/core-server/build-index.test.ts
@@ -15,6 +15,7 @@ describe('buildIndex', () => {
"id": "my-component-a--story-one",
"importPath": "./core/src/core-server/utils/__mockdata__/docs-id-generation/A.stories.jsx",
"name": "Story One",
+ "subtype": "story",
"tags": [
"dev",
"test",
@@ -43,6 +44,7 @@ describe('buildIndex', () => {
"id": "my-component-b--story-one",
"importPath": "./core/src/core-server/utils/__mockdata__/docs-id-generation/B.stories.jsx",
"name": "Story One",
+ "subtype": "story",
"tags": [
"dev",
"test",
diff --git a/code/core/src/core-server/utils/StoryIndexGenerator.test.ts b/code/core/src/core-server/utils/StoryIndexGenerator.test.ts
index 27fa366eba4e..13c781fb45f2 100644
--- a/code/core/src/core-server/utils/StoryIndexGenerator.test.ts
+++ b/code/core/src/core-server/utils/StoryIndexGenerator.test.ts
@@ -88,6 +88,7 @@ describe('StoryIndexGenerator', () => {
"id": "a--story-one",
"importPath": "./src/A.stories.js",
"name": "Story One",
+ "subtype": "story",
"tags": [
"dev",
"test",
@@ -151,6 +152,7 @@ describe('StoryIndexGenerator', () => {
"id": "f--story-one",
"importPath": "./src/F.story.ts",
"name": "Story One",
+ "subtype": "story",
"tags": [
"dev",
"test",
@@ -198,6 +200,7 @@ describe('StoryIndexGenerator', () => {
"id": "stories--story-one",
"importPath": "./src/stories.ts",
"name": "Story One",
+ "subtype": "story",
"tags": [
"dev",
"test",
@@ -232,6 +235,7 @@ describe('StoryIndexGenerator', () => {
"id": "componentpath-extension--story-one",
"importPath": "./src/componentPath/extension.stories.js",
"name": "Story One",
+ "subtype": "story",
"tags": [
"dev",
"test",
@@ -245,6 +249,7 @@ describe('StoryIndexGenerator', () => {
"id": "componentpath-noextension--story-one",
"importPath": "./src/componentPath/noExtension.stories.js",
"name": "Story One",
+ "subtype": "story",
"tags": [
"dev",
"test",
@@ -258,6 +263,7 @@ describe('StoryIndexGenerator', () => {
"id": "componentpath-package--story-one",
"importPath": "./src/componentPath/package.stories.js",
"name": "Story One",
+ "subtype": "story",
"tags": [
"dev",
"test",
@@ -271,6 +277,7 @@ describe('StoryIndexGenerator', () => {
"id": "nested-button--story-one",
"importPath": "./src/nested/Button.stories.ts",
"name": "Story One",
+ "subtype": "story",
"tags": [
"dev",
"test",
@@ -285,6 +292,7 @@ describe('StoryIndexGenerator', () => {
"id": "second-nested-g--story-one",
"importPath": "./src/second-nested/G.stories.ts",
"name": "Story One",
+ "subtype": "story",
"tags": [
"dev",
"test",
@@ -318,6 +326,7 @@ describe('StoryIndexGenerator', () => {
"id": "a--story-one",
"importPath": "./src/A.stories.js",
"name": "Story One",
+ "subtype": "story",
"tags": [
"dev",
"test",
@@ -346,6 +355,7 @@ describe('StoryIndexGenerator', () => {
"id": "b--story-one",
"importPath": "./src/B.stories.ts",
"name": "Story One",
+ "subtype": "story",
"tags": [
"dev",
"test",
@@ -360,6 +370,7 @@ describe('StoryIndexGenerator', () => {
"id": "componentpath-extension--story-one",
"importPath": "./src/componentPath/extension.stories.js",
"name": "Story One",
+ "subtype": "story",
"tags": [
"dev",
"test",
@@ -373,6 +384,7 @@ describe('StoryIndexGenerator', () => {
"id": "componentpath-noextension--story-one",
"importPath": "./src/componentPath/noExtension.stories.js",
"name": "Story One",
+ "subtype": "story",
"tags": [
"dev",
"test",
@@ -386,6 +398,7 @@ describe('StoryIndexGenerator', () => {
"id": "componentpath-package--story-one",
"importPath": "./src/componentPath/package.stories.js",
"name": "Story One",
+ "subtype": "story",
"tags": [
"dev",
"test",
@@ -412,6 +425,7 @@ describe('StoryIndexGenerator', () => {
"id": "d--story-one",
"importPath": "./src/D.stories.jsx",
"name": "Story One",
+ "subtype": "story",
"tags": [
"dev",
"test",
@@ -426,6 +440,7 @@ describe('StoryIndexGenerator', () => {
"id": "example-button--story-one",
"importPath": "./src/Button.stories.ts",
"name": "Story One",
+ "subtype": "story",
"tags": [
"dev",
"test",
@@ -440,6 +455,7 @@ describe('StoryIndexGenerator', () => {
"id": "first-nested-deeply-f--story-one",
"importPath": "./src/first-nested/deeply/F.stories.js",
"name": "Story One",
+ "subtype": "story",
"tags": [
"dev",
"test",
@@ -453,6 +469,7 @@ describe('StoryIndexGenerator', () => {
"id": "first-nested-deeply-features--with-csf-1",
"importPath": "./src/first-nested/deeply/Features.stories.jsx",
"name": "With CSF 1",
+ "subtype": "story",
"tags": [
"dev",
"test",
@@ -466,6 +483,7 @@ describe('StoryIndexGenerator', () => {
"id": "first-nested-deeply-features--with-play",
"importPath": "./src/first-nested/deeply/Features.stories.jsx",
"name": "With Play",
+ "subtype": "story",
"tags": [
"dev",
"test",
@@ -480,6 +498,7 @@ describe('StoryIndexGenerator', () => {
"id": "first-nested-deeply-features--with-render",
"importPath": "./src/first-nested/deeply/Features.stories.jsx",
"name": "With Render",
+ "subtype": "story",
"tags": [
"dev",
"test",
@@ -493,6 +512,7 @@ describe('StoryIndexGenerator', () => {
"id": "first-nested-deeply-features--with-story-fn",
"importPath": "./src/first-nested/deeply/Features.stories.jsx",
"name": "With Story Fn",
+ "subtype": "story",
"tags": [
"dev",
"test",
@@ -506,6 +526,7 @@ describe('StoryIndexGenerator', () => {
"id": "first-nested-deeply-features--with-test",
"importPath": "./src/first-nested/deeply/Features.stories.jsx",
"name": "With Test",
+ "subtype": "story",
"tags": [
"dev",
"test",
@@ -533,6 +554,7 @@ describe('StoryIndexGenerator', () => {
"id": "h--story-one",
"importPath": "./src/H.stories.mjs",
"name": "Story One",
+ "subtype": "story",
"tags": [
"dev",
"test",
@@ -547,6 +569,7 @@ describe('StoryIndexGenerator', () => {
"id": "nested-button--story-one",
"importPath": "./src/nested/Button.stories.ts",
"name": "Story One",
+ "subtype": "story",
"tags": [
"dev",
"test",
@@ -561,6 +584,7 @@ describe('StoryIndexGenerator', () => {
"id": "second-nested-g--story-one",
"importPath": "./src/second-nested/G.stories.ts",
"name": "Story One",
+ "subtype": "story",
"tags": [
"dev",
"test",
@@ -614,6 +638,7 @@ describe('StoryIndexGenerator', () => {
"id": "a--story-one",
"importPath": "./src/A.stories.js",
"name": "Story One",
+ "subtype": "story",
"tags": [
"dev",
"test",
@@ -642,6 +667,7 @@ describe('StoryIndexGenerator', () => {
"id": "b--story-one",
"importPath": "./src/B.stories.ts",
"name": "Story One",
+ "subtype": "story",
"tags": [
"dev",
"test",
@@ -656,6 +682,7 @@ describe('StoryIndexGenerator', () => {
"id": "componentpath-extension--story-one",
"importPath": "./src/componentPath/extension.stories.js",
"name": "Story One",
+ "subtype": "story",
"tags": [
"dev",
"test",
@@ -669,6 +696,7 @@ describe('StoryIndexGenerator', () => {
"id": "componentpath-noextension--story-one",
"importPath": "./src/componentPath/noExtension.stories.js",
"name": "Story One",
+ "subtype": "story",
"tags": [
"dev",
"test",
@@ -682,6 +710,7 @@ describe('StoryIndexGenerator', () => {
"id": "componentpath-package--story-one",
"importPath": "./src/componentPath/package.stories.js",
"name": "Story One",
+ "subtype": "story",
"tags": [
"dev",
"test",
@@ -708,6 +737,7 @@ describe('StoryIndexGenerator', () => {
"id": "d--story-one",
"importPath": "./src/D.stories.jsx",
"name": "Story One",
+ "subtype": "story",
"tags": [
"dev",
"test",
@@ -722,6 +752,7 @@ describe('StoryIndexGenerator', () => {
"id": "example-button--story-one",
"importPath": "./src/Button.stories.ts",
"name": "Story One",
+ "subtype": "story",
"tags": [
"dev",
"test",
@@ -736,6 +767,7 @@ describe('StoryIndexGenerator', () => {
"id": "first-nested-deeply-f--story-one",
"importPath": "./src/first-nested/deeply/F.stories.js",
"name": "Story One",
+ "subtype": "story",
"tags": [
"dev",
"test",
@@ -749,6 +781,7 @@ describe('StoryIndexGenerator', () => {
"id": "first-nested-deeply-features--with-csf-1",
"importPath": "./src/first-nested/deeply/Features.stories.jsx",
"name": "With CSF 1",
+ "subtype": "story",
"tags": [
"dev",
"test",
@@ -762,6 +795,7 @@ describe('StoryIndexGenerator', () => {
"id": "first-nested-deeply-features--with-play",
"importPath": "./src/first-nested/deeply/Features.stories.jsx",
"name": "With Play",
+ "subtype": "story",
"tags": [
"dev",
"test",
@@ -776,6 +810,7 @@ describe('StoryIndexGenerator', () => {
"id": "first-nested-deeply-features--with-render",
"importPath": "./src/first-nested/deeply/Features.stories.jsx",
"name": "With Render",
+ "subtype": "story",
"tags": [
"dev",
"test",
@@ -789,6 +824,7 @@ describe('StoryIndexGenerator', () => {
"id": "first-nested-deeply-features--with-story-fn",
"importPath": "./src/first-nested/deeply/Features.stories.jsx",
"name": "With Story Fn",
+ "subtype": "story",
"tags": [
"dev",
"test",
@@ -802,6 +838,7 @@ describe('StoryIndexGenerator', () => {
"id": "first-nested-deeply-features--with-test",
"importPath": "./src/first-nested/deeply/Features.stories.jsx",
"name": "With Test",
+ "subtype": "story",
"tags": [
"dev",
"test",
@@ -829,6 +866,7 @@ describe('StoryIndexGenerator', () => {
"id": "h--story-one",
"importPath": "./src/H.stories.mjs",
"name": "Story One",
+ "subtype": "story",
"tags": [
"dev",
"test",
@@ -843,6 +881,7 @@ describe('StoryIndexGenerator', () => {
"id": "nested-button--story-one",
"importPath": "./src/nested/Button.stories.ts",
"name": "Story One",
+ "subtype": "story",
"tags": [
"dev",
"test",
@@ -857,6 +896,7 @@ describe('StoryIndexGenerator', () => {
"id": "second-nested-g--story-one",
"importPath": "./src/second-nested/G.stories.ts",
"name": "Story One",
+ "subtype": "story",
"tags": [
"dev",
"test",
@@ -1077,6 +1117,7 @@ describe('StoryIndexGenerator', () => {
"id": "b--story-one",
"importPath": "./src/B.stories.ts",
"name": "Story One",
+ "subtype": "story",
"tags": [
"dev",
"test",
@@ -1143,6 +1184,7 @@ describe('StoryIndexGenerator', () => {
"id": "b--story-one",
"importPath": "./src/B.stories.ts",
"name": "Story One",
+ "subtype": "story",
"tags": [
"dev",
"test",
@@ -1201,6 +1243,7 @@ describe('StoryIndexGenerator', () => {
"id": "a--story-one",
"importPath": "./src/A.stories.js",
"name": "Story One",
+ "subtype": "story",
"tags": [
"dev",
"test",
@@ -1259,6 +1302,7 @@ describe('StoryIndexGenerator', () => {
"id": "a--story-one",
"importPath": "./src/A.stories.js",
"name": "Story One",
+ "subtype": "story",
"tags": [
"dev",
"test",
@@ -1309,6 +1353,7 @@ describe('StoryIndexGenerator', () => {
"id": "duplicate-a--story-one",
"importPath": "./duplicate/A.stories.js",
"name": "Story One",
+ "subtype": "story",
"tags": [
"dev",
"test",
@@ -1323,6 +1368,7 @@ describe('StoryIndexGenerator', () => {
"id": "duplicate-a--story-two",
"importPath": "./duplicate/SecondA.stories.js",
"name": "Story Two",
+ "subtype": "story",
"tags": [
"dev",
"test",
@@ -1388,6 +1434,7 @@ describe('StoryIndexGenerator', () => {
"id": "my-component-a--story-one",
"importPath": "./docs-id-generation/A.stories.jsx",
"name": "Story One",
+ "subtype": "story",
"tags": [
"dev",
"test",
@@ -1452,6 +1499,7 @@ describe('StoryIndexGenerator', () => {
"id": "a--story-one",
"importPath": "./src/A.stories.js",
"name": "Story One",
+ "subtype": "story",
"tags": [
"dev",
"test",
@@ -1604,6 +1652,7 @@ describe('StoryIndexGenerator', () => {
"id": "a--story-one",
"importPath": "./src/A.stories.js",
"name": "Story One",
+ "subtype": "story",
"tags": [
"dev",
"test",
@@ -1693,6 +1742,7 @@ describe('StoryIndexGenerator', () => {
"id": "a--story-one",
"importPath": "./src/A.stories.js",
"name": "Story One",
+ "subtype": "story",
"tags": [
"dev",
"test",
@@ -1721,6 +1771,7 @@ describe('StoryIndexGenerator', () => {
"id": "b--story-one",
"importPath": "./src/B.stories.ts",
"name": "Story One",
+ "subtype": "story",
"tags": [
"dev",
"test",
@@ -1791,6 +1842,7 @@ describe('StoryIndexGenerator', () => {
"id": "my-component-b--story-one",
"importPath": "./docs-id-generation/B.stories.jsx",
"name": "Story One",
+ "subtype": "story",
"tags": [
"dev",
"test",
@@ -1898,6 +1950,7 @@ describe('StoryIndexGenerator', () => {
title: 'ComponentTitle',
importPath: 'Path',
type: 'story',
+ subtype: 'story',
};
expect(() => {
generator.chooseDuplicate(mockEntry, { ...mockEntry, importPath: 'DifferentPath' }, []);
diff --git a/code/core/src/core-server/utils/StoryIndexGenerator.ts b/code/core/src/core-server/utils/StoryIndexGenerator.ts
index d66996b3dbcd..68367af6a3f8 100644
--- a/code/core/src/core-server/utils/StoryIndexGenerator.ts
+++ b/code/core/src/core-server/utils/StoryIndexGenerator.ts
@@ -65,6 +65,7 @@ export const AUTODOCS_TAG = 'autodocs';
export const ATTACHED_MDX_TAG = 'attached-mdx';
export const UNATTACHED_MDX_TAG = 'unattached-mdx';
export const PLAY_FN_TAG = 'play-fn';
+export const TEST_FN_TAG = 'test-fn';
/** Was this docs entry generated by a .mdx file? (see discussion below) */
export function isMdxEntry({ tags }: DocsIndexEntry) {
@@ -428,8 +429,8 @@ export class StoryIndexGenerator {
]);
}
- const entries: ((StoryIndexEntryWithExtra | DocsCacheEntry) & { tags: Tag[] })[] =
- indexInputs.map((input) => {
+ const storyEntries: (StoryIndexEntryWithExtra & { tags: Tag[] })[] = indexInputs.map(
+ (input) => {
const name = input.name ?? storyNameFromExport(input.exportName);
const componentPath =
input.rawComponentPath &&
@@ -441,6 +442,7 @@ export class StoryIndexGenerator {
return {
type: 'story',
+ subtype: input.type === 'story' ? input.subtype : 'story',
id,
extra: {
metaId: input.metaId,
@@ -451,25 +453,29 @@ export class StoryIndexGenerator {
importPath,
componentPath,
tags,
+ ...(input.type === 'story' && input.subtype === 'test'
+ ? { parent: input.parent, parentName: input.parentName }
+ : {}),
...(input.exportName ? { exportName: input.exportName } : {}),
};
- });
+ }
+ );
// We need a docs entry attached to the CSF file if either:
// a) autodocs is globally enabled
// b) we have autodocs enabled for this file
- const hasAutodocsTag = entries.some((entry) => entry.tags.includes(AUTODOCS_TAG));
+ const hasAutodocsTag = storyEntries.some((entry) => entry.tags.includes(AUTODOCS_TAG));
const createDocEntry = hasAutodocsTag && !!this.options.docs;
if (createDocEntry && this.options.build?.test?.disableAutoDocs !== true) {
const docsName = this.options.docs?.defaultName ?? 'Docs';
const name = docsName;
const { metaId } = indexInputs[0];
- const { title } = entries[0];
+ const { title } = storyEntries[0];
const id = toId(metaId ?? title, name);
const tags = combineTags(...projectTags, ...(indexInputs[0].tags ?? []));
- entries.unshift({
+ const docsEntry: DocsCacheEntry & { tags: Tag[] } = {
id,
title,
name,
@@ -477,11 +483,17 @@ export class StoryIndexGenerator {
type: 'docs',
tags,
storiesImports: [],
- });
+ };
+
+ return {
+ entries: [docsEntry, ...storyEntries],
+ dependents: [],
+ type: 'stories',
+ };
}
return {
- entries,
+ entries: storyEntries,
dependents: [],
type: 'stories',
};
diff --git a/code/core/src/core-server/utils/__tests__/index-extraction.test.ts b/code/core/src/core-server/utils/__tests__/index-extraction.test.ts
index 4c6bea472f1f..ab582f0bb3ab 100644
--- a/code/core/src/core-server/utils/__tests__/index-extraction.test.ts
+++ b/code/core/src/core-server/utils/__tests__/index-extraction.test.ts
@@ -32,6 +32,7 @@ describe('story extraction', () => {
// properties identical to the auto-generated ones, eg. 'StoryOne' -> 'Story One'
{
type: 'story',
+ subtype: 'story',
importPath: fileName,
exportName: 'StoryOne',
name: 'Story One',
@@ -43,6 +44,7 @@ describe('story extraction', () => {
// properties different from the auto-generated ones, eg. 'StoryOne' -> 'Another Story Name'
{
type: 'story',
+ subtype: 'story',
importPath: fileName,
exportName: 'StoryOne',
name: 'Another Story Name',
@@ -71,6 +73,7 @@ describe('story extraction', () => {
"id": "a--story-one",
"importPath": "./src/A.stories.js",
"name": "Story One",
+ "subtype": "story",
"tags": [
"story-tag-from-indexer",
],
@@ -87,6 +90,7 @@ describe('story extraction', () => {
"id": "some-fully-custom-id",
"importPath": "./src/A.stories.js",
"name": "Another Story Name",
+ "subtype": "story",
"tags": [
"story-tag-from-indexer",
],
@@ -114,6 +118,7 @@ describe('story extraction', () => {
exportName: 'StoryOne',
importPath: fileName,
type: 'story',
+ subtype: 'story',
},
],
},
@@ -135,6 +140,7 @@ describe('story extraction', () => {
"id": "f--story-one",
"importPath": "./src/first-nested/deeply/F.stories.js",
"name": "Story One",
+ "subtype": "story",
"tags": [],
"title": "F",
"type": "story",
@@ -164,6 +170,7 @@ describe('story extraction', () => {
tags: ['story-tag-from-indexer'],
importPath: fileName,
type: 'story',
+ subtype: 'story',
},
],
},
@@ -185,6 +192,7 @@ describe('story extraction', () => {
"id": "a--story-one",
"importPath": "./src/first-nested/deeply/F.stories.js",
"name": "Story One",
+ "subtype": "story",
"tags": [
"story-tag-from-indexer",
],
@@ -216,6 +224,7 @@ describe('story extraction', () => {
tags: ['story-tag-from-indexer'],
importPath: fileName,
type: 'story',
+ subtype: 'story',
},
],
},
@@ -237,6 +246,7 @@ describe('story extraction', () => {
"id": "a--story-one",
"importPath": "./src/A.stories.js",
"name": "Story One",
+ "subtype": "story",
"tags": [
"story-tag-from-indexer",
],
@@ -268,6 +278,7 @@ describe('story extraction', () => {
tags: ['story-tag-from-indexer'],
importPath: fileName,
type: 'story',
+ subtype: 'story',
},
// exportName + custom title (ignoring custom name) -> id
{
@@ -277,6 +288,7 @@ describe('story extraction', () => {
tags: ['story-tag-from-indexer'],
importPath: fileName,
type: 'story',
+ subtype: 'story',
},
// exportName + custom metaId (ignoring custom title and name) -> id
{
@@ -286,6 +298,7 @@ describe('story extraction', () => {
tags: ['story-tag-from-indexer'],
importPath: fileName,
type: 'story',
+ subtype: 'story',
},
],
},
@@ -307,6 +320,7 @@ describe('story extraction', () => {
"id": "a--story-one",
"importPath": "./src/A.stories.js",
"name": "Story One",
+ "subtype": "story",
"tags": [
"story-tag-from-indexer",
],
@@ -323,6 +337,7 @@ describe('story extraction', () => {
"id": "custom-title--story-two",
"importPath": "./src/A.stories.js",
"name": "Custom Name For Second Story",
+ "subtype": "story",
"tags": [
"story-tag-from-indexer",
],
@@ -339,6 +354,7 @@ describe('story extraction', () => {
"id": "custom-meta-id--story-three",
"importPath": "./src/A.stories.js",
"name": "Story Three",
+ "subtype": "story",
"tags": [
"story-tag-from-indexer",
],
@@ -367,6 +383,7 @@ describe('story extraction', () => {
tags: ['story-tag-from-indexer'],
importPath: fileName,
type: 'story',
+ subtype: 'story',
},
],
},
@@ -388,6 +405,7 @@ describe('story extraction', () => {
"id": "a--story-one",
"importPath": "./src/A.stories.js",
"name": "Story One",
+ "subtype": "story",
"tags": [
"story-tag-from-indexer",
],
@@ -421,6 +439,7 @@ describe('docs entries from story extraction', () => {
tags: [AUTODOCS_TAG, 'story-tag-from-indexer'],
importPath: fileName,
type: 'story',
+ subtype: 'story',
},
],
},
@@ -454,6 +473,7 @@ describe('docs entries from story extraction', () => {
"id": "a--story-one",
"importPath": "./src/A.stories.js",
"name": "Story One",
+ "subtype": "story",
"tags": [
"autodocs",
"story-tag-from-indexer",
diff --git a/code/core/src/core-server/utils/stories-json.test.ts b/code/core/src/core-server/utils/stories-json.test.ts
index 0c426a7b960b..725f6de19aa1 100644
--- a/code/core/src/core-server/utils/stories-json.test.ts
+++ b/code/core/src/core-server/utils/stories-json.test.ts
@@ -154,6 +154,7 @@ describe('useStoriesJson', () => {
"id": "a--story-one",
"importPath": "./src/A.stories.js",
"name": "Story One",
+ "subtype": "story",
"tags": [
"dev",
"test",
@@ -181,6 +182,7 @@ describe('useStoriesJson', () => {
"id": "b--story-one",
"importPath": "./src/B.stories.ts",
"name": "Story One",
+ "subtype": "story",
"tags": [
"dev",
"test",
@@ -195,6 +197,7 @@ describe('useStoriesJson', () => {
"id": "componentpath-extension--story-one",
"importPath": "./src/componentPath/extension.stories.js",
"name": "Story One",
+ "subtype": "story",
"tags": [
"dev",
"test",
@@ -208,6 +211,7 @@ describe('useStoriesJson', () => {
"id": "componentpath-noextension--story-one",
"importPath": "./src/componentPath/noExtension.stories.js",
"name": "Story One",
+ "subtype": "story",
"tags": [
"dev",
"test",
@@ -221,6 +225,7 @@ describe('useStoriesJson', () => {
"id": "componentpath-package--story-one",
"importPath": "./src/componentPath/package.stories.js",
"name": "Story One",
+ "subtype": "story",
"tags": [
"dev",
"test",
@@ -246,6 +251,7 @@ describe('useStoriesJson', () => {
"id": "d--story-one",
"importPath": "./src/D.stories.jsx",
"name": "Story One",
+ "subtype": "story",
"tags": [
"dev",
"test",
@@ -313,6 +319,7 @@ describe('useStoriesJson', () => {
"id": "example-button--story-one",
"importPath": "./src/Button.stories.ts",
"name": "Story One",
+ "subtype": "story",
"tags": [
"dev",
"test",
@@ -326,6 +333,7 @@ describe('useStoriesJson', () => {
"id": "first-nested-deeply-f--story-one",
"importPath": "./src/first-nested/deeply/F.stories.js",
"name": "Story One",
+ "subtype": "story",
"tags": [
"dev",
"test",
@@ -338,6 +346,7 @@ describe('useStoriesJson', () => {
"id": "first-nested-deeply-features--with-csf-1",
"importPath": "./src/first-nested/deeply/Features.stories.jsx",
"name": "With CSF 1",
+ "subtype": "story",
"tags": [
"dev",
"test",
@@ -350,6 +359,7 @@ describe('useStoriesJson', () => {
"id": "first-nested-deeply-features--with-play",
"importPath": "./src/first-nested/deeply/Features.stories.jsx",
"name": "With Play",
+ "subtype": "story",
"tags": [
"dev",
"test",
@@ -363,6 +373,7 @@ describe('useStoriesJson', () => {
"id": "first-nested-deeply-features--with-render",
"importPath": "./src/first-nested/deeply/Features.stories.jsx",
"name": "With Render",
+ "subtype": "story",
"tags": [
"dev",
"test",
@@ -375,6 +386,7 @@ describe('useStoriesJson', () => {
"id": "first-nested-deeply-features--with-story-fn",
"importPath": "./src/first-nested/deeply/Features.stories.jsx",
"name": "With Story Fn",
+ "subtype": "story",
"tags": [
"dev",
"test",
@@ -387,6 +399,7 @@ describe('useStoriesJson', () => {
"id": "first-nested-deeply-features--with-test",
"importPath": "./src/first-nested/deeply/Features.stories.jsx",
"name": "With Test",
+ "subtype": "story",
"tags": [
"dev",
"test",
@@ -413,6 +426,7 @@ describe('useStoriesJson', () => {
"id": "h--story-one",
"importPath": "./src/H.stories.mjs",
"name": "Story One",
+ "subtype": "story",
"tags": [
"dev",
"test",
@@ -426,6 +440,7 @@ describe('useStoriesJson', () => {
"id": "nested-button--story-one",
"importPath": "./src/nested/Button.stories.ts",
"name": "Story One",
+ "subtype": "story",
"tags": [
"dev",
"test",
@@ -439,6 +454,7 @@ describe('useStoriesJson', () => {
"id": "second-nested-g--story-one",
"importPath": "./src/second-nested/G.stories.ts",
"name": "Story One",
+ "subtype": "story",
"tags": [
"dev",
"test",
diff --git a/code/core/src/core-server/utils/summarizeIndex.test.ts b/code/core/src/core-server/utils/summarizeIndex.test.ts
index a486525dbe5d..a90e0b27f703 100644
--- a/code/core/src/core-server/utils/summarizeIndex.test.ts
+++ b/code/core/src/core-server/utils/summarizeIndex.test.ts
@@ -66,6 +66,7 @@ describe('summarizeIndex', () => {
importPath: './src/stories/Button.stories.ts',
tags: ['autodocs', 'story'],
type: 'story',
+ subtype: 'story',
},
'example-button--secondary': {
id: 'example-button--secondary',
@@ -74,6 +75,7 @@ describe('summarizeIndex', () => {
importPath: './src/stories/Button.stories.ts',
tags: ['autodocs', 'story'],
type: 'story',
+ subtype: 'story',
},
'example-button--large': {
id: 'example-button--large',
@@ -82,6 +84,7 @@ describe('summarizeIndex', () => {
importPath: './src/stories/Button.stories.ts',
tags: ['autodocs', 'story'],
type: 'story',
+ subtype: 'story',
},
'example-button--small': {
id: 'example-button--small',
@@ -90,6 +93,7 @@ describe('summarizeIndex', () => {
importPath: './src/stories/Button.stories.ts',
tags: ['autodocs', 'story'],
type: 'story',
+ subtype: 'story',
},
'example-header--docs': {
id: 'example-header--docs',
@@ -107,6 +111,7 @@ describe('summarizeIndex', () => {
importPath: './src/stories/Header.stories.ts',
tags: ['autodocs', 'story'],
type: 'story',
+ subtype: 'story',
},
'example-header--logged-out': {
id: 'example-header--logged-out',
@@ -115,6 +120,7 @@ describe('summarizeIndex', () => {
importPath: './src/stories/Header.stories.ts',
tags: ['autodocs', 'story'],
type: 'story',
+ subtype: 'story',
},
'example-page--logged-out': {
id: 'example-page--logged-out',
@@ -123,6 +129,7 @@ describe('summarizeIndex', () => {
importPath: './src/stories/Page.stories.ts',
tags: ['story'],
type: 'story',
+ subtype: 'story',
},
'example-page--logged-in': {
id: 'example-page--logged-in',
@@ -131,6 +138,7 @@ describe('summarizeIndex', () => {
importPath: './src/stories/Page.stories.ts',
tags: ['play-fn', 'story'],
type: 'story',
+ subtype: 'story',
},
},
})
@@ -140,14 +148,17 @@ describe('summarizeIndex', () => {
"componentCount": 0,
"exampleDocsCount": 3,
"exampleStoryCount": 8,
+ "maxTestsPerStory": 0,
"mdxCount": 0,
"onboardingDocsCount": 0,
"onboardingStoryCount": 0,
"pageStoryCount": 0,
"playStoryCount": 0,
+ "singleTestStoryCount": 0,
"storyCount": 0,
"svelteCsfV4Count": 0,
"svelteCsfV5Count": 0,
+ "testStoryCount": 0,
"version": 5,
}
`);
@@ -182,6 +193,7 @@ describe('summarizeIndex', () => {
importPath: './src/stories/Button.stories.ts',
tags: ['autodocs', 'story'],
type: 'story',
+ subtype: 'story',
},
'example-button--warning': {
id: 'example-button--warning',
@@ -190,6 +202,7 @@ describe('summarizeIndex', () => {
importPath: './src/stories/Button.stories.ts',
tags: ['autodocs', 'story', 'svelte-csf-v4'],
type: 'story',
+ subtype: 'story',
},
},
})
@@ -199,14 +212,17 @@ describe('summarizeIndex', () => {
"componentCount": 0,
"exampleDocsCount": 2,
"exampleStoryCount": 1,
+ "maxTestsPerStory": 0,
"mdxCount": 0,
"onboardingDocsCount": 0,
"onboardingStoryCount": 1,
"pageStoryCount": 0,
"playStoryCount": 0,
+ "singleTestStoryCount": 0,
"storyCount": 0,
"svelteCsfV4Count": 0,
"svelteCsfV5Count": 0,
+ "testStoryCount": 0,
"version": 5,
}
`);
@@ -223,6 +239,7 @@ describe('summarizeIndex', () => {
importPath: './src/stories/renderers/react/errors.stories.tsx',
tags: ['story'],
type: 'story',
+ subtype: 'story',
},
'stories-renderers-react-hooks--basic': {
id: 'stories-renderers-react-hooks--basic',
@@ -231,6 +248,7 @@ describe('summarizeIndex', () => {
importPath: './src/stories/renderers/react/hooks.stories.tsx',
tags: ['story'],
type: 'story',
+ subtype: 'story',
},
'stories-renderers-react-js-argtypes--js-class-component': {
id: 'stories-renderers-react-js-argtypes--js-class-component',
@@ -239,6 +257,7 @@ describe('summarizeIndex', () => {
importPath: './src/stories/renderers/react/js-argtypes.stories.jsx',
tags: ['story', 'svelte-csf-v5'],
type: 'story',
+ subtype: 'story',
},
'stories-renderers-react-js-argtypes--js-function-component': {
id: 'stories-renderers-react-js-argtypes--js-function-component',
@@ -247,6 +266,7 @@ describe('summarizeIndex', () => {
importPath: './src/stories/renderers/react/js-argtypes.stories.jsx',
tags: ['story', 'svelte-csf-v4'],
type: 'story',
+ subtype: 'story',
},
},
})
@@ -256,14 +276,113 @@ describe('summarizeIndex', () => {
"componentCount": 3,
"exampleDocsCount": 0,
"exampleStoryCount": 0,
+ "maxTestsPerStory": 0,
"mdxCount": 0,
"onboardingDocsCount": 0,
"onboardingStoryCount": 0,
"pageStoryCount": 0,
"playStoryCount": 0,
+ "singleTestStoryCount": 0,
"storyCount": 4,
"svelteCsfV4Count": 1,
"svelteCsfV5Count": 1,
+ "testStoryCount": 0,
+ "version": 5,
+ }
+ `);
+ });
+ it('test function', () => {
+ expect(
+ summarizeIndex({
+ v: 5,
+ entries: {
+ 'component-testing-test-fn--default': {
+ type: 'story',
+ subtype: 'story',
+ id: 'component-testing-test-fn--default',
+ name: 'Default',
+ title: 'component-testing/test-fn',
+ importPath: './core/src/component-testing/components/test-fn.stories.tsx',
+ tags: ['dev', 'test', 'vitest', 'some-tag'],
+ },
+ 'component-testing-test-fn--default:simple': {
+ type: 'story',
+ subtype: 'story',
+ id: 'component-testing-test-fn--default:simple',
+ name: 'simple',
+ title: 'component-testing/test-fn',
+ importPath: './core/src/component-testing/components/test-fn.stories.tsx',
+ tags: ['dev', 'test', 'vitest', 'some-tag', 'test-fn'],
+ parent: 'component-testing-test-fn--default',
+ },
+ 'component-testing-test-fn--default:referring-to-function-in-file': {
+ type: 'story',
+ subtype: 'story',
+ id: 'component-testing-test-fn--default:referring-to-function-in-file',
+ name: 'referring to function in file',
+ title: 'component-testing/test-fn',
+ importPath: './core/src/component-testing/components/test-fn.stories.tsx',
+ tags: ['dev', 'test', 'vitest', 'some-tag', 'test-fn'],
+ parent: 'component-testing-test-fn--default',
+ },
+ 'component-testing-test-fn--default:with-overrides': {
+ type: 'story',
+ subtype: 'story',
+ id: 'component-testing-test-fn--default:with-overrides',
+ name: 'with overrides',
+ title: 'component-testing/test-fn',
+ importPath: './core/src/component-testing/components/test-fn.stories.tsx',
+ tags: ['dev', 'test', 'vitest', 'some-tag', 'test-fn'],
+ parent: 'component-testing-test-fn--default',
+ },
+ 'component-testing-test-fn--default:with-play-function': {
+ type: 'story',
+ subtype: 'story',
+ id: 'component-testing-test-fn--default:with-play-function',
+ name: 'with play function',
+ title: 'component-testing/test-fn',
+ importPath: './core/src/component-testing/components/test-fn.stories.tsx',
+ tags: ['dev', 'test', 'vitest', 'some-tag', 'test-fn'],
+ parent: 'component-testing-test-fn--default',
+ },
+ 'component-testing-test-fn--default-extended': {
+ type: 'story',
+ subtype: 'story',
+ id: 'component-testing-test-fn--default-extended',
+ name: 'Default Extended',
+ title: 'component-testing/test-fn',
+ importPath: './core/src/component-testing/components/test-fn.stories.tsx',
+ tags: ['dev', 'test', 'vitest', 'some-tag'],
+ },
+ 'component-testing-test-fn--default-extended:should-have-extended-args': {
+ type: 'story',
+ subtype: 'story',
+ id: 'component-testing-test-fn--default-extended:should-have-extended-args',
+ name: 'should have extended args',
+ title: 'component-testing/test-fn',
+ importPath: './core/src/component-testing/components/test-fn.stories.tsx',
+ tags: ['dev', 'test', 'vitest', 'some-tag', 'test-fn'],
+ parent: 'component-testing-test-fn--default-extended',
+ },
+ },
+ })
+ ).toMatchInlineSnapshot(`
+ {
+ "autodocsCount": 0,
+ "componentCount": 1,
+ "exampleDocsCount": 0,
+ "exampleStoryCount": 0,
+ "maxTestsPerStory": 4,
+ "mdxCount": 0,
+ "onboardingDocsCount": 0,
+ "onboardingStoryCount": 0,
+ "pageStoryCount": 0,
+ "playStoryCount": 0,
+ "singleTestStoryCount": 1,
+ "storyCount": 7,
+ "svelteCsfV4Count": 0,
+ "svelteCsfV5Count": 0,
+ "testStoryCount": 5,
"version": 5,
}
`);
@@ -280,6 +399,7 @@ describe('summarizeIndex', () => {
importPath: './src/stories/Page.stories.ts',
tags: ['story'],
type: 'story',
+ subtype: 'story',
},
'example-page--logged-in': {
id: 'example-page--logged-in',
@@ -288,6 +408,7 @@ describe('summarizeIndex', () => {
importPath: './src/stories/Page.stories.ts',
tags: ['play-fn', 'story'],
type: 'story',
+ subtype: 'story',
},
'addons-docs-docspage-autoplay--docs': {
id: 'addons-docs-docspage-autoplay--docs',
@@ -305,6 +426,7 @@ describe('summarizeIndex', () => {
importPath: './template-stories/addons/docs/docspage/autoplay.stories.ts',
tags: ['play-fn', 'story'],
type: 'story',
+ subtype: 'story',
},
},
})
@@ -314,14 +436,17 @@ describe('summarizeIndex', () => {
"componentCount": 1,
"exampleDocsCount": 0,
"exampleStoryCount": 2,
+ "maxTestsPerStory": 0,
"mdxCount": 0,
"onboardingDocsCount": 0,
"onboardingStoryCount": 0,
"pageStoryCount": 1,
"playStoryCount": 1,
+ "singleTestStoryCount": 0,
"storyCount": 1,
"svelteCsfV4Count": 0,
"svelteCsfV5Count": 0,
+ "testStoryCount": 0,
"version": 5,
}
`);
@@ -347,6 +472,7 @@ describe('summarizeIndex', () => {
importPath: './src/stories/Button.stories.ts',
tags: ['autodocs', 'story'],
type: 'story',
+ subtype: 'story',
},
'example-button--small': {
id: 'example-button--small',
@@ -355,6 +481,7 @@ describe('summarizeIndex', () => {
importPath: './src/stories/Button.stories.ts',
tags: ['autodocs', 'story'],
type: 'story',
+ subtype: 'story',
},
'lib-preview-api-shortcuts--docs': {
id: 'lib-preview-api-shortcuts--docs',
@@ -373,14 +500,17 @@ describe('summarizeIndex', () => {
"componentCount": 0,
"exampleDocsCount": 1,
"exampleStoryCount": 2,
+ "maxTestsPerStory": 0,
"mdxCount": 0,
"onboardingDocsCount": 0,
"onboardingStoryCount": 0,
"pageStoryCount": 0,
"playStoryCount": 0,
+ "singleTestStoryCount": 0,
"storyCount": 0,
"svelteCsfV4Count": 0,
"svelteCsfV5Count": 0,
+ "testStoryCount": 0,
"version": 5,
}
`);
@@ -425,14 +555,17 @@ describe('summarizeIndex', () => {
"componentCount": 0,
"exampleDocsCount": 1,
"exampleStoryCount": 0,
+ "maxTestsPerStory": 0,
"mdxCount": 2,
"onboardingDocsCount": 0,
"onboardingStoryCount": 0,
"pageStoryCount": 0,
"playStoryCount": 0,
+ "singleTestStoryCount": 0,
"storyCount": 0,
"svelteCsfV4Count": 0,
"svelteCsfV5Count": 0,
+ "testStoryCount": 0,
"version": 5,
}
`);
diff --git a/code/core/src/core-server/utils/summarizeIndex.ts b/code/core/src/core-server/utils/summarizeIndex.ts
index b81a29c1e04b..5e6520323c78 100644
--- a/code/core/src/core-server/utils/summarizeIndex.ts
+++ b/code/core/src/core-server/utils/summarizeIndex.ts
@@ -1,7 +1,7 @@
import { isExampleStoryId } from 'storybook/internal/telemetry';
import type { IndexEntry, StoryIndex } from 'storybook/internal/types';
-import { AUTODOCS_TAG, PLAY_FN_TAG, isMdxEntry } from './StoryIndexGenerator';
+import { AUTODOCS_TAG, PLAY_FN_TAG, TEST_FN_TAG, isMdxEntry } from './StoryIndexGenerator';
const PAGE_REGEX = /(page|screen)/i;
const SVELTE_CSF_TAG = 'svelte-csf';
@@ -34,10 +34,12 @@ export function summarizeIndex(storyIndex: StoryIndex) {
let exampleDocsCount = 0;
let pageStoryCount = 0;
let playStoryCount = 0;
+ let testStoryCount = 0;
let autodocsCount = 0;
let mdxCount = 0;
let svelteCsfV4Count = 0;
let svelteCsfV5Count = 0;
+ const testsPerParentStory = new Map();
Object.values(storyIndex.entries).forEach((entry) => {
if (isCLIExampleEntry(entry)) {
if (entry.type === 'story') {
@@ -64,6 +66,10 @@ export function summarizeIndex(storyIndex: StoryIndex) {
if (entry.tags?.includes(PLAY_FN_TAG)) {
playStoryCount += 1;
}
+ if (entry.tags?.includes(TEST_FN_TAG) && entry.parent) {
+ testStoryCount += 1;
+ testsPerParentStory.set(entry.parent, (testsPerParentStory.get(entry.parent) ?? 0) + 1);
+ }
if (entry.tags?.includes('svelte-csf-v4')) {
svelteCsfV4Count += 1;
} else if (entry.tags?.includes('svelte-csf-v5')) {
@@ -78,11 +84,25 @@ export function summarizeIndex(storyIndex: StoryIndex) {
}
});
const componentCount = componentTitles.size;
+ let maxTestsPerStory = 0;
+ let singleTestStoryCount = 0;
+ testsPerParentStory.forEach((count) => {
+ if (count > maxTestsPerStory) {
+ maxTestsPerStory = count;
+ }
+ if (count === 1) {
+ singleTestStoryCount += 1;
+ }
+ });
+
return {
storyCount,
componentCount,
pageStoryCount,
playStoryCount,
+ testStoryCount,
+ maxTestsPerStory,
+ singleTestStoryCount,
autodocsCount,
mdxCount,
exampleStoryCount,
diff --git a/code/core/src/csf-tools/CsfFile.test.ts b/code/core/src/csf-tools/CsfFile.test.ts
index 30dd816eddd1..17487fea5750 100644
--- a/code/core/src/csf-tools/CsfFile.test.ts
+++ b/code/core/src/csf-tools/CsfFile.test.ts
@@ -1981,10 +1981,8 @@ describe('CsfFile', () => {
).parse();
expect(indexInputs).toMatchInlineSnapshot(`
- - type: story
- importPath: foo/bar.stories.js
+ - importPath: foo/bar.stories.js
exportName: A
- name: A
title: custom foo title
metaId: component-id
tags:
@@ -2003,10 +2001,11 @@ describe('CsfFile', () => {
storyFn: false
mount: false
moduleMock: false
- - type: story
- importPath: foo/bar.stories.js
+ type: story
+ subtype: story
+ name: A
+ - importPath: foo/bar.stories.js
exportName: B
- name: B
title: custom foo title
metaId: component-id
tags:
@@ -2025,6 +2024,9 @@ describe('CsfFile', () => {
storyFn: false
mount: false
moduleMock: false
+ type: story
+ subtype: story
+ name: B
`);
});
@@ -2045,10 +2047,8 @@ describe('CsfFile', () => {
).parse();
expect(indexInputs).toMatchInlineSnapshot(`
- - type: story
- importPath: foo/bar.stories.js
+ - importPath: foo/bar.stories.js
exportName: A
- name: A
title: custom foo title
metaId: component-id
tags:
@@ -2065,6 +2065,9 @@ describe('CsfFile', () => {
storyFn: false
mount: false
moduleMock: false
+ type: story
+ subtype: story
+ name: A
`);
});
@@ -2084,10 +2087,8 @@ describe('CsfFile', () => {
).parse();
expect(indexInputs).toMatchInlineSnapshot(`
- - type: story
- importPath: foo/bar.stories.js
+ - importPath: foo/bar.stories.js
exportName: A
- name: A
title: custom foo title
tags:
- component-tag
@@ -2110,6 +2111,9 @@ describe('CsfFile', () => {
storyFn: false
mount: false
moduleMock: false
+ type: story
+ subtype: story
+ name: A
`);
});
@@ -2135,7 +2139,7 @@ describe('CsfFile', () => {
});
});
- describe('componenent paths', () => {
+ describe('component paths', () => {
it('no component', () => {
const { indexInputs } = loadCsf(
dedent`
@@ -2152,10 +2156,8 @@ describe('CsfFile', () => {
).parse();
expect(indexInputs).toMatchInlineSnapshot(`
- - type: story
- importPath: foo/bar.stories.js
+ - importPath: foo/bar.stories.js
exportName: A
- name: A
title: custom foo title
tags: []
__id: custom-foo-title--a
@@ -2170,6 +2172,9 @@ describe('CsfFile', () => {
storyFn: false
mount: false
moduleMock: false
+ type: story
+ subtype: story
+ name: A
`);
});
@@ -2191,10 +2196,8 @@ describe('CsfFile', () => {
).parse();
expect(indexInputs).toMatchInlineSnapshot(`
- - type: story
- importPath: foo/bar.stories.js
+ - importPath: foo/bar.stories.js
exportName: A
- name: A
title: custom foo title
tags: []
__id: custom-foo-title--a
@@ -2209,6 +2212,9 @@ describe('CsfFile', () => {
storyFn: false
mount: false
moduleMock: false
+ type: story
+ subtype: story
+ name: A
`);
});
@@ -2229,11 +2235,9 @@ describe('CsfFile', () => {
).parse();
expect(indexInputs).toMatchInlineSnapshot(`
- - type: story
- importPath: foo/bar.stories.js
+ - importPath: foo/bar.stories.js
rawComponentPath: ../src/Component.js
exportName: A
- name: A
title: custom foo title
tags: []
__id: custom-foo-title--a
@@ -2248,6 +2252,9 @@ describe('CsfFile', () => {
storyFn: false
mount: false
moduleMock: false
+ type: story
+ subtype: story
+ name: A
`);
});
@@ -2268,11 +2275,9 @@ describe('CsfFile', () => {
).parse();
expect(indexInputs).toMatchInlineSnapshot(`
- - type: story
- importPath: foo/bar.stories.js
+ - importPath: foo/bar.stories.js
rawComponentPath: some-library
exportName: A
- name: A
title: custom foo title
tags: []
__id: custom-foo-title--a
@@ -2287,6 +2292,9 @@ describe('CsfFile', () => {
storyFn: false
mount: false
moduleMock: false
+ type: story
+ subtype: story
+ name: A
`);
});
});
@@ -2580,6 +2588,69 @@ describe('CsfFile', () => {
`);
});
+ it('story test - index snapshot', () => {
+ expect(
+ parse(
+ dedent`
+ import { config } from '#.storybook/preview'
+ const meta = config.meta({ component: 'foo' });
+ export default meta;
+ export const A = meta.story({ args: { label: 'foo' } })
+ A.test('simple test', async () => {})
+ `
+ )
+ ).toMatchInlineSnapshot(`
+ meta:
+ component: '''foo'''
+ title: Default Title
+ stories:
+ - id: default-title--a
+ name: A
+ __stats:
+ factory: true
+ tests: true
+ play: false
+ render: false
+ loaders: false
+ beforeEach: false
+ globals: false
+ tags: false
+ storyFn: false
+ mount: false
+ moduleMock: false
+ `);
+ });
+
+ it('story test - parsing', () => {
+ const data = loadCsf(
+ dedent`
+ import { config } from '#.storybook/preview'
+ const meta = config.meta({ component: 'foo' });
+ export default meta;
+ export const A = meta.story({ args: { label: 'foo' } })
+ A.test('simple test', async () => {})
+ A.test('with overrides', { args: { label: 'bar' } }, async () => {})
+ const runTest = () => {}
+ A.test('with function reference', runTest)
+ A.test('with function call', runTest())
+ `,
+ { makeTitle }
+ ).parse();
+ const story = data._stories['A'];
+ expect(story.__stats.tests).toBe(true);
+
+ const storyTests = data.getStoryTests('A');
+ expect(storyTests).toHaveLength(4);
+ expect(storyTests[0].name).toBe('simple test');
+ expect(storyTests[1].name).toBe('with overrides');
+ expect(storyTests[2].name).toBe('with function reference');
+ expect(storyTests[3].name).toBe('with function call');
+ expect(storyTests[0].function).toBeDefined();
+ expect(storyTests[1].function).toBeDefined();
+ expect(storyTests[2].function).toBeDefined();
+ expect(storyTests[3].function).toBeDefined();
+ });
+
it('story name', () => {
expect(
parse(
diff --git a/code/core/src/csf-tools/CsfFile.ts b/code/core/src/csf-tools/CsfFile.ts
index ab23eb33045a..213bcf2fd53e 100644
--- a/code/core/src/csf-tools/CsfFile.ts
+++ b/code/core/src/csf-tools/CsfFile.ts
@@ -11,7 +11,7 @@ import {
types as t,
traverse,
} from 'storybook/internal/babel';
-import { isExportStory, storyNameFromExport, toId } from 'storybook/internal/csf';
+import { isExportStory, storyNameFromExport, toId, toTestId } from 'storybook/internal/csf';
import { logger } from 'storybook/internal/node-logger';
import type {
ComponentAnnotations,
@@ -76,6 +76,34 @@ function parseTags(prop: t.Node) {
}) as Tag[];
}
+function parseTestTags(optionsNode: t.Node | null | undefined, program: t.Program) {
+ if (!optionsNode) {
+ return [] as string[];
+ }
+
+ let node: t.Node = optionsNode;
+ if (t.isIdentifier(node)) {
+ node = findVarInitialization(node.name, program);
+ }
+
+ if (t.isObjectExpression(node)) {
+ const tagsProp = node.properties.find(
+ (property) =>
+ t.isObjectProperty(property) && t.isIdentifier(property.key) && property.key.name === 'tags'
+ ) as t.ObjectProperty | undefined;
+
+ if (tagsProp) {
+ let tagsNode: t.Node = tagsProp.value as t.Node;
+ if (t.isIdentifier(tagsNode)) {
+ tagsNode = findVarInitialization(tagsNode.name, program);
+ }
+ return parseTags(tagsNode);
+ }
+ }
+
+ return [] as string[];
+}
+
const formatLocation = (node: t.Node, fileName?: string) => {
let loc = '';
if (node.loc) {
@@ -237,6 +265,15 @@ export interface StaticStory extends Pick {})
+ // B.test('foo', context, () => {})
+ if (
+ t.isCallExpression(expression) &&
+ t.isMemberExpression(expression.callee) &&
+ t.isIdentifier(expression.callee.object) &&
+ t.isIdentifier(expression.callee.property) &&
+ expression.callee.property.name === 'test' &&
+ expression.arguments.length >= 2 &&
+ t.isStringLiteral(expression.arguments[0])
+ ) {
+ const exportName = expression.callee.object.name;
+ const testName = expression.arguments[0].value;
+ const testFunction =
+ expression.arguments.length === 2 ? expression.arguments[1] : expression.arguments[2];
+ const testArguments =
+ expression.arguments.length === 2 ? null : expression.arguments[1];
+ const tags = parseTestTags(testArguments as t.Node | null, self._ast.program);
+
+ self._tests.push({
+ function: testFunction,
+ name: testName,
+ node: expression,
+ // can't set id because meta title isn't available yet
+ // so it's set later on
+ id: 'FIXME',
+ tags,
+ parent: { node: self._storyStatements[exportName] },
+ });
+
+ // TODO: fix this when stories fail
+ self._stories[exportName].__stats.tests = true;
+ }
},
},
CallExpression: {
@@ -723,9 +795,18 @@ export class CsfFile {
const configParent = configCandidate?.path?.parentPath?.node;
if (t.isImportDeclaration(configParent)) {
if (isValidPreviewPath(configParent.source.value)) {
- const metaNode = node.arguments[0] as t.ObjectExpression;
- self._metaVariableName = callee.property.name;
self._metaIsFactory = true;
+ const metaDeclarator = path.findParent((p) =>
+ p.isVariableDeclarator()
+ ) as NodePath;
+
+ // find the name of the meta variable declaration
+ // e.g. const foo = preview.meta({ ... });
+ // otherwise fallback to meta
+ self._metaVariableName = t.isIdentifier(metaDeclarator.node.id)
+ ? metaDeclarator.node.id.name
+ : callee.property.name;
+ const metaNode = node.arguments[0] as t.ObjectExpression;
self._parseMeta(metaNode, self._ast.program);
} else {
throw new BadMetaError(
@@ -801,6 +882,18 @@ export class CsfFile {
stats.mount = hasMount(storyAnnotations.play ?? self._metaAnnotations.play);
stats.moduleMock = !!self.imports.find((fname) => isModuleMock(fname));
+ const storyNode = self._storyStatements[key];
+ const storyTests = self._tests.filter((t) => t.parent.node === storyNode);
+ if (storyTests.length > 0) {
+ // TODO: [test-syntax] if we want to add a tag for the story that contains tests, this is the place for it
+ // acc[key].tags = [...(acc[key].tags || []), 'story-with-tests'];
+
+ stats.tests = true;
+ storyTests.forEach((test) => {
+ test.id = toTestId(id, test.name);
+ });
+ }
+
return acc;
},
{} as Record
@@ -840,6 +933,14 @@ export class CsfFile {
return Object.values(this._stories);
}
+ public getStoryTests(story: string | t.Node) {
+ const storyNode = typeof story === 'string' ? this._storyStatements[story] : story;
+ if (!storyNode) {
+ return [];
+ }
+ return this._tests.filter((t) => t.parent.node === storyNode);
+ }
+
public get indexInputs(): IndexInput[] {
const { fileName } = this._options;
if (!fileName) {
@@ -849,22 +950,58 @@ export class CsfFile {
);
}
- return Object.entries(this._stories).map(([exportName, story]) => {
+ const index: IndexInput[] = [];
+
+ Object.entries(this._stories).map(([exportName, story]) => {
// don't remove any duplicates or negations -- tags will be combined in the index
const tags = [...(this._meta?.tags ?? []), ...(story.tags ?? [])];
- return {
- type: 'story',
+ const storyInput = {
importPath: fileName,
rawComponentPath: this._rawComponentPath,
exportName,
- name: story.name,
title: this.meta?.title,
metaId: this.meta?.id,
tags,
__id: story.id,
__stats: story.__stats,
};
+
+ const tests = this.getStoryTests(exportName);
+ const hasTests = tests.length > 0;
+
+ index.push({
+ ...storyInput,
+ type: 'story',
+ subtype: 'story',
+ name: story.name,
+ });
+
+ if (hasTests) {
+ tests.forEach((test) => {
+ index.push({
+ ...storyInput,
+ // TODO implementent proper title => path behavior in `transformStoryIndexToStoriesHash`
+ // title: `${storyInput.title}/${story.name}`,
+ type: 'story',
+ subtype: 'test',
+ name: test.name,
+ parent: story.id,
+ parentName: story.name,
+ tags: [
+ ...storyInput.tags,
+ // this tag comes before test tags so users can invert if they like
+ '!autodocs',
+ ...test.tags,
+ // this tag comes after test tags so users can't change it
+ 'test-fn',
+ ],
+ __id: test.id,
+ });
+ });
+ }
});
+
+ return index;
}
}
diff --git a/code/core/src/csf-tools/storyIndexer.test.ts b/code/core/src/csf-tools/storyIndexer.test.ts
new file mode 100644
index 000000000000..7ce38a5cf317
--- /dev/null
+++ b/code/core/src/csf-tools/storyIndexer.test.ts
@@ -0,0 +1,61 @@
+import { describe, expect, it } from 'vitest';
+
+import { loadCsf } from './CsfFile';
+
+const getIndex = (code: string) => {
+ const inputs = loadCsf(code, { makeTitle: () => 'title', fileName: 'a.stories.ts' }).parse()
+ .indexInputs;
+
+ return {
+ raw: inputs,
+ entries: inputs.map((i) => i.name),
+ };
+};
+
+describe('test fn', () => {
+ it('indexes CSF v1 to v3 stories', () => {
+ const { entries } = getIndex(
+ `
+ export default { component: 'foo' };
+ export const CSF1 = () => 'foo';
+ export const CSF2 = (args) => 'foo';
+ export const CSF3 = {};
+ export const CustomName = {
+ name: 'Custom name',
+ };
+ `
+ );
+ expect(entries).toMatchInlineSnapshot(`
+ [
+ "CSF 1",
+ "CSF 2",
+ "CSF 3",
+ "Custom name",
+ ]
+ `);
+ });
+
+ it('indexes test functions', () => {
+ const { entries } = getIndex(
+ `
+ import { config } from '#.storybook/preview'
+ const meta = config.meta({ component: 'foo' });
+ export const A = meta.story({})
+ A.test('async test function', async () => {})
+ A.test('sync test function', () => {})
+ A.test('with overrides', { args: { label: 'bar' } }, () => {})
+ const reference = () => {}
+ A.test('with function reference', reference)
+ `
+ );
+ expect(entries).toMatchInlineSnapshot(`
+ [
+ "A",
+ "async test function",
+ "sync test function",
+ "with overrides",
+ "with function reference",
+ ]
+ `);
+ });
+});
diff --git a/code/core/src/csf-tools/vitest-plugin/transformer.test.ts b/code/core/src/csf-tools/vitest-plugin/transformer.test.ts
index 7d9d89464aa2..840d16f57499 100644
--- a/code/core/src/csf-tools/vitest-plugin/transformer.test.ts
+++ b/code/core/src/csf-tools/vitest-plugin/transformer.test.ts
@@ -73,7 +73,7 @@ describe('transformer', () => {
export const Story = {};
const _isRunningFromThisFile = convertToFilePath(import.meta.url).includes(globalThis.__vitest_worker__.filepath ?? _expect.getState().testPath);
if (_isRunningFromThisFile) {
- _test("Story", _testStory("Story", Story, _meta, []));
+ _test("Story", _testStory("Story", Story, _meta, [], "automatic-calculated-title--story"));
}
`);
});
@@ -102,7 +102,7 @@ describe('transformer', () => {
export const Story = {};
const _isRunningFromThisFile = convertToFilePath(import.meta.url).includes(globalThis.__vitest_worker__.filepath ?? _expect.getState().testPath);
if (_isRunningFromThisFile) {
- _test("Story", _testStory("Story", Story, _meta, []));
+ _test("Story", _testStory("Story", Story, _meta, [], "automatic-calculated-title--story"));
}
`);
});
@@ -132,7 +132,7 @@ describe('transformer', () => {
export const Story = {};
const _isRunningFromThisFile = convertToFilePath(import.meta.url).includes(globalThis.__vitest_worker__.filepath ?? _expect.getState().testPath);
if (_isRunningFromThisFile) {
- _test("Story", _testStory("Story", Story, meta, []));
+ _test("Story", _testStory("Story", Story, meta, [], "automatic-calculated-title--story"));
}
`);
});
@@ -163,7 +163,7 @@ describe('transformer', () => {
export const Story = {};
const _isRunningFromThisFile = convertToFilePath(import.meta.url).includes(globalThis.__vitest_worker__.filepath ?? _expect.getState().testPath);
if (_isRunningFromThisFile) {
- _test("Story", _testStory("Story", Story, meta, []));
+ _test("Story", _testStory("Story", Story, meta, [], "automatic-calculated-title--story"));
}
`);
});
@@ -199,7 +199,7 @@ describe('transformer', () => {
};
const _isRunningFromThisFile = convertToFilePath(import.meta.url).includes(globalThis.__vitest_worker__.filepath ?? _expect.getState().testPath);
if (_isRunningFromThisFile) {
- _test("Primary", _testStory("Primary", Primary, _meta, []));
+ _test("Primary", _testStory("Primary", Primary, _meta, [], "automatic-calculated-title--primary"));
}
`);
});
@@ -224,7 +224,7 @@ describe('transformer', () => {
};
const _isRunningFromThisFile = convertToFilePath(import.meta.url).includes(globalThis.__vitest_worker__.filepath ?? _expect.getState().testPath);
if (_isRunningFromThisFile) {
- _test("custom name", _testStory("Primary", Primary, _meta, []));
+ _test("custom name", _testStory("Primary", Primary, _meta, [], "automatic-calculated-title--primary"));
}
`);
});
@@ -247,7 +247,7 @@ describe('transformer', () => {
Story.storyName = 'custom name';
const _isRunningFromThisFile = convertToFilePath(import.meta.url).includes(globalThis.__vitest_worker__.filepath ?? _expect.getState().testPath);
if (_isRunningFromThisFile) {
- _test("custom name", _testStory("Story", Story, _meta, []));
+ _test("custom name", _testStory("Story", Story, _meta, [], "automatic-calculated-title--story"));
}
`);
});
@@ -282,7 +282,7 @@ describe('transformer', () => {
export { Primary };
const _isRunningFromThisFile = convertToFilePath(import.meta.url).includes(globalThis.__vitest_worker__.filepath ?? _expect.getState().testPath);
if (_isRunningFromThisFile) {
- _test("Primary", _testStory("Primary", Primary, _meta, []));
+ _test("Primary", _testStory("Primary", Primary, _meta, [], "automatic-calculated-title--primary"));
}
`);
});
@@ -316,7 +316,7 @@ describe('transformer', () => {
export { Primary as PrimaryStory };
const _isRunningFromThisFile = convertToFilePath(import.meta.url).includes(globalThis.__vitest_worker__.filepath ?? _expect.getState().testPath);
if (_isRunningFromThisFile) {
- _test("PrimaryStory", _testStory("PrimaryStory", Primary, _meta, []));
+ _test("PrimaryStory", _testStory("PrimaryStory", Primary, _meta, [], "automatic-calculated-title--primary-story"));
}
`);
});
@@ -352,8 +352,8 @@ describe('transformer', () => {
export { Primary };
const _isRunningFromThisFile = convertToFilePath(import.meta.url).includes(globalThis.__vitest_worker__.filepath ?? _expect.getState().testPath);
if (_isRunningFromThisFile) {
- _test("Secondary", _testStory("Secondary", Secondary, _meta, []));
- _test("Primary", _testStory("Primary", Primary, _meta, []));
+ _test("Secondary", _testStory("Secondary", Secondary, _meta, [], "automatic-calculated-title--secondary"));
+ _test("Primary", _testStory("Primary", Primary, _meta, [], "automatic-calculated-title--primary"));
}
`);
});
@@ -384,7 +384,7 @@ describe('transformer', () => {
export const nonStory = 123;
const _isRunningFromThisFile = convertToFilePath(import.meta.url).includes(globalThis.__vitest_worker__.filepath ?? _expect.getState().testPath);
if (_isRunningFromThisFile) {
- _test("Story", _testStory("Story", Story, _meta, []));
+ _test("Story", _testStory("Story", Story, _meta, [], "automatic-calculated-title--story"));
}
`);
});
@@ -441,7 +441,7 @@ describe('transformer', () => {
export const NotIncluded = {};
const _isRunningFromThisFile = convertToFilePath(import.meta.url).includes(globalThis.__vitest_worker__.filepath ?? _expect.getState().testPath);
if (_isRunningFromThisFile) {
- _test("Included", _testStory("Included", Included, _meta, []));
+ _test("Included", _testStory("Included", Included, _meta, [], "automatic-calculated-title--included"));
}
`);
});
@@ -472,7 +472,7 @@ describe('transformer', () => {
};
const _isRunningFromThisFile = convertToFilePath(import.meta.url).includes(globalThis.__vitest_worker__.filepath ?? _expect.getState().testPath);
if (_isRunningFromThisFile) {
- _test("Included", _testStory("Included", Included, _meta, []));
+ _test("Included", _testStory("Included", Included, _meta, [], "automatic-calculated-title--included"));
}
`);
});
@@ -500,7 +500,7 @@ describe('transformer', () => {
};
const _isRunningFromThisFile = convertToFilePath(import.meta.url).includes(globalThis.__vitest_worker__.filepath ?? _expect.getState().testPath);
if (_isRunningFromThisFile) {
- _test("Skipped", _testStory("Skipped", Skipped, _meta, ["skip-me"]));
+ _test("Skipped", _testStory("Skipped", Skipped, _meta, ["skip-me"], "automatic-calculated-title--skipped"));
}
`);
});
@@ -532,7 +532,7 @@ describe('transformer', () => {
export const Primary = {};
const _isRunningFromThisFile = convertToFilePath(import.meta.url).includes(globalThis.__vitest_worker__.filepath ?? _expect.getState().testPath);
if (_isRunningFromThisFile) {
- _test("Primary", _testStory("Primary", Primary, meta, []));
+ _test("Primary", _testStory("Primary", Primary, meta, [], "automatic-calculated-title--primary"));
}
`);
@@ -589,7 +589,7 @@ describe('transformer', () => {
export const Story = meta.story({});
const _isRunningFromThisFile = convertToFilePath(import.meta.url).includes(globalThis.__vitest_worker__.filepath ?? _expect.getState().testPath);
if (_isRunningFromThisFile) {
- _test("Story", _testStory("Story", Story, meta, []));
+ _test("Story", _testStory("Story", Story, meta, [], "automatic-calculated-title--story"));
}
`);
});
@@ -616,7 +616,7 @@ describe('transformer', () => {
});
const _isRunningFromThisFile = convertToFilePath(import.meta.url).includes(globalThis.__vitest_worker__.filepath ?? _expect.getState().testPath);
if (_isRunningFromThisFile) {
- _test("custom name", _testStory("Primary", Primary, meta, []));
+ _test("custom name", _testStory("Primary", Primary, meta, [], "automatic-calculated-title--primary"));
}
`);
});
@@ -652,7 +652,7 @@ describe('transformer', () => {
export { Primary };
const _isRunningFromThisFile = convertToFilePath(import.meta.url).includes(globalThis.__vitest_worker__.filepath ?? _expect.getState().testPath);
if (_isRunningFromThisFile) {
- _test("Primary", _testStory("Primary", Primary, meta, []));
+ _test("Primary", _testStory("Primary", Primary, meta, [], "automatic-calculated-title--primary"));
}
`);
});
@@ -688,7 +688,7 @@ describe('transformer', () => {
export { Primary as PrimaryStory };
const _isRunningFromThisFile = convertToFilePath(import.meta.url).includes(globalThis.__vitest_worker__.filepath ?? _expect.getState().testPath);
if (_isRunningFromThisFile) {
- _test("PrimaryStory", _testStory("PrimaryStory", Primary, meta, []));
+ _test("PrimaryStory", _testStory("PrimaryStory", Primary, meta, [], "automatic-calculated-title--primary-story"));
}
`);
});
@@ -724,8 +724,8 @@ describe('transformer', () => {
export { Primary };
const _isRunningFromThisFile = convertToFilePath(import.meta.url).includes(globalThis.__vitest_worker__.filepath ?? _expect.getState().testPath);
if (_isRunningFromThisFile) {
- _test("Secondary", _testStory("Secondary", Secondary, _meta, []));
- _test("Primary", _testStory("Primary", Primary, _meta, []));
+ _test("Secondary", _testStory("Secondary", Secondary, _meta, [], "automatic-calculated-title--secondary"));
+ _test("Primary", _testStory("Primary", Primary, _meta, [], "automatic-calculated-title--primary"));
}
`);
});
@@ -756,7 +756,7 @@ describe('transformer', () => {
export const nonStory = 123;
const _isRunningFromThisFile = convertToFilePath(import.meta.url).includes(globalThis.__vitest_worker__.filepath ?? _expect.getState().testPath);
if (_isRunningFromThisFile) {
- _test("Story", _testStory("Story", Story, _meta, []));
+ _test("Story", _testStory("Story", Story, _meta, [], "automatic-calculated-title--story"));
}
`);
});
@@ -784,6 +784,72 @@ describe('transformer', () => {
_describe.skip("No valid tests found");
`);
});
+ it('should support test annotation', async () => {
+ const code = `
+ import { config } from '#.storybook/preview';
+ const meta = config.meta({ component: Button });
+ export const A = meta.story({});
+ A.test("foo", { args: { primary: true }}, () => {});
+ A.test("bar", () => {});
+ `;
+
+ const result = await transform({ code });
+
+ expect(result.code).toMatchInlineSnapshot(`
+ import { test as _test, expect as _expect, describe as _describe } from "vitest";
+ import { testStory as _testStory, convertToFilePath } from "@storybook/addon-vitest/internal/test-utils";
+ import { config } from '#.storybook/preview';
+ const meta = config.meta({
+ component: Button,
+ title: "automatic/calculated/title"
+ });
+ export const A = meta.story({});
+ A.test("foo", {
+ args: {
+ primary: true
+ }
+ }, () => {});
+ A.test("bar", () => {});
+ const _isRunningFromThisFile = convertToFilePath(import.meta.url).includes(globalThis.__vitest_worker__.filepath ?? _expect.getState().testPath);
+ if (_isRunningFromThisFile) {
+ _describe("A ", () => {
+ _test("base story", _testStory("A", A, meta, [], "automatic-calculated-title--a"));
+ _test("foo", _testStory("A", A, meta, [], "automatic-calculated-title--a:foo", "foo"));
+ _test("bar", _testStory("A", A, meta, [], "automatic-calculated-title--a:bar", "bar"));
+ });
+ }
+ `);
+ });
+ });
+
+ describe('test syntax', () => {
+ it('should add test statement to story tests', async () => {
+ const code = `
+ import { config } from '#.storybook/preview';
+ const meta = config.meta({});
+ export const Primary = meta.story({});
+ Primary.test("foo", () => {});
+ `;
+ const result = await transform({ code });
+
+ expect(result.code).toMatchInlineSnapshot(`
+ import { test as _test, expect as _expect, describe as _describe } from "vitest";
+ import { testStory as _testStory, convertToFilePath } from "@storybook/addon-vitest/internal/test-utils";
+ import { config } from '#.storybook/preview';
+ const meta = config.meta({
+ title: "automatic/calculated/title"
+ });
+ export const Primary = meta.story({});
+ Primary.test("foo", () => {});
+ const _isRunningFromThisFile = convertToFilePath(import.meta.url).includes(globalThis.__vitest_worker__.filepath ?? _expect.getState().testPath);
+ if (_isRunningFromThisFile) {
+ _describe("Primary ", () => {
+ _test("base story", _testStory("Primary", Primary, meta, [], "automatic-calculated-title--primary"));
+ _test("foo", _testStory("Primary", Primary, meta, [], "automatic-calculated-title--primary:foo", "foo"));
+ });
+ }
+ `);
+ });
});
describe('tags filtering mechanism', () => {
@@ -814,7 +880,7 @@ describe('transformer', () => {
export const NotIncluded = meta.story({});
const _isRunningFromThisFile = convertToFilePath(import.meta.url).includes(globalThis.__vitest_worker__.filepath ?? _expect.getState().testPath);
if (_isRunningFromThisFile) {
- _test("Included", _testStory("Included", Included, meta, []));
+ _test("Included", _testStory("Included", Included, meta, [], "automatic-calculated-title--included"));
}
`);
});
@@ -846,7 +912,7 @@ describe('transformer', () => {
});
const _isRunningFromThisFile = convertToFilePath(import.meta.url).includes(globalThis.__vitest_worker__.filepath ?? _expect.getState().testPath);
if (_isRunningFromThisFile) {
- _test("Included", _testStory("Included", Included, meta, []));
+ _test("Included", _testStory("Included", Included, meta, [], "automatic-calculated-title--included"));
}
`);
});
@@ -875,7 +941,7 @@ describe('transformer', () => {
});
const _isRunningFromThisFile = convertToFilePath(import.meta.url).includes(globalThis.__vitest_worker__.filepath ?? _expect.getState().testPath);
if (_isRunningFromThisFile) {
- _test("Skipped", _testStory("Skipped", Skipped, meta, ["skip-me"]));
+ _test("Skipped", _testStory("Skipped", Skipped, meta, ["skip-me"], "automatic-calculated-title--skipped"));
}
`);
});
@@ -887,6 +953,7 @@ describe('transformer', () => {
import { config } from '#.storybook/preview';
const meta = config.meta({});
export const Primary = meta.story({});
+ Primary.test("foo", () => {});
`;
const { code: transformedCode, map } = await transform({
@@ -894,29 +961,90 @@ describe('transformer', () => {
});
expect(transformedCode).toMatchInlineSnapshot(`
- import { test as _test, expect as _expect } from "vitest";
+ import { test as _test, expect as _expect, describe as _describe } from "vitest";
import { testStory as _testStory, convertToFilePath } from "@storybook/addon-vitest/internal/test-utils";
import { config } from '#.storybook/preview';
const meta = config.meta({
title: "automatic/calculated/title"
});
export const Primary = meta.story({});
+ Primary.test("foo", () => {});
const _isRunningFromThisFile = convertToFilePath(import.meta.url).includes(globalThis.__vitest_worker__.filepath ?? _expect.getState().testPath);
if (_isRunningFromThisFile) {
- _test("Primary", _testStory("Primary", Primary, meta, []));
+ _describe("Primary ", () => {
+ _test("base story", _testStory("Primary", Primary, meta, [], "automatic-calculated-title--primary"));
+ _test("foo", _testStory("Primary", Primary, meta, [], "automatic-calculated-title--primary:foo", "foo"));
+ });
}
`);
const consumer = await new SourceMapConsumer(map as unknown as RawSourceMap);
- // Locate `__test("Primary"...` in the transformed code
+ // Locate `_test("base story"...` in the transformed code
const testPrimaryLine =
- transformedCode.split('\n').findIndex((line) => line.includes('_test("Primary"')) + 1;
+ transformedCode.split('\n').findIndex((line) => line.includes('_test("base story"')) + 1;
const testPrimaryColumn = transformedCode
.split('\n')
- [testPrimaryLine - 1].indexOf('_test("Primary"');
+ [testPrimaryLine - 1].indexOf('_test("base story"');
- // Get the original position from the source map for `__test("Primary"...`
+ // Get the original position from the source map for `_test("base story"...`
+ const originalPosition = consumer.originalPositionFor({
+ line: testPrimaryLine,
+ column: testPrimaryColumn,
+ });
+
+ // Locate `export const Primary` in the original code
+ const originalPrimaryLine =
+ originalCode.split('\n').findIndex((line) => line.includes('export const Primary')) + 1;
+ const originalPrimaryColumn = originalCode
+ .split('\n')
+ [originalPrimaryLine - 1].indexOf('export const Primary');
+
+ // The original locations of the transformed code should match with the ones of the original code
+ expect(originalPosition.line, 'original line location').toBe(originalPrimaryLine);
+ expect(originalPosition.column, 'original column location').toBe(originalPrimaryColumn);
+ });
+
+ it.skip('should remap the location of story tests', async () => {
+ const originalCode = `
+ import { config } from '#.storybook/preview';
+ const meta = config.meta({});
+ export const Primary = meta.story({});
+ Primary.test("foo", () => {});
+ `;
+
+ const { code: transformedCode, map } = await transform({
+ code: originalCode,
+ });
+
+ expect(transformedCode).toMatchInlineSnapshot(`
+ import { test as _test, expect as _expect, describe as _describe } from "vitest";
+ import { testStory as _testStory, convertToFilePath } from "@storybook/addon-vitest/internal/test-utils";
+ import { config } from '#.storybook/preview';
+ const meta = config.meta({
+ title: "automatic/calculated/title"
+ });
+ export const Primary = meta.story({});
+ Primary.test("foo", () => {});
+ const _isRunningFromThisFile = convertToFilePath(import.meta.url).includes(globalThis.__vitest_worker__.filepath ?? _expect.getState().testPath);
+ if (_isRunningFromThisFile) {
+ _describe("Primary", () => {
+ _test("base story", _testStory("Primary", Primary, meta, []));
+ _test("foo", _testStory("Primary", Primary, meta, [], "foo"));
+ });
+ }
+ `);
+
+ const consumer = await new SourceMapConsumer(map as unknown as RawSourceMap);
+
+ // Locate `_test("render test"...` in the transformed code
+ const testPrimaryLine =
+ transformedCode.split('\n').findIndex((line) => line.includes('_test("render test"')) + 1;
+ const testPrimaryColumn = transformedCode
+ .split('\n')
+ [testPrimaryLine - 1].indexOf('_test("render test"');
+
+ // Get the original position from the source map for `_test("render test"...`
const originalPosition = consumer.originalPositionFor({
line: testPrimaryLine,
column: testPrimaryColumn,
@@ -932,6 +1060,32 @@ describe('transformer', () => {
// The original locations of the transformed code should match with the ones of the original code
expect(originalPosition.line, 'original line location').toBe(originalPrimaryLine);
expect(originalPosition.column, 'original column location').toBe(originalPrimaryColumn);
+
+ // Locate `_test("foo"...` in the transformed code
+ const storyTestLine =
+ transformedCode.split('\n').findIndex((line) => line.includes('_test("foo"')) + 1;
+ const storyTestColumn = transformedCode
+ .split('\n')
+ [storyTestLine - 1].indexOf('_test("foo"');
+
+ // Get the original position from the source map for `_test("foo"...`
+ const originalTestPosition = consumer.originalPositionFor({
+ line: storyTestLine,
+ column: storyTestColumn,
+ });
+
+ // Locate `Primary.test("foo"'` in the original code
+ const originalStoryTestLine =
+ originalCode.split('\n').findIndex((line) => line.includes('Primary.test("foo"')) + 1;
+ const originalStoryTestColumn = originalCode
+ .split('\n')
+ [originalStoryTestLine - 1].indexOf('Primary.test("foo"');
+
+ // The original locations of the transformed code should match with the ones of the original code
+ expect(originalTestPosition.line, 'original line location').toBe(originalStoryTestLine);
+ expect(originalTestPosition.column, 'original column location').toBe(
+ originalStoryTestColumn
+ );
});
});
});
diff --git a/code/core/src/csf-tools/vitest-plugin/transformer.ts b/code/core/src/csf-tools/vitest-plugin/transformer.ts
index 22745c1537e0..cf43a9d3806b 100644
--- a/code/core/src/csf-tools/vitest-plugin/transformer.ts
+++ b/code/core/src/csf-tools/vitest-plugin/transformer.ts
@@ -7,7 +7,7 @@ import type { StoriesEntry, Tag } from 'storybook/internal/types';
import { dedent } from 'ts-dedent';
-import { formatCsf, loadCsf } from '../CsfFile';
+import { type StoryTest, formatCsf, loadCsf } from '../CsfFile';
type TagsFilter = {
include: string[];
@@ -31,6 +31,18 @@ const isValidTest = (storyTags: string[], tagsFilter: TagsFilter) => {
* bundles.
*/
+/**
+ * We add double space characters so that it's possible to do a regex for all test run use cases.
+ * Otherwise, if there were two unrelated stories like "Primary Button" and "Primary Button Mobile",
+ * once you run tests for "Primary Button" and its children it would also match "Primary Button
+ * Mobile". As it turns out, this limitation is also present in the Vitest VSCode extension and the
+ * issue would occur with normal vitest tests as well, but because we use double spaces, we
+ * circumvent the issue.
+ */
+const DOUBLE_SPACES = ' ';
+const getLiteralWithZeroWidthSpace = (testTitle: string) =>
+ t.stringLiteral(`${testTitle}${DOUBLE_SPACES}`);
+
export async function vitestTransform({
code,
fileName,
@@ -96,7 +108,7 @@ export async function vitestTransform({
// Filter out stories based on the passed tags filter
const validStories: (typeof parsed)['_storyStatements'] = {};
- Object.keys(parsed._stories).map((key) => {
+ Object.keys(parsed._stories).forEach((key) => {
const finalTags = combineTags(
'test',
'dev',
@@ -133,143 +145,222 @@ export async function vitestTransform({
];
ast.program.body.unshift(...imports);
- } else {
- const vitestExpectId = parsed._file.path.scope.generateUidIdentifier('expect');
- const testStoryId = parsed._file.path.scope.generateUidIdentifier('testStory');
- const skipTagsId = t.identifier(JSON.stringify(tagsFilter.skip));
-
- /**
- * In Storybook users might be importing stories from other story files. As a side effect, tests
- * can get re-triggered. To avoid this, we add a guard to only run tests if the current file is
- * the one running the test.
- *
- * Const isRunningFromThisFile = import.meta.url.includes(expect.getState().testPath ??
- * globalThis.**vitest_worker**.filepath) if(isRunningFromThisFile) { ... }
- */
- function getTestGuardDeclaration() {
- const isRunningFromThisFileId =
- parsed._file.path.scope.generateUidIdentifier('isRunningFromThisFile');
-
- // expect.getState().testPath
- const testPathProperty = t.memberExpression(
- t.callExpression(t.memberExpression(vitestExpectId, t.identifier('getState')), []),
- t.identifier('testPath')
- );
-
- // There is a bug in Vitest where expect.getState().testPath is undefined when called outside of a test function so we add this fallback in the meantime
- // https://github.com/vitest-dev/vitest/issues/6367
- // globalThis.__vitest_worker__.filepath
- const filePathProperty = t.memberExpression(
- t.memberExpression(t.identifier('globalThis'), t.identifier('__vitest_worker__')),
- t.identifier('filepath')
- );
-
- // Combine testPath and filepath using the ?? operator
- const nullishCoalescingExpression = t.logicalExpression(
- '??',
- // TODO: switch order of testPathProperty and filePathProperty when the bug is fixed
- // https://github.com/vitest-dev/vitest/issues/6367 (or probably just use testPathProperty)
- filePathProperty,
- testPathProperty
- );
-
- // Create the final expression: convertToFilePath(import.meta.url).includes(...)
- const includesCall = t.callExpression(
- t.memberExpression(
- t.callExpression(t.identifier('convertToFilePath'), [
- t.memberExpression(
- t.memberExpression(t.identifier('import'), t.identifier('meta')),
- t.identifier('url')
- ),
- ]),
- t.identifier('includes')
- ),
- [nullishCoalescingExpression]
- );
-
- const isRunningFromThisFileDeclaration = t.variableDeclaration('const', [
- t.variableDeclarator(isRunningFromThisFileId, includesCall),
- ]);
- return { isRunningFromThisFileDeclaration, isRunningFromThisFileId };
- }
- const { isRunningFromThisFileDeclaration, isRunningFromThisFileId } = getTestGuardDeclaration();
-
- ast.program.body.push(isRunningFromThisFileDeclaration);
-
- const getTestStatementForStory = ({
- localName,
- exportName,
- testTitle,
- node,
- }: {
- localName: string;
- exportName: string;
- testTitle: string;
- node: t.Node;
- }): t.ExpressionStatement => {
- // Create the _test expression directly using the exportName identifier
- const testStoryCall = t.expressionStatement(
- t.callExpression(vitestTestId, [
- t.stringLiteral(testTitle),
- t.callExpression(testStoryId, [
- t.stringLiteral(exportName),
- t.identifier(localName),
- t.identifier(metaExportName),
- skipTagsId,
- ]),
- ])
- );
+ return formatCsf(parsed, { sourceMaps: true, sourceFileName: fileName }, code);
+ }
+
+ const vitestExpectId = parsed._file.path.scope.generateUidIdentifier('expect');
+ const testStoryId = parsed._file.path.scope.generateUidIdentifier('testStory');
+ const skipTagsId = t.identifier(JSON.stringify(tagsFilter.skip));
+
+ /**
+ * In Storybook users might be importing stories from other story files. As a side effect, tests
+ * can get re-triggered. To avoid this, we add a guard to only run tests if the current file is
+ * the one running the test.
+ *
+ * Const isRunningFromThisFile = import.meta.url.includes(expect.getState().testPath ??
+ * globalThis.**vitest_worker**.filepath) if(isRunningFromThisFile) { ... }
+ */
+ function getTestGuardDeclaration() {
+ const isRunningFromThisFileId =
+ parsed._file.path.scope.generateUidIdentifier('isRunningFromThisFile');
+
+ // expect.getState().testPath
+ const testPathProperty = t.memberExpression(
+ t.callExpression(t.memberExpression(vitestExpectId, t.identifier('getState')), []),
+ t.identifier('testPath')
+ );
+
+ // There is a bug in Vitest where expect.getState().testPath is undefined when called outside of a test function so we add this fallback in the meantime
+ // https://github.com/vitest-dev/vitest/issues/6367
+ // globalThis.__vitest_worker__.filepath
+ const filePathProperty = t.memberExpression(
+ t.memberExpression(t.identifier('globalThis'), t.identifier('__vitest_worker__')),
+ t.identifier('filepath')
+ );
+
+ // Combine testPath and filepath using the ?? operator
+ const nullishCoalescingExpression = t.logicalExpression(
+ '??',
+ // TODO: switch order of testPathProperty and filePathProperty when the bug is fixed
+ // https://github.com/vitest-dev/vitest/issues/6367 (or probably just use testPathProperty)
+ filePathProperty,
+ testPathProperty
+ );
+ // Create the final expression: import.meta.url.includes(...)
+ const includesCall = t.callExpression(
+ t.memberExpression(
+ t.callExpression(t.identifier('convertToFilePath'), [
+ t.memberExpression(
+ t.memberExpression(t.identifier('import'), t.identifier('meta')),
+ t.identifier('url')
+ ),
+ ]),
+ t.identifier('includes')
+ ),
+ [nullishCoalescingExpression]
+ );
+
+ const isRunningFromThisFileDeclaration = t.variableDeclaration('const', [
+ t.variableDeclarator(isRunningFromThisFileId, includesCall),
+ ]);
+ return { isRunningFromThisFileDeclaration, isRunningFromThisFileId };
+ }
+
+ const { isRunningFromThisFileDeclaration, isRunningFromThisFileId } = getTestGuardDeclaration();
+
+ ast.program.body.push(isRunningFromThisFileDeclaration);
+
+ const getTestStatementForStory = ({
+ localName,
+ exportName,
+ testTitle,
+ node,
+ overrideSourcemap = true,
+ storyId,
+ }: {
+ localName: string;
+ exportName: string;
+ testTitle: string;
+ node: t.Node;
+ overrideSourcemap?: boolean;
+ storyId: string;
+ }): t.ExpressionStatement => {
+ // Create the _test expression directly using the exportName identifier
+ const testStoryCall = t.expressionStatement(
+ t.callExpression(vitestTestId, [
+ t.stringLiteral(testTitle),
+ t.callExpression(testStoryId, [
+ t.stringLiteral(exportName),
+ t.identifier(localName),
+ t.identifier(metaExportName),
+ skipTagsId,
+ t.stringLiteral(storyId),
+ ]),
+ ])
+ );
+
+ if (overrideSourcemap) {
// Preserve sourcemaps location
testStoryCall.loc = node.loc;
+ }
- // Return just the testStoryCall as composeStoryCall is not needed
- return testStoryCall;
- };
+ // Return just the testStoryCall as composeStoryCall is not needed
+ return testStoryCall;
+ };
+
+ const getDescribeStatementForStory = (options: {
+ localName: string;
+ describeTitle: string;
+ exportName: string;
+ tests: StoryTest[];
+ node: t.Node;
+ parentStoryId: string;
+ }): t.ExpressionStatement => {
+ const { localName, describeTitle, exportName, tests, node, parentStoryId } = options;
+ const describeBlock = t.callExpression(vitestDescribeId, [
+ getLiteralWithZeroWidthSpace(describeTitle),
+ t.arrowFunctionExpression(
+ [],
+ t.blockStatement([
+ getTestStatementForStory({
+ ...options,
+ testTitle: 'base story',
+ overrideSourcemap: false,
+ storyId: parentStoryId,
+ }),
+ ...tests.map(({ name: testName, node: testNode, id: storyId }) => {
+ const testStatement = t.expressionStatement(
+ t.callExpression(vitestTestId, [
+ t.stringLiteral(testName),
+ t.callExpression(testStoryId, [
+ t.stringLiteral(exportName),
+ t.identifier(localName),
+ t.identifier(metaExportName),
+ t.arrayExpression([]),
+ t.stringLiteral(storyId),
+ t.stringLiteral(testName),
+ ]),
+ ])
+ );
+ testStatement.loc = testNode.loc;
+ return testStatement;
+ }),
+ ])
+ ),
+ ]);
- const storyTestStatements = Object.entries(validStories)
- .map(([exportName, node]) => {
- if (node === null) {
- logger.warn(
- dedent`
+ describeBlock.loc = node.loc;
+ return t.expressionStatement(describeBlock);
+ };
+
+ const storyTestStatements = Object.entries(validStories)
+ .map(([exportName, node]) => {
+ if (node === null) {
+ logger.warn(
+ dedent`
[Storybook]: Could not transform "${exportName}" story into test at "${fileName}".
Please make sure to define stories in the same file and not re-export stories coming from other files".
`
- );
- return;
- }
+ );
+ return;
+ }
- const localName = parsed._stories[exportName].localName ?? exportName;
- // use the story's name as the test title for vitest, and fallback to exportName
- const testTitle = parsed._stories[exportName].name ?? exportName;
- return getTestStatementForStory({ testTitle, localName, exportName, node });
- })
- .filter((st) => !!st) as t.ExpressionStatement[];
+ const localName = parsed._stories[exportName].localName ?? exportName;
+ // use the story's name as the test title for vitest, and fallback to exportName
+ const testTitle = parsed._stories[exportName].name ?? exportName;
+ const storyId = parsed._stories[exportName].id;
+ const tests = parsed.getStoryTests(exportName);
+
+ if (tests?.length > 0) {
+ return getDescribeStatementForStory({
+ localName,
+ describeTitle: testTitle,
+ exportName,
+ tests,
+ node,
+ parentStoryId: storyId,
+ });
+ }
- const testBlock = t.ifStatement(isRunningFromThisFileId, t.blockStatement(storyTestStatements));
+ return getTestStatementForStory({
+ testTitle,
+ localName,
+ exportName,
+ node,
+ storyId,
+ });
+ })
+ .filter((st) => !!st) as t.ExpressionStatement[];
- ast.program.body.push(testBlock);
+ const testBlock = t.ifStatement(isRunningFromThisFileId, t.blockStatement(storyTestStatements));
- const imports = [
- t.importDeclaration(
- [
- t.importSpecifier(vitestTestId, t.identifier('test')),
- t.importSpecifier(vitestExpectId, t.identifier('expect')),
- ],
- t.stringLiteral('vitest')
- ),
- t.importDeclaration(
- [
- t.importSpecifier(testStoryId, t.identifier('testStory')),
- t.importSpecifier(t.identifier('convertToFilePath'), t.identifier('convertToFilePath')),
- ],
- t.stringLiteral('@storybook/addon-vitest/internal/test-utils')
- ),
- ];
+ ast.program.body.push(testBlock);
- ast.program.body.unshift(...imports);
- }
+ const hasTests = Object.keys(validStories).some(
+ (exportName) => parsed.getStoryTests(exportName).length > 0
+ );
+
+ const imports = [
+ t.importDeclaration(
+ [
+ t.importSpecifier(vitestTestId, t.identifier('test')),
+ t.importSpecifier(vitestExpectId, t.identifier('expect')),
+ ...(hasTests ? [t.importSpecifier(vitestDescribeId, t.identifier('describe'))] : []),
+ ],
+ t.stringLiteral('vitest')
+ ),
+ t.importDeclaration(
+ [
+ t.importSpecifier(testStoryId, t.identifier('testStory')),
+ t.importSpecifier(t.identifier('convertToFilePath'), t.identifier('convertToFilePath')),
+ ],
+ t.stringLiteral('@storybook/addon-vitest/internal/test-utils')
+ ),
+ ];
+
+ ast.program.body.unshift(...imports);
return formatCsf(parsed, { sourceMaps: true, sourceFileName: fileName }, code);
}
diff --git a/code/core/src/csf/csf-factories.test.ts b/code/core/src/csf/csf-factories.test.ts
index 1ddce9f0f70b..cd25dc723c08 100644
--- a/code/core/src/csf/csf-factories.test.ts
+++ b/code/core/src/csf/csf-factories.test.ts
@@ -1,6 +1,7 @@
-import { test } from 'vitest';
+//* @vitest-environment happy-dom */
+import { describe, expect, test, vi } from 'vitest';
-import { definePreview, definePreviewAddon } from './csf-factories';
+import { definePreview, definePreviewAddon, getStoryChildren } from './csf-factories';
interface Addon1Types {
parameters: { foo?: { value: string } };
@@ -14,9 +15,11 @@ interface Addon2Types {
const addon2 = definePreviewAddon({});
-const preview = definePreview({ addons: [addon, addon2] });
+const preview = definePreview({ addons: [addon, addon2], renderToCanvas: () => {} });
-const meta = preview.meta({});
+const meta = preview.meta({
+ render: () => 'hello',
+});
test('addon parameters are inferred', () => {
const MyStory = meta.story({
@@ -41,3 +44,34 @@ test('addon parameters are inferred', () => {
},
});
});
+
+describe('test function', () => {
+ test('without overrides', async () => {
+ const MyStory = meta.story({ args: { label: 'foo' } });
+ const testFn = vi.fn(() => console.log('testFn'));
+ const testName = 'should run test';
+
+ // register test
+ MyStory.test(testName, testFn);
+ const test = getStoryChildren(MyStory).find(({ input }) => input.name === testName)!;
+ expect(test.input.args).toEqual({ label: 'foo' });
+
+ // execute test
+ await test.run(undefined, testName);
+ expect(testFn).toHaveBeenCalled();
+ });
+ test('with overrides', async () => {
+ const MyStory = meta.story({ args: { label: 'foo' } });
+ const testFn = vi.fn();
+ const testName = 'should run test';
+
+ // register test
+ MyStory.test(testName, { args: { label: 'bar' } }, testFn);
+ const test = getStoryChildren(MyStory).find(({ input }) => input.name === testName)!;
+ expect(test.input.args).toEqual({ label: 'bar' });
+
+ // execute test
+ await test.run();
+ expect(testFn).toHaveBeenCalled();
+ });
+});
diff --git a/code/core/src/csf/csf-factories.ts b/code/core/src/csf/csf-factories.ts
index 630af00586e1..22af4a85f64c 100644
--- a/code/core/src/csf/csf-factories.ts
+++ b/code/core/src/csf/csf-factories.ts
@@ -1,4 +1,4 @@
-import type { AddonTypes, PlayFunction, StoryContext } from 'storybook/internal/csf';
+import type { AddonTypes, StoryContext } from 'storybook/internal/csf';
import { combineTags } from 'storybook/internal/csf';
import type {
Args,
@@ -8,6 +8,7 @@ import type {
ProjectAnnotations,
Renderer,
StoryAnnotations,
+ TestFunction,
} from 'storybook/internal/types';
import {
@@ -17,6 +18,7 @@ import {
normalizeArrays,
normalizeProjectAnnotations,
} from '../preview-api/index';
+import { mountDestructured } from '../preview-api/modules/preview-web/render/mount-utils';
import { getCoreAnnotations } from './core-annotations';
export interface Preview {
@@ -108,9 +110,6 @@ function defineMeta<
_tag: 'Meta',
input,
preview,
- get composed(): never {
- throw new Error('Not implemented');
- },
// @ts-expect-error hard
story(
story: StoryAnnotations | (() => TRenderer['storyResult']) = {}
@@ -137,13 +136,21 @@ export interface Story<
name: string;
};
meta: Meta;
- __compose: () => ComposedStoryFn;
play: TInput['play'];
- run: (context?: Partial>>) => Promise;
+ run: (
+ context?: Partial>>,
+ testName?: string
+ ) => Promise;
extend>(
input: TInput
): Story;
+ test(name: string, fn: TestFunction): void;
+ test(
+ name: string,
+ annotations: StoryAnnotations,
+ fn: TestFunction
+ ): void;
}
export function isStory(input: unknown): input is Story {
@@ -155,7 +162,6 @@ function defineStory<
TInput extends StoryAnnotations,
>(input: TInput, meta: Meta): Story {
let composed: ComposedStoryFn;
-
const compose = () => {
if (!composed) {
composed = composeStory(
@@ -167,11 +173,16 @@ function defineStory<
}
return composed;
};
+
+ const __children: Story[] = [];
+
return {
_tag: 'Story',
input,
meta,
+ // @ts-expect-error this is a private property used only once in renderers/react/src/preview
__compose: compose,
+ __children,
get composed() {
const composed = compose();
const { args, argTypes, parameters, id, tags, globals, storyName: name } = composed;
@@ -180,15 +191,44 @@ function defineStory<
get play() {
return input.play ?? meta.input?.play ?? (async () => {});
},
- get run() {
- return compose().run ?? (async () => {});
+ async run(context) {
+ await compose().run(context);
+ },
+ test(
+ name: string,
+ overridesOrTestFn: StoryAnnotations | TestFunction,
+ testFn?: TestFunction
+ ): void {
+ const annotations = typeof overridesOrTestFn !== 'function' ? overridesOrTestFn : {};
+ const testFunction = typeof overridesOrTestFn !== 'function' ? testFn! : overridesOrTestFn;
+
+ const play =
+ mountDestructured(this.play) || mountDestructured(testFunction)
+ ? async ({ context }: StoryContext) => {
+ await this.play?.(context);
+ await testFunction(context);
+ }
+ : async (context: StoryContext) => {
+ await this.play?.(context);
+ await testFunction(context);
+ };
+
+ const test = this.extend({
+ ...annotations,
+ name,
+ tags: ['test-fn', '!autodocs', ...(annotations.tags ?? [])],
+ play,
+ });
+ __children.push(test);
+
+ return test as unknown as void;
},
extend>(input: TInput) {
return defineStory(
{
...this.input,
...input,
- args: { ...this.input.args, ...input.args },
+ args: { ...(this.input.args || {}), ...input.args },
argTypes: combineParameters(this.input.argTypes, input.argTypes),
afterEach: [
...normalizeArrays(this.input?.afterEach ?? []),
@@ -215,3 +255,12 @@ function defineStory<
},
};
}
+
+export function getStoryChildren(
+ story: Story
+): Story[] {
+ if ('__children' in story) {
+ return story.__children as Story[];
+ }
+ return [];
+}
diff --git a/code/core/src/csf/index.ts b/code/core/src/csf/index.ts
index 5501ed1a8e00..aac52e2a8a55 100644
--- a/code/core/src/csf/index.ts
+++ b/code/core/src/csf/index.ts
@@ -27,6 +27,10 @@ const sanitizeSafe = (string: string, part: string) => {
export const toId = (kind: string, name?: string) =>
`${sanitizeSafe(kind, 'kind')}${name ? `--${sanitizeSafe(name, 'name')}` : ''}`;
+/** Generate a storybook test ID from a story ID and test name. */
+export const toTestId = (parentId: string, testName: string) =>
+ `${parentId}:${sanitizeSafe(testName, 'test')}`;
+
/** Transform a CSF named export into a readable story name */
export const storyNameFromExport = (key: string) => toStartCaseStr(key);
diff --git a/code/core/src/csf/story.ts b/code/core/src/csf/story.ts
index 497a5678adac..0a128a1670a2 100644
--- a/code/core/src/csf/story.ts
+++ b/code/core/src/csf/story.ts
@@ -284,6 +284,10 @@ export type PlayFunction =
context: PlayFunctionContext
) => Promise | void;
+export type TestFunction = (
+ context: StoryContext
+) => Promise | void;
+
// This is the type of story function passed to a decorator -- does not rely on being passed any context
export type PartialStoryFn = (
update?: StoryContextUpdate>
diff --git a/code/core/src/manager-api/lib/stories.test.ts b/code/core/src/manager-api/lib/stories.test.ts
index 299ad2c12ae6..1e023e1aaf9e 100644
--- a/code/core/src/manager-api/lib/stories.test.ts
+++ b/code/core/src/manager-api/lib/stories.test.ts
@@ -191,6 +191,7 @@ describe('transformStoryIndexV4toV5', () => {
"id": "component-a--story-1",
"importPath": "./path/to/component-a.ts",
"name": "Story 1",
+ "subtype": "story",
"tags": [
"dev",
],
@@ -201,6 +202,7 @@ describe('transformStoryIndexV4toV5', () => {
"id": "component-a--story-2",
"importPath": "./path/to/component-a.ts",
"name": "Story 2",
+ "subtype": "story",
"tags": [
"dev",
],
@@ -211,6 +213,7 @@ describe('transformStoryIndexV4toV5', () => {
"id": "component-b--story-3",
"importPath": "./path/to/component-b.ts",
"name": "Story 3",
+ "subtype": "story",
"tags": [
"dev",
],
@@ -233,6 +236,7 @@ describe('transformStoryIndexToStoriesHash', () => {
'1': {
id: '1',
type: 'story',
+ subtype: 'story',
title: 'Story 1',
name: 'Story 1',
importPath: './path/to/story-1.ts',
@@ -242,6 +246,7 @@ describe('transformStoryIndexToStoriesHash', () => {
'2': {
id: '2',
type: 'story',
+ subtype: 'story',
title: 'Story 2',
name: 'Story 2',
importPath: './path/to/story-2.ts',
diff --git a/code/core/src/manager-api/lib/stories.ts b/code/core/src/manager-api/lib/stories.ts
index e4f56e7fc425..a9413f8119c8 100644
--- a/code/core/src/manager-api/lib/stories.ts
+++ b/code/core/src/manager-api/lib/stories.ts
@@ -5,6 +5,7 @@ import type {
API_GroupEntry,
API_HashEntry,
API_IndexHash,
+ API_PreparedIndexEntry,
API_PreparedStoryIndex,
API_Provider,
API_RootEntry,
@@ -71,9 +72,10 @@ export const transformSetStoriesStoryDataToPreparedStoryIndex = (
...base,
};
} else {
- const { argTypes, args, initialArgs }: any = story;
+ const { argTypes, args, initialArgs } = story;
acc[id] = {
type: 'story',
+ subtype: 'story',
...base,
parameters,
argTypes,
@@ -113,7 +115,7 @@ export const transformStoryIndexV3toV4 = (index: StoryIndexV3): API_PreparedStor
return {
v: 4,
entries: Object.values(index.stories).reduce(
- (acc, entry: any) => {
+ (acc, entry) => {
let type: IndexEntry['type'] = 'story';
if (
entry.parameters?.docsOnly ||
@@ -125,7 +127,7 @@ export const transformStoryIndexV3toV4 = (index: StoryIndexV3): API_PreparedStor
type,
...(type === 'docs' && { tags: ['stories-mdx'], storiesImports: [] }),
...entry,
- };
+ } as API_PreparedIndexEntry;
// @ts-expect-error (we're removing something that should not be there)
delete acc[entry.id].story;
@@ -173,7 +175,7 @@ type ToStoriesHashOptions = {
export const transformStoryIndexToStoriesHash = (
input: API_PreparedStoryIndex | StoryIndexV2 | StoryIndexV3,
{ provider, docsOptions, filters, allStatuses }: ToStoriesHashOptions
-): API_IndexHash | any => {
+): API_IndexHash => {
if (!input.v) {
throw new Error('Composition: Missing stories.json version');
}
@@ -184,31 +186,30 @@ export const transformStoryIndexToStoriesHash = (
index = index.v === 4 ? transformStoryIndexV4toV5(index as any) : index;
index = index as API_PreparedStoryIndex;
- const entryValues = Object.values(index.entries).filter((entry: any) => {
- let result = true;
+ const indexEntries = Object.values(index.entries);
+ const filterFunctions = Object.values(filters);
- // All stories with a failing status should always show up, regardless of the applied filters
- const storyStatuses = allStatuses[entry.id] ?? {};
- if (Object.values(storyStatuses).some(({ value }) => value === 'status-value:error')) {
- return result;
+ const entryValues = indexEntries.filter((entry) => {
+ const statuses = allStatuses[entry.id] ?? {};
+ if (Object.values(statuses).some(({ value }) => value === 'status-value:error')) {
+ // All stories with a failing status should always show up, regardless of the applied filters
+ return true;
}
- Object.values(filters).forEach((filter) => {
- if (result === false) {
- return;
- }
- result = filter({ ...entry, statuses: storyStatuses });
- });
+ if (filterFunctions.every((fn) => fn({ ...entry, statuses }))) {
+ return true;
+ }
- return result;
+ const children = indexEntries.filter((item) => 'parent' in item && item.parent === entry.id);
+ return children.some((child) => filterFunctions.every((fn) => fn({ ...child, statuses })));
});
const { sidebar = {} } = provider.getConfig();
- const { showRoots, collapsedRoots = [], renderLabel }: any = sidebar;
+ const { showRoots, collapsedRoots = [], renderLabel } = sidebar;
const setShowRoots = typeof showRoots !== 'undefined';
- const storiesHashOutOfOrder = entryValues.reduce((acc: any, item: any) => {
+ const storiesHashOutOfOrder = entryValues.reduce((acc, item) => {
if (docsOptions.docsMode && item.type !== 'docs') {
return acc;
}
@@ -224,7 +225,7 @@ export const transformStoryIndexToStoriesHash = (
const parent = idx > 0 && list[idx - 1];
const id = sanitize(parent ? `${parent}-${name}` : name!);
- if (name.trim() === '') {
+ if (name!.trim() === '') {
throw new Error(dedent`Invalid title ${title} ending in slash.`);
}
@@ -232,7 +233,7 @@ export const transformStoryIndexToStoriesHash = (
throw new Error(
dedent`
Invalid part '${name}', leading to id === parentId ('${id}'), inside title '${title}'
-
+
Did you create a path that uses the separator char accidentally, such as 'Vue ' where '/' is a separator char? See https://github.com/storybookjs/storybook/issues/6128
`
);
@@ -242,7 +243,7 @@ export const transformStoryIndexToStoriesHash = (
}, [] as string[]);
// Now, let's add an entry to the hash for each path/name pair
- paths.forEach((id: any, idx: any) => {
+ paths.forEach((id, idx) => {
// The child is the next path, OR the story/docs entry itself
const childId = paths[idx + 1] || item.id;
@@ -295,13 +296,12 @@ export const transformStoryIndexToStoriesHash = (
}
});
- // Finally add an entry for the docs/story itself
+ // Finally add an entry for the docs/story/test itself
acc[item.id] = {
- type: 'story',
tags: [],
...item,
depth: paths.length,
- parent: paths[paths.length - 1],
+ parent: 'parent' in item ? item.parent : paths[paths.length - 1],
renderLabel,
prepared: !!item.parameters,
} as API_DocsEntry | API_StoryEntry;
@@ -310,43 +310,71 @@ export const transformStoryIndexToStoriesHash = (
}, {} as API_IndexHash);
// This function adds a "root" or "orphan" and all of its descendents to the hash.
- function addItem(acc: API_IndexHash | any, item: API_HashEntry | any) {
+ function addItem(acc: API_IndexHash, item: API_HashEntry) {
// If we were already inserted as part of a group, that's great.
- if (acc[item.id]) {
- return acc;
- }
-
- acc[item.id] = item;
- // Ensure we add the children depth-first *before* inserting any other entries,
- // and compute tags from the children put in the accumulator afterwards, once
- // they're all known and we can compute a sound intersection.
- if (item.type === 'root' || item.type === 'group' || item.type === 'component') {
- item.children.forEach((childId: any) => addItem(acc, storiesHashOutOfOrder[childId]));
-
- item.tags = item.children.reduce((currentTags: Tag[] | null, childId: any): Tag[] => {
- const child = acc[childId];
-
- // On the first child, we have nothing to intersect against so we use it as a source of data.
- return currentTags === null ? child.tags : intersect(currentTags, child.tags);
- }, null);
+ if (!acc[item.id]) {
+ acc[item.id] = item;
+
+ // Ensure we add the children depth-first *before* inserting any other entries.
+ if ('children' in item && item.children) {
+ item.children.forEach((childId) => addItem(acc, storiesHashOutOfOrder[childId]));
+
+ item.tags =
+ item.children.reduce((currentTags: Tag[] | null, childId): Tag[] => {
+ // On the first child, we have nothing to intersect against so we use it as a source of data.
+ return currentTags === null
+ ? acc[childId].tags
+ : intersect(currentTags, acc[childId].tags);
+ }, null) || [];
+ }
}
if (item.type === 'component') {
- // attach importPath to the component node which should be the same for all children
- // this way we can add "open in editor" to the component node
- item.importPath = acc[item.children[0]].importPath;
+ const firstChild = acc[item.children[0]];
+ if (firstChild && 'importPath' in firstChild) {
+ // attach importPath to the component node which should be the same for all children
+ // this way we can add "open in editor" to the component node
+ item.importPath = firstChild.importPath;
+ }
}
return acc;
}
// We'll do two passes over the data, adding all the orphans, then all the roots
- const orphanHash = Object.values(storiesHashOutOfOrder)
- .filter((i: any) => i.type !== 'root' && !i.parent)
- .reduce(addItem, {});
+ let storiesHash = Object.values(storiesHashOutOfOrder)
+ .filter((i) => i.type !== 'root' && !i.parent)
+ .reduce((acc, item) => addItem(acc, item), {} as API_IndexHash);
+
+ storiesHash = Object.values(storiesHashOutOfOrder)
+ .filter((i) => i.type === 'root')
+ .reduce(addItem, storiesHash);
+
+ // Update stories to include tests as children, and increase depth for those tests
+ storiesHash = Object.values(storiesHash).reduce((acc, item) => {
+ if (item.type === 'story' && item.subtype === 'test') {
+ const story = acc[item.parent] as API_StoryEntry;
+ const component = acc[story.parent] as API_ComponentEntry;
+ acc[component.id] = {
+ ...component,
+ // Remove test from the component node as it will be attached to the story node instead
+ children: component.children && component.children.filter((id) => id !== item.id),
+ };
+ acc[story.id] = {
+ ...story,
+ // Add test to the story node
+ children: (story.children || []).concat(item.id),
+ };
+ acc[item.id] = {
+ ...item,
+ depth: item.depth + 1,
+ };
+ } else {
+ acc[item.id] = item;
+ }
+ return acc;
+ }, {} as API_IndexHash);
- return Object.values(storiesHashOutOfOrder)
- .filter((i: any) => i.type === 'root')
- .reduce(addItem, orphanHash);
+ return storiesHash;
};
/** Now we need to patch in the existing prepared stories */
@@ -359,6 +387,10 @@ export const addPreparedStories = (newHash: API_IndexHash, oldHash?: API_IndexHa
Object.entries(newHash).map(([id, newEntry]) => {
const oldEntry = oldHash[id];
if (newEntry.type === 'story' && oldEntry?.type === 'story' && oldEntry.prepared) {
+ if ('children' in oldEntry) {
+ // Prevent old entry from re-adding children if the story no longer has any (e.g. due to filters)
+ delete oldEntry.children;
+ }
return [id, { ...oldEntry, ...newEntry, prepared: true }];
}
diff --git a/code/core/src/manager-api/modules/stories.ts b/code/core/src/manager-api/modules/stories.ts
index f0b866ef57d9..4c3b6fd1bd42 100644
--- a/code/core/src/manager-api/modules/stories.ts
+++ b/code/core/src/manager-api/modules/stories.ts
@@ -30,6 +30,7 @@ import type {
API_LoadedRefData,
API_PreparedStoryIndex,
API_StoryEntry,
+ API_TestEntry,
API_ViewMode,
Args,
ComponentTitle,
@@ -188,20 +189,20 @@ export interface SubAPI {
/**
* Updates the arguments for the given story with the provided new arguments.
*
- * @param {API_StoryEntry} story - The story to update the arguments for.
+ * @param {API_StoryEntry | API_TestEntry} story - The story to update the arguments for.
* @param {Args} newArgs - The new arguments to set for the story.
* @returns {void}
*/
- updateStoryArgs(story: API_StoryEntry, newArgs: Args): void;
+ updateStoryArgs(story: API_StoryEntry | API_TestEntry, newArgs: Args): void;
/**
* Resets the arguments for the given story to their initial values.
*
- * @param {API_StoryEntry} story - The story to reset the arguments for.
+ * @param {API_StoryEntry | API_TestEntry} story - The story to reset the arguments for.
* @param {string[]} [argNames] - An optional array of argument names to reset. If not provided,
* all arguments will be reset.
* @returns {void}
*/
- resetStoryArgs: (story: API_StoryEntry, argNames?: string[]) => void;
+ resetStoryArgs: (story: API_StoryEntry | API_TestEntry, argNames?: string[]) => void;
/**
* Finds the leaf entry for the given story ID in the given story index.
*
@@ -495,8 +496,9 @@ export const init: ModuleFn = ({
}
if (node.type === 'story') {
results.push(node.id);
- } else if ('children' in node) {
- node.children.forEach((childId) => findChildEntriesRecursively(childId, results));
+ }
+ if ('children' in node) {
+ node.children?.forEach((childId) => findChildEntriesRecursively(childId, results));
}
return results;
};
diff --git a/code/core/src/manager-api/root.tsx b/code/core/src/manager-api/root.tsx
index 9e77d90d5ece..193a0e892b7d 100644
--- a/code/core/src/manager-api/root.tsx
+++ b/code/core/src/manager-api/root.tsx
@@ -34,6 +34,7 @@ import type {
API_RootEntry,
API_StateMerger,
API_StoryEntry,
+ API_TestEntry,
ArgTypes,
Args,
Globals,
@@ -488,7 +489,7 @@ export function useGlobalTypes(): ArgTypes {
return useStorybookApi().getGlobalTypes();
}
-function useCurrentStory(): API_StoryEntry | API_DocsEntry {
+function useCurrentStory(): API_StoryEntry | API_TestEntry | API_DocsEntry {
const { getCurrentStoryData } = useStorybookApi();
return getCurrentStoryData();
diff --git a/code/core/src/manager-api/tests/mockStoriesEntries.ts b/code/core/src/manager-api/tests/mockStoriesEntries.ts
index a66fd8184896..fde09e90100e 100644
--- a/code/core/src/manager-api/tests/mockStoriesEntries.ts
+++ b/code/core/src/manager-api/tests/mockStoriesEntries.ts
@@ -11,6 +11,7 @@ export const mockEntries: StoryIndex['entries'] = {
},
'component-a--story-1': {
type: 'story',
+ subtype: 'story',
id: 'component-a--story-1',
title: 'Component A',
name: 'Story 1',
@@ -18,6 +19,7 @@ export const mockEntries: StoryIndex['entries'] = {
},
'component-a--story-2': {
type: 'story',
+ subtype: 'story',
id: 'component-a--story-2',
title: 'Component A',
name: 'Story 2',
@@ -25,6 +27,7 @@ export const mockEntries: StoryIndex['entries'] = {
},
'component-b--story-3': {
type: 'story',
+ subtype: 'story',
id: 'component-b--story-3',
title: 'Component B',
name: 'Story 3',
@@ -34,6 +37,7 @@ export const mockEntries: StoryIndex['entries'] = {
export const docsEntries: StoryIndex['entries'] = {
'component-a--page': {
type: 'story',
+ subtype: 'story',
id: 'component-a--page',
title: 'Component A',
name: 'Page',
@@ -41,6 +45,7 @@ export const docsEntries: StoryIndex['entries'] = {
},
'component-a--story-2': {
type: 'story',
+ subtype: 'story',
id: 'component-a--story-2',
title: 'Component A',
name: 'Story 2',
@@ -56,6 +61,7 @@ export const docsEntries: StoryIndex['entries'] = {
},
'component-c--story-4': {
type: 'story',
+ subtype: 'story',
id: 'component-c--story-4',
title: 'Component c',
name: 'Story 4',
@@ -65,6 +71,7 @@ export const docsEntries: StoryIndex['entries'] = {
export const navigationEntries: StoryIndex['entries'] = {
'a--1': {
type: 'story',
+ subtype: 'story',
title: 'a',
name: '1',
id: 'a--1',
@@ -72,6 +79,7 @@ export const navigationEntries: StoryIndex['entries'] = {
},
'a--2': {
type: 'story',
+ subtype: 'story',
title: 'a',
name: '2',
id: 'a--2',
@@ -79,6 +87,7 @@ export const navigationEntries: StoryIndex['entries'] = {
},
'b-c--1': {
type: 'story',
+ subtype: 'story',
title: 'b/c',
name: '1',
id: 'b-c--1',
@@ -86,6 +95,7 @@ export const navigationEntries: StoryIndex['entries'] = {
},
'b-d--1': {
type: 'story',
+ subtype: 'story',
title: 'b/d',
name: '1',
id: 'b-d--1',
@@ -93,6 +103,7 @@ export const navigationEntries: StoryIndex['entries'] = {
},
'b-d--2': {
type: 'story',
+ subtype: 'story',
title: 'b/d',
name: '2',
id: 'b-d--2',
@@ -100,6 +111,7 @@ export const navigationEntries: StoryIndex['entries'] = {
},
'custom-id--1': {
type: 'story',
+ subtype: 'story',
title: 'b/e',
name: '1',
id: 'custom-id--1',
@@ -109,6 +121,7 @@ export const navigationEntries: StoryIndex['entries'] = {
export const preparedEntries: API_PreparedStoryIndex['entries'] = {
'a--1': {
type: 'story',
+ subtype: 'story',
title: 'a',
name: '1',
parameters: {},
@@ -118,6 +131,7 @@ export const preparedEntries: API_PreparedStoryIndex['entries'] = {
},
'b--1': {
type: 'story',
+ subtype: 'story',
title: 'b',
name: '1',
parameters: {},
diff --git a/code/core/src/manager-api/tests/refs.test.ts b/code/core/src/manager-api/tests/refs.test.ts
index b277f2b9ea8c..07eddf864e49 100644
--- a/code/core/src/manager-api/tests/refs.test.ts
+++ b/code/core/src/manager-api/tests/refs.test.ts
@@ -1227,6 +1227,7 @@ describe('Refs API', () => {
name: '1',
importPath: './path/to/a1.ts',
type: 'story',
+ subtype: 'story',
},
'a--2': {
id: 'a--2',
@@ -1234,6 +1235,7 @@ describe('Refs API', () => {
name: '2',
importPath: './path/to/a2.ts',
type: 'story',
+ subtype: 'story',
},
},
};
diff --git a/code/core/src/manager-api/tests/stories.test.ts b/code/core/src/manager-api/tests/stories.test.ts
index 9ef6b08bb902..521f6de75105 100644
--- a/code/core/src/manager-api/tests/stories.test.ts
+++ b/code/core/src/manager-api/tests/stories.test.ts
@@ -127,6 +127,7 @@ describe('stories API', () => {
});
expect(index!['component-a--story-1']).toMatchObject({
type: 'story',
+ subtype: 'story',
id: 'component-a--story-1',
parent: 'component-a',
title: 'Component A',
@@ -146,6 +147,7 @@ describe('stories API', () => {
entries: {
'design-system-some-component--my-story': {
type: 'story',
+ subtype: 'story',
id: 'design-system-some-component--my-story',
title: ' Design System / Some Component ', // note the leading/trailing whitespace around each part of the path
name: ' My Story ', // we only trim the path, so this will be kept as-is (it may intentionally have whitespace)
@@ -171,6 +173,7 @@ describe('stories API', () => {
});
expect(index!['design-system-some-component--my-story']).toMatchObject({
type: 'story',
+ subtype: 'story',
title: ' Design System / Some Component ', // title is kept as-is, because it may be used as identifier
name: ' My Story ', // story name is kept as-is, because it's set directly on the story
});
@@ -184,6 +187,7 @@ describe('stories API', () => {
entries: {
'root-first--story-1': {
type: 'story',
+ subtype: 'story',
id: 'root-first--story-1',
title: 'Root/First',
name: 'Story 1',
@@ -223,6 +227,7 @@ describe('stories API', () => {
entries: {
'a-b--1': {
type: 'story',
+ subtype: 'story',
id: 'a-b--1',
title: 'a/b',
name: '1',
@@ -248,6 +253,7 @@ describe('stories API', () => {
});
expect(index!['a-b--1']).toMatchObject({
type: 'story',
+ subtype: 'story',
id: 'a-b--1',
parent: 'a-b',
name: '1',
@@ -264,6 +270,7 @@ describe('stories API', () => {
entries: {
'a--1': {
type: 'story',
+ subtype: 'story',
id: 'a--1',
title: 'a',
name: '1',
@@ -281,6 +288,7 @@ describe('stories API', () => {
});
expect(index!['a--1']).toMatchObject({
type: 'story',
+ subtype: 'story',
id: 'a--1',
parent: 'a',
title: 'a',
@@ -296,6 +304,7 @@ describe('stories API', () => {
entries: {
'a--1': {
type: 'story',
+ subtype: 'story',
id: 'a--1',
title: 'a',
name: '1',
@@ -304,6 +313,7 @@ describe('stories API', () => {
},
'a--2': {
type: 'story',
+ subtype: 'story',
id: 'a--2',
title: 'a',
name: '2',
@@ -323,6 +333,7 @@ describe('stories API', () => {
});
expect(index!['a--1']).toMatchObject({
type: 'story',
+ subtype: 'story',
id: 'a--1',
parent: 'a',
title: 'a',
@@ -331,6 +342,7 @@ describe('stories API', () => {
});
expect(index!['a--2']).toMatchObject({
type: 'story',
+ subtype: 'story',
id: 'a--2',
parent: 'a',
title: 'a',
@@ -348,6 +360,7 @@ describe('stories API', () => {
entries: {
'a-sampleone': {
type: 'story',
+ subtype: 'story',
id: 'a-sampleone',
title: 'A/SampleOne',
name: '1',
@@ -356,6 +369,7 @@ describe('stories API', () => {
},
'a-sampletwo': {
type: 'story',
+ subtype: 'story',
id: 'a-sampletwo',
title: 'A/SampleTwo',
name: '2',
@@ -418,9 +432,30 @@ describe('stories API', () => {
api.setIndex({
v: 5,
entries: {
- 'a--1': { type: 'story', title: 'a', name: '1', id: 'a--1', importPath: './a.ts' },
- 'b--1': { type: 'story', title: 'b', name: '1', id: 'b--1', importPath: './b.ts' },
- 'a--2': { type: 'story', title: 'a', name: '2', id: 'a--2', importPath: './a.ts' },
+ 'a--1': {
+ type: 'story',
+ subtype: 'story',
+ title: 'a',
+ name: '1',
+ id: 'a--1',
+ importPath: './a.ts',
+ },
+ 'b--1': {
+ type: 'story',
+ subtype: 'story',
+ title: 'b',
+ name: '1',
+ id: 'b--1',
+ importPath: './b.ts',
+ },
+ 'a--2': {
+ type: 'story',
+ subtype: 'story',
+ title: 'a',
+ name: '2',
+ id: 'a--2',
+ importPath: './a.ts',
+ },
},
});
const { index } = store.getState();
@@ -447,6 +482,7 @@ describe('stories API', () => {
entries: {
'prepared--story': {
type: 'story',
+ subtype: 'story',
id: 'prepared--story',
title: 'Prepared',
name: 'Story',
@@ -459,6 +495,7 @@ describe('stories API', () => {
const { index } = store.getState();
expect(index!['prepared--story']).toMatchObject({
type: 'story',
+ subtype: 'story',
id: 'prepared--story',
parent: 'prepared',
title: 'Prepared',
@@ -594,6 +631,7 @@ describe('stories API', () => {
entries: {
'component-a--story-1': {
type: 'story',
+ subtype: 'story',
id: 'component-a--story-1',
title: 'Component A',
name: 'Story 1',
@@ -645,6 +683,7 @@ describe('stories API', () => {
entries: {
'component-a--story-1': {
type: 'story',
+ subtype: 'story',
id: 'component-a--story-1',
title: 'Component A',
name: 'Story 1',
@@ -1190,6 +1229,7 @@ describe('stories API', () => {
const { index } = store.getState();
expect(index!['component-a--story-1']).toMatchObject({
type: 'story',
+ subtype: 'story',
id: 'component-a--story-1',
parent: 'component-a',
title: 'Component A',
@@ -1436,6 +1476,7 @@ describe('stories API', () => {
"parent": "a",
"prepared": false,
"renderLabel": undefined,
+ "subtype": "story",
"tags": [],
"title": "a",
"type": "story",
@@ -1448,6 +1489,7 @@ describe('stories API', () => {
"parent": "a",
"prepared": false,
"renderLabel": undefined,
+ "subtype": "story",
"tags": [],
"title": "a",
"type": "story",
@@ -1515,6 +1557,7 @@ describe('stories API', () => {
"parent": "a",
"prepared": false,
"renderLabel": undefined,
+ "subtype": "story",
"tags": [],
"title": "a",
"type": "story",
@@ -1560,6 +1603,7 @@ describe('stories API', () => {
"parent": "a",
"prepared": false,
"renderLabel": undefined,
+ "subtype": "story",
"tags": [],
"title": "a",
"type": "story",
@@ -1572,6 +1616,7 @@ describe('stories API', () => {
"parent": "a",
"prepared": false,
"renderLabel": undefined,
+ "subtype": "story",
"tags": [],
"title": "a",
"type": "story",
diff --git a/code/core/src/manager-api/tests/url.test.js b/code/core/src/manager-api/tests/url.test.js
index 92c6ca8694d9..349783223291 100644
--- a/code/core/src/manager-api/tests/url.test.js
+++ b/code/core/src/manager-api/tests/url.test.js
@@ -173,6 +173,7 @@ describe('initModule', () => {
fullAPI: Object.assign(fullAPI, {
getCurrentStoryData: () => ({
type: 'story',
+ subtype: 'story',
args: { a: 1, b: 2 },
initialArgs: { a: 1, b: 1 },
}),
@@ -218,7 +219,7 @@ describe('initModule', () => {
state: { location },
navigate,
fullAPI: Object.assign(fullAPI, {
- getCurrentStoryData: () => ({ type: 'story', args: { a: 1 } }),
+ getCurrentStoryData: () => ({ type: 'story', subtype: 'story', args: { a: 1 } }),
}),
});
diff --git a/code/core/src/manager/components/mobile/navigation/MobileNavigation.stories.tsx b/code/core/src/manager/components/mobile/navigation/MobileNavigation.stories.tsx
index 74bfdc9bfb6f..f495a2b9862b 100644
--- a/code/core/src/manager/components/mobile/navigation/MobileNavigation.stories.tsx
+++ b/code/core/src/manager/components/mobile/navigation/MobileNavigation.stories.tsx
@@ -45,6 +45,7 @@ const mockManagerStore: any = {
},
someStoryId: {
type: 'story',
+ subtype: 'story',
id: 'someStoryId',
name: 'story',
parent: 'someComponentId',
@@ -121,6 +122,7 @@ export const LongStoryName: Story = {
},
someStoryId: {
type: 'story',
+ subtype: 'story',
id: 'someStoryId',
name: 'someLongStoryName',
parent: 'someComponentId',
diff --git a/code/core/src/manager/components/sidebar/Explorer.stories.tsx b/code/core/src/manager/components/sidebar/Explorer.stories.tsx
index 34adad0a32a0..67f4ba63b948 100644
--- a/code/core/src/manager/components/sidebar/Explorer.stories.tsx
+++ b/code/core/src/manager/components/sidebar/Explorer.stories.tsx
@@ -22,11 +22,6 @@ export default {
],
};
-const selected = {
- refId: 'storybook_internal',
- storyId: 'root-1-child-a2--grandchild-a1-1',
-};
-
const simple: Record = {
storybook_internal: {
title: undefined,
@@ -81,7 +76,10 @@ const withRefs: Record = {
export const Simple = () => (
@@ -90,7 +88,10 @@ export const Simple = () => (
export const WithRefs = () => (
diff --git a/code/core/src/manager/components/sidebar/HighlightStyles.tsx b/code/core/src/manager/components/sidebar/HighlightStyles.tsx
index 80fc202a8b38..714c8f88567e 100644
--- a/code/core/src/manager/components/sidebar/HighlightStyles.tsx
+++ b/code/core/src/manager/components/sidebar/HighlightStyles.tsx
@@ -17,7 +17,7 @@ export const HighlightStyles: FC = ({ refId, itemId }) => (
background,
'&:hover, &:focus': { background },
},
- [`&[data-nodetype="story"], &[data-nodetype="document"]`]: {
+ [`&[data-nodetype="story"], &[data-nodetype="document"], &[data-nodetype="test"]`]: {
color: color.defaultText,
background,
'&:hover, &:focus': { background },
diff --git a/code/core/src/manager/components/sidebar/IconSymbols.tsx b/code/core/src/manager/components/sidebar/IconSymbols.tsx
index df1dfe9319b6..c916f74bde9d 100644
--- a/code/core/src/manager/components/sidebar/IconSymbols.tsx
+++ b/code/core/src/manager/components/sidebar/IconSymbols.tsx
@@ -19,6 +19,7 @@ const GROUP_ID = 'icon--group';
const COMPONENT_ID = 'icon--component';
const DOCUMENT_ID = 'icon--document';
const STORY_ID = 'icon--story';
+const TEST_ID = 'icon--test';
const SUCCESS_ID = 'icon--success';
const ERROR_ID = 'icon--error';
const WARNING_ID = 'icon--warning';
@@ -28,7 +29,7 @@ export const IconSymbols: FC = () => {
return (