diff --git a/code/addons/interactions/src/components/InteractionsPanel.stories.tsx b/code/addons/interactions/src/components/InteractionsPanel.stories.tsx index b9273b5a04f..d228b177a81 100644 --- a/code/addons/interactions/src/components/InteractionsPanel.stories.tsx +++ b/code/addons/interactions/src/components/InteractionsPanel.stories.tsx @@ -56,6 +56,17 @@ export default meta; type Story = StoryObj<typeof meta>; export const Passing: Story = { + // TODO: Remove after prototyping + beforeEach: async ({ reporting }) => { + reporting.addReport({ + id: 'a11y', + status: 'passed', + version: 1, + result: { + violations: [{ id: 'a11y', impact: 'critical', description: 'A11y violation' }], + }, + }); + }, args: { interactions: getInteractions(CallStates.DONE), }, diff --git a/code/addons/test/src/node/reporter.ts b/code/addons/test/src/node/reporter.ts index 833ab0bf382..e83d2ee3f54 100644 --- a/code/addons/test/src/node/reporter.ts +++ b/code/addons/test/src/node/reporter.ts @@ -6,6 +6,7 @@ import type { TestingModuleProgressReportPayload, TestingModuleProgressReportProgress, } from 'storybook/internal/core-events'; +import type { Report } from 'storybook/internal/preview-api'; import type { API_StatusUpdate } from '@storybook/types'; @@ -26,6 +27,7 @@ export type TestResultResult = storyId: string; testRunId: string; duration: number; + reports: Report[]; } | { status: 'failed'; @@ -33,6 +35,7 @@ export type TestResultResult = duration: number; testRunId: string; failureMessages: string[]; + reports: Report[]; }; export type TestResult = { @@ -113,16 +116,26 @@ 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[]; 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 []; } diff --git a/code/addons/test/src/vitest-plugin/test-utils.ts b/code/addons/test/src/vitest-plugin/test-utils.ts index cdd199d3998..9ac6fbb487e 100644 --- a/code/addons/test/src/vitest-plugin/test-utils.ts +++ b/code/addons/test/src/vitest-plugin/test-utils.ts @@ -3,7 +3,7 @@ /* 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 { setViewport } from './viewports'; @@ -22,10 +22,14 @@ export const testStory = ( 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/src/core-events/index.ts b/code/core/src/core-events/index.ts index d5d51eeecdf..e22b5207c18 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_COMPLETED = 'storyCompleted', STORY_MISSING = 'storyMissing', STORY_ERRORED = 'storyErrored', STORY_THREW_EXCEPTION = 'storyThrewException', @@ -140,6 +141,7 @@ export const { STORY_PREPARED, STORY_RENDER_PHASE_CHANGED, STORY_RENDERED, + STORY_COMPLETED, STORY_SPECIFIED, STORY_THREW_EXCEPTION, STORY_UNCHANGED, diff --git a/code/core/src/manager/globals/exports.ts b/code/core/src/manager/globals/exports.ts index b9141e853f9..5516b01c574 100644 --- a/code/core/src/manager/globals/exports.ts +++ b/code/core/src/manager/globals/exports.ts @@ -797,6 +797,7 @@ export default { 'STORIES_EXPAND_ALL', 'STORY_ARGS_UPDATED', 'STORY_CHANGED', + 'STORY_COMPLETED', 'STORY_ERRORED', 'STORY_INDEX_INVALIDATED', 'STORY_MISSING', @@ -861,6 +862,7 @@ export default { 'STORIES_EXPAND_ALL', 'STORY_ARGS_UPDATED', 'STORY_CHANGED', + 'STORY_COMPLETED', 'STORY_ERRORED', 'STORY_INDEX_INVALIDATED', 'STORY_MISSING', @@ -925,6 +927,7 @@ export default { 'STORIES_EXPAND_ALL', 'STORY_ARGS_UPDATED', 'STORY_CHANGED', + 'STORY_COMPLETED', 'STORY_ERRORED', 'STORY_INDEX_INVALIDATED', 'STORY_MISSING', diff --git a/code/core/src/preview-api/index.ts b/code/core/src/preview-api/index.ts index 0a61c7333ab..d067acad89c 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/render/StoryRender.ts b/code/core/src/preview-api/modules/preview-web/render/StoryRender.ts index 3441a5c64e9..895674b6b73 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,6 +14,7 @@ import type { import { PLAY_FUNCTION_THREW_EXCEPTION, + STORY_COMPLETED, STORY_RENDERED, STORY_RENDER_PHASE_CHANGED, UNHANDLED_ERRORS_WHILE_PLAYING, @@ -34,6 +35,7 @@ export type RenderPhase = | 'playing' | 'played' | 'completed' + | 'finished' | 'aborted' | 'errored'; @@ -288,7 +290,7 @@ export class StoryRender<TRenderer extends Renderer> implements Render<TRenderer const ignoreUnhandledErrors = this.story.parameters?.test?.dangerouslyIgnoreUnhandledErrors === true; - const unhandledErrors: Set<unknown> = new Set(); + const unhandledErrors: Set<unknown> = new Set<unknown>(); const onError = (event: ErrorEvent | PromiseRejectionEvent) => unhandledErrors.add('error' in event ? event.error : event.reason); @@ -349,9 +351,30 @@ export class StoryRender<TRenderer extends Renderer> implements Render<TRenderer await this.runPhase(abortSignal, 'completed', async () => this.channel.emit(STORY_RENDERED, id) ); + + // The event name 'completed' is unfortunately already reserved by the STORY_RENDERED event + await this.runPhase(abortSignal, 'finished', async () => + this.channel.emit(STORY_COMPLETED, { + storyId: id, + unhandledExceptions: !ignoreUnhandledErrors + ? Array.from(unhandledErrors).map(serializeError) + : [], + status: !ignoreUnhandledErrors && unhandledErrors.size > 0 ? 'error' : 'success', + reporters: context.reporting.reports, + }) + ); } catch (err) { this.phase = 'errored'; this.callbacks.showException(err as Error); + + await this.runPhase(abortSignal, 'finished', async () => + this.channel.emit(STORY_COMPLETED, { + storyId: id, + unhandledExceptions: [serializeError(err)], + status: 'error', + reporters: [], + }) + ); } // 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.ts b/code/core/src/preview-api/modules/store/StoryStore.ts index cd7dc40a6ed..01404a5f1bf 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<T extends Record<string, any>, K extends keyof T>( obj: T, @@ -253,12 +254,14 @@ export class StoryStore<TRenderer extends Renderer> { getStoryContext(story: PreparedStory<TRenderer>, { 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/portable-stories.ts b/code/core/src/preview-api/modules/store/csf/portable-stories.ts index b9efd7da979..a2971bcf5db 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'; @@ -146,12 +147,15 @@ export function composeStory<TRenderer extends Renderer = Renderer, TArgs extend ...story.storyGlobals, }; + const reporting = new ReporterAPI(); + const initializeContext = () => { const context: StoryContext<TRenderer> = prepareContext({ hooks: new HooksContext(), globals, args: { ...story.initialArgs }, viewMode: 'story', + reporting, loaded: {}, abortSignal: new AbortController().signal, step: (label, play) => story.runStep(label, play, context), @@ -258,6 +262,7 @@ export function composeStory<TRenderer extends Renderer = Renderer, TArgs extend argTypes: story.argTypes as StrictArgTypes<TArgs>, play: playFunction!, run, + reporting, tags: story.tags, } ); diff --git a/code/core/src/preview-api/modules/store/index.ts b/code/core/src/preview-api/modules/store/index.ts index f6694ad9017..ea16e35bc90 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 00000000000..dc2acc34d1d --- /dev/null +++ b/code/core/src/preview-api/modules/store/reporter-api.ts @@ -0,0 +1,14 @@ +export interface Report { + id: string; + version: number; + result: unknown; + 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 7f8d52add05..c5a58b280cd 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<TArgs>; + reporting: ReporterAPI; tags: Tag[]; globals: Globals; };