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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@
if a file that called `quibble()` is part of a circular `require()`, it now
gets the partially loaded module, as it would without quibble, instead of
a second copy
* Importing a module that doesn't exist (and isn't mocked) now fails with
Node's `ERR_MODULE_NOT_FOUND` instead of `ENOENT`
[#100](https://github.com/testdouble/quibble/issues/100)

# 0.12.1

Expand Down
42 changes: 36 additions & 6 deletions lib/loader-helpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
// module-format dance.
//
// Only planResolve/nextResolveContext/finishResolve/recoverResolve/
// stubbedLoadResult and stripQueryAndHash are used outside this file;
// forgetUnresolvedError/stubbedLoadResult and stripQueryAndHash are used
// outside this file;
// everything else here is a private helper for them.

/**
Expand Down Expand Up @@ -60,6 +61,13 @@ ${
`
}

// The `ERR_MODULE_NOT_FOUND` that `nextResolve` threw for each URL that
// recoverResolve() handed back in its place, so that load() can rethrow it
// if nothing ends up mocking that URL. Keyed without the query: on Node 26+,
// `--loader=quibble` runs alongside the registerHooks() hooks, which re-tag
// the URL with their own generation and strip it before calling nextLoad
const unresolvedErrors = new Map()

function stripQueryAndHash (url) {
return url.replace(/\?.*/, '').replace(/#.*/, '')
}
Expand Down Expand Up @@ -205,18 +213,33 @@ function finishResolve (state, plan, result, normalizeUrl = url => url) {
*/
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
const url = unresolvedUrl(plan.specifier, plan.parentURL, plan.stubModuleGeneration)
const key = stripQueryAndHash(url)
// If several files import the same missing module, keep the first one's
// error, since Node only loads it once
if (!unresolvedErrors.has(key)) {
unresolvedErrors.set(key, error)
}
return { url, 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
* Called with every URL `nextResolve` resolved: the module exists now, so an
* earlier failure to find it no longer applies.
*
* @param {string} url
*/
function forgetUnresolvedError (url) {
unresolvedErrors.delete(stripQueryAndHash(url))
}

/**
* The `load` hook's stub/passthrough decision. Returns the stub result,
* throws the resolve error for a URL that doesn't exist and isn't mocked, or
* returns `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).
*
Expand All @@ -225,6 +248,12 @@ function recoverResolve (plan, error) {
*/
function stubbedLoadResult (state, url) {
const mockingInfo = getStubsInfo(state, url)
const strippedUrl = stripQueryAndHash(url)

// Loading it would fail with an `ENOENT` instead, see recoverResolve()
if (!mockingInfo && unresolvedErrors.has(strippedUrl)) {
throw unresolvedErrors.get(strippedUrl)
}

return mockingInfo
? {
Expand All @@ -240,6 +269,7 @@ module.exports = {
nextResolveContext,
finishResolve,
recoverResolve,
forgetUnresolvedError,
stubbedLoadResult,
stripQueryAndHash
}
2 changes: 2 additions & 0 deletions lib/quibble-sync-hooks.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ const {
nextResolveContext,
finishResolve,
recoverResolve,
forgetUnresolvedError,
stubbedLoadResult,
stripQueryAndHash
} = require('./loader-helpers')
Expand Down Expand Up @@ -38,6 +39,7 @@ exports.createSyncHooks = function createSyncHooks (state) {
context.__quibbleSuppressed = true
try {
const result = nextResolve(plan.nextSpecifier, nextResolveContext(plan, context))
forgetUnresolvedError(result.url)
if (plan.kind === 'quibble') {
// Same off-thread-loader caveat as below: work with untagged URLs
const url = removeQuibbleQuery(result.url)
Expand Down
2 changes: 2 additions & 0 deletions lib/quibble.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ const {
nextResolveContext,
finishResolve,
recoverResolve,
forgetUnresolvedError,
stubbedLoadResult,
stripQueryAndHash
} = helpers
Expand Down Expand Up @@ -49,6 +50,7 @@ export async function resolve (specifier, context, nextResolve) {
context.__quibbleSuppressed = true
try {
const result = await nextResolve(plan.nextSpecifier, nextResolveContext(plan, context))
forgetUnresolvedError(result.url)
if (plan.kind === 'quibble') {
importGraph.recordImport(context.parentURL, result.url)
if (!importGraph.needsQuibbling(result.url)) {
Expand Down
3 changes: 3 additions & 0 deletions test/esm-fixtures/imports-a-missing-module.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
// Statically imports a module that doesn't exist and that no test mocks, to
// check that quibble's loader still fails the import the way Node would
import './this-module-is-imported-but-does-not-exist.mjs'
39 changes: 39 additions & 0 deletions test/esm-lib/quibble-esm.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
import 'fs'
import quibble from 'quibble'
import { fileURLToPath, pathToFileURL } from 'url'
import { mkdtemp, realpath, rm, writeFile } from 'fs/promises'
import os from 'os'
import path from 'path'

const __filename = fileURLToPath(import.meta.url)

Expand Down Expand Up @@ -287,6 +290,42 @@ export default {
quibble.reset()

assert.deepEqual(quibble.listMockedModules(), [])
},
'importing a missing module that is not mocked rejects like Node does': async function () {
// Under the auto-registered loader, quibble's hooks only see imports
// once something is mocked
await quibble.esm('../esm-fixtures/a-module.mjs', undefined, 'unrelated')

await assert.rejects(import('../esm-fixtures/this-module-does-not-exist-and-is-not-mocked.mjs'), {
code: 'ERR_MODULE_NOT_FOUND',
message: /Cannot find module '.*this-module-does-not-exist-and-is-not-mocked\.mjs' imported from .*quibble-esm\.test\.mjs/
})
await assert.rejects(import('a-package-that-does-not-exist'), {
code: 'ERR_MODULE_NOT_FOUND',
message: /Cannot find package 'a-package-that-does-not-exist'/
})
await assert.rejects(import('../esm-fixtures/imports-a-missing-module.mjs'), {
code: 'ERR_MODULE_NOT_FOUND',
message: /imported from .*imports-a-missing-module\.mjs/
})
},
'a missing module that is created later can be imported': async function () {
await quibble.esm('../esm-fixtures/a-module.mjs', undefined, 'unrelated')
// The real path, since Node resolves symlinks (macOS's tmpdir is one)
const dir = await realpath(await mkdtemp(path.join(os.tmpdir(), 'quibble-')))
const file = path.join(dir, 'created-later.mjs')

try {
await assert.rejects(import(pathToFileURL(file).href), { code: 'ERR_MODULE_NOT_FOUND' })
await writeFile(file, "export default 'created later'")
// A new mock gives it a new URL, so Node doesn't hand back the failed import
await quibble.esm('../esm-fixtures/a-module.mjs', undefined, 'unrelated again')

const { default: result } = await import(pathToFileURL(file).href)
assert.equal(result, 'created later')
} finally {
await rm(dir, { recursive: true, force: true })
}
}
}

Expand Down
Loading