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 }) + } } }