diff --git a/packages/vitest/src/runtime/moduleRunner/nativeModuleMocker.ts b/packages/vitest/src/runtime/moduleRunner/nativeModuleMocker.ts index 893316d8b..1259521d1 100644 --- a/packages/vitest/src/runtime/moduleRunner/nativeModuleMocker.ts +++ b/packages/vitest/src/runtime/moduleRunner/nativeModuleMocker.ts @@ -15,15 +15,17 @@ import { BareModuleMocker, normalizeModuleId } from './bareModuleMocker' export class NativeModuleMocker extends BareModuleMocker { public wrapDynamicImport(moduleFactory: () => Promise): Promise { - if (typeof moduleFactory === 'function') { - const promise = new Promise((resolve, reject) => { - this.resolveMocks().finally(() => { - moduleFactory().then(resolve, reject) - }) - }) - return promise + if (typeof moduleFactory !== 'function') { + return moduleFactory } - return moduleFactory + // Node.js can evaluate the module outside of the importer's async chain, + // so the stack of an import error would not point at the import + const importer = new Error('import location') + return new Promise((resolve, reject) => { + this.resolveMocks().finally(() => { + moduleFactory().then(resolve, (error) => reject(addImporterFrame(error, importer))) + }) + }) } public resolveMockedModule(url: string, parentURL: string): module.ResolveFnOutput | undefined { @@ -288,3 +290,29 @@ function transformCode(code: string, format: string, filename: string) { } return code } + +function addImporterFrame(error: unknown, importer: Error): unknown { + const lines = importer.stack?.split('\n') || [] + const index = lines.findIndex((line) => line.includes('wrapDynamicImport')) + const frame = index === -1 ? undefined : lines[index + 1] + const file = frame?.match(/\(?([^()\s]+):\d+:\d+\)?$/)?.[1] + if (!frame || !file || frame.includes('node:')) { + return error + } + const asyncFrame = frame.replace(/^(\s*)at /, '$1at async ') + const seen = new Set() + let current: unknown = error + while (current instanceof Error && !seen.has(current)) { + seen.add(current) + const stack = current.stack + // older Node.js versions keep the async frame of the import themselves + if ( + typeof stack === 'string' && + !stack.split('\n').some((line) => line.includes('at async ') && line.includes(file)) + ) { + current.stack = `${stack}\n${asyncFrame}` + } + current = current.cause + } + return error +} diff --git a/test/e2e/test/no-module-runner.test.ts b/test/e2e/test/no-module-runner.test.ts index 3e7b3a78d..264141b11 100644 --- a/test/e2e/test/no-module-runner.test.ts +++ b/test/e2e/test/no-module-runner.test.ts @@ -766,14 +766,22 @@ test('not reported') }) // "slice" removes the stack from unhandled error because it references built artifacts - expect(stderr.split('\n').slice(0, 17).join('\n')).toMatchInlineSnapshot(` + expect(stderr.split('\n').slice(0, 25).join('\n')).toMatchInlineSnapshot(` " ⎯⎯⎯⎯⎯⎯ Failed Suites 1 ⎯⎯⎯⎯⎯⎯⎯ FAIL add.test.js [ add.test.js ] Error: [vitest] There was an error when mocking a module. If you are using "vi.mock" factory, make sure there are no top level variables inside, since this call is hoisted to top of the file. Read more: https://vitest.dev/api/vi.html#vi-mock + ❯ add.test.js:2:1 + 1| + 2| import { add } from './add.js' + | ^ + 3| vi.mock('./add.js', async () => { + 4| await Promise.resolve() + Caused by: Error: error from factory ❯ add.test.js:5:9 + ❯ add.test.js:2:1 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯