From c61511d4a5c5cc2a3db524fe389f4d1d97668068 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Tue, 17 Feb 2026 18:24:31 +0900 Subject: [PATCH 1/2] fix: preserve stack trace of `resolves/rejects` chained assertion error (#9679) --- packages/expect/src/jest-expect.ts | 32 +++++--- test/cli/test/stacktraces.test.ts | 122 ++++++++++++++++++++++++++++- 2 files changed, 141 insertions(+), 13 deletions(-) diff --git a/packages/expect/src/jest-expect.ts b/packages/expect/src/jest-expect.ts index dad17d6d31d7..e483676f1062 100644 --- a/packages/expect/src/jest-expect.ts +++ b/packages/expect/src/jest-expect.ts @@ -1098,7 +1098,7 @@ export const JestChaiExpect: ChaiPlugin = (chai, utils) => { return (...args: any[]) => { utils.flag(this, '_name', key) - const promise = obj.then( + const promise = Promise.resolve(obj).then( (value: any) => { utils.flag(this, 'object', value) return result.call(this, ...args) @@ -1111,13 +1111,17 @@ export const JestChaiExpect: ChaiPlugin = (chai, utils) => { { showDiff: false }, ) as Error _error.cause = err - _error.stack = (error.stack as string).replace( - error.message, - _error.message, - ) throw _error }, - ) + ).catch((err: any) => { + if (isError(err) && error.stack) { + err.stack = error.stack.replace( + error.message, + err.message, + ) + } + throw err + }) return recordAsyncExpect( test, @@ -1166,7 +1170,7 @@ export const JestChaiExpect: ChaiPlugin = (chai, utils) => { return (...args: any[]) => { utils.flag(this, '_name', key) - const promise = wrapper.then( + const promise = Promise.resolve(wrapper).then( (value: any) => { const _error = new AssertionError( `promise resolved "${utils.inspect( @@ -1178,17 +1182,21 @@ export const JestChaiExpect: ChaiPlugin = (chai, utils) => { actual: value, }, ) as any - _error.stack = (error.stack as string).replace( - error.message, - _error.message, - ) throw _error }, (err: any) => { utils.flag(this, 'object', err) return result.call(this, ...args) }, - ) + ).catch((err: any) => { + if (isError(err) && error.stack) { + err.stack = error.stack.replace( + error.message, + err.message, + ) + } + throw err + }) return recordAsyncExpect( test, diff --git a/test/cli/test/stacktraces.test.ts b/test/cli/test/stacktraces.test.ts index 042663203c97..717fcae406e3 100644 --- a/test/cli/test/stacktraces.test.ts +++ b/test/cli/test/stacktraces.test.ts @@ -1,7 +1,7 @@ import { resolve } from 'pathe' import { glob } from 'tinyglobby' import { describe, expect, it } from 'vitest' -import { runVitest } from '../../test-utils' +import { runInlineTests, runVitest } from '../../test-utils' // To prevent the warning coming up in snapshots process.setMaxListeners(20) @@ -193,3 +193,123 @@ it('custom helper with captureStackTrace', async () => { } `) }) + +it('resolves/rejects', async () => { + const { stderr, errorTree } = await runInlineTests({ + 'repro.test.ts': ` + import { test, expect } from 'vitest' + + test('resolves: resolved promise with mismatched value', async () => { + await expect(Promise.resolve(3)).resolves.toBe(4) + }) + + test('rejects: rejected promise with mismatched value', async () => { + await expect(Promise.reject(3)).rejects.toBe(4) + }) + + test('rejects: resolves when rejection expected', async () => { + await expect(Promise.resolve(3)).rejects.toBe(4) + }) + + test('resolves: rejects when resolve expected', async () => { + await expect(Promise.reject(3)).resolves.toBe(4) + }) + `, + }) + + expect(stderr).toMatchInlineSnapshot(` + " + ⎯⎯⎯⎯⎯⎯⎯ Failed Tests 4 ⎯⎯⎯⎯⎯⎯⎯ + + FAIL repro.test.ts > resolves: resolved promise with mismatched value + AssertionError: expected 3 to be 4 // Object.is equality + + - Expected + + Received + + - 4 + + 3 + + ❯ repro.test.ts:5:40 + 3| + 4| test('resolves: resolved promise with mismatched value', async (… + 5| await expect(Promise.resolve(3)).resolves.toBe(4) + | ^ + 6| }) + 7| + + ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/4]⎯ + + FAIL repro.test.ts > rejects: rejected promise with mismatched value + AssertionError: expected 3 to be 4 // Object.is equality + + - Expected + + Received + + - 4 + + 3 + + ❯ repro.test.ts:9:39 + 7| + 8| test('rejects: rejected promise with mismatched value', async ()… + 9| await expect(Promise.reject(3)).rejects.toBe(4) + | ^ + 10| }) + 11| + + ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[2/4]⎯ + + FAIL repro.test.ts > rejects: resolves when rejection expected + AssertionError: promise resolved "3" instead of rejecting + + - Expected: + Error { + "message": "rejected promise", + } + + + Received: + 3 + + ❯ repro.test.ts:13:40 + 11| + 12| test('rejects: resolves when rejection expected', async () => { + 13| await expect(Promise.resolve(3)).rejects.toBe(4) + | ^ + 14| }) + 15| + + ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[3/4]⎯ + + FAIL repro.test.ts > resolves: rejects when resolve expected + AssertionError: promise rejected "3" instead of resolving + ❯ repro.test.ts:17:39 + 15| + 16| test('resolves: rejects when resolve expected', async () => { + 17| await expect(Promise.reject(3)).resolves.toBe(4) + | ^ + 18| }) + 19| + + ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[4/4]⎯ + + " + `) + expect(errorTree()).toMatchInlineSnapshot(` + { + "repro.test.ts": { + "rejects: rejected promise with mismatched value": [ + "expected 3 to be 4 // Object.is equality", + ], + "rejects: resolves when rejection expected": [ + "promise resolved "3" instead of rejecting", + ], + "resolves: rejects when resolve expected": [ + "promise rejected "3" instead of resolving", + ], + "resolves: resolved promise with mismatched value": [ + "expected 3 to be 4 // Object.is equality", + ], + }, + } + `) +}) From bb20389f4b1a74e8357ae1229896d0c8817088c1 Mon Sep 17 00:00:00 2001 From: Leslie Hoare Date: Tue, 17 Feb 2026 13:16:59 +0000 Subject: [PATCH 2/2] fix: handle module-sync condition in vmThreads/vmForks require (fix #9650) (#9651) --- .../src/runtime/vm/commonjs-executor.ts | 52 +++++++++++- test/cli/test/vm-threads.test.ts | 58 +++++++++++++- test/core/test/parse-cjs-conditions.test.ts | 80 +++++++++++++++++++ 3 files changed, 187 insertions(+), 3 deletions(-) create mode 100644 test/core/test/parse-cjs-conditions.test.ts diff --git a/packages/vitest/src/runtime/vm/commonjs-executor.ts b/packages/vitest/src/runtime/vm/commonjs-executor.ts index f34e3f07d7fe..1b0e01704c27 100644 --- a/packages/vitest/src/runtime/vm/commonjs-executor.ts +++ b/packages/vitest/src/runtime/vm/commonjs-executor.ts @@ -200,10 +200,29 @@ export class CommonjsExecutor { m.exports = JSON.parse(code) } + private static cjsConditions: Set | undefined + private static getCjsConditions(): Set { + if (!CommonjsExecutor.cjsConditions) { + CommonjsExecutor.cjsConditions = parseCjsConditions( + process.execArgv, + process.env.NODE_OPTIONS, + ) + } + return CommonjsExecutor.cjsConditions + } + public createRequire = (filename: string | URL): NodeJS.Require => { const _require = createRequire(filename) + const resolve = (id: string, options?: { paths?: string[] }) => { + return _require.resolve(id, { + ...options, + // Works on Node 22.12+ where _resolveFilename supports conditions. + // Silently ignored on older Node versions. + conditions: CommonjsExecutor.getCjsConditions(), + } as any) + } const require = ((id: string) => { - const resolved = _require.resolve(id) + const resolved = resolve(id) const ext = extname(resolved) if (ext === '.node' || isBuiltin(resolved)) { return this.requireCoreModule(resolved) @@ -211,7 +230,8 @@ export class CommonjsExecutor { const module = new this.Module(resolved) return this.loadCommonJSModule(module, resolved) }) as NodeJS.Require - require.resolve = _require.resolve + require.resolve = resolve as NodeJS.RequireResolve + require.resolve.paths = _require.resolve.paths Object.defineProperty(require, 'extensions', { get: () => this.extensions, set: () => {}, @@ -381,3 +401,31 @@ export class CommonjsExecutor { return moduleExports } } + +// The "module-sync" exports condition (added in Node 22.12/20.19 when +// require(esm) was unflagged) can resolve to ESM files that our CJS +// vm.Script executor cannot handle. We exclude it by passing explicit +// CJS conditions to require.resolve (Node 22.12+). +// Must be a Set because Node's internal resolver calls conditions.has(). +// User-specified --conditions/-C flags are respected, except module-sync. +export function parseCjsConditions( + execArgv: string[], + nodeOptions?: string, +): Set { + const conditions = ['node', 'require', 'node-addons'] + const args = [ + ...execArgv, + ...(nodeOptions?.split(/\s+/) ?? []), + ] + for (let i = 0; i < args.length; i++) { + const arg = args[i] + const eqMatch = arg.match(/^(?:--conditions|-C)=(.+)$/) + if (eqMatch) { + conditions.push(eqMatch[1]) + } + else if ((arg === '--conditions' || arg === '-C') && i + 1 < args.length) { + conditions.push(args[++i]) + } + } + return new Set(conditions.filter(c => c !== 'module-sync')) +} diff --git a/test/cli/test/vm-threads.test.ts b/test/cli/test/vm-threads.test.ts index a92b492964b7..ca69e341a5dc 100644 --- a/test/cli/test/vm-threads.test.ts +++ b/test/cli/test/vm-threads.test.ts @@ -1,6 +1,6 @@ import { expect, test } from 'vitest' -import { createFile, resolvePath, runVitest } from '../../test-utils' +import { createFile, resolvePath, runInlineTests, runVitest } from '../../test-utils' test('importing files in restricted fs works correctly', async () => { createFile( @@ -15,3 +15,59 @@ test('importing files in restricted fs works correctly', async () => { expect(stderr).toBe('') expect(exitCode).toBe(0) }) + +// The module-sync condition was added in Node 22.12/20.19 when require(esm) +// was unflagged. The fix uses the _resolveFilename conditions option which +// is only available on Node 22.12+. Node 20 is unfixable and reaches EOL +// April 2026. +const nodeMajor = Number(process.versions.node.split('.')[0]) +test.skipIf(nodeMajor < 22)('can require package with module-sync exports condition', async () => { + const { stderr, exitCode } = await runInlineTests({ + // .mjs module-sync entry + 'node_modules/module-sync-mjs/package.json': JSON.stringify({ + name: 'module-sync-mjs', + exports: { + '.': { + 'module-sync': './index.mjs', + 'require': './index.cjs', + }, + }, + }), + 'node_modules/module-sync-mjs/index.mjs': 'export const value = "esm";', + 'node_modules/module-sync-mjs/index.cjs': 'module.exports = { value: "cjs" };', + // .js module-sync entry with "type": "module" + 'node_modules/module-sync-js/package.json': JSON.stringify({ + name: 'module-sync-js', + type: 'module', + exports: { + '.': { + 'module-sync': './index.js', + 'require': './index.cjs', + }, + }, + }), + 'node_modules/module-sync-js/index.js': 'export const value = "esm";', + 'node_modules/module-sync-js/index.cjs': 'module.exports = { value: "cjs" };', + 'basic.test.js': ` + import { createRequire } from 'node:module' + import { expect, test } from 'vitest' + + const require = createRequire(import.meta.url) + + test('require loads cjs entry for module-sync package (.mjs)', () => { + const mod = require('module-sync-mjs') + expect(mod.value).toBe('cjs') + }) + + test('require loads cjs entry for module-sync package (.js with type: module)', () => { + const mod = require('module-sync-js') + expect(mod.value).toBe('cjs') + }) + `, + }, { + pool: 'vmThreads', + }) + + expect(stderr).toBe('') + expect(exitCode).toBe(0) +}) diff --git a/test/core/test/parse-cjs-conditions.test.ts b/test/core/test/parse-cjs-conditions.test.ts new file mode 100644 index 000000000000..8ca5a32a3d42 --- /dev/null +++ b/test/core/test/parse-cjs-conditions.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'vitest' +import { parseCjsConditions } from '../../../packages/vitest/src/runtime/vm/commonjs-executor' + +describe('parseCjsConditions', () => { + it('returns default conditions with no arguments', () => { + const result = parseCjsConditions([], undefined) + expect(result).toEqual(new Set(['node', 'require', 'node-addons'])) + }) + + it('parses --conditions=value from execArgv', () => { + const result = parseCjsConditions(['--conditions=custom'], undefined) + expect(result).toEqual(new Set(['node', 'require', 'node-addons', 'custom'])) + }) + + it('parses --conditions value (space-separated) from execArgv', () => { + const result = parseCjsConditions(['--conditions', 'custom'], undefined) + expect(result).toEqual(new Set(['node', 'require', 'node-addons', 'custom'])) + }) + + it('parses -C=value from execArgv', () => { + const result = parseCjsConditions(['-C=custom'], undefined) + expect(result).toEqual(new Set(['node', 'require', 'node-addons', 'custom'])) + }) + + it('parses -C value (space-separated) from execArgv', () => { + const result = parseCjsConditions(['-C', 'custom'], undefined) + expect(result).toEqual(new Set(['node', 'require', 'node-addons', 'custom'])) + }) + + it('parses conditions from NODE_OPTIONS', () => { + const result = parseCjsConditions([], '--conditions=custom') + expect(result).toEqual(new Set(['node', 'require', 'node-addons', 'custom'])) + }) + + it('parses space-separated conditions from NODE_OPTIONS', () => { + const result = parseCjsConditions([], '--conditions custom') + expect(result).toEqual(new Set(['node', 'require', 'node-addons', 'custom'])) + }) + + it('handles multiple conditions from both sources', () => { + const result = parseCjsConditions( + ['--conditions=from-cli', '-C', 'another'], + '--conditions=from-env', + ) + expect(result).toEqual(new Set([ + 'node', + 'require', + 'node-addons', + 'from-cli', + 'another', + 'from-env', + ])) + }) + + it('filters out module-sync', () => { + const result = parseCjsConditions(['--conditions=module-sync'], undefined) + expect(result).toEqual(new Set(['node', 'require', 'node-addons'])) + }) + + it('filters out module-sync but keeps other conditions', () => { + const result = parseCjsConditions( + ['--conditions=module-sync', '--conditions=custom'], + undefined, + ) + expect(result).toEqual(new Set(['node', 'require', 'node-addons', 'custom'])) + }) + + it('ignores unrelated execArgv entries', () => { + const result = parseCjsConditions( + ['--experimental-vm-modules', '-e', 'console.log("hi")'], + undefined, + ) + expect(result).toEqual(new Set(['node', 'require', 'node-addons'])) + }) + + it('ignores trailing --conditions with no value', () => { + const result = parseCjsConditions(['--conditions'], undefined) + expect(result).toEqual(new Set(['node', 'require', 'node-addons'])) + }) +})