From 6b73473fa404c4cf10941c29287ba73b2e20261d Mon Sep 17 00:00:00 2001 From: Clark Fischer <439978+clarkf@users.noreply.github.com> Date: Mon, 23 Oct 2023 15:04:34 +0000 Subject: [PATCH] feat(vitest): filter stacktraces (fix #1999) (#4338) --- docs/config/index.md | 28 ++++++++++++++++ packages/utils/src/source-map.ts | 6 +++- packages/vitest/src/node/error.ts | 1 + packages/vitest/src/node/reporters/json.ts | 1 + packages/vitest/src/node/reporters/junit.ts | 1 + packages/vitest/src/node/reporters/tap.ts | 1 + packages/vitest/src/node/workspace.ts | 1 + packages/vitest/src/types/config.ts | 11 ++++++- .../fixtures/error-with-stack.test.js | 21 ++++++++++++ .../test/__snapshots__/runner.test.ts.snap | 32 +++++++++++++++++++ test/stacktraces/test/runner.test.ts | 14 ++++++++ 11 files changed, 115 insertions(+), 2 deletions(-) create mode 100644 test/stacktraces/fixtures/error-with-stack.test.js diff --git a/docs/config/index.md b/docs/config/index.md index 841e1e8b26d2..acd5052dc91f 100644 --- a/docs/config/index.md +++ b/docs/config/index.md @@ -1785,6 +1785,34 @@ export default defineConfig({ }) ``` +### onStackTrace + +- **Type**: `(error: Error, frame: ParsedStack) => boolean | void` +- **Version**: Since Vitest 1.0.0-beta.3 + +Apply a filtering function to each frame of each stacktrace when handling errors. The first argument, `error`, is an object with the same properties as a standard `Error`, but it is not an actual instance. + +Can be useful for filtering out stacktrace frames from third-party libraries. + +```ts +import type { ParsedStack } from 'vitest' +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + onStackTrace(error: Error, { file }: ParsedStack): boolean | void { + // If we've encountered a ReferenceError, show the whole stack. + if (error.name === 'ReferenceError') + return + + // Reject all frames from third party libraries. + if (file.includes('node_modules')) + return false + }, + }, +}) +``` + ### diff - **Type:** `string` diff --git a/packages/utils/src/source-map.ts b/packages/utils/src/source-map.ts index c9c8959b0d94..5b1e9ec12183 100644 --- a/packages/utils/src/source-map.ts +++ b/packages/utils/src/source-map.ts @@ -10,6 +10,7 @@ export type { SourceMapInput } from '@jridgewell/trace-mapping' export interface StackTraceParserOptions { ignoreStackEntries?: (RegExp | string)[] getSourceMap?: (file: string) => unknown + frameFilter?: (error: Error, frame: ParsedStack) => boolean | void } const CHROME_IE_STACK_REGEXP = /^\s*at .*(\S+:\d+|\(native\))/m @@ -179,7 +180,10 @@ export function parseErrorStacktrace(e: ErrorWithDiff, options: StackTraceParser return e.stacks const stackStr = e.stack || e.stackStr || '' - const stackFrames = parseStacktrace(stackStr, options) + let stackFrames = parseStacktrace(stackStr, options) + + if (options.frameFilter) + stackFrames = stackFrames.filter(f => options.frameFilter!(e, f) !== false) e.stacks = stackFrames return stackFrames diff --git a/packages/vitest/src/node/error.ts b/packages/vitest/src/node/error.ts index ba01b68bebb6..7a4a0eb88555 100644 --- a/packages/vitest/src/node/error.ts +++ b/packages/vitest/src/node/error.ts @@ -49,6 +49,7 @@ export async function printError(error: unknown, project: WorkspaceProject | und const parserOptions: StackTraceParserOptions = { // only browser stack traces require remapping getSourceMap: file => project.getBrowserSourceMapModuleById(file), + frameFilter: project.config.onStackTrace, } if (fullStack) diff --git a/packages/vitest/src/node/reporters/json.ts b/packages/vitest/src/node/reporters/json.ts index 5405122a1fe1..00c24888a572 100644 --- a/packages/vitest/src/node/reporters/json.ts +++ b/packages/vitest/src/node/reporters/json.ts @@ -186,6 +186,7 @@ export class JsonReporter implements Reporter { const project = this.ctx.getProjectByTaskId(test.id) const stack = parseErrorStacktrace(error, { getSourceMap: file => project.getBrowserSourceMapModuleById(file), + frameFilter: this.ctx.config.onStackTrace, }) const frame = stack[0] if (!frame) diff --git a/packages/vitest/src/node/reporters/junit.ts b/packages/vitest/src/node/reporters/junit.ts index 1a34c7b34eca..c66cb30feba5 100644 --- a/packages/vitest/src/node/reporters/junit.ts +++ b/packages/vitest/src/node/reporters/junit.ts @@ -135,6 +135,7 @@ export class JUnitReporter implements Reporter { const project = this.ctx.getProjectByTaskId(task.id) const stack = parseErrorStacktrace(error, { getSourceMap: file => project.getBrowserSourceMapModuleById(file), + frameFilter: this.ctx.config.onStackTrace, }) // TODO: This is same as printStack but without colors. Find a way to reuse code. diff --git a/packages/vitest/src/node/reporters/tap.ts b/packages/vitest/src/node/reporters/tap.ts index 50fcf288bf84..0800d9ad5c14 100644 --- a/packages/vitest/src/node/reporters/tap.ts +++ b/packages/vitest/src/node/reporters/tap.ts @@ -76,6 +76,7 @@ export class TapReporter implements Reporter { task.result.errors.forEach((error) => { const stacks = parseErrorStacktrace(error, { getSourceMap: file => project.getBrowserSourceMapModuleById(file), + frameFilter: this.ctx.config.onStackTrace, }) const stack = stacks[0] diff --git a/packages/vitest/src/node/workspace.ts b/packages/vitest/src/node/workspace.ts index 8f459bfce0d4..65d62c372f12 100644 --- a/packages/vitest/src/node/workspace.ts +++ b/packages/vitest/src/node/workspace.ts @@ -315,6 +315,7 @@ export class WorkspaceProject { resolveSnapshotPath: undefined, }, onConsoleLog: undefined!, + onStackTrace: undefined!, sequence: { ...this.ctx.config.sequence, sequencer: undefined!, diff --git a/packages/vitest/src/types/config.ts b/packages/vitest/src/types/config.ts index b5f9e83e2004..cd4d052f5af2 100644 --- a/packages/vitest/src/types/config.ts +++ b/packages/vitest/src/types/config.ts @@ -11,7 +11,7 @@ import type { JSDOMOptions } from './jsdom-options' import type { HappyDOMOptions } from './happy-dom-options' import type { Reporter } from './reporter' import type { SnapshotStateOptions } from './snapshot' -import type { Arrayable } from './general' +import type { Arrayable, ParsedStack } from './general' import type { BenchmarkUserOptions } from './benchmark' import type { BrowserConfigOptions, ResolvedBrowserOptions } from './browser' import type { Pool, PoolOptions } from './pool-options' @@ -537,6 +537,14 @@ export interface InlineConfig { */ onConsoleLog?: (log: string, type: 'stdout' | 'stderr') => false | void + /** + * Enable stack trace filtering. If absent, all stack trace frames + * will be shown. + * + * Return `false` to omit the frame. + */ + onStackTrace?: (error: Error, frame: ParsedStack) => boolean | void + /** * Indicates if CSS files should be processed. * @@ -788,6 +796,7 @@ export type ProjectConfig = Omit< | 'resolveSnapshotPath' | 'passWithNoTests' | 'onConsoleLog' + | 'onStackTrace' | 'dangerouslyIgnoreUnhandledErrors' | 'slowTestThreshold' | 'inspect' diff --git a/test/stacktraces/fixtures/error-with-stack.test.js b/test/stacktraces/fixtures/error-with-stack.test.js new file mode 100644 index 000000000000..7786fa9e90b1 --- /dev/null +++ b/test/stacktraces/fixtures/error-with-stack.test.js @@ -0,0 +1,21 @@ +import { test } from 'vitest' + +test('error in deps', () => { + a() +}) + +function a() { + b() +} + +function b() { + c() +} + +function c() { + d() +} + +function d() { + throw new Error('Something truly horrible has happened!') +} diff --git a/test/stacktraces/test/__snapshots__/runner.test.ts.snap b/test/stacktraces/test/__snapshots__/runner.test.ts.snap index 77080e915158..1ec6515c94cb 100644 --- a/test/stacktraces/test/__snapshots__/runner.test.ts.snap +++ b/test/stacktraces/test/__snapshots__/runner.test.ts.snap @@ -1,5 +1,26 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html +exports[`stacktrace filtering > filters stacktraces > stacktrace-filtering 1`] = ` +"⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯ + + FAIL error-with-stack.test.js > error in deps +Error: Something truly horrible has happened! + ❯ d error-with-stack.test.js:20:9 + 18| + 19| function d() { + 20| throw new Error('Something truly horrible has happened!') + | ^ + 21| } + 22| + ❯ c error-with-stack.test.js:16:3 + ❯ a error-with-stack.test.js:8:3 + ❯ error-with-stack.test.js:4:3 + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯ + +" +`; + exports[`stacktrace should print error frame source file correctly > error-in-deps > error-in-deps 1`] = ` "⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯ @@ -71,6 +92,17 @@ exports[`stacktraces should respect sourcemaps > error-in-deps.test.js > error-i " `; +exports[`stacktraces should respect sourcemaps > error-with-stack.test.js > error-with-stack.test.js 1`] = ` +" ❯ d error-with-stack.test.js:20:9 + 18| + 19| function d() { + 20| throw new Error('Something truly horrible has happened!') + | ^ + 21| } + 22| + ❯ c error-with-stack.test.js:16:3" +`; + exports[`stacktraces should respect sourcemaps > mocked-global.test.js > mocked-global.test.js 1`] = ` " ❯ mocked-global.test.js:6:13 4| diff --git a/test/stacktraces/test/runner.test.ts b/test/stacktraces/test/runner.test.ts index 69feb285138a..a4e165760fe1 100644 --- a/test/stacktraces/test/runner.test.ts +++ b/test/stacktraces/test/runner.test.ts @@ -52,3 +52,17 @@ describe('stacktrace should print error frame source file correctly', async () = expect(stderr).toMatchSnapshot('error-in-deps') }, 30000) }) + +describe('stacktrace filtering', async () => { + const root = resolve(__dirname, '../fixtures') + const testFile = resolve(root, './error-with-stack.test.js') + + it('filters stacktraces', async () => { + const { stderr } = await runVitest({ + root, + onStackTrace: (_error, { method }) => method !== 'b', + }, [testFile]) + + expect(stderr).toMatchSnapshot('stacktrace-filtering') + }, 30000) +})