diff --git a/packages/browser/package.json b/packages/browser/package.json index 13325eec3..3f5867f95 100644 --- a/packages/browser/package.json +++ b/packages/browser/package.json @@ -39,7 +39,7 @@ "prepublishOnly": "pnpm build" }, "peerDependencies": { - "vitest": ">=0.32.3" + "vitest": ">=0.34.0" }, "dependencies": { "estree-walker": "^3.0.3", diff --git a/packages/browser/src/client/vite.config.ts b/packages/browser/src/client/vite.config.ts index 3d615fbf6..99b08c2ea 100644 --- a/packages/browser/src/client/vite.config.ts +++ b/packages/browser/src/client/vite.config.ts @@ -12,6 +12,7 @@ export default defineConfig({ minify: false, outDir: '../../dist/client', emptyOutDir: false, + assetsDir: '__vitest_browser__', }, plugins: [ { diff --git a/packages/snapshot/src/port/state.ts b/packages/snapshot/src/port/state.ts index f766f0e52..ae22eb182 100644 --- a/packages/snapshot/src/port/state.ts +++ b/packages/snapshot/src/port/state.ts @@ -6,7 +6,7 @@ */ import type { ParsedStack } from '@vitest/utils' -import { parseErrorStacktrace } from '@vitest/utils' +import { parseErrorStacktrace } from '@vitest/utils/source-map' import type { OptionsReceived as PrettyFormatOptions } from 'pretty-format' import type { SnapshotData, SnapshotEnvironment, SnapshotMatchOptions, SnapshotResult, SnapshotStateOptions, SnapshotUpdateState } from '../types' import type { InlineSnapshot } from './inlineSnapshot' @@ -128,7 +128,7 @@ export default class SnapshotState { ): void { this._dirty = true if (options.isInline) { - const stacks = parseErrorStacktrace(options.error || new Error('snapshot'), []) + const stacks = parseErrorStacktrace(options.error || new Error('snapshot'), { ignoreStackEntries: [] }) const stack = this._inferInlineSnapshotStack(stacks) if (!stack) { throw new Error( diff --git a/packages/utils/package.json b/packages/utils/package.json index 2a924970f..5fc8ab043 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -32,6 +32,10 @@ "types": "./dist/helpers.d.ts", "import": "./dist/helpers.js" }, + "./source-map": { + "types": "./dist/source-map.d.ts", + "import": "./dist/source-map.js" + }, "./*": "./*" }, "main": "./dist/index.js", @@ -50,5 +54,8 @@ "diff-sequences": "^29.4.3", "loupe": "^2.3.6", "pretty-format": "^29.5.0" + }, + "devDependencies": { + "@jridgewell/trace-mapping": "^0.3.18" } } diff --git a/packages/utils/rollup.config.js b/packages/utils/rollup.config.js index 3118cdcda..504e2d267 100644 --- a/packages/utils/rollup.config.js +++ b/packages/utils/rollup.config.js @@ -7,11 +7,12 @@ import json from '@rollup/plugin-json' import pkg from './package.json' assert { type: 'json' } const entries = { - index: 'src/index.ts', - helpers: 'src/helpers.ts', - diff: 'src/diff/index.ts', - error: 'src/error.ts', - types: 'src/types.ts', + 'index': 'src/index.ts', + 'helpers': 'src/helpers.ts', + 'diff': 'src/diff/index.ts', + 'error': 'src/error.ts', + 'source-map': 'src/source-map.ts', + 'types': 'src/types.ts', } const external = [ diff --git a/packages/utils/source-map.ts b/packages/utils/source-map.ts new file mode 100644 index 000000000..96fe88850 --- /dev/null +++ b/packages/utils/source-map.ts @@ -0,0 +1 @@ +export * from './dist/source-map.js' diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index cfe390aeb..2af5b1160 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -7,4 +7,4 @@ export * from './display' export * from './constants' export * from './colors' export * from './base' -export * from './source-map' +export * from './offset' diff --git a/packages/utils/src/offset.ts b/packages/utils/src/offset.ts new file mode 100644 index 000000000..46d0981ac --- /dev/null +++ b/packages/utils/src/offset.ts @@ -0,0 +1,42 @@ +export const lineSplitRE = /\r?\n/ + +export function positionToOffset( + source: string, + lineNumber: number, + columnNumber: number, +): number { + const lines = source.split(lineSplitRE) + const nl = /\r\n/.test(source) ? 2 : 1 + let start = 0 + + if (lineNumber > lines.length) + return source.length + + for (let i = 0; i < lineNumber - 1; i++) + start += lines[i].length + nl + + return start + columnNumber +} + +export function offsetToLineNumber( + source: string, + offset: number, +): number { + if (offset > source.length) { + throw new Error( + `offset is longer than source length! offset ${offset} > length ${source.length}`, + ) + } + const lines = source.split(lineSplitRE) + const nl = /\r\n/.test(source) ? 2 : 1 + let counted = 0 + let line = 0 + for (; line < lines.length; line++) { + const lineLength = lines[line].length + nl + if (counted + lineLength >= offset) + break + + counted += lineLength + } + return line + 1 +} diff --git a/packages/utils/src/source-map.ts b/packages/utils/src/source-map.ts index 895fc4b5d..542ada272 100644 --- a/packages/utils/src/source-map.ts +++ b/packages/utils/src/source-map.ts @@ -1,8 +1,19 @@ import { resolve } from 'pathe' +import type { SourceMapInput } from '@jridgewell/trace-mapping' +import { TraceMap, originalPositionFor } from '@jridgewell/trace-mapping' import type { ErrorWithDiff, ParsedStack } from './types' import { isPrimitive, notNullish } from './helpers' -export const lineSplitRE = /\r?\n/ +export { TraceMap, originalPositionFor, generatedPositionFor } from '@jridgewell/trace-mapping' +export type { SourceMapInput } from '@jridgewell/trace-mapping' + +export interface StackTraceParserOptions { + ignoreStackEntries?: (RegExp | string)[] + getSourceMap?: (file: string) => unknown +} + +const CHROME_IE_STACK_REGEXP = /^\s*at .*(\S+:\d+|\(native\))/m +const SAFARI_NATIVE_CODE_REGEXP = /^(eval@)?(\[native code])?$/ const stackIgnorePatterns = [ 'node:internal', @@ -15,6 +26,8 @@ const stackIgnorePatterns = [ '/node_modules/chai/', '/node_modules/tinypool/', '/node_modules/tinyspy/', + '/deps/chai.js', + /__vitest_browser__/, ] function extractLocation(urlLike: string) { @@ -26,14 +39,61 @@ function extractLocation(urlLike: string) { const parts = regExp.exec(urlLike.replace(/^\(|\)$/g, '')) if (!parts) return [urlLike] - return [parts[1], parts[2] || undefined, parts[3] || undefined] + let url = parts[1] + if (url.startsWith('http:') || url.startsWith('https:')) { + const urlObj = new URL(url) + url = urlObj.pathname + } + if (url.startsWith('/@fs/')) { + url + = url.slice(typeof process !== 'undefined' && process.platform === 'win32' ? 5 : 4) + } + return [url, parts[2] || undefined, parts[3] || undefined] +} + +export function parseSingleFFOrSafariStack(raw: string): ParsedStack | null { + let line = raw.trim() + + if (SAFARI_NATIVE_CODE_REGEXP.test(line)) + return null + + if (line.includes(' > eval')) + line = line.replace(/ line (\d+)(?: > eval line \d+)* > eval:\d+:\d+/g, ':$1') + + if (!line.includes('@') && !line.includes(':')) + return null + + const functionNameRegex = /((.*".+"[^@]*)?[^@]*)(?:@)/ + const matches = line.match(functionNameRegex) + const functionName = matches && matches[1] ? matches[1] : undefined + const [url, lineNumber, columnNumber] = extractLocation(line.replace(functionNameRegex, '')) + + if (!url || !lineNumber || !columnNumber) + return null + + return { + file: url, + method: functionName || '', + line: Number.parseInt(lineNumber), + column: Number.parseInt(columnNumber), + } +} + +export function parseSingleStack(raw: string) { + const line = raw.trim() + if (!CHROME_IE_STACK_REGEXP.test(line)) + return parseSingleFFOrSafariStack(line) + return parseSingleV8Stack(line) } // Based on https://github.com/stacktracejs/error-stack-parser // Credit to stacktracejs -export function parseSingleStack(raw: string): ParsedStack | null { +export function parseSingleV8Stack(raw: string): ParsedStack | null { let line = raw.trim() + if (!CHROME_IE_STACK_REGEXP.test(line)) + return null + if (line.includes('(eval ')) line = line.replace(/eval code/g, 'eval').replace(/(\(eval at [^()]*)|(,.*$)/g, '') @@ -75,23 +135,40 @@ export function parseSingleStack(raw: string): ParsedStack | null { } } -export function parseStacktrace(stack: string, ignore = stackIgnorePatterns): ParsedStack[] { - const stackFrames = stack - .split('\n') - .map((raw): ParsedStack | null => { - const stack = parseSingleStack(raw) - - if (!stack || (ignore.length && ignore.some(p => stack.file.match(p)))) - return null - +export function parseStacktrace(stack: string, options: StackTraceParserOptions = {}): ParsedStack[] { + const { ignoreStackEntries = stackIgnorePatterns } = options + let stacks = !CHROME_IE_STACK_REGEXP.test(stack) + ? parseFFOrSafariStackTrace(stack) + : parseV8Stacktrace(stack) + if (ignoreStackEntries.length) + stacks = stacks.filter(stack => !ignoreStackEntries.some(p => stack.file.match(p))) + return stacks.map((stack) => { + const map = options.getSourceMap?.(stack.file) as SourceMapInput | null | undefined + if (!map || typeof map !== 'object' || !map.version) return stack - }) + const traceMap = new TraceMap(map) + const { line, column } = originalPositionFor(traceMap, stack) + if (line != null && column != null) + return { ...stack, line, column } + return stack + }) +} + +function parseFFOrSafariStackTrace(stack: string): ParsedStack[] { + return stack + .split('\n') + .map(line => parseSingleFFOrSafariStack(line)) .filter(notNullish) +} - return stackFrames +function parseV8Stacktrace(stack: string): ParsedStack[] { + return stack + .split('\n') + .map(line => parseSingleV8Stack(line)) + .filter(notNullish) } -export function parseErrorStacktrace(e: ErrorWithDiff, ignore = stackIgnorePatterns): ParsedStack[] { +export function parseErrorStacktrace(e: ErrorWithDiff, options: StackTraceParserOptions = {}): ParsedStack[] { if (!e || isPrimitive(e)) return [] @@ -99,49 +176,8 @@ export function parseErrorStacktrace(e: ErrorWithDiff, ignore = stackIgnorePatte return e.stacks const stackStr = e.stack || e.stackStr || '' - const stackFrames = parseStacktrace(stackStr, ignore) + const stackFrames = parseStacktrace(stackStr, options) e.stacks = stackFrames return stackFrames } - -export function positionToOffset( - source: string, - lineNumber: number, - columnNumber: number, -): number { - const lines = source.split(lineSplitRE) - const nl = /\r\n/.test(source) ? 2 : 1 - let start = 0 - - if (lineNumber > lines.length) - return source.length - - for (let i = 0; i < lineNumber - 1; i++) - start += lines[i].length + nl - - return start + columnNumber -} - -export function offsetToLineNumber( - source: string, - offset: number, -): number { - if (offset > source.length) { - throw new Error( - `offset is longer than source length! offset ${offset} > length ${source.length}`, - ) - } - const lines = source.split(lineSplitRE) - const nl = /\r\n/.test(source) ? 2 : 1 - let counted = 0 - let line = 0 - for (; line < lines.length; line++) { - const lineLength = lines[line].length + nl - if (counted + lineLength >= offset) - break - - counted += lineLength - } - return line + 1 -} diff --git a/packages/vitest/LICENSE.md b/packages/vitest/LICENSE.md index 367c92f19..e738d39a4 100644 --- a/packages/vitest/LICENSE.md +++ b/packages/vitest/LICENSE.md @@ -58,89 +58,6 @@ Repository: git+https://github.com/antfu/install-pkg.git --------------------------------------- -## @jridgewell/resolve-uri -License: MIT -By: Justin Ridgewell -Repository: https://github.com/jridgewell/resolve-uri - -> Copyright 2019 Justin Ridgewell -> -> Permission is hereby granted, free of charge, to any person obtaining a copy -> of this software and associated documentation files (the "Software"), to deal -> in the Software without restriction, including without limitation the rights -> to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -> copies of the Software, and to permit persons to whom the Software is -> furnished to do so, subject to the following conditions: -> -> The above copyright notice and this permission notice shall be included in -> all copies or substantial portions of the Software. -> -> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -> IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -> FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -> AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -> LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -> OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -> SOFTWARE. - ---------------------------------------- - -## @jridgewell/sourcemap-codec -License: MIT -By: Rich Harris -Repository: git+https://github.com/jridgewell/sourcemap-codec.git - -> The MIT License -> -> Copyright (c) 2015 Rich Harris -> -> Permission is hereby granted, free of charge, to any person obtaining a copy -> of this software and associated documentation files (the "Software"), to deal -> in the Software without restriction, including without limitation the rights -> to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -> copies of the Software, and to permit persons to whom the Software is -> furnished to do so, subject to the following conditions: -> -> The above copyright notice and this permission notice shall be included in -> all copies or substantial portions of the Software. -> -> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -> IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -> FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -> AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -> LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -> OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -> THE SOFTWARE. - ---------------------------------------- - -## @jridgewell/trace-mapping -License: MIT -By: Justin Ridgewell -Repository: git+https://github.com/jridgewell/trace-mapping.git - -> Copyright 2022 Justin Ridgewell -> -> Permission is hereby granted, free of charge, to any person obtaining a copy -> of this software and associated documentation files (the "Software"), to deal -> in the Software without restriction, including without limitation the rights -> to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -> copies of the Software, and to permit persons to whom the Software is -> furnished to do so, subject to the following conditions: -> -> The above copyright notice and this permission notice shall be included in -> all copies or substantial portions of the Software. -> -> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -> IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -> FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -> AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -> LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -> OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -> SOFTWARE. - ---------------------------------------- - ## @nodelib/fs.scandir License: MIT Repository: https://github.com/nodelib/nodelib/tree/master/packages/fs/fs.scandir diff --git a/packages/vitest/package.json b/packages/vitest/package.json index b5acbf00a..7d21ab7d2 100644 --- a/packages/vitest/package.json +++ b/packages/vitest/package.json @@ -163,7 +163,6 @@ "@ampproject/remapping": "^2.2.1", "@antfu/install-pkg": "^0.1.1", "@edge-runtime/vm": "3.0.3", - "@jridgewell/trace-mapping": "^0.3.18", "@sinonjs/fake-timers": "^11.0.0", "@types/diff": "^5.0.3", "@types/estree": "^1.0.1", diff --git a/packages/vitest/src/api/setup.ts b/packages/vitest/src/api/setup.ts index 545ff2cf0..ad06cc32c 100644 --- a/packages/vitest/src/api/setup.ts +++ b/packages/vitest/src/api/setup.ts @@ -7,6 +7,7 @@ import { parse, stringify } from 'flatted' import type { WebSocket } from 'ws' import { WebSocketServer } from 'ws' import type { ViteDevServer } from 'vite' +import type { StackTraceParserOptions } from '@vitest/utils/source-map' import { API_PATH } from '../constants' import type { Vitest } from '../node' import type { File, ModuleGraphData, Reporter, TaskResultPack, UserConsoleLog } from '../types' @@ -158,13 +159,19 @@ class WebSocketReporter implements Reporter { if (this.clients.size === 0) return - packs.forEach(([, result]) => { + packs.forEach(([taskId, result]) => { + const project = this.ctx.getProjectByTaskId(taskId) + + const parserOptions: StackTraceParserOptions = { + getSourceMap: file => project.getBrowserSourceMapModuleById(file), + } + // TODO remove after "error" deprecation is removed if (result?.error && !isPrimitive(result.error)) - result.error.stacks = parseErrorStacktrace(result.error) + result.error.stacks = parseErrorStacktrace(result.error, parserOptions) result?.errors?.forEach((error) => { if (!isPrimitive(error)) - error.stacks = parseErrorStacktrace(error) + error.stacks = parseErrorStacktrace(error, parserOptions) }) }) diff --git a/packages/vitest/src/node/cli-api.ts b/packages/vitest/src/node/cli-api.ts index de9b328a3..a64561389 100644 --- a/packages/vitest/src/node/cli-api.ts +++ b/packages/vitest/src/node/cli-api.ts @@ -98,7 +98,7 @@ export async function startVitest( } catch (e) { process.exitCode = 1 - await ctx.logger.printError(e, true, 'Unhandled Error') + await ctx.logger.printError(e, { fullStack: true, type: 'Unhandled Error' }) ctx.logger.error('\n\n') return ctx } diff --git a/packages/vitest/src/node/core.ts b/packages/vitest/src/node/core.ts index 27eb7a7d5..9a93c90e6 100644 --- a/packages/vitest/src/node/core.ts +++ b/packages/vitest/src/node/core.ts @@ -8,7 +8,7 @@ import c from 'picocolors' import { normalizeRequestId } from 'vite-node/utils' import { ViteNodeRunner } from 'vite-node/client' import { SnapshotManager } from '@vitest/snapshot/manager' -import type { CancelReason } from '@vitest/runner' +import type { CancelReason, File } from '@vitest/runner' import { ViteNodeServer } from 'vite-node/server' import type { ArgumentsType, CoverageProvider, OnServerRestartHandler, Reporter, ResolvedConfig, UserConfig, UserWorkspaceConfig, VitestRunMode } from '../types' import { hasFailed, noop, slash, toArray } from '../utils' @@ -153,6 +153,14 @@ export class Vitest { return this.coreWorkspace || null } + public getProjectByTaskId(taskId: string): WorkspaceProject { + const task = this.state.idMap.get(taskId) + const projectName = (task as File).projectName || task?.file?.projectName + return this.projects.find(p => p.getName() === projectName) + || this.getCoreWorkspaceProject() + || this.projects[0] + } + private async resolveWorkspace(options: UserConfig, cliOptions: UserConfig) { const configDir = this.server.config.configFile ? dirname(this.server.config.configFile) @@ -557,9 +565,8 @@ export class Vitest { public getModuleProjects(id: string) { return this.projects.filter((project) => { - return project.server.moduleGraph.getModuleById(id) - || project.browser?.moduleGraph.getModuleById(id) - || project.browser?.moduleGraph.getModulesByFile(id)?.size + return project.getModuleById(id) + // TODO: reevaluate || project.browser?.moduleGraph.getModulesByFile(id)?.size }) } diff --git a/packages/vitest/src/node/error.ts b/packages/vitest/src/node/error.ts index fd4c54949..c09c8628d 100644 --- a/packages/vitest/src/node/error.ts +++ b/packages/vitest/src/node/error.ts @@ -3,7 +3,8 @@ import { existsSync, readFileSync } from 'node:fs' import { normalize, relative } from 'pathe' import c from 'picocolors' import cliTruncate from 'cli-truncate' -import { stringify } from '@vitest/utils' +import type { StackTraceParserOptions } from '@vitest/utils/source-map' +import { inspect } from '@vitest/utils' import type { ErrorWithDiff, ParsedStack } from '../types' import { lineSplitRE, parseErrorStacktrace, positionToOffset } from '../utils/source-map' import { F_POINTER } from '../utils/figures' @@ -12,15 +13,18 @@ import { isPrimitive } from '../utils' import type { Vitest } from './core' import { divider } from './reporters/renderers/utils' import type { Logger } from './logger' +import type { WorkspaceProject } from './workspace' interface PrintErrorOptions { type?: string + logger: Logger fullStack?: boolean showCodeFrame?: boolean } -export async function printError(error: unknown, ctx: Vitest, options: PrintErrorOptions = {}) { +export async function printError(error: unknown, project: WorkspaceProject | undefined, options: PrintErrorOptions) { const { showCodeFrame = true, fullStack = false, type } = options + const logger = options.logger let e = error as ErrorWithDiff if (isPrimitive(e)) { @@ -39,37 +43,44 @@ export async function printError(error: unknown, ctx: Vitest, options: PrintErro } // Error may have occured even before the configuration was resolved - if (!ctx.config) - return printErrorMessage(e, ctx.logger) + if (!project) + return printErrorMessage(e, logger) - const stacks = parseErrorStacktrace(e, fullStack ? [] : undefined) + const parserOptions: StackTraceParserOptions = { + // only browser stack traces require remapping + getSourceMap: file => project.getBrowserSourceMapModuleById(file), + } + + if (fullStack) + parserOptions.ignoreStackEntries = [] + const stacks = parseErrorStacktrace(e, parserOptions) const nearest = error instanceof TypeCheckError ? error.stacks[0] : stacks.find(stack => - ctx.getModuleProjects(stack.file).length + project.getModuleById(stack.file) && existsSync(stack.file), ) const errorProperties = getErrorProperties(e) if (type) - printErrorType(type, ctx) - printErrorMessage(e, ctx.logger) + printErrorType(type, project.ctx) + printErrorMessage(e, logger) // E.g. AssertionError from assert does not set showDiff but has both actual and expected properties if (e.diff) - displayDiff(e.diff, ctx.logger.console) + displayDiff(e.diff, logger.console) // if the error provide the frame if (e.frame) { - ctx.logger.error(c.yellow(e.frame)) + logger.error(c.yellow(e.frame)) } else { - printStack(ctx, stacks, nearest, errorProperties, (s) => { + printStack(project, stacks, nearest, errorProperties, (s) => { if (showCodeFrame && s === nearest && nearest) { const sourceCode = readFileSync(nearest.file, 'utf-8') - ctx.logger.error(generateCodeFrame(sourceCode, 4, s.line, s.column)) + logger.error(generateCodeFrame(sourceCode, 4, s.line, s.column)) } }) } @@ -79,24 +90,24 @@ export async function printError(error: unknown, ctx: Vitest, options: PrintErro const afterEnvTeardown = (e as any).VITEST_AFTER_ENV_TEARDOWN // testName has testPath inside if (testPath) - ctx.logger.error(c.red(`This error originated in "${c.bold(testPath)}" test file. It doesn't mean the error was thrown inside the file itself, but while it was running.`)) + logger.error(c.red(`This error originated in "${c.bold(testPath)}" test file. It doesn't mean the error was thrown inside the file itself, but while it was running.`)) if (testName) { - ctx.logger.error(c.red(`The latest test that might've caused the error is "${c.bold(testName)}". It might mean one of the following:` + logger.error(c.red(`The latest test that might've caused the error is "${c.bold(testName)}". It might mean one of the following:` + '\n- The error was thrown, while Vitest was running this test.' + '\n- This was the last recorded test before the error was thrown, if error originated after test finished its execution.')) } if (afterEnvTeardown) { - ctx.logger.error(c.red('This error was caught after test environment was torn down. Make sure to cancel any running tasks before test finishes:' + logger.error(c.red('This error was caught after test environment was torn down. Make sure to cancel any running tasks before test finishes:' + '\n- cancel timeouts using clearTimeout and clearInterval' + '\n- wait for promises to resolve using the await keyword')) } if (typeof e.cause === 'object' && e.cause && 'name' in e.cause) { (e.cause as any).name = `Caused by: ${(e.cause as any).name}` - await printError(e.cause, ctx, { fullStack, showCodeFrame: false }) + await printError(e.cause, project, { fullStack, showCodeFrame: false, logger: options.logger }) } - handleImportOutsideModuleError(e.stack || e.stackStr || '', ctx) + handleImportOutsideModuleError(e.stack || e.stackStr || '', logger) } function printErrorType(type: string, ctx: Vitest) { @@ -139,7 +150,7 @@ const esmErrors = [ 'Unexpected token \'export\'', ] -function handleImportOutsideModuleError(stack: string, ctx: Vitest) { +function handleImportOutsideModuleError(stack: string, logger: Logger) { if (!esmErrors.some(e => stack.includes(e))) return @@ -151,9 +162,9 @@ function handleImportOutsideModuleError(stack: string, ctx: Vitest) { name = name.split('/')[0] if (name) - printModuleWarningForPackage(ctx.logger, path, name) + printModuleWarningForPackage(logger, path, name) else - printModuleWarningForSourceCode(ctx.logger, path) + printModuleWarningForSourceCode(logger, path) } function printModuleWarningForPackage(logger: Logger, path: string, name: string) { @@ -205,17 +216,17 @@ function printErrorMessage(error: ErrorWithDiff, logger: Logger) { } function printStack( - ctx: Vitest, + project: WorkspaceProject, stack: ParsedStack[], highlight: ParsedStack | undefined, errorProperties: Record, onStack?: ((stack: ParsedStack) => void), ) { - const logger = ctx.logger + const logger = project.ctx.logger for (const frame of stack) { const color = frame === highlight ? c.cyan : c.gray - const path = relative(ctx.config.root, frame.file) + const path = relative(project.config.root, frame.file) logger.error(color(` ${c.dim(F_POINTER)} ${[frame.method, `${path}:${c.dim(`${frame.line}:${frame.column}`)}`].filter(Boolean).join(' ')}`)) onStack?.(frame) @@ -225,7 +236,7 @@ function printStack( const hasProperties = Object.keys(errorProperties).length > 0 if (hasProperties) { logger.error(c.red(c.dim(divider()))) - const propertiesString = stringify(errorProperties, 10, { printBasicPrototype: false }) + const propertiesString = inspect(errorProperties) logger.error(c.red(c.bold('Serialized Error:')), c.gray(propertiesString)) } } diff --git a/packages/vitest/src/node/logger.ts b/packages/vitest/src/node/logger.ts index e576210de..c509b5169 100644 --- a/packages/vitest/src/node/logger.ts +++ b/packages/vitest/src/node/logger.ts @@ -7,6 +7,13 @@ import { divider } from './reporters/renderers/utils' import { RandomSequencer } from './sequencers/RandomSequencer' import type { Vitest } from './core' import { printError } from './error' +import type { WorkspaceProject } from './workspace' + +interface ErrorOptions { + type?: string + fullStack?: boolean + project?: WorkspaceProject +} export class Logger { outputStream = process.stdout @@ -68,11 +75,14 @@ export class Logger { this.console.log(`\u001B[1;1H\u001B[J${log}`) } - printError(err: unknown, fullStack = false, type?: string) { - return printError(err, this.ctx, { + printError(err: unknown, options: ErrorOptions = {}) { + const { fullStack = false, type } = options + const project = options.project ?? this.ctx.getCoreWorkspaceProject() ?? this.ctx.projects[0] + return printError(err, project, { fullStack, type, showCodeFrame: true, + logger: this, }) } @@ -137,7 +147,7 @@ export class Logger { this.log(c.red(divider(c.bold(c.inverse(' Unhandled Errors '))))) this.log(errorMessage) await Promise.all(errors.map(async (err) => { - await this.printError(err, true, (err as ErrorWithDiff).type || 'Unhandled Error') + await this.printError(err, { fullStack: true, type: (err as ErrorWithDiff).type || 'Unhandled Error' }) })) this.log(c.red(divider())) } @@ -149,7 +159,7 @@ export class Logger { this.log(c.red(divider(c.bold(c.inverse(' Source Errors '))))) this.log(errorMessage) await Promise.all(errors.map(async (err) => { - await this.printError(err, true) + await this.printError(err, { fullStack: true }) })) this.log(c.red(divider())) } diff --git a/packages/vitest/src/node/plugins/index.ts b/packages/vitest/src/node/plugins/index.ts index a71864ccf..ca080eab5 100644 --- a/packages/vitest/src/node/plugins/index.ts +++ b/packages/vitest/src/node/plugins/index.ts @@ -160,7 +160,7 @@ export async function VitestPlugin(options: UserConfig = {}, ctx = new Vitest('t (await import('../../api/setup')).setup(ctx) } catch (err) { - await ctx.logger.printError(err, true) + await ctx.logger.printError(err, { fullStack: true }) process.exit(1) } diff --git a/packages/vitest/src/node/plugins/workspace.ts b/packages/vitest/src/node/plugins/workspace.ts index 004bdb76b..87fc22e55 100644 --- a/packages/vitest/src/node/plugins/workspace.ts +++ b/packages/vitest/src/node/plugins/workspace.ts @@ -110,7 +110,7 @@ export function WorkspaceVitestPlugin(project: WorkspaceProject, options: Worksp await project.setServer(options, server) } catch (err) { - await project.ctx.logger.printError(err, true) + await project.ctx.logger.printError(err, { fullStack: true }) process.exit(1) } diff --git a/packages/vitest/src/node/pools/browser.ts b/packages/vitest/src/node/pools/browser.ts index eeb31c665..e1680db45 100644 --- a/packages/vitest/src/node/pools/browser.ts +++ b/packages/vitest/src/node/pools/browser.ts @@ -38,7 +38,7 @@ export function createBrowserPool(ctx: Vitest): ProcessPool { const provider = project.browserProvider! providers.add(provider) - const origin = `http://${ctx.config.browser.api?.host || 'localhost'}:${project.browser.config.server.port}` + const origin = `http://${ctx.config.browser.api?.host || 'localhost'}:${project.browser!.config.server.port}` const paths = files.map(file => relative(project.config.root, file)) const isolate = project.config.isolate diff --git a/packages/vitest/src/node/pools/rpc.ts b/packages/vitest/src/node/pools/rpc.ts index ad05fbcad..020ea2cf4 100644 --- a/packages/vitest/src/node/pools/rpc.ts +++ b/packages/vitest/src/node/pools/rpc.ts @@ -6,7 +6,7 @@ export function createMethodsRPC(project: WorkspaceProject): RuntimeRPC { const ctx = project.ctx return { async onWorkerExit(error, code) { - await ctx.logger.printError(error, false, 'Unexpected Exit') + await ctx.logger.printError(error, { type: 'Unexpected Exit' }) process.exit(code || 1) }, snapshotSaved(snapshot) { diff --git a/packages/vitest/src/node/reporters/base.ts b/packages/vitest/src/node/reporters/base.ts index a5aad35b8..57597cce3 100644 --- a/packages/vitest/src/node/reporters/base.ts +++ b/packages/vitest/src/node/reporters/base.ts @@ -335,7 +335,14 @@ export abstract class BaseReporter implements Reporter { for (const task of tasks) { // merge identical errors task.result?.errors?.forEach((error) => { - const errorItem = error?.stackStr && errorsQueue.find(i => i[0]?.stackStr === error.stackStr) + const errorItem = error?.stackStr && errorsQueue.find((i) => { + const hasStr = i[0]?.stackStr === error.stackStr + if (!hasStr) + return false + const currentProjectName = (task as File)?.projectName || task.file?.projectName + const projectName = (i[1][0] as File)?.projectName || i[1][0].file?.projectName + return projectName === currentProjectName + }) if (errorItem) errorItem[1].push(task) else @@ -352,7 +359,8 @@ export abstract class BaseReporter implements Reporter { this.ctx.logger.error(`${c.red(c.bold(c.inverse(' FAIL ')))} ${formatProjectName(projectName)}${name}`) } - await this.ctx.logger.printError(error) + const project = this.ctx.getProjectByTaskId(tasks[0].id) + await this.ctx.logger.printError(error, { project }) errorDivider() await Promise.resolve() } @@ -361,7 +369,7 @@ export abstract class BaseReporter implements Reporter { registerUnhandledRejection() { process.on('unhandledRejection', async (err) => { process.exitCode = 1 - await this.ctx.logger.printError(err, true, 'Unhandled Rejection') + await this.ctx.logger.printError(err, { fullStack: true, type: 'Unhandled Rejection' }) this.ctx.logger.error('\n\n') process.exit(1) }) diff --git a/packages/vitest/src/node/reporters/json.ts b/packages/vitest/src/node/reporters/json.ts index cc032f913..5405122a1 100644 --- a/packages/vitest/src/node/reporters/json.ts +++ b/packages/vitest/src/node/reporters/json.ts @@ -183,7 +183,10 @@ export class JsonReporter implements Reporter { if (!error) return - const stack = parseErrorStacktrace(error) + const project = this.ctx.getProjectByTaskId(test.id) + const stack = parseErrorStacktrace(error, { + getSourceMap: file => project.getBrowserSourceMapModuleById(file), + }) const frame = stack[0] if (!frame) return diff --git a/packages/vitest/src/node/reporters/junit.ts b/packages/vitest/src/node/reporters/junit.ts index e0fccb84f..c1dc5bbf8 100644 --- a/packages/vitest/src/node/reporters/junit.ts +++ b/packages/vitest/src/node/reporters/junit.ts @@ -125,14 +125,17 @@ export class JUnitReporter implements Reporter { await this.logger.log(``) } - async writeErrorDetails(error: ErrorWithDiff): Promise { + async writeErrorDetails(task: Task, error: ErrorWithDiff): Promise { const errorName = error.name ?? error.nameStr ?? 'Unknown Error' const errorDetails = `${errorName}: ${error.message}` // Be sure to escape any XML in the error Details await this.baseLog(escapeXML(errorDetails)) - const stack = parseErrorStacktrace(error) + const project = this.ctx.getProjectByTaskId(task.id) + const stack = parseErrorStacktrace(error, { + getSourceMap: file => project.getBrowserSourceMapModuleById(file), + }) // TODO: This is same as printStack but without colors. Find a way to reuse code. for (const frame of stack) { @@ -185,7 +188,7 @@ export class JUnitReporter implements Reporter { if (!error) return - await this.writeErrorDetails(error) + await this.writeErrorDetails(task, error) }) } } diff --git a/packages/vitest/src/node/reporters/tap.ts b/packages/vitest/src/node/reporters/tap.ts index d37538c3e..50fcf288b 100644 --- a/packages/vitest/src/node/reporters/tap.ts +++ b/packages/vitest/src/node/reporters/tap.ts @@ -68,11 +68,15 @@ export class TapReporter implements Reporter { else { this.logger.log(`${ok} ${id} - ${tapString(task.name)}${comment}`) + const project = this.ctx.getProjectByTaskId(task.id) + if (task.result?.state === 'fail' && task.result.errors) { this.logger.indent() task.result.errors.forEach((error) => { - const stacks = parseErrorStacktrace(error) + const stacks = parseErrorStacktrace(error, { + getSourceMap: file => project.getBrowserSourceMapModuleById(file), + }) const stack = stacks[0] this.logger.log('---') diff --git a/packages/vitest/src/node/workspace.ts b/packages/vitest/src/node/workspace.ts index d123d71e3..44b2cb913 100644 --- a/packages/vitest/src/node/workspace.ts +++ b/packages/vitest/src/node/workspace.ts @@ -67,7 +67,7 @@ export class WorkspaceProject { server!: ViteDevServer vitenode!: ViteNodeServer runner!: ViteNodeRunner - browser: ViteDevServer = undefined! + browser?: ViteDevServer typechecker?: Typechecker closingPromise: Promise | undefined @@ -86,6 +86,20 @@ export class WorkspaceProject { return this.ctx.getCoreWorkspaceProject() === this } + getModuleById(id: string) { + return this.server.moduleGraph.getModuleById(id) + || this.browser?.moduleGraph.getModuleById(id) + } + + getSourceMapModuleById(id: string) { + const mod = this.server.moduleGraph.getModuleById(id) + return mod?.ssrTransformResult?.map || mod?.transformResult?.map + } + + getBrowserSourceMapModuleById(id: string) { + return this.browser?.moduleGraph.getModuleById(id)?.transformResult?.map + } + get reporters() { return this.ctx.reporters } diff --git a/packages/vitest/src/typecheck/typechecker.ts b/packages/vitest/src/typecheck/typechecker.ts index 1cf4176e8..e56ce36fc 100644 --- a/packages/vitest/src/typecheck/typechecker.ts +++ b/packages/vitest/src/typecheck/typechecker.ts @@ -2,7 +2,7 @@ import { rm } from 'node:fs/promises' import type { ExecaChildProcess } from 'execa' import { execa } from 'execa' import { basename, extname, resolve } from 'pathe' -import { TraceMap, generatedPositionFor } from '@jridgewell/trace-mapping' +import { TraceMap, generatedPositionFor } from '@vitest/utils/source-map' import type { RawSourceMap } from '@ampproject/remapping' import { getTasks } from '../utils' import { ensurePackageInstalled } from '../node/pkg' diff --git a/packages/vitest/src/utils/source-map.ts b/packages/vitest/src/utils/source-map.ts index 626dacd66..5f86df694 100644 --- a/packages/vitest/src/utils/source-map.ts +++ b/packages/vitest/src/utils/source-map.ts @@ -1,8 +1,10 @@ export { lineSplitRE, - parseSingleStack, - parseStacktrace, - parseErrorStacktrace, positionToOffset, offsetToLineNumber, } from '@vitest/utils' +export { + parseSingleStack, + parseStacktrace, + parseErrorStacktrace, +} from '@vitest/utils/source-map' diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7438ef3ea..b103522bd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1206,6 +1206,10 @@ importers: pretty-format: specifier: ^29.5.0 version: 29.5.0 + devDependencies: + '@jridgewell/trace-mapping': + specifier: ^0.3.18 + version: 0.3.18 packages/vite-node: dependencies: @@ -1328,9 +1332,6 @@ importers: '@edge-runtime/vm': specifier: 3.0.3 version: 3.0.3 - '@jridgewell/trace-mapping': - specifier: ^0.3.18 - version: 0.3.18 '@sinonjs/fake-timers': specifier: ^11.0.0 version: 11.0.0 diff --git a/test/browser/package.json b/test/browser/package.json index c2477b82c..c4a8eeb41 100644 --- a/test/browser/package.json +++ b/test/browser/package.json @@ -6,7 +6,7 @@ "test": "pnpm run test:webdriverio && pnpm run test:playwright", "test:webdriverio": "PROVIDER=webdriverio node --test specs/", "test:playwright": "PROVIDER=playwright node --test specs/", - "coverage": "vitest --coverage.enabled --coverage.provider=istanbul" + "coverage": "vitest --coverage.enabled --coverage.provider=istanbul --browser.headless=yes" }, "devDependencies": { "@vitest/browser": "workspace:*", diff --git a/test/browser/specs/runner.test.mjs b/test/browser/specs/runner.test.mjs index bfadbb3b3..713876a32 100644 --- a/test/browser/specs/runner.test.mjs +++ b/test/browser/specs/runner.test.mjs @@ -14,13 +14,16 @@ const { stderr, stdout } = await execa('npx', ['vitest', '--run', `--browser.nam reject: false, }) -await test('tests are actually running', async () => { - const browserResult = await readFile('./browser.json', 'utf-8') - const browserResultJson = JSON.parse(browserResult) +const browserResult = await readFile('./browser.json', 'utf-8') +const browserResultJson = JSON.parse(browserResult) + +const getPassed = results => results.filter(result => result.status === 'passed') +const getFailed = results => results.filter(result => result.status === 'failed') - const passedTests = browserResultJson.testResults.filter(result => result.status === 'passed') - const failedTests = browserResultJson.testResults.filter(result => result.status === 'failed') +const passedTests = getPassed(browserResultJson.testResults) +const failedTests = getFailed(browserResultJson.testResults) +await test('tests are actually running', async () => { assert.ok(browserResultJson.testResults.length === 8, 'Not all the tests have been run') assert.ok(passedTests.length === 7, 'Some tests failed') assert.ok(failedTests.length === 1, 'Some tests have passed but should fail') @@ -61,6 +64,11 @@ await test('logs are redirected to stderr', async () => { assert.match(stderr, /Timer "invalid timeEnd" does not exist/, 'prints errored timeEnd') }) +await test('stack trace points to correct file in every browser', () => { + // dependeing on the browser it references either `.toBe()` or `expect()` + assert.match(stderr, /test\/failing.test.ts:4:(12|17)/, 'prints stack trace') +}) + await test('popup apis should log a warning', () => { assert.ok(stderr.includes('Vitest encountered a \`alert\("test"\)\`'), 'prints warning for alert') assert.ok(stderr.includes('Vitest encountered a \`confirm\("test"\)\`'), 'prints warning for confirm') diff --git a/test/browser/vitest.config.mts b/test/browser/vitest.config.mts index 52236bf24..7247ea6ba 100644 --- a/test/browser/vitest.config.mts +++ b/test/browser/vitest.config.mts @@ -11,7 +11,7 @@ export default defineConfig({ include: ['test/**.test.{ts,js}'], browser: { enabled: true, - name: 'chrome', + name: process.env.BROWSER || 'chrome', headless: false, provider: process.env.PROVIDER || 'webdriverio', }, diff --git a/test/reporters/src/context.ts b/test/reporters/src/context.ts index ba7be09b6..08dc96d3d 100644 --- a/test/reporters/src/context.ts +++ b/test/reporters/src/context.ts @@ -32,6 +32,7 @@ export function getContext(): Context { state: state as StateManager, config: config as ResolvedConfig, server: server as ViteDevServer, + getProjectByTaskId: () => ({ getBrowserSourceMapModuleById: () => undefined }) as any, } context.logger = {