Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 50 additions & 2 deletions packages/vitest/src/runtime/vm/commonjs-executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,18 +200,38 @@ export class CommonjsExecutor {
m.exports = JSON.parse(code)
}

private static cjsConditions: Set<string> | undefined
private static getCjsConditions(): Set<string> {
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)
}
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: () => {},
Expand Down Expand Up @@ -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<string> {
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'))
}
58 changes: 57 additions & 1 deletion test/cli/test/vm-threads.test.ts
Original file line number Diff line number Diff line change
@@ -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(
Expand All @@ -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)
})
80 changes: 80 additions & 0 deletions test/core/test/parse-cjs-conditions.test.ts
Original file line number Diff line number Diff line change
@@ -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']))
})
})
Loading