',
+ target: ['.css-1av19vu'],
+ },
+ ],
+ impact: 'serious',
+ message:
+ 'Element has insufficient color contrast of 2.76 (foreground color: #029cfd, background color: #f6f9fc, font size: 10.5pt (14px), font weight: normal). Expected contrast ratio of 4.5:1',
+ '_constructor-name_': 'CheckResult',
+ },
+ ],
+ all: [],
+ none: [],
+ impact: 'serious',
+ html: '
',
+ target: ['.css-1mjgzsp'],
+ failureSummary:
+ 'Fix any of the following:\n Element has insufficient color contrast of 2.76 (foreground color: #029cfd, background color: #f6f9fc, font size: 10.5pt (14px), font weight: normal). Expected contrast ratio of 4.5:1',
+ },
+ ],
+ },
+];
+
+describe('afterEach', () => {
+ beforeEach(() => {
+ vi.mocked(getIsVitestRunning).mockReturnValue(false);
+ vi.mocked(getIsVitestStandaloneRun).mockReturnValue(true);
+ });
+
+ const createContext = (overrides: Partial = {}): StoryContext =>
+ ({
+ reporting: {
+ reports: [],
+ addReport: vi.fn(),
+ },
+ parameters: {
+ a11y: {},
+ },
+ globals: {
+ a11y: {},
+ },
+ tags: [A11Y_TEST_TAG],
+ ...overrides,
+ }) as any;
+
+ it('should run accessibility checks and report results', async () => {
+ const context = createContext();
+ const result = {
+ violations,
+ };
+
+ mockedRun.mockResolvedValue(result as any);
+
+ await expect(() => experimental_afterEach(context)).rejects.toThrow();
+
+ expect(mockedRun).toHaveBeenCalledWith(context.parameters.a11y);
+
+ expect(context.reporting.addReport).toHaveBeenCalledWith({
+ type: 'a11y',
+ version: 1,
+ result,
+ status: 'failed',
+ });
+ });
+
+ it('should run accessibility checks and report results without throwing', async () => {
+ const context = createContext();
+ const result = {
+ violations,
+ };
+
+ mockedRun.mockResolvedValue(result as any);
+ mocks.getIsVitestStandaloneRun.mockReturnValue(false);
+
+ await experimental_afterEach(context);
+
+ expect(mockedRun).toHaveBeenCalledWith(context.parameters.a11y);
+
+ expect(context.reporting.addReport).toHaveBeenCalledWith({
+ type: 'a11y',
+ version: 1,
+ result,
+ status: 'failed',
+ });
+ });
+
+ it('should report passed status when there are no violations', async () => {
+ const context = createContext();
+ const result = {
+ violations: [],
+ };
+ mockedRun.mockResolvedValue(result as any);
+
+ await experimental_afterEach(context);
+
+ expect(mockedRun).toHaveBeenCalledWith(context.parameters.a11y);
+ expect(context.reporting.addReport).toHaveBeenCalledWith({
+ type: 'a11y',
+ version: 1,
+ result,
+ status: 'passed',
+ });
+ });
+
+ it('should report warning status when there are only warnings', async () => {
+ const context = createContext({
+ parameters: {
+ a11y: {
+ warnings: ['minor'],
+ },
+ },
+ });
+ const result = {
+ violations: [
+ { impact: 'minor', nodes: [] },
+ { impact: 'critical', nodes: [] },
+ ],
+ };
+ mockedRun.mockResolvedValue(result as any);
+
+ await expect(async () => experimental_afterEach(context)).rejects.toThrow();
+
+ expect(mockedRun).toHaveBeenCalledWith(context.parameters.a11y);
+ expect(context.reporting.addReport).toHaveBeenCalledWith({
+ type: 'a11y',
+ version: 1,
+ result,
+ status: 'failed',
+ });
+ });
+
+ it('should report error status when there are warnings and errors', async () => {
+ const context = createContext({
+ parameters: {
+ a11y: {
+ warnings: ['minor'],
+ },
+ },
+ });
+ const result = {
+ violations: [
+ { impact: 'minor', nodes: [] },
+ { impact: 'critical', nodes: [] },
+ ],
+ };
+ mockedRun.mockResolvedValue(result as any);
+
+ await expect(async () => experimental_afterEach(context)).rejects.toThrow();
+
+ expect(mockedRun).toHaveBeenCalledWith(context.parameters.a11y);
+ expect(context.reporting.addReport).toHaveBeenCalledWith({
+ type: 'a11y',
+ version: 1,
+ result,
+ status: 'failed',
+ });
+ });
+
+ it('should run accessibility checks if "a11ytest" flag is not available and is not running in Vitest', async () => {
+ const context = createContext({
+ tags: [],
+ });
+ const result = {
+ violations: [],
+ };
+ mockedRun.mockResolvedValue(result as any);
+ vi.mocked(getIsVitestRunning).mockReturnValue(false);
+
+ await experimental_afterEach(context);
+
+ expect(mockedRun).toHaveBeenCalledWith(context.parameters.a11y);
+ expect(context.reporting.addReport).toHaveBeenCalledWith({
+ type: 'a11y',
+ version: 1,
+ result,
+ status: 'passed',
+ });
+ });
+
+ it('should not run accessibility checks when manual is true', async () => {
+ const context = createContext({
+ parameters: {
+ a11y: {
+ manual: true,
+ },
+ },
+ });
+
+ await experimental_afterEach(context);
+
+ expect(mockedRun).not.toHaveBeenCalled();
+ expect(context.reporting.addReport).not.toHaveBeenCalled();
+ });
+
+ it('should not run accessibility checks when disable is true', async () => {
+ const context = createContext({
+ parameters: {
+ a11y: {
+ disable: true,
+ },
+ },
+ });
+
+ await experimental_afterEach(context);
+
+ expect(mockedRun).not.toHaveBeenCalled();
+ expect(context.reporting.addReport).not.toHaveBeenCalled();
+ });
+
+ it('should not run accessibility checks when globals manual is true', async () => {
+ const context = createContext({
+ globals: {
+ a11y: {
+ manual: true,
+ },
+ },
+ });
+
+ await experimental_afterEach(context);
+
+ expect(mockedRun).not.toHaveBeenCalled();
+ expect(context.reporting.addReport).not.toHaveBeenCalled();
+ });
+
+ it('should not run accessibility checks if vitest is running and story is not tagged with a11ytest', async () => {
+ const context = createContext({
+ tags: [],
+ });
+ vi.mocked(getIsVitestRunning).mockReturnValue(true);
+
+ await experimental_afterEach(context);
+
+ expect(mockedRun).not.toHaveBeenCalled();
+ expect(context.reporting.addReport).not.toHaveBeenCalled();
+ });
+
+ it('should report error when run throws an error', async () => {
+ const context = createContext();
+ const error = new Error('Test error');
+ mockedRun.mockRejectedValue(error);
+
+ await expect(() => experimental_afterEach(context)).rejects.toThrow();
+
+ expect(mockedRun).toHaveBeenCalledWith(context.parameters.a11y);
+ expect(context.reporting.addReport).toHaveBeenCalledWith({
+ type: 'a11y',
+ version: 1,
+ result: {
+ error,
+ },
+ status: 'failed',
+ });
+ });
+
+ it('should report error when run throws an error', async () => {
+ const context = createContext();
+ const error = new Error('Test error');
+ mockedRun.mockRejectedValue(error);
+
+ await expect(() => experimental_afterEach(context)).rejects.toThrow();
+
+ expect(mockedRun).toHaveBeenCalledWith(context.parameters.a11y);
+ expect(context.reporting.addReport).toHaveBeenCalledWith({
+ type: 'a11y',
+ version: 1,
+ result: {
+ error,
+ },
+ status: 'failed',
+ });
+ });
+});
diff --git a/code/addons/a11y/src/preview.tsx b/code/addons/a11y/src/preview.tsx
index 1325793c119a..f7d2f9aa43ff 100644
--- a/code/addons/a11y/src/preview.tsx
+++ b/code/addons/a11y/src/preview.tsx
@@ -1 +1,95 @@
-import './a11yRunner';
+// Source: https://github.com/chaance/vitest-axe/blob/main/src/to-have-no-violations.ts
+import * as matchers from 'vitest-axe/matchers';
+
+import type { AfterEach } from 'storybook/internal/types';
+
+import { expect } from '@storybook/test';
+
+import { run } from './a11yRunner';
+import { A11Y_TEST_TAG } from './constants';
+import type { A11yParameters } from './params';
+import { getIsVitestRunning, getIsVitestStandaloneRun } from './utils';
+
+expect.extend(matchers);
+
+// eslint-disable-next-line @typescript-eslint/naming-convention
+export const experimental_afterEach: AfterEach = async ({
+ reporting,
+ parameters,
+ globals,
+ tags,
+}) => {
+ const a11yParameter: A11yParameters | undefined = parameters.a11y;
+ const a11yGlobals = globals.a11y;
+ const warnings = a11yParameter?.warnings ?? [];
+
+ const shouldRunEnvironmentIndependent =
+ a11yParameter?.manual !== true &&
+ a11yParameter?.disable !== true &&
+ a11yGlobals?.manual !== true;
+
+ if (shouldRunEnvironmentIndependent) {
+ if (getIsVitestRunning() && !tags.includes(A11Y_TEST_TAG)) {
+ return;
+ }
+ try {
+ const result = await run(a11yParameter);
+
+ if (result) {
+ const hasViolations = (result?.violations.length ?? 0) > 0;
+
+ const hasErrors = result?.violations.some(
+ (violation) => !warnings.includes(violation.impact!)
+ );
+
+ reporting.addReport({
+ type: 'a11y',
+ version: 1,
+ result: result,
+ status: hasErrors ? 'failed' : hasViolations ? 'warning' : 'passed',
+ });
+
+ /**
+ * When Vitest is running outside of Storybook, we need to throw an error to fail the test
+ * run when there are accessibility issues.
+ *
+ * @todo In the future, we want to always throw an error when there are accessibility
+ * issues. This is a temporary solution. Later, portable stories and Storybook should
+ * implement proper try catch handling.
+ */
+ if (getIsVitestStandaloneRun()) {
+ if (hasErrors) {
+ // @ts-expect-error - todo - fix type extension of expect from @storybook/test
+ expect(result).toHaveNoViolations();
+ }
+ }
+ }
+ /**
+ * @todo Later we don't want to catch errors here. Instead, we want to throw them and let
+ * Storybook/portable stories handle them on a higher level.
+ */
+ } catch (e) {
+ reporting.addReport({
+ type: 'a11y',
+ version: 1,
+ result: {
+ error: e,
+ },
+ status: 'failed',
+ });
+
+ if (getIsVitestStandaloneRun()) {
+ throw e;
+ }
+ }
+ }
+};
+
+export const initialGlobals = {
+ a11y: {
+ manual: false,
+ },
+};
+
+// A11Y_TEST_TAG constant in ./constants.ts. Has to be statically analyzable.
+export const tags = ['a11ytest'];
diff --git a/code/addons/a11y/src/types.ts b/code/addons/a11y/src/types.ts
new file mode 100644
index 000000000000..9e116e5aab2b
--- /dev/null
+++ b/code/addons/a11y/src/types.ts
@@ -0,0 +1,3 @@
+import type { AxeResults } from 'axe-core';
+
+export type A11YReport = AxeResults | { error: Error };
diff --git a/code/addons/a11y/src/utils.ts b/code/addons/a11y/src/utils.ts
new file mode 100644
index 000000000000..0864a2e3b2f7
--- /dev/null
+++ b/code/addons/a11y/src/utils.ts
@@ -0,0 +1,25 @@
+export function getIsVitestStandaloneRun() {
+ try {
+ return process.env.VITEST_STORYBOOK === 'false';
+ } catch {
+ try {
+ // @ts-expect-error Suppress TypeScript warning about wrong setting. Doesn't matter, because we don't use tsc for bundling.
+ return import.meta.env.VITEST_STORYBOOK === 'false';
+ } catch (e) {
+ return false;
+ }
+ }
+}
+
+export function getIsVitestRunning() {
+ try {
+ return process?.env.MODE === 'test';
+ } catch {
+ try {
+ // @ts-expect-error Suppress TypeScript warning about wrong setting. Doesn't matter, because we don't use tsc for bundling.
+ return import.meta.env.MODE === 'test';
+ } catch (e) {
+ return false;
+ }
+ }
+}
diff --git a/code/addons/interactions/src/components/Interaction.tsx b/code/addons/interactions/src/components/Interaction.tsx
index 610d0c1b032b..4ceef384d02a 100644
--- a/code/addons/interactions/src/components/Interaction.tsx
+++ b/code/addons/interactions/src/components/Interaction.tsx
@@ -37,7 +37,7 @@ const RowContainer = styled('div', {
? transparentize(0.93, theme.color.negative)
: theme.background.warning,
}),
- paddingLeft: call.ancestors.length * 20,
+ paddingLeft: (call.ancestors?.length ?? 0) * 20,
}),
({ theme, call, pausedAt }) =>
pausedAt === call.id && {
@@ -164,7 +164,7 @@ export const Interaction = ({
pausedAt?: Call['id'];
}) => {
const [isHovered, setIsHovered] = React.useState(false);
- const isInteractive = !controlStates.goto || !call.interceptable || !!call.ancestors.length;
+ const isInteractive = !controlStates.goto || !call.interceptable || !!call.ancestors?.length;
if (isHidden) {
return null;
diff --git a/code/addons/interactions/src/components/MethodCall.tsx b/code/addons/interactions/src/components/MethodCall.tsx
index fbe5ee038afb..c272f7863065 100644
--- a/code/addons/interactions/src/components/MethodCall.tsx
+++ b/code/addons/interactions/src/components/MethodCall.tsx
@@ -429,7 +429,7 @@ export const MethodCall = ({
return ;
}
- const path = call.path.flatMap((elem, index) => {
+ const path = call.path?.flatMap((elem, index) => {
// eslint-disable-next-line no-underscore-dangle
const callId = (elem as CallRef).__callId__;
return [
@@ -443,7 +443,7 @@ export const MethodCall = ({
];
});
- const args = call.args.flatMap((arg, index, array) => {
+ const args = call.args?.flatMap((arg, index, array) => {
const node = ;
return index < array.length - 1
? [node, , , ]
diff --git a/code/addons/links/package.json b/code/addons/links/package.json
index 5a99aa4267c9..c49cd1263543 100644
--- a/code/addons/links/package.json
+++ b/code/addons/links/package.json
@@ -65,7 +65,7 @@
"prep": "jiti ../../../scripts/prepare/addon-bundle.ts"
},
"dependencies": {
- "@storybook/csf": "^0.1.11",
+ "@storybook/csf": "0.1.12",
"@storybook/global": "^5.0.0",
"ts-dedent": "^2.0.0"
},
diff --git a/code/addons/test/package.json b/code/addons/test/package.json
index 9bcf07d466d2..06213e0bf703 100644
--- a/code/addons/test/package.json
+++ b/code/addons/test/package.json
@@ -80,7 +80,7 @@
"prep": "jiti ../../../scripts/prepare/addon-bundle.ts"
},
"dependencies": {
- "@storybook/csf": "^0.1.11",
+ "@storybook/csf": "0.1.12",
"@storybook/global": "^5.0.0",
"@storybook/icons": "^1.2.12",
"@storybook/instrumenter": "workspace:*",
diff --git a/code/addons/test/src/components/Interaction.tsx b/code/addons/test/src/components/Interaction.tsx
index 610d0c1b032b..4ceef384d02a 100644
--- a/code/addons/test/src/components/Interaction.tsx
+++ b/code/addons/test/src/components/Interaction.tsx
@@ -37,7 +37,7 @@ const RowContainer = styled('div', {
? transparentize(0.93, theme.color.negative)
: theme.background.warning,
}),
- paddingLeft: call.ancestors.length * 20,
+ paddingLeft: (call.ancestors?.length ?? 0) * 20,
}),
({ theme, call, pausedAt }) =>
pausedAt === call.id && {
@@ -164,7 +164,7 @@ export const Interaction = ({
pausedAt?: Call['id'];
}) => {
const [isHovered, setIsHovered] = React.useState(false);
- const isInteractive = !controlStates.goto || !call.interceptable || !!call.ancestors.length;
+ const isInteractive = !controlStates.goto || !call.interceptable || !!call.ancestors?.length;
if (isHidden) {
return null;
diff --git a/code/addons/test/src/components/MethodCall.tsx b/code/addons/test/src/components/MethodCall.tsx
index fbe5ee038afb..59b907d13daa 100644
--- a/code/addons/test/src/components/MethodCall.tsx
+++ b/code/addons/test/src/components/MethodCall.tsx
@@ -425,11 +425,11 @@ export const MethodCall = ({
return null;
}
- if (call.method === 'step' && call.path.length === 0) {
+ if (call.method === 'step' && call.path?.length === 0) {
return ;
}
- const path = call.path.flatMap((elem, index) => {
+ const path = call.path?.flatMap((elem, index) => {
// eslint-disable-next-line no-underscore-dangle
const callId = (elem as CallRef).__callId__;
return [
@@ -443,7 +443,7 @@ export const MethodCall = ({
];
});
- const args = call.args.flatMap((arg, index, array) => {
+ const args = call.args?.flatMap((arg, index, array) => {
const node = ;
return index < array.length - 1
? [node, , , ]
diff --git a/code/addons/test/src/components/TestProviderRender.stories.tsx b/code/addons/test/src/components/TestProviderRender.stories.tsx
index 9345ebbaff82..dead913949cf 100644
--- a/code/addons/test/src/components/TestProviderRender.stories.tsx
+++ b/code/addons/test/src/components/TestProviderRender.stories.tsx
@@ -64,6 +64,7 @@ const baseState: TestProviderState = {
status: 'passed',
duration: 100,
testRunId: 'test-run-id',
+ reports: [],
},
],
},
diff --git a/code/addons/test/src/components/TestProviderRender.tsx b/code/addons/test/src/components/TestProviderRender.tsx
index 16197a7c7ced..35c7eb4b8a9e 100644
--- a/code/addons/test/src/components/TestProviderRender.tsx
+++ b/code/addons/test/src/components/TestProviderRender.tsx
@@ -1,4 +1,4 @@
-import React, { type ComponentProps, type FC, useCallback, useRef, useState } from 'react';
+import React, { type ComponentProps, type FC, useCallback, useMemo, useRef, useState } from 'react';
import { Button, ListItem } from 'storybook/internal/components';
import {
@@ -6,6 +6,7 @@ import {
type TestProviderConfig,
type TestProviderState,
} from 'storybook/internal/core-events';
+import { addons } from 'storybook/internal/manager-api';
import type { API } from 'storybook/internal/manager-api';
import { styled, useTheme } from 'storybook/internal/theming';
@@ -22,6 +23,8 @@ import {
import { isEqual } from 'es-toolkit';
import { debounce } from 'es-toolkit/compat';
+// Relatively importing from a11y to get the ADDON_ID
+import { ADDON_ID as A11Y_ADDON_ID } from '../../../a11y/src/constants';
import { type Config, type Details } from '../constants';
import { type TestStatus } from '../node/reporter';
import { Description } from './Description';
@@ -83,12 +86,51 @@ export const TestProviderRender: FC<
const theme = useTheme();
const coverageSummary = state.details?.coverageSummary;
+ const isA11yAddon = addons.experimental_getRegisteredAddons().includes(A11Y_ADDON_ID);
+
const [config, updateConfig] = useConfig(
api,
state.id,
state.config || { a11y: false, coverage: false }
);
+ const a11yResults = useMemo(() => {
+ if (!isA11yAddon) {
+ return [];
+ }
+
+ return state.details?.testResults?.flatMap((result) =>
+ result.results
+ .filter((it) => !entryId || it.storyId === entryId || it.storyId.startsWith(`${entryId}-`))
+ .map((r) => r.reports.find((report) => report.type === 'a11y'))
+ );
+ }, [isA11yAddon, state.details?.testResults, entryId]);
+
+ const a11yStatus = useMemo<'positive' | 'warning' | 'negative' | 'unknown'>(() => {
+ if (!isA11yAddon || config.a11y === false) {
+ return 'unknown';
+ }
+
+ if (!a11yResults) {
+ return 'unknown';
+ }
+
+ const failed = a11yResults.some((result) => result?.status === 'failed');
+ const warning = a11yResults.some((result) => result?.status === 'warning');
+
+ if (failed) {
+ return 'negative';
+ } else if (warning) {
+ return 'warning';
+ }
+
+ return 'positive';
+ }, [a11yResults, isA11yAddon, config.a11y]);
+
+ const a11yNotPassedAmount = a11yResults?.filter(
+ (result) => result?.status === 'failed' || result?.status === 'warning'
+ ).length;
+
const storyId = entryId?.includes('--') ? entryId : undefined;
const results = (state.details?.testResults || [])
.flatMap((test) => {
@@ -183,6 +225,20 @@ export const TestProviderRender: FC<
/>
}
/>
+ {isA11yAddon && (
+ }
+ right={
+ updateConfig({ a11y: !config.a11y })}
+ />
+ }
+ />
+ )}
) : (
@@ -219,6 +275,13 @@ export const TestProviderRender: FC<
icon={}
/>
)}
+ {isA11yAddon && (
+ }
+ right={a11yNotPassedAmount || null}
+ />
+ )}
)}
diff --git a/code/addons/test/src/manager.tsx b/code/addons/test/src/manager.tsx
index 0664fc140627..c5c4b9f21402 100644
--- a/code/addons/test/src/manager.tsx
+++ b/code/addons/test/src/manager.tsx
@@ -77,29 +77,60 @@ addons.register(ADDON_ID, (api) => {
return;
}
- api.experimental_updateStatus(
- TEST_PROVIDER_ID,
- Object.fromEntries(
- update.details.testResults.flatMap((testResult) =>
- testResult.results
- .filter(({ storyId }) => storyId)
- .map(({ storyId, status, testRunId, ...rest }) => [
- storyId,
- {
- title: 'Component tests',
- status: statusMap[status],
- description:
- 'failureMessages' in rest && rest.failureMessages
- ? rest.failureMessages.join('\n')
- : '',
- data: { testRunId },
- onClick: openAddonPanel,
- sidebarContextMenu: false,
- } as API_StatusObject,
- ])
+ (async () => {
+ await api.experimental_updateStatus(
+ TEST_PROVIDER_ID,
+ Object.fromEntries(
+ update.details.testResults.flatMap((testResult) =>
+ testResult.results
+ .filter(({ storyId }) => storyId)
+ .map(({ storyId, status, testRunId, ...rest }) => [
+ storyId,
+ {
+ title: 'Component tests',
+ status: statusMap[status],
+ description:
+ 'failureMessages' in rest && rest.failureMessages
+ ? rest.failureMessages.join('\n')
+ : '',
+ data: { testRunId },
+ onClick: openAddonPanel,
+ sidebarContextMenu: false,
+ } as API_StatusObject,
+ ])
+ )
)
- )
- );
+ );
+
+ await api.experimental_updateStatus(
+ 'storybook/addon-a11y/test-provider',
+ Object.fromEntries(
+ update.details.testResults.flatMap((testResult) =>
+ testResult.results
+ .filter(({ storyId }) => storyId)
+ .map(({ storyId, status, testRunId, reports, ...rest }) => {
+ const a11yReport = reports.find((r: any) => r.type === 'a11y');
+ return [
+ storyId,
+ a11yReport
+ ? {
+ title: 'Accessibility tests',
+ description: '',
+ status: statusMap[a11yReport.status],
+ data: { testRunId },
+ onClick: () => {
+ api.setSelectedPanel('storybook/a11y/panel');
+ api.togglePanel(true);
+ },
+ sidebarContextMenu: false,
+ }
+ : null,
+ ] as const;
+ })
+ )
+ )
+ );
+ })();
},
} as Addon_TestProviderType);
}
diff --git a/code/addons/test/src/node/reporter.ts b/code/addons/test/src/node/reporter.ts
index 7405cf740c7b..8e4c9bf4f681 100644
--- a/code/addons/test/src/node/reporter.ts
+++ b/code/addons/test/src/node/reporter.ts
@@ -6,15 +6,11 @@ import type {
TestingModuleProgressReportPayload,
TestingModuleProgressReportProgress,
} from 'storybook/internal/core-events';
+import type { Report } from 'storybook/internal/preview-api';
import type { API_StatusUpdate } from '@storybook/types';
import type { Suite } from '@vitest/runner';
-// TODO
-// We can theoretically avoid the `@vitest/runner` dependency by copying over the necessary
-// functions from the `@vitest/runner` package. It is not complex and does not have
-// any significant dependencies.
-import { getTests } from '@vitest/runner/utils';
import { throttle } from 'es-toolkit';
import { TEST_PROVIDER_ID } from '../constants';
@@ -28,6 +24,7 @@ export type TestResultResult =
storyId: string;
testRunId: string;
duration: number;
+ reports: Report[];
}
| {
status: Extract;
@@ -35,6 +32,7 @@ export type TestResultResult =
duration: number;
testRunId: string;
failureMessages: string[];
+ reports: Report[];
};
export type TestResult = {
@@ -72,7 +70,13 @@ export class StorybookReporter implements Reporter {
this.start = Date.now();
}
- getProgressReport(finishedAt?: number) {
+ async getProgressReport(finishedAt?: number) {
+ // TODO
+ // We can theoretically avoid the `@vitest/runner` dependency by copying over the necessary
+ // functions from the `@vitest/runner` package. It is not complex and does not have
+ // any significant dependencies.
+ const { getTests } = await import('@vitest/runner/utils');
+
const files = this.ctx.state.getFiles();
const fileTests = getTests(files).filter((t) => t.mode === 'run' || t.mode === 'only');
@@ -113,16 +117,30 @@ export class StorybookReporter implements Reporter {
const status = statusMap[t.result?.state || t.mode] || 'skipped';
const storyId = (t.meta as any).storyId as string;
+ const reports =
+ ((t.meta as any).reports as Report[])?.map((report) => ({
+ status: report.status,
+ type: report.type,
+ })) ?? [];
const duration = t.result?.duration || 0;
const testRunId = this.start.toString();
switch (status) {
case 'passed':
case 'pending':
- return [{ status, storyId, duration, testRunId } as TestResultResult];
+ return [{ status, storyId, duration, testRunId, reports } as TestResultResult];
case 'failed':
const failureMessages = t.result?.errors?.map((e) => e.stack || e.message) || [];
- return [{ status, storyId, duration, failureMessages, testRunId } as TestResultResult];
+ return [
+ {
+ status,
+ storyId,
+ duration,
+ failureMessages,
+ testRunId,
+ reports,
+ } as TestResultResult,
+ ];
default:
return [];
}
@@ -159,7 +177,7 @@ export class StorybookReporter implements Reporter {
this.sendReport({
providerId: TEST_PROVIDER_ID,
status: 'pending',
- ...this.getProgressReport(),
+ ...(await this.getProgressReport()),
});
} catch (e) {
this.sendReport({
@@ -190,7 +208,7 @@ export class StorybookReporter implements Reporter {
const unhandledErrors = this.ctx.state.getUnhandledErrors();
const isCancelled = this.ctx.isCancelling;
- const report = this.getProgressReport(Date.now());
+ const report = await this.getProgressReport(Date.now());
const testSuiteFailures = report.details.testResults.filter(
(t) => t.status === 'failed' && t.results.length === 0
diff --git a/code/addons/test/src/node/test-manager.ts b/code/addons/test/src/node/test-manager.ts
index 544a23f5da49..430fd45eb409 100644
--- a/code/addons/test/src/node/test-manager.ts
+++ b/code/addons/test/src/node/test-manager.ts
@@ -45,6 +45,9 @@ export class TestManager {
if (payload.providerId !== TEST_PROVIDER_ID) {
return;
}
+
+ process.env.VITEST_STORYBOOK_CONFIG = JSON.stringify(payload.config);
+
if (this.coverage !== payload.config.coverage) {
try {
this.coverage = payload.config.coverage;
@@ -71,6 +74,13 @@ export class TestManager {
return;
}
+ if (payload.config) {
+ this.handleConfigChange({
+ providerId: payload.providerId,
+ config: payload.config as any,
+ });
+ }
+
if (this.watchMode !== payload.watchMode) {
this.watchMode = payload.watchMode;
await this.vitestManager.restartVitest({ watchMode: this.watchMode, coverage: false });
diff --git a/code/addons/test/src/node/vitest-manager.ts b/code/addons/test/src/node/vitest-manager.ts
index 9ce9139821bb..78c929732d7d 100644
--- a/code/addons/test/src/node/vitest-manager.ts
+++ b/code/addons/test/src/node/vitest-manager.ts
@@ -63,18 +63,32 @@ export class VitestManager {
: { enabled: false }
) as CoverageOptions;
- this.vitest = await createVitest('test', {
- watch: watchMode,
- passWithNoTests: false,
- changed: watchMode,
- // TODO:
- // Do we want to enable Vite's default reporter?
- // The output in the terminal might be too spamy and it might be better to
- // find a way to just show errors and warnings for example
- // Otherwise it might be hard for the user to discover Storybook related logs
- reporters: ['default', new StorybookReporter(this.testManager)],
- coverage: coverageOptions,
- });
+ this.vitest = await createVitest(
+ 'test',
+ {
+ watch: watchMode,
+ passWithNoTests: false,
+ changed: watchMode,
+ // TODO:
+ // Do we want to enable Vite's default reporter?
+ // The output in the terminal might be too spamy and it might be better to
+ // find a way to just show errors and warnings for example
+ // Otherwise it might be hard for the user to discover Storybook related logs
+ reporters: ['default', new StorybookReporter(this.testManager)],
+ coverage: coverageOptions,
+ },
+ {
+ define: {
+ // polyfilling process.env.VITEST_STORYBOOK to 'true' in the browser
+ 'process.env.VITEST_STORYBOOK': 'true',
+ },
+ }
+ );
+
+ this.vitest.configOverride.env = {
+ // We signal to the test runner that we are running it via Storybook
+ VITEST_STORYBOOK: 'true',
+ };
if (this.vitest) {
this.vitest.onCancel(() => {
diff --git a/code/addons/test/src/postinstall.ts b/code/addons/test/src/postinstall.ts
index 3ea45bd01bf7..4c03c493c1a9 100644
--- a/code/addons/test/src/postinstall.ts
+++ b/code/addons/test/src/postinstall.ts
@@ -22,10 +22,14 @@ import { dedent } from 'ts-dedent';
import { type PostinstallOptions } from '../../../lib/cli-storybook/src/add';
import { printError, printInfo, printSuccess, step } from './postinstall-logger';
+import { getAddonNames } from './utils';
const ADDON_NAME = '@storybook/experimental-addon-test' as const;
const EXTENSIONS = ['.js', '.jsx', '.ts', '.tsx', '.cts', '.mts', '.cjs', '.mjs'] as const;
+const addonInteractionsName = '@storybook/addon-interactions';
+const addonA11yName = '@storybook/addon-a11y';
+
const findFile = async (basename: string, extraExtensions: string[] = []) =>
findUp([...EXTENSIONS, ...extraExtensions].map((ext) => basename + ext));
@@ -145,12 +149,7 @@ export default async function postInstall(options: PostinstallOptions) {
return;
}
- const addonInteractionsName = '@storybook/addon-interactions';
- const interactionsAddon = info.addons.find((addon: string | { name: string }) => {
- // account for addons as objects, as well as addons with PnP paths
- const addonName = typeof addon === 'string' ? addon : addon.name;
- return addonName.includes(addonInteractionsName);
- });
+ const interactionsAddon = info.addons.find((addon) => addon.includes(addonInteractionsName));
if (!!interactionsAddon) {
let shouldUninstall = options.yes;
@@ -271,16 +270,33 @@ export default async function postInstall(options: PostinstallOptions) {
(config) => existsSync(config)
);
+ const a11yAddon = info.addons.find((addon) => addon.includes(addonA11yName));
+
+ const imports = [
+ `import { beforeAll } from 'vitest';`,
+ `import { setProjectAnnotations } from '${annotationsImport}';`,
+ ];
+
+ const projectAnnotations = [];
+
+ if (a11yAddon) {
+ imports.push(`import * as a11yAddonAnnotations from '@storybook/addon-a11y/preview';`);
+ projectAnnotations.push('a11yAddonAnnotations');
+ }
+
+ if (previewExists) {
+ imports.push(`import * as projectAnnotations from './preview';`);
+ projectAnnotations.push('projectAnnotations');
+ }
+
await writeFile(
vitestSetupFile,
dedent`
- import { beforeAll } from 'vitest';
- import { setProjectAnnotations } from '${annotationsImport}';
- ${previewExists ? `import * as projectAnnotations from './preview';` : ''}
+ ${imports.join('\n')}
// This is an important step to apply the right configuration when testing your stories.
// More info at: https://storybook.js.org/docs/api/portable-stories/portable-stories-vitest#setprojectannotations
- const project = setProjectAnnotations(${previewExists ? '[projectAnnotations]' : '[]'});
+ const project = setProjectAnnotations([${projectAnnotations.join(', ')}]);
beforeAll(project.beforeAll);
`
@@ -476,6 +492,7 @@ async function getStorybookInfo({ configDir, packageManager: pkgMgr }: Postinsta
const frameworkName = typeof framework === 'string' ? framework : framework?.name;
validateFrameworkName(frameworkName);
const frameworkPackageName = extractProperFrameworkName(frameworkName);
+ const addons = getAddonNames(config);
const presets = await loadAllPresets({
corePresets: [join(frameworkName, 'preset')],
@@ -514,6 +531,6 @@ async function getStorybookInfo({ configDir, packageManager: pkgMgr }: Postinsta
frameworkPackageName,
builderPackageName,
rendererPackageName,
- addons: config.addons,
+ addons,
};
}
diff --git a/code/addons/test/src/utils.ts b/code/addons/test/src/utils.ts
index d890c6ec66e6..066f09af15f1 100644
--- a/code/addons/test/src/utils.ts
+++ b/code/addons/test/src/utils.ts
@@ -1,5 +1,7 @@
import { type StorybookTheme, useTheme } from 'storybook/internal/theming';
+import type { StorybookConfig } from '@storybook/types';
+
import Filter from 'ansi-to-html';
import stripAnsi from 'strip-ansi';
@@ -39,3 +41,19 @@ export function useAnsiToHtmlFilter() {
const theme = useTheme();
return createAnsiToHtmlFilter(theme);
}
+
+export function getAddonNames(mainConfig: StorybookConfig): string[] {
+ const addons = mainConfig.addons || [];
+ const addonList = addons.map((addon) => {
+ let name = '';
+ if (typeof addon === 'string') {
+ name = addon;
+ } else if (typeof addon === 'object') {
+ name = addon.name;
+ }
+
+ return name;
+ });
+
+ return addonList.filter((item): item is NonNullable => item != null);
+}
diff --git a/code/addons/test/src/vitest-plugin/global-setup.ts b/code/addons/test/src/vitest-plugin/global-setup.ts
index ac3a5f5a8fcd..bff48208229a 100644
--- a/code/addons/test/src/vitest-plugin/global-setup.ts
+++ b/code/addons/test/src/vitest-plugin/global-setup.ts
@@ -7,6 +7,18 @@ import { logger } from 'storybook/internal/node-logger';
let storybookProcess: ChildProcess | null = null;
+const getIsVitestStandaloneRun = () => {
+ try {
+ // @ts-expect-error Suppress TypeScript warning about wrong setting. Doesn't matter, because we don't use tsc for bundling.
+ return (import.meta.env || process?.env).STORYBOOK !== 'true';
+ } catch (e) {
+ return false;
+ }
+};
+
+const isVitestStandaloneRun = getIsVitestStandaloneRun();
+
+// TODO: Not run when executed via Storybook
const checkStorybookRunning = async (storybookUrl: string): Promise => {
try {
const response = await fetch(`${storybookUrl}/iframe.html`, { method: 'HEAD' });
@@ -56,7 +68,7 @@ const killProcess = (process: ChildProcess) => {
};
export const setup = async ({ config }: GlobalSetupContext) => {
- if (config.watch) {
+ if (config.watch && isVitestStandaloneRun) {
await startStorybookIfNotRunning();
}
};
diff --git a/code/addons/test/src/vitest-plugin/index.ts b/code/addons/test/src/vitest-plugin/index.ts
index fff8419a5a5f..def3eab2b796 100644
--- a/code/addons/test/src/vitest-plugin/index.ts
+++ b/code/addons/test/src/vitest-plugin/index.ts
@@ -18,6 +18,7 @@ import sirv from 'sirv';
import { convertPathToPattern } from 'tinyglobby';
import { dedent } from 'ts-dedent';
+import { TestManager } from '../node/test-manager';
import type { InternalOptions, UserOptions } from './types';
const defaultOptions: UserOptions = {
@@ -154,6 +155,9 @@ export const storybookTest = (options?: UserOptions): Plugin => {
...config.test.env,
// To be accessed by the setup file
__STORYBOOK_URL__: storybookUrl,
+ // We signal the test runner that we are not running it via Storybook
+ // We are overriding the environment variable to 'true' if vitest runs via @storybook/addon-test's backend
+ VITEST_STORYBOOK: 'false',
__VITEST_INCLUDE_TAGS__: finalOptions.tags.include.join(','),
__VITEST_EXCLUDE_TAGS__: finalOptions.tags.exclude.join(','),
__VITEST_SKIP_TAGS__: finalOptions.tags.skip.join(','),
@@ -162,7 +166,27 @@ export const storybookTest = (options?: UserOptions): Plugin => {
config.envPrefix = Array.from(new Set([...(config.envPrefix || []), 'STORYBOOK_', 'VITE_']));
if (config.test.browser) {
+ config.define ??= {
+ ...config.define,
+ // polyfilling process.env.VITEST_STORYBOOK to 'false' in the browser
+ 'process.env.VITEST_STORYBOOK': JSON.stringify('false'),
+ };
+
config.test.browser.screenshotFailures ??= false;
+
+ config.test.browser.commands ??= {
+ getInitialGlobals: () => {
+ const envConfig = JSON.parse(process.env.VITEST_STORYBOOK_CONFIG ?? '{}');
+
+ const isA11yEnabled = process.env.VITEST_STORYBOOK ? (envConfig.a11y ?? false) : true;
+
+ return {
+ a11y: {
+ manual: !isA11yEnabled,
+ },
+ };
+ },
+ };
}
// copying straight from https://github.com/vitejs/vite/blob/main/packages/vite/src/node/constants.ts#L60
diff --git a/code/addons/test/src/vitest-plugin/test-utils.ts b/code/addons/test/src/vitest-plugin/test-utils.ts
index cdd199d3998b..a00ff6d7f6fc 100644
--- a/code/addons/test/src/vitest-plugin/test-utils.ts
+++ b/code/addons/test/src/vitest-plugin/test-utils.ts
@@ -3,29 +3,49 @@
/* eslint-disable no-underscore-dangle */
import { type RunnerTask, type TaskContext, type TaskMeta, type TestContext } from 'vitest';
-import { composeStory } from 'storybook/internal/preview-api';
+import { type Report, composeStory } from 'storybook/internal/preview-api';
import type { ComponentAnnotations, ComposedStoryFn } from 'storybook/internal/types';
+import { server } from '@vitest/browser/context';
+
import { setViewport } from './viewports';
+declare module '@vitest/browser/context' {
+ interface BrowserCommands {
+ getInitialGlobals: () => Promise>;
+ }
+}
+
+const { getInitialGlobals } = server.commands;
+
export const testStory = (
exportName: string,
story: ComposedStoryFn,
meta: ComponentAnnotations,
skipTags: string[]
) => {
- const composedStory = composeStory(story, meta, undefined, undefined, exportName);
return async (context: TestContext & TaskContext & { story: ComposedStoryFn }) => {
+ const composedStory = composeStory(
+ story,
+ meta,
+ { initialGlobals: (await getInitialGlobals?.()) ?? {} },
+ undefined,
+ exportName
+ );
if (composedStory === undefined || skipTags?.some((tag) => composedStory.tags.includes(tag))) {
context.skip();
}
context.story = composedStory;
- const _task = context.task as RunnerTask & { meta: TaskMeta & { storyId: string } };
+ const _task = context.task as RunnerTask & {
+ meta: TaskMeta & { storyId: string; reports: Report[] };
+ };
_task.meta.storyId = composedStory.id;
await setViewport(composedStory.parameters, composedStory.globals);
await composedStory.run();
+
+ _task.meta.reports = composedStory.reporting.reports;
};
};
diff --git a/code/core/package.json b/code/core/package.json
index cbd3091aeb8d..ffde669c6209 100644
--- a/code/core/package.json
+++ b/code/core/package.json
@@ -274,7 +274,7 @@
"prep": "jiti ./scripts/prep.ts"
},
"dependencies": {
- "@storybook/csf": "^0.1.11",
+ "@storybook/csf": "0.1.12",
"better-opn": "^3.0.2",
"browser-assert": "^1.2.1",
"esbuild": "^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0",
diff --git a/code/core/src/core-events/data/phases.ts b/code/core/src/core-events/data/phases.ts
new file mode 100644
index 000000000000..d52524d5426b
--- /dev/null
+++ b/code/core/src/core-events/data/phases.ts
@@ -0,0 +1,7 @@
+import type { Report } from '../../preview-api';
+
+export interface StoryFinishedPayload {
+ storyId: string;
+ status: 'error' | 'success';
+ reporters: Report[];
+}
diff --git a/code/core/src/core-events/index.ts b/code/core/src/core-events/index.ts
index 8a1e0a238b8f..4a0371c6c44e 100644
--- a/code/core/src/core-events/index.ts
+++ b/code/core/src/core-events/index.ts
@@ -32,6 +32,7 @@ enum events {
STORY_CHANGED = 'storyChanged',
STORY_UNCHANGED = 'storyUnchanged',
STORY_RENDERED = 'storyRendered',
+ STORY_FINISHED = 'storyFinished',
STORY_MISSING = 'storyMissing',
STORY_ERRORED = 'storyErrored',
STORY_THREW_EXCEPTION = 'storyThrewException',
@@ -142,6 +143,7 @@ export const {
STORY_PREPARED,
STORY_RENDER_PHASE_CHANGED,
STORY_RENDERED,
+ STORY_FINISHED,
STORY_SPECIFIED,
STORY_THREW_EXCEPTION,
STORY_UNCHANGED,
@@ -174,3 +176,4 @@ export * from './data/request-response';
export * from './data/save-story';
export * from './data/whats-new';
export * from './data/testing-module';
+export * from './data/phases';
diff --git a/code/core/src/manager-api/lib/addons.ts b/code/core/src/manager-api/lib/addons.ts
index a7aa438e0863..e893dfa7b984 100644
--- a/code/core/src/manager-api/lib/addons.ts
+++ b/code/core/src/manager-api/lib/addons.ts
@@ -132,6 +132,10 @@ export class AddonStore {
loadAddons = (api: any) => {
Object.values(this.loaders).forEach((value: any) => value(api));
};
+
+ experimental_getRegisteredAddons() {
+ return Object.keys(this.loaders);
+ }
}
// Enforce addons store to be a singleton
diff --git a/code/core/src/manager-api/modules/stories.ts b/code/core/src/manager-api/modules/stories.ts
index e92e7a90ce55..3c5aac769c6a 100644
--- a/code/core/src/manager-api/modules/stories.ts
+++ b/code/core/src/manager-api/modules/stories.ts
@@ -7,6 +7,7 @@ import type {
API_LeafEntry,
API_LoadedRefData,
API_PreparedStoryIndex,
+ API_StatusObject,
API_StatusState,
API_StatusUpdate,
API_StoryEntry,
@@ -268,6 +269,12 @@ export interface SubAPI {
* @returns {Promise} A promise that resolves when the preview has been set as initialized.
*/
setPreviewInitialized: (ref?: ComposedRef) => Promise;
+ /**
+ * Returns the current status of the stories.
+ *
+ * @returns {API_StatusState} The current status of the stories.
+ */
+ getCurrentStoryStatus: () => Record;
/**
* Updates the status of a collection of stories.
*
@@ -630,6 +637,11 @@ export const init: ModuleFn = ({
}
},
+ getCurrentStoryStatus: () => {
+ const { status, storyId } = store.getState();
+ return status[storyId as StoryId];
+ },
+
/* EXPERIMENTAL APIs */
experimental_updateStatus: async (id, input) => {
const { status, internal_index: index } = store.getState();
diff --git a/code/core/src/manager/components/sidebar/SidebarBottom.tsx b/code/core/src/manager/components/sidebar/SidebarBottom.tsx
index 9763488d3058..37a872810a6a 100644
--- a/code/core/src/manager/components/sidebar/SidebarBottom.tsx
+++ b/code/core/src/manager/components/sidebar/SidebarBottom.tsx
@@ -1,7 +1,7 @@
import React, { Fragment, useEffect, useRef, useState } from 'react';
import { styled } from '@storybook/core/theming';
-import { type API_FilterFunction } from '@storybook/core/types';
+import { type API_FilterFunction, type API_StatusValue } from '@storybook/core/types';
import {
TESTING_MODULE_CRASH_REPORT,
@@ -129,7 +129,10 @@ export const SidebarBottomBase = ({
});
};
- const onProgressReport = ({ providerId, ...result }: TestingModuleProgressReportPayload) => {
+ const onProgressReport = async ({
+ providerId,
+ ...result
+ }: TestingModuleProgressReportPayload) => {
const statusResult = 'status' in result ? result.status : undefined;
api.updateTestProviderState(
providerId,
diff --git a/code/core/src/manager/globals/exports.ts b/code/core/src/manager/globals/exports.ts
index 168a936a4880..297281e0e93d 100644
--- a/code/core/src/manager/globals/exports.ts
+++ b/code/core/src/manager/globals/exports.ts
@@ -798,6 +798,7 @@ export default {
'STORY_ARGS_UPDATED',
'STORY_CHANGED',
'STORY_ERRORED',
+ 'STORY_FINISHED',
'STORY_INDEX_INVALIDATED',
'STORY_MISSING',
'STORY_PREPARED',
@@ -863,6 +864,7 @@ export default {
'STORY_ARGS_UPDATED',
'STORY_CHANGED',
'STORY_ERRORED',
+ 'STORY_FINISHED',
'STORY_INDEX_INVALIDATED',
'STORY_MISSING',
'STORY_PREPARED',
@@ -928,6 +930,7 @@ export default {
'STORY_ARGS_UPDATED',
'STORY_CHANGED',
'STORY_ERRORED',
+ 'STORY_FINISHED',
'STORY_INDEX_INVALIDATED',
'STORY_MISSING',
'STORY_PREPARED',
diff --git a/code/core/src/preview-api/index.ts b/code/core/src/preview-api/index.ts
index 0a61c7333ab3..d067acad89cc 100644
--- a/code/core/src/preview-api/index.ts
+++ b/code/core/src/preview-api/index.ts
@@ -60,6 +60,6 @@ export { createPlaywrightTest } from './modules/store/csf/portable-stories';
export type { PropDescriptor } from './store';
/** STORIES API */
-export { StoryStore } from './store';
+export { StoryStore, type Report, ReporterAPI } from './store';
export { Preview, PreviewWeb, PreviewWithSelection, UrlStore, WebView } from './preview-web';
export type { SelectionStore, View } from './preview-web';
diff --git a/code/core/src/preview-api/modules/preview-web/Preview.tsx b/code/core/src/preview-api/modules/preview-web/Preview.tsx
index 049fccf90901..e929d6749337 100644
--- a/code/core/src/preview-api/modules/preview-web/Preview.tsx
+++ b/code/core/src/preview-api/modules/preview-web/Preview.tsx
@@ -386,11 +386,6 @@ export class Preview {
throw new CalledPreviewMethodBeforeInitializationError({ methodName: 'onResetArgs' });
}
- // NOTE: we have to be careful here and avoid await-ing when updating a rendered's args.
- // That's because below in `renderStoryToElement` we have also bound to this event and will
- // render the story in the same tick.
- // However, we can do that safely as the current story is available in `this.storyRenders`
-
// NOTE: we have to be careful here and avoid await-ing when updating a rendered's args.
// That's because below in `renderStoryToElement` we have also bound to this event and will
// render the story in the same tick.
diff --git a/code/core/src/preview-api/modules/preview-web/PreviewWeb.mockdata.ts b/code/core/src/preview-api/modules/preview-web/PreviewWeb.mockdata.ts
index 6505922c842e..2ed2352ae4e7 100644
--- a/code/core/src/preview-api/modules/preview-web/PreviewWeb.mockdata.ts
+++ b/code/core/src/preview-api/modules/preview-web/PreviewWeb.mockdata.ts
@@ -12,8 +12,8 @@ import type {
import {
DOCS_RENDERED,
STORY_ERRORED,
+ STORY_FINISHED,
STORY_MISSING,
- STORY_RENDERED,
STORY_RENDER_PHASE_CHANGED,
STORY_THREW_EXCEPTION,
} from '@storybook/core/core-events';
@@ -204,11 +204,11 @@ export const waitForEvents = (
// the async parts, so we need to listen for the "done" events
export const waitForRender = () =>
waitForEvents([
- STORY_RENDERED,
DOCS_RENDERED,
- STORY_THREW_EXCEPTION,
+ STORY_FINISHED,
STORY_ERRORED,
STORY_MISSING,
+ STORY_THREW_EXCEPTION,
]);
export const waitForRenderPhase = (phase: RenderPhase) => {
diff --git a/code/core/src/preview-api/modules/preview-web/render/StoryRender.test.ts b/code/core/src/preview-api/modules/preview-web/render/StoryRender.test.ts
index 2bd3a9ca3830..41fdea4b6771 100644
--- a/code/core/src/preview-api/modules/preview-web/render/StoryRender.test.ts
+++ b/code/core/src/preview-api/modules/preview-web/render/StoryRender.test.ts
@@ -1,12 +1,14 @@
// @vitest-environment happy-dom
import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { STORY_FINISHED } from 'storybook/internal/core-events';
+
import { Channel } from '@storybook/core/channels';
import type { PreparedStory, Renderer, StoryContext, StoryIndexEntry } from '@storybook/core/types';
-import type { StoryStore } from '../../store';
+import { ReporterAPI, type StoryStore } from '../../store';
import { PREPARE_ABORTED } from './Render';
-import { StoryRender } from './StoryRender';
+import { StoryRender, serializeError } from './StoryRender';
const entry = {
type: 'story',
@@ -40,6 +42,7 @@ const buildStory = (overrides: Partial = {}): PreparedStory =>
tags: [],
applyLoaders: vi.fn(),
applyBeforeEach: vi.fn(),
+ applyAfterEach: vi.fn(),
unboundStoryFn: vi.fn(),
playFunction: vi.fn(),
mount: (context: StoryContext) => () => mountSpy(context),
@@ -48,7 +51,9 @@ const buildStory = (overrides: Partial = {}): PreparedStory =>
const buildStore = (overrides: Partial> = {}): StoryStore =>
({
- getStoryContext: () => ({}),
+ getStoryContext: () => ({
+ reporting: new ReporterAPI(),
+ }),
addCleanupCallbacks: vi.fn(),
cleanupStory: vi.fn(),
...overrides,
@@ -255,6 +260,91 @@ describe('StoryRender', () => {
expect(actualMount).toHaveBeenCalled();
});
+ it('should handle the "finished" phase correctly when the story finishes successfully', async () => {
+ // Arrange - setup StoryRender and async gate blocking finished phase
+ const [finishGate, resolveFinishGate] = createGate();
+ const story = buildStory({
+ playFunction: vi.fn(async () => {
+ await finishGate;
+ }),
+ });
+ const store = buildStore();
+
+ const channel = new Channel({});
+ const emitSpy = vi.spyOn(channel, 'emit');
+
+ const render = new StoryRender(
+ channel,
+ store,
+ vi.fn() as any,
+ {} as any,
+ entry.id,
+ 'story',
+ { autoplay: true },
+ story
+ );
+
+ // Act - render, resolve finish gate, teardown
+ render.renderToElement({} as any);
+ await tick(); // go from 'loading' to 'rendering' phase
+ resolveFinishGate();
+ await tick(); // go from 'rendering' to 'finished' phase
+ render.teardown();
+
+ // Assert - ensure finished phase is handled correctly
+ expect(render.phase).toBe('finished');
+ expect(emitSpy).toHaveBeenCalledWith(STORY_FINISHED, {
+ reporters: [],
+ status: 'success',
+ storyId: 'id',
+ });
+ });
+
+ it('should handle the "finished" phase correctly when the story throws an error', async () => {
+ // Arrange - setup StoryRender and async gate blocking finished phase
+ const [finishGate, rejectFinishGate] = createGate();
+ const error = new Error('Test error');
+ const story = buildStory({
+ parameters: {},
+ playFunction: vi.fn(async () => {
+ await finishGate;
+ throw error;
+ }),
+ });
+ const store = buildStore();
+
+ const channel = new Channel({});
+ const emitSpy = vi.spyOn(channel, 'emit');
+
+ const render = new StoryRender(
+ channel,
+ store,
+ vi.fn() as any,
+ {
+ showException: vi.fn(),
+ } as any,
+ entry.id,
+ 'story',
+ { autoplay: true },
+ story
+ );
+
+ // Act - render, reject finish gate, teardown
+ render.renderToElement({} as any);
+ await tick(); // go from 'loading' to 'rendering' phase
+ rejectFinishGate();
+ await tick(); // go from 'rendering' to 'finished' phase
+ render.teardown();
+
+ // Assert - ensure finished phase is handled correctly
+ expect(render.phase).toBe('finished');
+ expect(emitSpy).toHaveBeenCalledWith(STORY_FINISHED, {
+ reporters: [],
+ status: 'error',
+ storyId: 'id',
+ });
+ });
+
describe('teardown', () => {
it('throws PREPARE_ABORTED if torndown during prepare', async () => {
const [importGate, openImportGate] = createGate();
diff --git a/code/core/src/preview-api/modules/preview-web/render/StoryRender.ts b/code/core/src/preview-api/modules/preview-web/render/StoryRender.ts
index 3441a5c64e97..0eacdf7e7551 100644
--- a/code/core/src/preview-api/modules/preview-web/render/StoryRender.ts
+++ b/code/core/src/preview-api/modules/preview-web/render/StoryRender.ts
@@ -14,8 +14,10 @@ import type {
import {
PLAY_FUNCTION_THREW_EXCEPTION,
+ STORY_FINISHED,
STORY_RENDERED,
STORY_RENDER_PHASE_CHANGED,
+ type StoryFinishedPayload,
UNHANDLED_ERRORS_WHILE_PLAYING,
} from '@storybook/core/core-events';
import { MountMustBeDestructuredError, NoStoryMountedError } from '@storybook/core/preview-errors';
@@ -33,11 +35,13 @@ export type RenderPhase =
| 'rendering'
| 'playing'
| 'played'
+ | 'afterEach'
| 'completed'
+ | 'finished'
| 'aborted'
| 'errored';
-function serializeError(error: any) {
+export function serializeError(error: any) {
try {
const { name = 'Error', message = String(error), stack } = error;
return { name, message, stack };
@@ -132,7 +136,9 @@ export class StoryRender implements Render implements Render implements Render = new Set();
+ const unhandledErrors: Set = new Set();
const onError = (event: ErrorEvent | PromiseRejectionEvent) =>
unhandledErrors.add('error' in event ? event.error : event.reason);
@@ -349,9 +356,39 @@ export class StoryRender implements Render
this.channel.emit(STORY_RENDERED, id)
);
+
+ if (this.phase !== 'errored') {
+ await this.runPhase(abortSignal, 'afterEach', async () => {
+ await applyAfterEach(context);
+ });
+ }
+
+ const hasUnhandledErrors = !ignoreUnhandledErrors && unhandledErrors.size > 0;
+
+ const hasSomeReportsFailed = context.reporting.reports.some(
+ (report) => report.status === 'failed'
+ );
+
+ const hasStoryErrored = hasUnhandledErrors || hasSomeReportsFailed;
+
+ await this.runPhase(abortSignal, 'finished', async () =>
+ this.channel.emit(STORY_FINISHED, {
+ storyId: id,
+ status: hasStoryErrored ? 'error' : 'success',
+ reporters: context.reporting.reports,
+ } as StoryFinishedPayload)
+ );
} catch (err) {
this.phase = 'errored';
this.callbacks.showException(err as Error);
+
+ await this.runPhase(abortSignal, 'finished', async () =>
+ this.channel.emit(STORY_FINISHED, {
+ storyId: id,
+ status: 'error',
+ reporters: [],
+ } as StoryFinishedPayload)
+ );
}
// If a rerender was enqueued during the render, clear the queue and render again
diff --git a/code/core/src/preview-api/modules/store/StoryStore.test.ts b/code/core/src/preview-api/modules/store/StoryStore.test.ts
index 4cc27210c5af..b28638960727 100644
--- a/code/core/src/preview-api/modules/store/StoryStore.test.ts
+++ b/code/core/src/preview-api/modules/store/StoryStore.test.ts
@@ -645,6 +645,7 @@ describe('StoryStore', () => {
expect(store.raw()).toMatchInlineSnapshot(`
[
{
+ "applyAfterEach": [Function],
"applyBeforeEach": [Function],
"applyLoaders": [Function],
"argTypes": {
@@ -698,6 +699,7 @@ describe('StoryStore', () => {
"usesMount": false,
},
{
+ "applyAfterEach": [Function],
"applyBeforeEach": [Function],
"applyLoaders": [Function],
"argTypes": {
@@ -751,6 +753,7 @@ describe('StoryStore', () => {
"usesMount": false,
},
{
+ "applyAfterEach": [Function],
"applyBeforeEach": [Function],
"applyLoaders": [Function],
"argTypes": {
diff --git a/code/core/src/preview-api/modules/store/StoryStore.ts b/code/core/src/preview-api/modules/store/StoryStore.ts
index cd7dc40a6ed1..01404a5f1bf5 100644
--- a/code/core/src/preview-api/modules/store/StoryStore.ts
+++ b/code/core/src/preview-api/modules/store/StoryStore.ts
@@ -45,6 +45,7 @@ import {
prepareStory,
processCSFFile,
} from './csf';
+import { ReporterAPI } from './reporter-api';
export function picky, K extends keyof T>(
obj: T,
@@ -253,12 +254,14 @@ export class StoryStore {
getStoryContext(story: PreparedStory, { forceInitialArgs = false } = {}) {
const userGlobals = this.userGlobals.get();
const { initialGlobals } = this.userGlobals;
+ const reporting = new ReporterAPI();
return prepareContext({
...story,
args: forceInitialArgs ? story.initialArgs : this.args.get(story.id),
initialGlobals,
globalTypes: this.projectAnnotations.globalTypes,
userGlobals,
+ reporting,
globals: {
...userGlobals,
...story.storyGlobals,
diff --git a/code/core/src/preview-api/modules/store/csf/composeConfigs.test.ts b/code/core/src/preview-api/modules/store/csf/composeConfigs.test.ts
index 035aa60e7e14..d1539c4791e1 100644
--- a/code/core/src/preview-api/modules/store/csf/composeConfigs.test.ts
+++ b/code/core/src/preview-api/modules/store/csf/composeConfigs.test.ts
@@ -25,6 +25,7 @@ describe('composeConfigs', () => {
loaders: [],
beforeAll: expect.any(Function),
beforeEach: [],
+ experimental_afterEach: [],
runStep: expect.any(Function),
tags: [],
});
@@ -53,6 +54,7 @@ describe('composeConfigs', () => {
loaders: [],
beforeAll: expect.any(Function),
beforeEach: [],
+ experimental_afterEach: [],
runStep: expect.any(Function),
tags: [],
});
@@ -85,6 +87,7 @@ describe('composeConfigs', () => {
loaders: [],
beforeAll: expect.any(Function),
beforeEach: [],
+ experimental_afterEach: [],
runStep: expect.any(Function),
tags: [],
});
@@ -123,6 +126,7 @@ describe('composeConfigs', () => {
loaders: [],
beforeAll: expect.any(Function),
beforeEach: [],
+ experimental_afterEach: [],
runStep: expect.any(Function),
tags: [],
});
@@ -164,6 +168,7 @@ describe('composeConfigs', () => {
loaders: [],
beforeAll: expect.any(Function),
beforeEach: [],
+ experimental_afterEach: [],
runStep: expect.any(Function),
tags: [],
});
@@ -196,6 +201,7 @@ describe('composeConfigs', () => {
loaders: ['1', '2', '3', '4'],
beforeAll: expect.any(Function),
beforeEach: [],
+ experimental_afterEach: [],
runStep: expect.any(Function),
tags: [],
});
@@ -228,6 +234,7 @@ describe('composeConfigs', () => {
loaders: ['1', '2', '3'],
beforeAll: expect.any(Function),
beforeEach: [],
+ experimental_afterEach: [],
runStep: expect.any(Function),
tags: [],
});
@@ -256,6 +263,7 @@ describe('composeConfigs', () => {
loaders: [],
beforeAll: expect.any(Function),
beforeEach: [],
+ experimental_afterEach: [],
runStep: expect.any(Function),
tags: [],
});
@@ -285,6 +293,7 @@ describe('composeConfigs', () => {
loaders: [],
beforeAll: expect.any(Function),
beforeEach: [],
+ experimental_afterEach: [],
runStep: expect.any(Function),
tags: [],
});
@@ -317,6 +326,7 @@ describe('composeConfigs', () => {
loaders: [],
beforeAll: expect.any(Function),
beforeEach: [],
+ experimental_afterEach: [],
render: 'render-2',
renderToCanvas: 'renderToCanvas-2',
applyDecorators: 'applyDecorators-2',
diff --git a/code/core/src/preview-api/modules/store/csf/composeConfigs.ts b/code/core/src/preview-api/modules/store/csf/composeConfigs.ts
index 545ab719e5a3..e5ba4f731b83 100644
--- a/code/core/src/preview-api/modules/store/csf/composeConfigs.ts
+++ b/code/core/src/preview-api/modules/store/csf/composeConfigs.ts
@@ -64,6 +64,7 @@ export function composeConfigs(
loaders: getArrayField(moduleExportList, 'loaders'),
beforeAll: composeBeforeAllHooks(beforeAllHooks),
beforeEach: getArrayField(moduleExportList, 'beforeEach'),
+ experimental_afterEach: getArrayField(moduleExportList, 'experimental_afterEach'),
render: getSingletonField(moduleExportList, 'render'),
renderToCanvas: getSingletonField(moduleExportList, 'renderToCanvas'),
renderToDOM: getSingletonField(moduleExportList, 'renderToDOM'), // deprecated
diff --git a/code/core/src/preview-api/modules/store/csf/normalizeProjectAnnotations.ts b/code/core/src/preview-api/modules/store/csf/normalizeProjectAnnotations.ts
index 26711a130106..7c5ec15724fb 100644
--- a/code/core/src/preview-api/modules/store/csf/normalizeProjectAnnotations.ts
+++ b/code/core/src/preview-api/modules/store/csf/normalizeProjectAnnotations.ts
@@ -26,6 +26,7 @@ export function normalizeProjectAnnotations({
decorators,
loaders,
beforeEach,
+ experimental_afterEach,
globals,
initialGlobals,
...annotations
@@ -44,6 +45,7 @@ export function normalizeProjectAnnotations({
decorators: normalizeArrays(decorators),
loaders: normalizeArrays(loaders),
beforeEach: normalizeArrays(beforeEach),
+ experimental_afterEach: normalizeArrays(experimental_afterEach),
argTypesEnhancers: [
...(argTypesEnhancers || []),
inferArgTypes,
diff --git a/code/core/src/preview-api/modules/store/csf/normalizeStory.test.ts b/code/core/src/preview-api/modules/store/csf/normalizeStory.test.ts
index ddacda230514..e7cad9cb0558 100644
--- a/code/core/src/preview-api/modules/store/csf/normalizeStory.test.ts
+++ b/code/core/src/preview-api/modules/store/csf/normalizeStory.test.ts
@@ -56,6 +56,7 @@ describe('normalizeStory', () => {
"args": {},
"beforeEach": [],
"decorators": [],
+ "experimental_afterEach": [],
"globals": {},
"id": "title--story-export",
"loaders": [],
@@ -127,6 +128,7 @@ describe('normalizeStory', () => {
"args": {},
"beforeEach": [],
"decorators": [],
+ "experimental_afterEach": [],
"globals": {},
"id": "title--story-export",
"loaders": [],
@@ -167,6 +169,7 @@ describe('normalizeStory', () => {
"decorators": [
[Function],
],
+ "experimental_afterEach": [],
"globals": {},
"id": "title--story-export",
"loaders": [
@@ -225,6 +228,7 @@ describe('normalizeStory', () => {
[Function],
[Function],
],
+ "experimental_afterEach": [],
"globals": {},
"id": "title--story-export",
"loaders": [
diff --git a/code/core/src/preview-api/modules/store/csf/normalizeStory.ts b/code/core/src/preview-api/modules/store/csf/normalizeStory.ts
index 2dd2ae957390..7fb9f59d256b 100644
--- a/code/core/src/preview-api/modules/store/csf/normalizeStory.ts
+++ b/code/core/src/preview-api/modules/store/csf/normalizeStory.ts
@@ -60,6 +60,11 @@ export function normalizeStory(
...normalizeArrays(storyObject.beforeEach),
...normalizeArrays(story?.beforeEach),
];
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ const experimental_afterEach = [
+ ...normalizeArrays(storyObject.experimental_afterEach),
+ ...normalizeArrays(story?.experimental_afterEach),
+ ];
const { render, play, tags = [], globals = {} } = storyObject;
// eslint-disable-next-line no-underscore-dangle
@@ -75,6 +80,7 @@ export function normalizeStory(
argTypes: normalizeInputTypes(argTypes),
loaders,
beforeEach,
+ experimental_afterEach,
globals,
...(render && { render }),
...(userStoryFn && { userStoryFn }),
diff --git a/code/core/src/preview-api/modules/store/csf/portable-stories.ts b/code/core/src/preview-api/modules/store/csf/portable-stories.ts
index 22b449bd98f0..774137dcf85a 100644
--- a/code/core/src/preview-api/modules/store/csf/portable-stories.ts
+++ b/code/core/src/preview-api/modules/store/csf/portable-stories.ts
@@ -26,6 +26,7 @@ import { MountMustBeDestructuredError } from '@storybook/core/preview-errors';
import { dedent } from 'ts-dedent';
import { HooksContext } from '../../../addons';
+import { ReporterAPI } from '../reporter-api';
import { composeConfigs } from './composeConfigs';
import { getValuesFromArgTypes } from './getValuesFromArgTypes';
import { normalizeComponentAnnotations } from './normalizeComponentAnnotations';
@@ -143,12 +144,15 @@ export function composeStory {
const context: StoryContext = prepareContext({
hooks: new HooksContext(),
globals,
args: { ...story.initialArgs },
viewMode: 'story',
+ reporting,
loaded: {},
abortSignal: new AbortController().signal,
step: (label, play) => story.runStep(label, play, context),
@@ -255,6 +259,7 @@ export function composeStory,
play: playFunction!,
run,
+ reporting,
tags: story.tags,
}
);
@@ -405,4 +410,6 @@ async function runStory(
}
await playFunction(context);
}
+
+ await story.applyAfterEach(context);
}
diff --git a/code/core/src/preview-api/modules/store/csf/prepareStory.test.ts b/code/core/src/preview-api/modules/store/csf/prepareStory.test.ts
index e91a6fcfea05..4f3c88bbd55e 100644
--- a/code/core/src/preview-api/modules/store/csf/prepareStory.test.ts
+++ b/code/core/src/preview-api/modules/store/csf/prepareStory.test.ts
@@ -54,6 +54,10 @@ const addExtraContext = (
...context,
hooks: new HooksContext(),
viewMode: 'story' as const,
+ reporting: {
+ reports: [],
+ addReport: vi.fn(),
+ },
loaded: {},
mount: vi.fn(),
abortSignal: new AbortController().signal,
@@ -792,6 +796,7 @@ describe('prepareMeta', () => {
story,
applyLoaders,
applyBeforeEach,
+ applyAfterEach,
originalStoryFn,
unboundStoryFn,
undecoratedStoryFn,
diff --git a/code/core/src/preview-api/modules/store/csf/prepareStory.ts b/code/core/src/preview-api/modules/store/csf/prepareStory.ts
index d6db661270d3..d2d7bba0c6df 100644
--- a/code/core/src/preview-api/modules/store/csf/prepareStory.ts
+++ b/code/core/src/preview-api/modules/store/csf/prepareStory.ts
@@ -92,6 +92,20 @@ export function prepareStory(
return cleanupCallbacks;
};
+ const applyAfterEach = async (context: StoryContext): Promise => {
+ const reversedFinalizers = [
+ ...normalizeArrays(projectAnnotations.experimental_afterEach),
+ ...normalizeArrays(componentAnnotations.experimental_afterEach),
+ ...normalizeArrays(storyAnnotations.experimental_afterEach),
+ ].reverse();
+ for (const finalizer of reversedFinalizers) {
+ if (context.abortSignal.aborted) {
+ return;
+ }
+ await finalizer(context);
+ }
+ };
+
const undecoratedStoryFn = (context: StoryContext) =>
(context.originalStoryFn as ArgsStoryFn)(context.args, context);
@@ -150,6 +164,7 @@ export function prepareStory(
unboundStoryFn,
applyLoaders,
applyBeforeEach,
+ applyAfterEach,
playFunction,
runStep,
mount,
diff --git a/code/core/src/preview-api/modules/store/index.ts b/code/core/src/preview-api/modules/store/index.ts
index f6694ad9017b..ea16e35bc908 100644
--- a/code/core/src/preview-api/modules/store/index.ts
+++ b/code/core/src/preview-api/modules/store/index.ts
@@ -10,3 +10,4 @@ export * from './decorators';
export * from './args';
export * from './autoTitle';
export * from './sortStories';
+export * from './reporter-api';
diff --git a/code/core/src/preview-api/modules/store/reporter-api.ts b/code/core/src/preview-api/modules/store/reporter-api.ts
new file mode 100644
index 000000000000..593124b6b8eb
--- /dev/null
+++ b/code/core/src/preview-api/modules/store/reporter-api.ts
@@ -0,0 +1,14 @@
+export interface Report {
+ type: string;
+ version?: number;
+ result: T;
+ status: 'failed' | 'passed' | 'warning';
+}
+
+export class ReporterAPI {
+ reports: Report[] = [];
+
+ async addReport(report: Report) {
+ this.reports.push(report);
+ }
+}
diff --git a/code/core/src/types/modules/composedStory.ts b/code/core/src/types/modules/composedStory.ts
index 7f8d52add055..c5a58b280cd3 100644
--- a/code/core/src/types/modules/composedStory.ts
+++ b/code/core/src/types/modules/composedStory.ts
@@ -9,6 +9,7 @@ import type {
Tag,
} from '@storybook/csf';
+import type { ReporterAPI } from '../../preview-api';
import type {
AnnotatedStoryFn,
Args,
@@ -49,6 +50,7 @@ export type ComposedStoryFn<
storyName: string;
parameters: Parameters;
argTypes: StrictArgTypes;
+ reporting: ReporterAPI;
tags: Tag[];
globals: Globals;
};
diff --git a/code/core/src/types/modules/csf.ts b/code/core/src/types/modules/csf.ts
index ae949e766fa8..fe0914bd398a 100644
--- a/code/core/src/types/modules/csf.ts
+++ b/code/core/src/types/modules/csf.ts
@@ -3,6 +3,7 @@ import type { ViewMode as ViewModeBase } from '@storybook/csf';
import type { Addon_OptionsParameter } from './addons';
export type {
+ AfterEach,
AnnotatedStoryFn,
Args,
ArgsEnhancer,
@@ -11,6 +12,7 @@ export type {
ArgTypes,
ArgTypesEnhancer,
BaseAnnotations,
+ BeforeEach,
Canvas,
ComponentAnnotations,
ComponentId,
diff --git a/code/core/src/types/modules/story.ts b/code/core/src/types/modules/story.ts
index f8deb8c522f0..c4b07fa1cd30 100644
--- a/code/core/src/types/modules/story.ts
+++ b/code/core/src/types/modules/story.ts
@@ -109,6 +109,7 @@ export type PreparedStory =
unboundStoryFn: LegacyStoryFn;
applyLoaders: (context: StoryContext) => Promise['loaded']>;
applyBeforeEach: (context: StoryContext) => Promise;
+ applyAfterEach: (context: StoryContext) => Promise;
playFunction?: (context: StoryContext) => Promise | void;
runStep: StepRunner;
mount: (context: StoryContext) => () => Promise