Skip to content

Commit 06e1fcd

Browse files
authored
Merge pull request #102 from testdouble/support-register
support Node v20.6.0 ability to auto-register loader
2 parents a620990 + e8ca2bc commit 06e1fcd

12 files changed

Lines changed: 825 additions & 407 deletions

README.md

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -74,11 +74,10 @@ Quibble supports ES Modules. Quibble implements ES module support using [ES Modu
7474
Loaders](https://nodejs.org/api/esm.html#esm_experimental_loaders) which are the official way to
7575
"patch" Node.js' module loading mechanism for ESM.
7676

77-
> Note that Loader support is currently experimental and unstable. We will be doing our best
78-
to track the changes in the specification for the upcoming Node.js versions. Also note that
79-
Quibble ESM support is tested only for versions 13 and above.
77+
> Note that Loader support is currently experimental and unstable. We are doing our best
78+
to track the changes in the specification for the upcoming Node.js versions.
8079

81-
To use Quibble support, you must run Node with the `quibble` package as the loader:
80+
If you're running a Node.js version smaller than v20.6.0, you must run Node with the `quibble` package as a loader:
8281

8382
```sh
8483
node --loader=quibble ...
@@ -93,6 +92,9 @@ mocha --loader=quibble ...
9392
The `quibble` loader will enable the replacement of the ES modules with the stubs you specify, and
9493
without it, the stubbing will be ignored.
9594

95+
For versions larger or equal to v20.6.0, there is no need to specify a `--loader`, as registering the loader
96+
happens automatically once you use the API.
97+
9698
### Restrictions on ESM
9799

98100
* `defaultFakeCreator` is not yet supported.
@@ -140,7 +142,7 @@ resolves the path to the module that is the package's entry point:
140142
but returns an object with two properties:
141143
* `module`: the module returned by `await import(importPath)`.
142144
* `modulePath`: the full path to the module (file) that is the entry point to the package/module.
143-
145+
144146
> Note that when mocking internal Node.js modules (e.g. "[fs](https://nodejs.org/api/fs.html)")), you need to mock the named exports both as named exports and as properties in the default export, because Node.js exports internal modules both as named exports and as a default object. Example:
145147
146148
```js

example-esm/package-lock.json

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

example-esm/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22
"name": "quibble-example",
33
"private": true,
44
"scripts": {
5-
"test": "mocha --loader=quibble test/helper.js --recursive test/lib/"
5+
"test": "mocha --loader=quibble test/helper.js --recursive test/lib/",
6+
"test-auto-loader": "mocha test/helper.js --recursive test/lib/"
67
},
78
"devDependencies": {
89
"chai": "^4.3.7",

example/package-lock.json

Lines changed: 7 additions & 7 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

lib/canRegisterLoader.js

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
function canRegisterLoader () {
2+
const [major, minor] = process.versions.node
3+
.split('.')
4+
.map((m) => parseInt(m, 10))
5+
6+
return major > 20 || (major === 20 && minor >= 6)
7+
}
8+
9+
exports.canRegisterLoader = canRegisterLoader

lib/quibble-registered.mjs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import { globalPreload } from './quibble.mjs'
2+
export * from './quibble.mjs'
3+
4+
export function initialize ({ port }) {
5+
globalPreload({ port })
6+
}

lib/quibble.js

Lines changed: 58 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -21,14 +21,17 @@ const _ = {
2121
tap: require('lodash/tap'),
2222
values: require('lodash/values')
2323
}
24+
const { MessageChannel } = require('node:worker_threads')
25+
const { canRegisterLoader } = require('./canRegisterLoader')
2426

2527
const originalLoad = Module._load
2628
let config = null
2729
let quibbles = {}
2830
let ignoredCallerFiles = []
2931
let quibble
3032

31-
const quibbleUserToLoaderCommunication = () => globalThis[Symbol.for('__quibbleUserToLoaderCommunication')]
33+
const quibbleUserToLoaderCommunication = () =>
34+
globalThis[Symbol.for('__quibbleUserToLoaderCommunication')]
3235

3336
module.exports = quibble = function (request, stub) {
3437
request = quibble.absolutify(request)
@@ -79,23 +82,33 @@ quibble.absolutify = function (relativePath, parentFileName) {
7982

8083
quibble.esm = async function (specifier, namedExportStubs, defaultExportStub) {
8184
checkThatLoaderIsLoaded()
82-
if (namedExportStubs != null && !util.types.isProxy(namedExportStubs) && !isPlainObject(namedExportStubs)) {
83-
throw new Error('namedExportsStub argument must be either a plain object or null/undefined')
85+
if (
86+
namedExportStubs != null &&
87+
!util.types.isProxy(namedExportStubs) &&
88+
!isPlainObject(namedExportStubs)
89+
) {
90+
throw new Error(
91+
'namedExportsStub argument must be either a plain object or null/undefined'
92+
)
8493
}
8594

8695
let finalNamedExportStubs = namedExportStubs
8796

8897
if (finalNamedExportStubs != null && 'default' in finalNamedExportStubs) {
8998
if (defaultExportStub !== undefined) {
90-
throw new Error("conflict between a named export with the name 'default' and the default export stub. You can't have both")
99+
throw new Error(
100+
"conflict between a named export with the name 'default' and the default export stub. You can't have both"
101+
)
91102
}
92103
finalNamedExportStubs = { ...namedExportStubs }
93104
defaultExportStub = namedExportStubs.default
94105
delete finalNamedExportStubs.default
95106
}
96107

97108
const importPathIsBareSpecifier = isBareSpecifier(specifier)
98-
const parentUrl = importPathIsBareSpecifier ? undefined : hackErrorStackToGetCallerFile(true, true)
109+
const parentUrl = importPathIsBareSpecifier
110+
? undefined
111+
: hackErrorStackToGetCallerFile(true, true)
99112
const moduleUrl = importPathIsBareSpecifier
100113
? await importFunctions.dummyImportModuleToGetAtPath(specifier)
101114
: new URL(specifier, parentUrl).href
@@ -114,7 +127,9 @@ quibble.esmImportWithPath = async function esmImportWithPath (specifier) {
114127
checkThatLoaderIsLoaded()
115128

116129
const importPathIsBareSpecifier = isBareSpecifier(specifier)
117-
const parentUrl = importPathIsBareSpecifier ? undefined : hackErrorStackToGetCallerFile(true, true)
130+
const parentUrl = importPathIsBareSpecifier
131+
? undefined
132+
: hackErrorStackToGetCallerFile(true, true)
118133
const moduleUrl = importPathIsBareSpecifier
119134
? await importFunctions.dummyImportModuleToGetAtPath(specifier)
120135
: new URL(specifier, parentUrl).href
@@ -154,10 +169,14 @@ const fakeLoad = function (request, parent, isMain) {
154169
}
155170
}
156171
const stubbingThatMatchesRequest = function (request) {
157-
return _.ooFind(quibbles, function (stubbing, stubbedPath) {
158-
if (request === stubbedPath) return true
159-
if (nodeResolve(request) === stubbedPath) return true
160-
}, quibbles)
172+
return _.ooFind(
173+
quibbles,
174+
function (stubbing, stubbedPath) {
175+
if (request === stubbedPath) return true
176+
if (nodeResolve(request) === stubbedPath) return true
177+
},
178+
quibbles
179+
)
161180
}
162181

163182
const requireWasCalledFromAFileThatHasQuibbledStuff = function () {
@@ -198,15 +217,20 @@ const nodeResolve = function (request, options) {
198217
} catch (e) {}
199218
}
200219

201-
const hackErrorStackToGetCallerFile = function (includeGlobalIgnores = true, keepUrls = false) {
220+
const hackErrorStackToGetCallerFile = function (
221+
includeGlobalIgnores = true,
222+
keepUrls = false
223+
) {
202224
const originalFunc = Error.prepareStackTrace
203225
const originalStackTraceLimit = Error.stackTraceLimit
204226
try {
205227
Error.stackTraceLimit = Math.max(Error.stackTraceLimit, 30)
206228
Error.prepareStackTrace = function (e, stack) {
207229
return stack
208230
}
209-
const conversionFunc = keepUrls ? convertStackPathToUrl : convertStackUrlToPath
231+
const conversionFunc = keepUrls
232+
? convertStackPathToUrl
233+
: convertStackUrlToPath
210234
const e = new Error()
211235
const currentFile = conversionFunc(e.stack[0].getFileName())
212236
return _.flow([
@@ -230,7 +254,13 @@ const hackErrorStackToGetCallerFile = function (includeGlobalIgnores = true, kee
230254

231255
function checkThatLoaderIsLoaded () {
232256
if (!quibble.isLoaderLoaded()) {
233-
throw new Error('quibble loader not loaded. You cannot replace ES modules without a loader. Run node with `--loader=quibble`.')
257+
if (canRegisterLoader()) {
258+
registerEsmLoader()
259+
} else {
260+
throw new Error(
261+
'quibble loader not loaded. You cannot replace ES modules without a loader. Run Node.js with `--loader=quibble` or use Node.js v20.6.0 or higher.'
262+
)
263+
}
234264
}
235265
}
236266

@@ -267,7 +297,7 @@ function isBareSpecifier (modulePath) {
267297
try {
268298
// (yes, we DO use new for side-effects!)
269299
// eslint-disable-next-line
270-
new URL(modulePath)
300+
new URL(modulePath);
271301
} catch (error) {
272302
if (error.code === 'ERR_INVALID_URL') {
273303
return false
@@ -278,3 +308,17 @@ function isBareSpecifier (modulePath) {
278308

279309
return true
280310
}
311+
312+
function registerEsmLoader () {
313+
const { port1, port2 } = new MessageChannel()
314+
315+
Module.register(
316+
new URL('./quibble-registered.mjs', pathToFileURL(__filename)),
317+
{ data: { port: port2 }, transferList: [port2] }
318+
)
319+
320+
require('./thisWillRunInUserThread.js').thisWillRunInUserThread(
321+
globalThis,
322+
port1
323+
)
324+
}

lib/quibble.mjs

Lines changed: 1 addition & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import quibble from './quibble.js'
2+
import { thisWillRunInUserThread } from './thisWillRunInUserThread.js'
23

34
export default quibble
45
export const reset = quibble.reset
@@ -165,61 +166,6 @@ export const globalPreload = ({ port }) => {
165166
return `(${thisWillRunInUserThread})(globalThis, port)`
166167
}
167168

168-
async function thisWillRunInUserThread (globalThis, port) {
169-
globalThis[Symbol.for('__quibbleUserState')] = { quibbledModules: new Map() }
170-
171-
globalThis[Symbol.for('__quibbleUserToLoaderCommunication')] = {
172-
reset () {
173-
globalThis[Symbol.for('__quibbleUserState')].quibbledModules = new Map()
174-
175-
if (!loaderAndUserRunInSameThread(globalThis)) {
176-
const hasResetHappened = new Int32Array(new SharedArrayBuffer(4))
177-
port.postMessage({ type: 'reset', hasResetHappened })
178-
Atomics.wait(hasResetHappened, 0, 0)
179-
} else {
180-
const quibbleLoaderState = globalThis[Symbol.for('__quibbleLoaderState')]
181-
182-
quibbleLoaderState.quibbledModules = new Map()
183-
quibbleLoaderState.stubModuleGeneration++
184-
}
185-
},
186-
async addMockedModule (
187-
moduleUrl,
188-
{ namedExportStubs, defaultExportStub }
189-
) {
190-
globalThis[Symbol.for('__quibbleUserState')].quibbledModules.set(moduleUrl, {
191-
defaultExportStub,
192-
namedExportStubs
193-
})
194-
195-
if (!loaderAndUserRunInSameThread(globalThis)) {
196-
const hasAddMockedHappened = new Int32Array(new SharedArrayBuffer(4))
197-
198-
port.postMessage({
199-
type: 'addMockedModule',
200-
moduleUrl,
201-
namedExports: Object.keys(namedExportStubs || []),
202-
hasDefaultExport: defaultExportStub != null,
203-
hasAddMockedHappened
204-
})
205-
Atomics.wait(hasAddMockedHappened, 0, 0)
206-
} else {
207-
const quibbleLoaderState = globalThis[Symbol.for('__quibbleLoaderState')]
208-
209-
quibbleLoaderState.quibbledModules.set(moduleUrl, {
210-
hasDefaultExport: defaultExportStub != null,
211-
namedExports: Object.keys(namedExportStubs || [])
212-
})
213-
++quibbleLoaderState.stubModuleGeneration
214-
}
215-
}
216-
}
217-
218-
function loaderAndUserRunInSameThread (globalThis) {
219-
return !!globalThis[Symbol.for('__quibbleLoaderState')]
220-
}
221-
}
222-
223169
function addQueryToUrl (url, query, value) {
224170
const urlObject = new URL(url)
225171
urlObject.searchParams.set(query, value)

lib/thisWillRunInUserThread.js

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
exports.thisWillRunInUserThread = (globalThis, port) => {
2+
globalThis[Symbol.for('__quibbleUserState')] = { quibbledModules: new Map() }
3+
4+
globalThis[Symbol.for('__quibbleUserToLoaderCommunication')] = {
5+
reset () {
6+
globalThis[Symbol.for('__quibbleUserState')].quibbledModules = new Map()
7+
8+
if (!loaderAndUserRunInSameThread(globalThis)) {
9+
const hasResetHappened = new Int32Array(new SharedArrayBuffer(4))
10+
port.postMessage({ type: 'reset', hasResetHappened })
11+
Atomics.wait(hasResetHappened, 0, 0)
12+
} else {
13+
const quibbleLoaderState = globalThis[Symbol.for('__quibbleLoaderState')]
14+
15+
quibbleLoaderState.quibbledModules = new Map()
16+
quibbleLoaderState.stubModuleGeneration++
17+
}
18+
},
19+
async addMockedModule (
20+
moduleUrl,
21+
{ namedExportStubs, defaultExportStub }
22+
) {
23+
globalThis[Symbol.for('__quibbleUserState')].quibbledModules.set(moduleUrl, {
24+
defaultExportStub,
25+
namedExportStubs
26+
})
27+
28+
if (!loaderAndUserRunInSameThread(globalThis)) {
29+
const hasAddMockedHappened = new Int32Array(new SharedArrayBuffer(4))
30+
31+
port.postMessage({
32+
type: 'addMockedModule',
33+
moduleUrl,
34+
namedExports: Object.keys(namedExportStubs || []),
35+
hasDefaultExport: defaultExportStub != null,
36+
hasAddMockedHappened
37+
})
38+
Atomics.wait(hasAddMockedHappened, 0, 0)
39+
} else {
40+
const quibbleLoaderState = globalThis[Symbol.for('__quibbleLoaderState')]
41+
42+
quibbleLoaderState.quibbledModules.set(moduleUrl, {
43+
hasDefaultExport: defaultExportStub != null,
44+
namedExports: Object.keys(namedExportStubs || [])
45+
})
46+
++quibbleLoaderState.stubModuleGeneration
47+
}
48+
}
49+
}
50+
51+
function loaderAndUserRunInSameThread (globalThis) {
52+
return !!globalThis[Symbol.for('__quibbleLoaderState')]
53+
}
54+
}

0 commit comments

Comments
 (0)