From d5df4b3b7b74362bd7cb8ebec426568267b29811 Mon Sep 17 00:00:00 2001 From: Ross Brandes Date: Tue, 22 Sep 2026 16:01:05 -0400 Subject: [PATCH] Register ESM hooks with registerHooks() on Node 26+ to drop DEP0205 module.register() is deprecated as of Node 26 and warns once per process that replaces an ES module without --loader. Use module.registerHooks() (synchronous, in-thread) instead when it's safe to: - registerHooks() also intercepts CommonJS require(), which register() never did, so the sync resolve hook skips resolutions whose context.conditions includes "require". - registerHooks() exists on Node 22.15+/23.5+, but a Node bug (fixed in 22.23, 24.12, 25.2 and 26.0) means a sync hook can't be combined with an off-thread loader (--loader, tsx, ts-node, ...) once ESM imports CommonJS. So registerHooks() is only used where register() doesn't exist at all, or on Node 26+. - canRegisterLoader() now returns true if either API exists. Extracted the plan/resolve/load decision logic shared by the existing async hooks (quibble.mjs) and the new sync hooks (quibble-sync-hooks.js) into its own module. What's left un-shared, because it's genuinely specific to one style of hook: - the sync hooks' skip of CommonJS require() (registerHooks() sees require(), Module.register() never did) - the sync hooks' stripping of a __quibble query tag a coexisting --loader=quibble may have already added (finishResolve's normalizeUrl parameter) Adds test/esm-lib/quibble-loader-registration.test.js, which spawns fresh Node processes (so quibble has to auto-register) to check stubbing, CJS require() is untouched, no DEP0205 on stderr, and coexistence with another off-thread loader. --- CHANGELOG.md | 14 ++ lib/canRegisterLoader.js | 3 +- lib/loader-helpers.js | 217 ++++++++++++++++++ lib/quibble-sync-hooks.js | 65 ++++++ lib/quibble.js | 36 +++ lib/quibble.mjs | 158 ++----------- test/esm-fixtures/a-cjs-module.cjs | 1 + test/esm-fixtures/passthrough-loader.mjs | 8 + test/esm-fixtures/registration-child.js | 28 +++ .../quibble-loader-registration.test.js | 60 +++++ 10 files changed, 456 insertions(+), 134 deletions(-) create mode 100644 lib/loader-helpers.js create mode 100644 lib/quibble-sync-hooks.js create mode 100644 test/esm-fixtures/a-cjs-module.cjs create mode 100644 test/esm-fixtures/passthrough-loader.mjs create mode 100644 test/esm-fixtures/registration-child.js create mode 100644 test/esm-lib/quibble-loader-registration.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 83677f4..21aa75b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,17 @@ +# Unreleased + +* Stop emitting `[DEP0205] module.register() is deprecated` on Node.js 26+ + * On Node 26 and later, quibble now registers its ES module hooks with + `module.registerHooks()` (synchronous, in-thread) instead of + `module.register()` + * Earlier versions are unchanged and keep using `module.register()`. Node + versions from 22.15 through 22.22, 23.x, and 24.0 through 24.11 have + `registerHooks`, but it can't be combined with an off-thread loader (e.g. + `--loader`, tsx, ts-node) when ESM imports CommonJS, so quibble avoids it + there + * `canRegisterLoader()` now also returns true on a Node that only has + `registerHooks` + # 0.10.0 * Add initial TypeScript type definitions (`index.d.ts`) diff --git a/lib/canRegisterLoader.js b/lib/canRegisterLoader.js index a260406..0a31775 100644 --- a/lib/canRegisterLoader.js +++ b/lib/canRegisterLoader.js @@ -1,7 +1,8 @@ const Module = require('module') function canRegisterLoader () { - return !!Module.register + return typeof Module.registerHooks === 'function' || + typeof Module.register === 'function' } exports.canRegisterLoader = canRegisterLoader diff --git a/lib/loader-helpers.js b/lib/loader-helpers.js new file mode 100644 index 0000000..1a7adfc --- /dev/null +++ b/lib/loader-helpers.js @@ -0,0 +1,217 @@ +// Shared logic for the resolve/load hooks: the async ones in quibble.mjs +// (--loader / register) and the sync ones in quibble-sync-hooks.js +// (registerHooks). Kept as CJS so both can load it without an extra +// module-format dance. +// +// Only planResolve/finishResolve/recoverResolve/stubbedLoadResult and +// stripQueryAndHash are used outside this file; everything else here is a +// private helper for them. + +/** + * @typedef {{hasDefaultExport: boolean, namedExports: [string]}} ModuleLoaderMockInfo + * @typedef {{ + * quibbledModules: Map, + * stubModuleGeneration: number + * }} QuibbleLoaderState + */ + +/** + * `state.quibbledModules` is read on every call (never cached by callers): + * reset() replaces the Map. + * + * @param {QuibbleLoaderState} state + * @param {string} moduleUrl + * @returns {[string, ModuleLoaderMockInfo] | undefined} + */ +function getStubsInfo (state, moduleUrl) { + if (!state.quibbledModules) return undefined + if (!moduleUrl.includes('__quibble=')) return undefined + + const moduleKey = stripQueryAndHash(moduleUrl) + const moduleMockingInfo = state.quibbledModules.get(moduleKey) + + return moduleMockingInfo ? [moduleKey, moduleMockingInfo] : undefined +} + +/** + * @param {[string, ModuleLoaderMockInfo]} options + * @returns {string} + */ +function transformModuleSource ([moduleKey, mockingInfo]) { + return ` +${mockingInfo.namedExports + .map( + (name) => + `export let ${name} = globalThis[Symbol.for('__quibbleUserState')].quibbledModules.get(${JSON.stringify( + moduleKey + )}).namedExportStubs["${name}"]` + ) + .join(';\n')}; +${ + mockingInfo.hasDefaultExport + ? `export default globalThis[Symbol.for('__quibbleUserState')].quibbledModules.get(${JSON.stringify( + moduleKey + )}).defaultExportStub;` + : '' +} +` +} + +function stripQueryAndHash (url) { + return url.replace(/\?.*/, '').replace(/#.*/, '') +} + +/** Removes the marker query params the `import()` helpers add to specifiers */ +function stripMarkers (specifier) { + return specifier.includes('__quibble') + ? specifier + .replace(/[?&]__quibbleresolveurl/, '') + .replace(/[?&]__quibbleoriginal/, '') + : specifier +} + +function addQueryToUrl (url, query, value) { + const urlObject = new URL(url) + urlObject.searchParams.set(query, value) + return urlObject.href +} + +/** The URL to fall back to when the default resolver can't find `specifier` */ +function unresolvedUrl (specifier, parentURL, generation) { + return parentURL + ? addQueryToUrl(new URL(specifier, parentURL).href, '__quibble', generation) + : new URL(specifier).href +} + +/** + * Decides what a `resolve` hook should do with `specifier`, without calling + * `nextResolve` (that stays in the driver, since only it knows whether to + * `await` it). Shared by the async hooks (quibble.mjs) and the sync hooks + * (quibble-sync-hooks.js). + * + * @param {QuibbleLoaderState} state + * @param {string} specifier + * @param {object} context + * @returns { + * | { kind: 'reentrant' } + * | { kind: 'resolveUrl' | 'passthrough', nextSpecifier: string } + * | { kind: 'quibble', nextSpecifier: string, specifier: string, parentURL: string, stubModuleGeneration: number } + * } + */ +function planResolve (state, specifier, context) { + if (specifier.includes('__quibbleresolveurl')) { + return { kind: 'resolveUrl', nextSpecifier: stripMarkers(specifier) } + } + + if (!state.quibbledModules || specifier === 'quibble' || specifier.includes('__quibbleoriginal')) { + return { kind: 'passthrough', nextSpecifier: stripMarkers(specifier) } + } + + // Only here - resolving a plain, unmarked specifier - do we need to worry + // about Node 22+ re-entering this hook recursively while we're in the + // middle of our own `nextResolve` call below (e.g. while walking + // conditional/array-form `exports` fallbacks), handing back the same + // `context` object. Left unhandled, that re-entrant call would look just + // like a brand new import and get quibble-ified a second time. + // + // `context.__quibbleSuppressed` (set/cleared by the driver around + // `nextResolve`) flags that window. Node can also reuse/pool the same + // `context` object across *unrelated* resolutions, so this check is + // deliberately placed after every marker-based branch above: a stale + // leaked flag can at worst make us skip quibble-ifying one plain import + // that should have gotten one, never corrupt the stripping of an + // explicitly marked specifier (which is what caused a prior version of + // this fix to regress testdouble.js's test suite). + if (context.__quibbleSuppressed) { + return { kind: 'reentrant' } + } + + return { + kind: 'quibble', + nextSpecifier: stripMarkers(specifier), + specifier, + parentURL: context.parentURL, + stubModuleGeneration: state.stubModuleGeneration + } +} + +/** + * Interprets what `nextResolve` returned for a plan from `planResolve`. + * `normalizeUrl` (only needed by the sync hooks) strips a `__quibble` tag a + * coexisting off-thread `--loader=quibble` may have already added. + * + * @param {QuibbleLoaderState} state + * @param {ReturnType} plan + * @param {object} result - whatever `nextResolve` returned + * @param {(url: string) => string} [normalizeUrl] + */ +function finishResolve (state, plan, result, normalizeUrl = url => url) { + if (plan.kind === 'resolveUrl') { + const error = new Error() + error.code = 'QUIBBLE_RESOLVED_URL' + error.resolvedUrl = normalizeUrl(result.url) + throw error + } + + if (plan.kind === 'passthrough') { + return result + } + + const { url: nextUrl, ...ctx } = result + const url = normalizeUrl(nextUrl) + const quibbledUrl = addQueryToUrl(url, '__quibble', plan.stubModuleGeneration) + + if (url.startsWith('node:') && !getStubsInfo(state, quibbledUrl)) { + return { ...ctx, url } // It's allowed to change ctx for a builtin (but unlikely) + } + + return { ...ctx, url: quibbledUrl } +} + +/** + * Interprets an error `nextResolve` threw for a plan from `planResolve`. + * Either returns the fallback result for a `'quibble'` plan's + * `ERR_MODULE_NOT_FOUND`, or rethrows. + * + * @param {ReturnType} plan + * @param {Error} error + */ +function recoverResolve (plan, error) { + if (plan.kind === 'quibble' && error.code === 'ERR_MODULE_NOT_FOUND') { + return { + url: unresolvedUrl(plan.specifier, plan.parentURL, plan.stubModuleGeneration), + shortCircuit: true + } + } + + throw error +} + +/** + * The `load` hook's stub/passthrough decision. Returns the stub result, or + * `undefined` when the driver should call `nextLoad` itself (its return + * needs an `await` in the async hooks and not in the sync ones, so that part + * stays in each driver). + * + * @param {QuibbleLoaderState} state + * @param {string} url + */ +function stubbedLoadResult (state, url) { + const mockingInfo = getStubsInfo(state, url) + + return mockingInfo + ? { + source: transformModuleSource(mockingInfo), + format: 'module', + shortCircuit: true + } + : undefined +} + +module.exports = { + planResolve, + finishResolve, + recoverResolve, + stubbedLoadResult, + stripQueryAndHash +} diff --git a/lib/quibble-sync-hooks.js b/lib/quibble-sync-hooks.js new file mode 100644 index 0000000..94a5788 --- /dev/null +++ b/lib/quibble-sync-hooks.js @@ -0,0 +1,65 @@ +// Synchronous, same-thread driver for the hooks in `Module.registerHooks()`. +// The decision logic - what to do with a given specifier/URL - lives in +// planResolve()/finishResolve()/recoverResolve() (loader-helpers.js), shared +// with the async hooks in quibble.mjs. This file is only the sync driver for +// it, plus the two things that are genuinely sync-hooks-only (see below). + +const { + planResolve, + finishResolve, + recoverResolve, + stubbedLoadResult, + stripQueryAndHash +} = require('./loader-helpers') + +/** + * @param {import('./loader-helpers').QuibbleLoaderState} state + */ +exports.createSyncHooks = function createSyncHooks (state) { + function resolve (specifier, context, nextResolve) { + // registerHooks() hooks also see CommonJS require() calls, which + // register()-style hooks never did. Quibble's ESM cache-busting query + // string would break CJS resolution, so leave those alone. There's no + // equivalent in the async hooks (Module.register() never sees require()). + if (isRequire(context)) { + return nextResolve(specifier, context) + } + + const plan = planResolve(state, specifier, context) + + if (plan.kind === 'reentrant') { + return nextResolve(specifier, context) + } + + context.__quibbleSuppressed = true + try { + const result = nextResolve(plan.nextSpecifier, context) + // An explicit `--loader=quibble` can be registered (off-thread) + // alongside these hooks and will already have tagged the resolved URL; + // strip it before quibble re-tags it with its own generation number. + // The async hooks never run alongside another quibble instance. + return finishResolve(state, plan, result, removeQuibbleQuery) + } catch (error) { + return recoverResolve(plan, error) + } finally { + context.__quibbleSuppressed = false + } + } + + function load (url, context, nextLoad) { + return stubbedLoadResult(state, url) ?? nextLoad(stripQueryAndHash(url), context) + } + + return { resolve, load } +} + +function isRequire (context) { + return Array.isArray(context.conditions) && context.conditions.includes('require') +} + +function removeQuibbleQuery (url) { + if (!url.includes('__quibble=')) return url + const urlObject = new URL(url) + urlObject.searchParams.delete('__quibble') + return urlObject.href +} diff --git a/lib/quibble.js b/lib/quibble.js index 7b89f3f..dfbc02b 100644 --- a/lib/quibble.js +++ b/lib/quibble.js @@ -317,6 +317,10 @@ function isBareSpecifier (modulePath) { } function registerEsmLoader () { + if (shouldUseRegisterHooks()) { + return registerSyncEsmHooks() + } + const { port1, port2 } = new MessageChannel() Module.register( @@ -329,3 +333,35 @@ function registerEsmLoader () { port1 ) } + +// `module.register()` is runtime-deprecated (DEP0205) starting in Node 26, so +// that's where we switch over. Earlier Nodes that have `registerHooks` (22.15+, +// 23.5+) are deliberately left on `register()`: until 22.23 / 24.12 / 25.1 they +// throw ERR_INVALID_RETURN_PROPERTY_VALUE when a sync hook chains into any +// off-thread loader (--loader, tsx, ts-node, ...) and ESM imports a CJS file. +function shouldUseRegisterHooks () { + if (typeof Module.registerHooks !== 'function') { + return false + } + if (typeof Module.register !== 'function') { + return true + } + return parseInt(process.versions.node, 10) >= 26 +} + +// In-thread synchronous hooks, so no worker thread or +// MessagePort/Atomics.wait round trips are needed. Setting the loader-state +// global up front puts thisWillRunInUserThread on its same-thread branch. +function registerSyncEsmHooks () { + const state = { quibbledModules: new Map(), stubModuleGeneration: 0 } + globalThis[Symbol.for('__quibbleLoaderState')] = state + + Module.registerHooks( + require('./quibble-sync-hooks.js').createSyncHooks(state) + ) + + require('./thisWillRunInUserThread.js').thisWillRunInUserThread( + globalThis, + undefined + ) +} diff --git a/lib/quibble.mjs b/lib/quibble.mjs index cdb215e..b53fcb1 100644 --- a/lib/quibble.mjs +++ b/lib/quibble.mjs @@ -1,5 +1,14 @@ import quibble from './quibble.js' import { thisWillRunInUserThread } from './thisWillRunInUserThread.js' +import helpers from './loader-helpers.js' + +const { + planResolve, + finishResolve, + recoverResolve, + stubbedLoadResult, + stripQueryAndHash +} = helpers export default quibble export const reset = quibble.reset @@ -20,134 +29,30 @@ const quibbleLoaderState = { stubModuleGeneration: 0 } +// The decision logic - what to do with a given specifier/URL - lives in +// planResolve()/finishResolve()/recoverResolve() (loader-helpers.js), shared +// with the sync hooks in quibble-sync-hooks.js. This is just the async +// driver for it: decide the plan, call nextResolve (awaited, suppressed +// unless the plan says not to), and let finishResolve/recoverResolve +// interpret the outcome. export async function resolve (specifier, context, nextResolve) { - const resolve = async () => { - context.__quibbleSuppressed = true - try { - return await nextResolve( - specifier.includes('__quibble') - ? specifier - .replace(/[?&]__quibbleresolveurl/, '') - .replace(/[?&]__quibbleoriginal/, '') - : specifier, - context - ) - } finally { - context.__quibbleSuppressed = false - } - } - - // All of the branches below are driven entirely by markers on `specifier` - // itself, so they run unconditionally - regardless of `__quibbleSuppressed` - // - and are never at risk of being skipped by a stale suppression flag. - if (specifier.includes('__quibbleresolveurl')) { - const resolvedUrl = (await resolve()).url - const error = new Error() - error.code = 'QUIBBLE_RESOLVED_URL' - error.resolvedUrl = resolvedUrl - throw error - } - - if (!quibbleLoaderState.quibbledModules) { - return resolve() - } - - if (specifier === 'quibble') { - return resolve() - } + const plan = planResolve(quibbleLoaderState, specifier, context) - if (specifier.includes('__quibbleoriginal')) { - return resolve() - } - - // Only here - resolving a plain, unmarked specifier - do we need to worry - // about Node 22+ re-entering this hook recursively while we're in the - // middle of our own `nextResolve` call above (e.g. while walking - // conditional/array-form `exports` fallbacks), handing back the same - // `context` object. Left unhandled, that re-entrant call would look just - // like a brand new import and get quibble-ified a second time. - // - // `context.__quibbleSuppressed` (set/cleared around `nextResolve` in - // `resolve()` above) flags that window. Node can also reuse/pool the same - // `context` object across *unrelated* resolutions, so this check is - // deliberately placed after every marker-based branch above: a stale - // leaked flag can at worst make us skip quibble-ifying one plain import - // that should have gotten one, never corrupt the stripping of an - // explicitly marked specifier (which is what caused a prior version of - // this fix to regress testdouble.js's test suite). - if (context.__quibbleSuppressed) { + if (plan.kind === 'reentrant') { return nextResolve(specifier, context) } - const stubModuleGeneration = quibbleLoaderState.stubModuleGeneration - const { parentURL } = context - + context.__quibbleSuppressed = true try { - const { url, ...ctx } = await resolve() - - const quibbledUrl = addQueryToUrl(url, '__quibble', stubModuleGeneration) - - if (url.startsWith('node:') && !getStubsInfo(quibbledUrl)) { - return { ...ctx, url } // It's allowed to change ctx for a builtin (but unlikely) - } - - return { ...ctx, url: quibbledUrl } + const result = await nextResolve(plan.nextSpecifier, context) + return finishResolve(quibbleLoaderState, plan, result) } catch (error) { - if (error.code === 'ERR_MODULE_NOT_FOUND') { - return { - url: parentURL - ? addQueryToUrl( - new URL(specifier, parentURL).href - , '__quibble', stubModuleGeneration) - : new URL(specifier).href - } - } else { - throw error - } + return recoverResolve(plan, error) + } finally { + context.__quibbleSuppressed = false } } -/** - * @param {string} moduleUrl - * - * @returns {[string, ModuleLoaderMockInfo] | undefined} - * */ -function getStubsInfo (moduleUrl) { - if (!quibbleLoaderState.quibbledModules) return undefined - if (!moduleUrl.includes('__quibble=')) return undefined - - const moduleKey = moduleUrl.replace(/\?.*/, '').replace(/#.*/, '') - - const moduleMockingInfo = quibbleLoaderState.quibbledModules.get(moduleKey) - - return moduleMockingInfo ? [moduleKey, moduleMockingInfo] : undefined -} - -/** - * - * @param {[string, ModuleLoaderMockInfo]} options - * @returns - */ -function transformModuleSource ([moduleKey, mockingInfo]) { - return ` -${mockingInfo.namedExports - .map( - (name) => - `export let ${name} = globalThis[Symbol.for('__quibbleUserState')].quibbledModules.get(${JSON.stringify( - moduleKey - )}).namedExportStubs["${name}"]` - ) - .join(';\n')}; -${ - mockingInfo.hasDefaultExport - ? `export default globalThis[Symbol.for('__quibbleUserState')].quibbledModules.get(${JSON.stringify( - moduleKey - )}).defaultExportStub;` - : '' -} -` -} - /** * @param {string} url * @param {{ @@ -157,15 +62,8 @@ ${ * @returns {Promise<{ source: !(string | SharedArrayBuffer | Uint8Array), format: string}>} */ export async function load (url, context, nextLoad) { - const mockingInfo = getStubsInfo(url) - - return mockingInfo - ? { - source: transformModuleSource(mockingInfo), - format: 'module', - shortCircuit: true - } - : await nextLoad(url.replace(/\?.*/, '').replace(/#.*/, ''), context) + return stubbedLoadResult(quibbleLoaderState, url) ?? + await nextLoad(stripQueryAndHash(url), context) } export const globalPreload = ({ port }) => { @@ -204,9 +102,3 @@ export const globalPreload = ({ port }) => { return `(${thisWillRunInUserThread})(globalThis, port)` } - -function addQueryToUrl (url, query, value) { - const urlObject = new URL(url) - urlObject.searchParams.set(query, value) - return urlObject.href -} diff --git a/test/esm-fixtures/a-cjs-module.cjs b/test/esm-fixtures/a-cjs-module.cjs new file mode 100644 index 0000000..40b85e8 --- /dev/null +++ b/test/esm-fixtures/a-cjs-module.cjs @@ -0,0 +1 @@ +module.exports = { answer: 42 } diff --git a/test/esm-fixtures/passthrough-loader.mjs b/test/esm-fixtures/passthrough-loader.mjs new file mode 100644 index 0000000..f7ebaf3 --- /dev/null +++ b/test/esm-fixtures/passthrough-loader.mjs @@ -0,0 +1,8 @@ +// A do-nothing off-thread loader, standing in for tsx, ts-node, etc. +export async function resolve (specifier, context, nextResolve) { + return nextResolve(specifier, context) +} + +export async function load (url, context, nextLoad) { + return nextLoad(url, context) +} diff --git a/test/esm-fixtures/registration-child.js b/test/esm-fixtures/registration-child.js new file mode 100644 index 0000000..304418a --- /dev/null +++ b/test/esm-fixtures/registration-child.js @@ -0,0 +1,28 @@ +// Run in a fresh Node process by quibble-loader-registration.test.js, without +// `--loader=quibble`, so quibble has to register its own hooks. +const quibble = require('../../lib/quibble') + +;(async () => { + await quibble.esm('./a-module.mjs', { life: 41 }, 'default-export-replacement') + + const stubbed = await import('./a-module.mjs') + const requiredCjs = require('./a-cjs-module.cjs') + const importedCjs = await import('./a-cjs-module.cjs') + const requiredJson = require('../fixtures/a-function.json') + + quibble.reset() + const restored = await import('./a-module.mjs') + + process.stdout.write(JSON.stringify({ + stubbedDefault: stubbed.default, + stubbedLife: stubbed.life, + requiredCjs: requiredCjs.answer, + importedCjs: importedCjs.default.answer, + requiredJson: typeof requiredJson, + restoredDefault: restored.default, + restoredLife: restored.life + })) +})().catch(error => { + console.error(error) + process.exit(1) +}) diff --git a/test/esm-lib/quibble-loader-registration.test.js b/test/esm-lib/quibble-loader-registration.test.js new file mode 100644 index 0000000..5271b4d --- /dev/null +++ b/test/esm-lib/quibble-loader-registration.test.js @@ -0,0 +1,60 @@ +const path = require('path') +const { spawnSync } = require('child_process') +const { pathToFileURL } = require('url') +const { canRegisterLoader } = require('../../lib/canRegisterLoader') + +const child = path.join(__dirname, '../esm-fixtures/registration-child.js') +const passthroughLoader = pathToFileURL( + path.join(__dirname, '../esm-fixtures/passthrough-loader.mjs') +).href + +function runChild (nodeArgs = []) { + const { status, stdout, stderr } = spawnSync( + process.execPath, + [...nodeArgs, child], + { encoding: 'utf8', env: { ...process.env, NODE_OPTIONS: '' } } + ) + return { status, stderr, stdout, result: status === 0 ? JSON.parse(stdout) : null } +} + +function assertStubbingAndCjsWork ({ status, stderr, result }) { + assert.equal(status, 0, stderr) + assert.deepEqual(result, { + stubbedDefault: 'default-export-replacement', + stubbedLife: 41, + requiredCjs: 42, + importedCjs: 42, + requiredJson: 'object', + restoredDefault: 'default-export', + restoredLife: 42 + }) +} + +// These spawn fresh processes because the interesting behavior is what +// happens when quibble registers its own loader on first use. +module.exports = { + 'auto-registering the loader stubs ESM and leaves CJS require() alone': function () { + if (!canRegisterLoader()) return + + assertStubbingAndCjsWork(runChild()) + }, + + 'auto-registering the loader does not emit a deprecation warning': function () { + if (!canRegisterLoader()) return + + const { stderr } = runChild() + + assert.doesNotMatch(stderr, /DEP0\d+/) + assert.doesNotMatch(stderr, /module\.register\(\) is deprecated/) + }, + + 'auto-registering the loader works alongside another off-thread loader': function () { + if (!canRegisterLoader()) return + + // Guards against Node versions where in-thread hooks and off-thread + // loaders can't be mixed when ESM imports CJS (see shouldUseRegisterHooks) + assertStubbingAndCjsWork( + runChild(['--no-warnings', '--loader', passthroughLoader]) + ) + } +}