diff --git a/code/addons/vitest/src/node/test-manager.test.ts b/code/addons/vitest/src/node/test-manager.test.ts index 26ca2ecea936..467e80a03fce 100644 --- a/code/addons/vitest/src/node/test-manager.test.ts +++ b/code/addons/vitest/src/node/test-manager.test.ts @@ -350,6 +350,46 @@ describe('TestManager', () => { ]); }); + it('should describe failures with the source-mapped frames rather than the raw browser stack', async () => { + const testManager = await TestManager.start(options); + const failedResult = { + state: 'failed', + errors: [ + { + message: + '\n\x1B[34mClick to debug the error directly in Storybook: http://localhost:6006/?path=/story/story--one\x1B[39m\n\nexpect(element).toBeInTheDocument()', + stack: + 'Error: expect(element).toBeInTheDocument()\n at Proxy.expectWrapper (http://localhost:6006/deps/storybook_test.js?v=8c5dc1d5:13691:16)', + stacks: [ + { + method: 'toBeInTheDocument', + file: '/project/src/stories/Page.stories.ts', + line: 30, + column: 60, + }, + ], + }, + ], + } as unknown as TestResult; + + await testManager.runTestsWithState({ + storyIds: ['story--one'], + triggeredBy: 'global', + callback: async () => { + testManager.onTestCaseResult({ storyId: 'story--one', testResult: failedResult }); + testManager.onTestRunEnd({ totalTestCount: 1, unhandledErrors: [] }); + }, + }); + + expect(mockComponentTestStatusStore.set).toHaveBeenCalledWith([ + expect.objectContaining({ + storyId: 'story--one', + description: + 'expect(element).toBeInTheDocument()\n at toBeInTheDocument (/project/src/stories/Page.stories.ts:30:60)', + }), + ]); + }); + it('should filter tests', async () => { vitest.globTestSpecifications.mockImplementation(() => tests); const testManager = await TestManager.start(options); diff --git a/code/addons/vitest/src/node/test-manager.ts b/code/addons/vitest/src/node/test-manager.ts index 2080ac2a8f33..d99bb37bf89b 100644 --- a/code/addons/vitest/src/node/test-manager.ts +++ b/code/addons/vitest/src/node/test-manager.ts @@ -1,3 +1,4 @@ +import type { TestError } from 'vitest'; import type { TestResult, TestState } from 'vitest/node'; import type { experimental_UniversalStore } from 'storybook/internal/core-server'; @@ -37,6 +38,27 @@ export type TestManagerOptions = { onReady?: () => void; }; +/** Matches the banner that vitest-plugin/setup-file.ts prepends to the message of failed stories. */ +const DEBUG_BANNER_RE = + /\n?(?:\x1B\[\d+m)?Click to debug the error directly in Storybook: [^\n]*\n+/g; + +/** + * `error.stack` holds the raw browser stack, pointing at the Vite URLs of Storybook's pre-bundled + * internals. Vitest replaces it with `error.stacks`: source-mapped frames with Storybook's + * instrumentation filtered out, matching what its own terminal output shows. + */ +function formatError(error: TestError): string { + if (!error.stacks?.length) { + return error.stack || error.message || ''; + } + const message = (error.message ?? '').replace(DEBUG_BANNER_RE, ''); + const frames = error.stacks.map( + ({ method, file, line, column }) => + ` at ${method || ''} (${file}:${line}:${column})` + ); + return [message, ...frames].join('\n'); +} + const testStateToStatusValueMap: Record = { pending: 'status-value:pending', passed: 'status-value:success', @@ -225,7 +247,7 @@ export class TestManager { typeId: STATUS_TYPE_ID_COMPONENT_TEST, value: testStateToStatusValueMap[testResult.state], title: 'Component tests', - description: testResult.errors?.map((error) => error.stack || error.message).join('\n') ?? '', + description: testResult.errors?.map(formatError).join('\n') ?? '', sidebarContextMenu: false, })); diff --git a/code/addons/vitest/src/vitest-plugin/index.ts b/code/addons/vitest/src/vitest-plugin/index.ts index 1744cbf0c323..6915b62e3487 100644 --- a/code/addons/vitest/src/vitest-plugin/index.ts +++ b/code/addons/vitest/src/vitest-plugin/index.ts @@ -49,6 +49,7 @@ import { import type { InternalOptions, UserOptions } from './types.ts'; import { requiresProjectAnnotations } from './utils.ts'; import { AgentTelemetryReporter } from './agent-telemetry-reporter.ts'; +import { isStorybookInternalFrame } from './stack-frames.ts'; const WORKING_DIR = process.cwd(); @@ -345,6 +346,14 @@ export const storybookTest = async (options?: UserOptions): Promise => cacheDir: resolvePathInStorybookCache('sb-vitest', projectId), test: { expect: { requireAssertions: false }, + + onStackTrace: (error, frame) => { + if (isStorybookInternalFrame(frame.file)) { + return false; + } + return nonMutableInputConfig.test?.onStackTrace?.(error, frame) ?? true; + }, + setupFiles: [ ...internalSetupFiles, // if the existing setupFiles is a string, we have to include it otherwise we're overwriting it diff --git a/code/addons/vitest/src/vitest-plugin/stack-frames.test.ts b/code/addons/vitest/src/vitest-plugin/stack-frames.test.ts new file mode 100644 index 000000000000..8e34e2c05dd7 --- /dev/null +++ b/code/addons/vitest/src/vitest-plugin/stack-frames.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest'; + +import { isStorybookInternalFrame } from './stack-frames.ts'; + +describe('isStorybookInternalFrame', () => { + it.each([ + '/project/node_modules/storybook/dist/test/index.js', + '/project/node_modules/storybook/dist/instrumenter/index.js', + '/project/node_modules/storybook/dist/_browser-chunks/chunk-ORWLP677.js', + '/project/node_modules/@storybook/addon-vitest/dist/vitest-plugin/test-utils.js', + 'C:\\project\\node_modules\\storybook\\dist\\instrumenter\\index.js', + '/project/node_modules/.cache/storybook/10.6.0/hash/sb-vitest/deps/storybook_test.js', + '/project/node_modules/.cache/storybook/10.6.0/hash/sb-vitest/deps/@storybook_addon-vitest_internal_test-utils.js?v=777b44a5', + ])('filters out %s', (file) => { + expect(isStorybookInternalFrame(file)).toBe(true); + }); + + it.each([ + '/project/src/stories/Page.stories.ts', + '/project/src/stories/Page.tsx', + '/project/.storybook/preview.ts', + '/project/node_modules/@testing-library/dom/dist/index.js', + '/project/node_modules/.cache/storybook/10.6.0/hash/sb-vitest/deps/react-dom_client.js', + undefined, + ])('keeps %s', (file) => { + expect(isStorybookInternalFrame(file)).toBe(false); + }); +}); diff --git a/code/addons/vitest/src/vitest-plugin/stack-frames.ts b/code/addons/vitest/src/vitest-plugin/stack-frames.ts new file mode 100644 index 000000000000..31cc966a5b3f --- /dev/null +++ b/code/addons/vitest/src/vitest-plugin/stack-frames.ts @@ -0,0 +1,19 @@ +/** + * Storybook instruments `expect`, `userEvent` and Testing Library so the Interactions panel can + * replay a play function step by step. Every instrumented call therefore sits behind a handful of + * wrapper frames inside Storybook's own bundles, which push the failing line in the story far down + * the stack trace Vitest reports. + * + * These patterns match those bundles in the shapes a frame can take: the published package layout + * after source maps are applied, and the pre-bundled dependency files Vite serves when no source + * map is available. + */ +const INTERNAL_FRAME_PATTERNS = [ + /[\\/]storybook[\\/]dist[\\/]/, + /[\\/]@storybook[\\/][^\\/]+[\\/]dist[\\/]/, + /[\\/]deps[\\/]@?storybook[@_]/, +]; + +export function isStorybookInternalFrame(file: string | undefined): boolean { + return !!file && INTERNAL_FRAME_PATTERNS.some((pattern) => pattern.test(file)); +}