From b92ae394d6ac47520646a097b4bc8a5a99be51e1 Mon Sep 17 00:00:00 2001 From: Ross Brandes Date: Mon, 12 Oct 2026 11:10:40 -0400 Subject: [PATCH] Fail missing, unmocked imports with ERR_MODULE_NOT_FOUND, not ENOENT Fixes #100 So that missing modules can be mocked, recoverResolve turns Node's ERR_MODULE_NOT_FOUND into a made-up URL. When nothing mocks that URL, the load hook passed it to nextLoad, which threw ENOENT instead. Save the resolve error and rethrow it from the load hook when the URL isn't mocked. It's keyed by the URL without its query, because on Node 26+ the registerHooks() hooks run alongside --loader=quibble and re-tag the URL before it reaches the off-thread load hook. A successful resolve of the same URL forgets the error, so a file created later still loads. --- CHANGELOG.md | 3 ++ lib/loader-helpers.js | 42 ++++++++++++++++--- lib/quibble-sync-hooks.js | 2 + lib/quibble.mjs | 2 + .../esm-fixtures/imports-a-missing-module.mjs | 3 ++ test/esm-lib/quibble-esm.test.mjs | 39 +++++++++++++++++ 6 files changed, 85 insertions(+), 6 deletions(-) create mode 100644 test/esm-fixtures/imports-a-missing-module.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 340d24a..4a3dbb9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/lib/loader-helpers.js b/lib/loader-helpers.js index e24d42c..317c22d 100644 --- a/lib/loader-helpers.js +++ b/lib/loader-helpers.js @@ -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. /** @@ -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(/#.*/, '') } @@ -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). * @@ -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 ? { @@ -240,6 +269,7 @@ module.exports = { nextResolveContext, finishResolve, recoverResolve, + forgetUnresolvedError, stubbedLoadResult, stripQueryAndHash } diff --git a/lib/quibble-sync-hooks.js b/lib/quibble-sync-hooks.js index 6967036..808c616 100644 --- a/lib/quibble-sync-hooks.js +++ b/lib/quibble-sync-hooks.js @@ -9,6 +9,7 @@ const { nextResolveContext, finishResolve, recoverResolve, + forgetUnresolvedError, stubbedLoadResult, stripQueryAndHash } = require('./loader-helpers') @@ -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) diff --git a/lib/quibble.mjs b/lib/quibble.mjs index 339d8d8..88bec2a 100644 --- a/lib/quibble.mjs +++ b/lib/quibble.mjs @@ -8,6 +8,7 @@ const { nextResolveContext, finishResolve, recoverResolve, + forgetUnresolvedError, stubbedLoadResult, stripQueryAndHash } = helpers @@ -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)) { diff --git a/test/esm-fixtures/imports-a-missing-module.mjs b/test/esm-fixtures/imports-a-missing-module.mjs new file mode 100644 index 0000000..3131469 --- /dev/null +++ b/test/esm-fixtures/imports-a-missing-module.mjs @@ -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' diff --git a/test/esm-lib/quibble-esm.test.mjs b/test/esm-lib/quibble-esm.test.mjs index 6aa8987..8a98006 100644 --- a/test/esm-lib/quibble-esm.test.mjs +++ b/test/esm-lib/quibble-esm.test.mjs @@ -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) @@ -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 }) + } } }