Files
pnpm/pnpm11/exec/esm-node-path-loader/test/index.ts
Zoltan Kochan ae462e10ea feat(gvs): restore NODE_PATH resolution for scripts, ESM included (#13931)
With a global virtual store, package directories live outside the
project, so Node's upward node_modules walk from their real paths never
reaches the project's hoisted node_modules — undeclared (phantom)
dependencies stop resolving, and Node ignores NODE_PATH entirely for
ESM imports (Related to pnpm/pnpm#9618).

When enableGlobalVirtualStore is on (and extendNodePath is not
disabled), every child process pnpm spawns — run, exec, lifecycle
scripts, dlx — now receives NODE_PATH pointing at the private hoist dir
and the root node_modules, plus a NODE_OPTIONS --import flag that
registers a resolve hook restoring NODE_PATH lookups for ESM, replacing
the plugin-esm-node-path config dependency.

The flag is a self-contained constant: the registration module and the
hook are nested data: URLs, so no loader file has to exist on disk and
the flag stays valid regardless of which project or pnpm version
spawned the child. It prefers module.registerHooks() (in-thread, no
DEP0205 deprecation warning) and falls back to module.register() on
Node >=18.19 <22.15. Everything outside the RFC 3986 unreserved set is
percent-encoded: encodeURIComponent alone leaves single quotes bare,
and the NODE_OPTIONS tokenizer treats those as quote delimiters.

Both stacks embed identical hook sources and each asserts its derived
flag against the shared golden file
pnpm11/exec/esm-node-path-loader/test/import-flag.txt, so they cannot
drift apart silently.

Where the nodeOptions setting overwrites NODE_OPTIONS (run, exec,
recursive run, install lifecycle), the flag is re-applied. pacquet's
dlx and global installs force GVS off for their self-contained trees
and now strip the injected env from the caller's cloned config, so a
tool never resolves phantoms from the invoking project's tree; the
TypeScript dlx (GVS on by default) adds the flag and lets the bin shim
supply NODE_PATH.

This deliberately does not address the TypeScript-compiler side of
pnpm/pnpm#13210: tsc, tsserver, and bundlers implement their own
resolution and honor neither NODE_PATH nor Node loader hooks. That
class remains packageExtensions / compat-DB territory.
2026-08-16 11:20:12 +02:00

153 lines
6.3 KiB
TypeScript

import { spawnSync } from 'node:child_process'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { describe, expect, test } from '@jest/globals'
import {
addEsmNodePathLoaderOption,
esmNodePathLoaderImportFlag,
keepEsmNodePathLoaderOption,
} from '@pnpm/exec.esm-node-path-loader'
test('the flag is a self-contained data: URL import', () => {
expect(esmNodePathLoaderImportFlag).toMatch(/^--import=data:text\/javascript,/)
// The flag must never contain characters that Node's NODE_OPTIONS
// tokenizer would split or unquote.
expect(esmNodePathLoaderImportFlag).not.toMatch(/[\s"'\\]/)
})
test('the flag matches the golden copy shared with the Rust CLI', () => {
// The Rust CLI derives the same flag from its own embedded copy of the
// hook sources and asserts against the same file
// (pnpm/crates/config/src/esm_node_path_loader/tests.rs), so the two
// stacks cannot drift apart without one of the tests failing.
const golden = fs.readFileSync(new URL('./import-flag.txt', import.meta.url), 'utf8')
expect(esmNodePathLoaderImportFlag).toBe(golden)
})
describe('addEsmNodePathLoaderOption', () => {
test('returns just the flag when NODE_OPTIONS is empty', () => {
expect(addEsmNodePathLoaderOption(undefined)).toBe(esmNodePathLoaderImportFlag)
expect(addEsmNodePathLoaderOption('')).toBe(esmNodePathLoaderImportFlag)
})
test('appends the flag to existing NODE_OPTIONS', () => {
expect(addEsmNodePathLoaderOption('--max-old-space-size=4096'))
.toBe(`--max-old-space-size=4096 ${esmNodePathLoaderImportFlag}`)
})
test('does not duplicate the flag', () => {
const once = addEsmNodePathLoaderOption('--enable-source-maps')
expect(addEsmNodePathLoaderOption(once)).toBe(once)
})
})
describe('keepEsmNodePathLoaderOption', () => {
test('reapplies the flag when the replaced NODE_OPTIONS carried it', () => {
expect(keepEsmNodePathLoaderOption('--no-warnings', addEsmNodePathLoaderOption(undefined)))
.toBe(`--no-warnings ${esmNodePathLoaderImportFlag}`)
})
test('leaves NODE_OPTIONS alone when the previous value did not carry the flag', () => {
expect(keepEsmNodePathLoaderOption('--no-warnings', undefined)).toBe('--no-warnings')
expect(keepEsmNodePathLoaderOption('--no-warnings', '--enable-source-maps')).toBe('--no-warnings')
})
})
describe('the registered loader', () => {
test('resolves a phantom ESM import through NODE_PATH', () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pnpm-esm-node-path-loader-'))
const phantomDir = path.join(tmp, 'store', 'node_modules', 'phantom-dep')
fs.mkdirSync(phantomDir, { recursive: true })
fs.writeFileSync(path.join(phantomDir, 'package.json'), JSON.stringify({ name: 'phantom-dep', version: '1.0.0', main: 'index.js' }))
fs.writeFileSync(path.join(phantomDir, 'index.js'), 'module.exports = "phantom-resolved"')
const appDir = path.join(tmp, 'app')
fs.mkdirSync(appDir)
const script = path.join(appDir, 'main.mjs')
fs.writeFileSync(script, 'import dep from "phantom-dep"\nconsole.log(dep)')
const env = {
...process.env,
NODE_PATH: path.join(tmp, 'store', 'node_modules'),
}
const withoutLoader = spawnSync(process.execPath, [script], { env: { ...env, NODE_OPTIONS: '' } })
expect(withoutLoader.status).not.toBe(0)
const withLoader = spawnSync(process.execPath, [script], { env: { ...env, NODE_OPTIONS: esmNodePathLoaderImportFlag } })
expect(withLoader.stderr.toString()).toBe('')
expect(withLoader.status).toBe(0)
expect(withLoader.stdout.toString().trim()).toBe('phantom-resolved')
})
test('the fallback preserves import conditions for dual-condition packages', () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pnpm-esm-node-path-loader-'))
const dualDir = path.join(tmp, 'store', 'node_modules', 'dual-dep')
fs.mkdirSync(dualDir, { recursive: true })
fs.writeFileSync(path.join(dualDir, 'package.json'), JSON.stringify({
name: 'dual-dep',
version: '1.0.0',
exports: {
'.': {
import: './esm.mjs',
require: './cjs.js',
},
},
}))
fs.writeFileSync(path.join(dualDir, 'esm.mjs'), 'export default "esm-target"')
fs.writeFileSync(path.join(dualDir, 'cjs.js'), 'module.exports = "cjs-target"')
const script = path.join(tmp, 'main.mjs')
fs.writeFileSync(script, 'import dep from "dual-dep"\nconsole.log(dep)')
const result = spawnSync(process.execPath, [script], {
env: {
...process.env,
NODE_PATH: path.join(tmp, 'store', 'node_modules'),
NODE_OPTIONS: esmNodePathLoaderImportFlag,
},
})
expect(result.stderr.toString()).toBe('')
expect(result.status).toBe(0)
expect(result.stdout.toString().trim()).toBe('esm-target')
})
test('a resolution error from a NODE_PATH entry surfaces instead of the not-found error', () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pnpm-esm-node-path-loader-'))
const depDir = path.join(tmp, 'store', 'node_modules', 'exported-dep')
fs.mkdirSync(depDir, { recursive: true })
fs.writeFileSync(path.join(depDir, 'package.json'), JSON.stringify({
name: 'exported-dep',
version: '1.0.0',
exports: { '.': './index.js' },
}))
fs.writeFileSync(path.join(depDir, 'index.js'), 'module.exports = "main"')
const script = path.join(tmp, 'main.mjs')
fs.writeFileSync(script, 'await import("exported-dep/unexported")')
const result = spawnSync(process.execPath, [script], {
env: {
...process.env,
NODE_PATH: path.join(tmp, 'store', 'node_modules'),
NODE_OPTIONS: esmNodePathLoaderImportFlag,
},
})
expect(result.status).not.toBe(0)
expect(result.stderr.toString()).toContain('ERR_PACKAGE_PATH_NOT_EXPORTED')
})
test('still fails cleanly when the specifier is nowhere on NODE_PATH', () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pnpm-esm-node-path-loader-'))
const script = path.join(tmp, 'main.mjs')
fs.writeFileSync(script, 'await import("truly-missing-dep")')
const result = spawnSync(process.execPath, [script], {
env: {
...process.env,
NODE_PATH: tmp,
NODE_OPTIONS: esmNodePathLoaderImportFlag,
},
})
expect(result.status).not.toBe(0)
expect(result.stderr.toString()).toContain('truly-missing-dep')
})
})