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
14 changes: 9 additions & 5 deletions lib/esm-import-functions.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
const path = require('path')
const { pathToFileURL } = require('url')

exports.dummyImportModuleToGetAtPath = async function dummyImportModuleToGetAtPath (modulePath) {
// `parentUrl` is the file the specifier should be resolved from. Without it,
// the specifier is resolved from this file, which can't see the caller's
// dependencies when they're not installed in a flat node_modules (e.g. pnpm).
exports.dummyImportModuleToGetAtPath = async function dummyImportModuleToGetAtPath (modulePath, parentUrl) {
try {
const moduleUrl = path.isAbsolute(modulePath) ? pathToFileURL(modulePath) : modulePath

await import(addQueryToUrl(moduleUrl, '__quibbleresolveurl'))
await import(addQueryToUrl(moduleUrl, '__quibbleresolveurl', parentUrl))
} catch (error) {
if (error.code === 'QUIBBLE_RESOLVED_URL') {
return error.resolvedUrl
Expand All @@ -23,12 +26,13 @@ exports.importOriginalModule = async (fullImportPath) => {
return import(addQueryToUrl(fullImportPath, '__quibbleoriginal'))
}

function addQueryToUrl (url, query) {
function addQueryToUrl (url, query, value) {
const queryWithValue = value ? `${query}=${encodeURIComponent(value)}` : query
try {
const urlObject = new URL(url)
urlObject.searchParams.set(query, '')
return urlObject.href.replace(`${query}=`, query)
return urlObject.href.replace(`${query}=`, queryWithValue)
} catch {
return url + '?' + query
return url + '?' + queryWithValue
}
}
37 changes: 31 additions & 6 deletions lib/loader-helpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@
// (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.
// Only planResolve/nextResolveContext/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
Expand Down Expand Up @@ -60,15 +60,29 @@ function stripQueryAndHash (url) {
return url.replace(/\?.*/, '').replace(/#.*/, '')
}

const RESOLVE_URL_MARKER = /[?&]__quibbleresolveurl(?:=([^&#]*))?/

/** Removes the marker query params the `import()` helpers add to specifiers */
function stripMarkers (specifier) {
return specifier.includes('__quibble')
? specifier
.replace(/[?&]__quibbleresolveurl/, '')
.replace(RESOLVE_URL_MARKER, '')
.replace(/[?&]__quibbleoriginal/, '')
: specifier
}

/**
* The context to call `nextResolve` with for a plan from `planResolve`: the
* same one, unless the plan has to be resolved from a different parent.
* Call it after flagging `context.__quibbleSuppressed`, so the copy is
* flagged too.
*/
function nextResolveContext (plan, context) {
return plan.resolveFromURL
? { ...context, parentURL: plan.resolveFromURL }
: context
}

function addQueryToUrl (url, query, value) {
const urlObject = new URL(url)
urlObject.searchParams.set(query, value)
Expand All @@ -93,13 +107,23 @@ function unresolvedUrl (specifier, parentURL, generation) {
* @param {object} context
* @returns {
* | { kind: 'reentrant' }
* | { kind: 'resolveUrl' | 'passthrough', nextSpecifier: string }
* | { kind: 'resolveUrl', nextSpecifier: string, resolveFromURL: string | undefined }
* | { kind: '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) }
// The marker's value, if any, is the URL of the file that asked quibble
// to resolve `specifier`: resolve from there and not from the `import()`
// in esm-import-functions.js, which can't see that file's dependencies
// when they're not installed in a flat node_modules (e.g. pnpm).
const encodedResolveFromURL = specifier.match(RESOLVE_URL_MARKER)[1]
return {
kind: 'resolveUrl',
nextSpecifier: stripMarkers(specifier),
resolveFromURL: encodedResolveFromURL && decodeURIComponent(encodedResolveFromURL)
}
}

if (specifier === 'quibble' || specifier.includes('__quibbleoriginal')) {
Expand Down Expand Up @@ -209,6 +233,7 @@ function stubbedLoadResult (state, url) {

module.exports = {
planResolve,
nextResolveContext,
finishResolve,
recoverResolve,
stubbedLoadResult,
Expand Down
3 changes: 2 additions & 1 deletion lib/quibble-sync-hooks.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

const {
planResolve,
nextResolveContext,
finishResolve,
recoverResolve,
stubbedLoadResult,
Expand Down Expand Up @@ -36,7 +37,7 @@ exports.createSyncHooks = function createSyncHooks (state) {

context.__quibbleSuppressed = true
try {
const result = nextResolve(plan.nextSpecifier, context)
const result = nextResolve(plan.nextSpecifier, nextResolveContext(plan, context))
if (plan.kind === 'quibble') {
// Same off-thread-loader caveat as below: work with untagged URLs
const url = removeQuibbleQuery(result.url)
Expand Down
18 changes: 6 additions & 12 deletions lib/quibble.js
Original file line number Diff line number Diff line change
Expand Up @@ -105,12 +105,9 @@ quibble.esm = async function (specifier, namedExportStubs, defaultExportStub) {
delete finalNamedExportStubs.default
}

const importPathIsBareSpecifier = isBareSpecifier(specifier)
const parentUrl = importPathIsBareSpecifier
? undefined
: hackErrorStackToGetCallerFile(true, true)
const moduleUrl = importPathIsBareSpecifier
? await importFunctions.dummyImportModuleToGetAtPath(specifier)
const parentUrl = hackErrorStackToGetCallerFile(true, true)
const moduleUrl = isBareSpecifier(specifier)
? await importFunctions.dummyImportModuleToGetAtPath(specifier, parentUrl)
: new URL(specifier, parentUrl).href

await quibbleUserToLoaderCommunication().addMockedModule(moduleUrl, {
Expand All @@ -133,12 +130,9 @@ quibble.isLoaderLoaded = function () {
quibble.esmImportWithPath = async function esmImportWithPath (specifier) {
checkThatLoaderIsLoaded()

const importPathIsBareSpecifier = isBareSpecifier(specifier)
const parentUrl = importPathIsBareSpecifier
? undefined
: hackErrorStackToGetCallerFile(true, true)
const moduleUrl = importPathIsBareSpecifier
? await importFunctions.dummyImportModuleToGetAtPath(specifier)
const parentUrl = hackErrorStackToGetCallerFile(true, true)
const moduleUrl = isBareSpecifier(specifier)
? await importFunctions.dummyImportModuleToGetAtPath(specifier, parentUrl)
: new URL(specifier, parentUrl).href

return {
Expand Down
3 changes: 2 additions & 1 deletion lib/quibble.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import importGraphModule from './import-graph.js'

const {
planResolve,
nextResolveContext,
finishResolve,
recoverResolve,
stubbedLoadResult,
Expand Down Expand Up @@ -47,7 +48,7 @@ export async function resolve (specifier, context, nextResolve) {

context.__quibbleSuppressed = true
try {
const result = await nextResolve(plan.nextSpecifier, context)
const result = await nextResolve(plan.nextSpecifier, nextResolveContext(plan, context))
if (plan.kind === 'quibble') {
importGraph.recordImport(context.parentURL, result.url)
if (!importGraph.needsQuibbling(result.url)) {
Expand Down
7 changes: 7 additions & 0 deletions test/esm-fixtures/a-quibble-esm-wrapper.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
// Calls quibble.esm() on behalf of whoever calls it, like testdouble.js's td.replaceEsm()
import quibble from '../../lib/quibble.mjs'

quibble.ignoreCallsFromThisFile()

export default (specifier, namedExportStubs, defaultExportStub) =>
quibble.esm(specifier, namedExportStubs, defaultExportStub)
2 changes: 2 additions & 0 deletions test/esm-lib/node_modules/a-dependency-of-the-tests/index.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions test/esm-lib/node_modules/is-number/index.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 7 additions & 0 deletions test/esm-lib/node_modules/is-number/package.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 11 additions & 0 deletions test/esm-lib/quibble-cjs-esmImportWithPath.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,17 @@ module.exports = {
assert.deepEqual(isPromise(Promise.resolve()), true)
assert.deepEqual(isPromise(42), false)
},
'support importing esm and returning the path for a bare specifier only the caller can resolve': async function () {
// Lives in test/esm-lib/node_modules, so it can't be resolved from quibble's
// own lib directory (like a dependency installed by pnpm)
const { moduleUrl, module } = await quibble.esmImportWithPath('a-dependency-of-the-tests')

assert.deepEqual(moduleUrl, pathToFileURL(path.resolve(__dirname, 'node_modules/a-dependency-of-the-tests/index.js')).href)
assert.deepEqual({ ...module }, {
default: 'default-export',
namedExport: 'named-export'
})
},
'support importing esm and returning the path even when relative path quibbled': async function () {
await quibble.esm('./a-module.mjs', {
namedExport: 'replacement',
Expand Down
28 changes: 28 additions & 0 deletions test/esm-lib/quibble-esm.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,34 @@ export default {
const { default: result } = await import('is-promise')
assert.equal(result(), 42)
},
'mock a 3rd party lib that only the caller can resolve': async function () {
// Lives in test/esm-lib/node_modules, so it can't be resolved from quibble's
// own lib directory (like a dependency installed by pnpm)
await quibble.esm('a-dependency-of-the-tests', { namedExport: 'replacement' }, 'default-export-replacement')

const result = await import('a-dependency-of-the-tests')
assert.equal(result.default, 'default-export-replacement')
assert.equal(result.namedExport, 'replacement')
},
'mock the caller\'s copy of a 3rd party lib when quibble can resolve a different copy': async function () {
// test/esm-lib/node_modules/is-number shadows the real one in the root
// node_modules, which is the one quibble's own lib directory resolves to
// (like a pnpm monorepo where packages need different versions of it)
await quibble.esm('is-number', undefined, () => 'replacement')

const { default: isNumber } = await import('is-number')
assert.equal(isNumber(), 'replacement')
},
'mock a 3rd party lib that only the caller of a wrapper that ignores itself can resolve': async function () {
// Like testdouble.js's td.replaceEsm(): the wrapper's own directory can't
// resolve the specifier, so it has to be resolved from this file
const { default: wrappedQuibbleEsm } = await import('../esm-fixtures/a-quibble-esm-wrapper.mjs')
await wrappedQuibbleEsm('a-dependency-of-the-tests', { namedExport: 'replacement' }, 'default-export-replacement')

const result = await import('a-dependency-of-the-tests')
assert.equal(result.default, 'default-export-replacement')
assert.equal(result.namedExport, 'replacement')
},
'isLoaderLoader returns true if loader as loaded': async function () {
assert.equal(quibble.isLoaderLoaded(), true)
},
Expand Down
Loading