diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a0ec1a0..db2fe8b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,8 @@ # changelog * 2.5.3 _Oct.12.2023_ - * [update resolver](https://github.com/iambumblehead/esmock/pull/243) to latest version, slightly faster with fewer loops + * [update resolver](https://github.com/iambumblehead/esmock/pull/250) to latest version, slightly faster with fewer loops + * [add support for resolver](https://github.com/iambumblehead/esmock/pull/251) configuration option * 2.5.2 _Oct.06.2023_ * [update resolver](https://github.com/iambumblehead/esmock/pull/243) to improve module resolution. See resolvewithplus tags [v2.0.6](https://github.com/iambumblehead/resolvewithplus/releases/tag/v2.0.6) and [v2.0.7.](https://github.com/iambumblehead/resolvewithplus/releases/tag/v2.0.7) * **resolve "exports" before "main".** The [spec says:](https://nodejs.org/api/packages.html#package-entry-points) _the "exports" field takes precedence over "main" in supported versions of Node.js._ The updated resolver correctly returns "main" before "exports" (older resolver did not). diff --git a/package.json b/package.json index fc6d6983..41d110f3 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "esmock", "type": "module", - "version": "2.5.2", + "version": "2.5.3", "license": "ISC", "readmeFilename": "README.md", "description": "provides native ESM import and globals mocking for unit tests", diff --git a/src/esmock.d.ts b/src/esmock.d.ts index 520c2829..7bbabfd8 100644 --- a/src/esmock.d.ts +++ b/src/esmock.d.ts @@ -1,9 +1,11 @@ type MockMap = { [specifier: string]: any } +type Resolver = (id: string, parent: string) => string | null type Options = { strict?: boolean | undefined, purge?: boolean | undefined, - isModuleNotFoundError?: boolean | undefined + isModuleNotFoundError?: boolean | undefined, + resolver?: Resolver | undefined } type MockFunction = { @@ -90,5 +92,6 @@ export { strictest, type MockFunction, type MockMap, - type Options + type Options, + type Resolver } diff --git a/src/esmockArgs.js b/src/esmockArgs.js index 41cf4ad3..5be05cc2 100644 --- a/src/esmockArgs.js +++ b/src/esmockArgs.js @@ -1,3 +1,5 @@ +import resolver from 'resolvewithplus' + // extracts path or fileurl from stack, // ' at (/root/test.js:11:31)' -> /root/test.js // ' at Object.handler (file:///D:/a/test.js:11:31)' -> file:///D:/a/test.js @@ -17,7 +19,7 @@ export default (arg, optsextra) => { (new Error).stack.split('\n')[3].replace(stackpathre, '$2'), ...arg.slice(1) ] - arg[4] = {...arg[4], ...optsextra} + arg[4] = { resolver, ...arg[4], ...optsextra} return arg } diff --git a/src/esmockModule.js b/src/esmockModule.js index 6f47d054..01771844 100644 --- a/src/esmockModule.js +++ b/src/esmockModule.js @@ -17,6 +17,9 @@ const isDirPathRe = /^\.?\.?([a-zA-Z]:)?(\/|\\)/ const nextId = ((id = 0) => () => ++id)() const objProto = Object.getPrototypeOf({}) const isPlainObj = o => Object.getPrototypeOf(o) === objProto +const iscoremodule = resolvewith.iscoremodule +const protocolNodeRe = /^node:/ +const addprotocolnode = p => protocolNodeRe.test(p) ? p : `node:${p}` // assigning the object to its own prototypal inheritor can error, eg // 'Cannot assign to read only property \'F_OK\' of object \'#\'' @@ -46,7 +49,7 @@ const esmockModuleApply = (defLive, def, fileURL) => { // if safe, an extra "default.default" is added for compatibility with // babel-generated dist cjs files which also define "default.default" - if (!resolvewith.iscoremodule(fileURL) && Object.isExtensible(def.default)) + if (!iscoremodule(fileURL) && Object.isExtensible(def.default)) def.default.default = def.default return def @@ -59,7 +62,7 @@ const esmockModuleIsESM = (fileURL, isesm) => { if (typeof isesm === 'boolean') return isesm - isesm = !resolvewith.iscoremodule(fileURL) + isesm = !iscoremodule(fileURL) && isDirPathRe.test(fileURL) && esmockIsESMRe.test(fs.readFileSync(fileURL, 'utf-8')) @@ -108,6 +111,9 @@ const esmockModuleCreate = async (treeid, def, id, fileURL, opt) => { return mockModuleKey } +const esmockResolve = (id, parent, opt) => ( + iscoremodule(id) ? addprotocolnode(id) : opt.resolver(id, parent)) + const esmockModuleId = async (parent, treeid, defs, ids, opt, mocks, id) => { ids = ids || Object.keys(defs) id = ids[0] @@ -115,7 +121,7 @@ const esmockModuleId = async (parent, treeid, defs, ids, opt, mocks, id) => { if (!id) return mocks - const fileURL = resolvewith(id, parent) + const fileURL = esmockResolve(id, parent, opt) if (!fileURL && opt.isModuleNotFoundError !== false && id !== 'import') throw esmockErr.errModuleIdNotFound(id, parent) @@ -125,7 +131,7 @@ const esmockModuleId = async (parent, treeid, defs, ids, opt, mocks, id) => { } const esmockModule = async (moduleId, parent, defs, gdefs, opt) => { - const moduleFileURL = resolvewith(moduleId, parent) + const moduleFileURL = esmockResolve(moduleId, parent, opt) if (!moduleFileURL) throw esmockErr.errModuleIdNotFound(moduleId, parent) diff --git a/tests/local/customResolverChild.js b/tests/local/customResolverChild.js new file mode 100644 index 00000000..15c80096 --- /dev/null +++ b/tests/local/customResolverChild.js @@ -0,0 +1,7 @@ +const isMocked = false +const isCustomResolverChild = true + +export default { + isCustomResolverChild, + isMocked +} diff --git a/tests/local/customResolverParent.js b/tests/local/customResolverParent.js new file mode 100644 index 00000000..e6e5cb6d --- /dev/null +++ b/tests/local/customResolverParent.js @@ -0,0 +1,9 @@ +import child from 'RESOLVECUSTOM' +import path from 'node:path' + +const pathbasenameresult = path.basename('/the/very/happy-dog.png') + +export { + child, + pathbasenameresult +} diff --git a/tests/tests-node/esmock.node.resolver-custom.test.js b/tests/tests-node/esmock.node.resolver-custom.test.js new file mode 100644 index 00000000..84b9f854 --- /dev/null +++ b/tests/tests-node/esmock.node.resolver-custom.test.js @@ -0,0 +1,66 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import module from 'node:module' +import esmock from 'esmock' + +function resolverCustom (moduleId, parent) { + parent = parent.replace(/\/tests\/.*$/, '/tests/tests-node/') + + // This logic looks unusual because of constraints here. This function must: + // * must work at windows, where path.join and path.resolve cause issues + // * must be string-serializable, no external funcions + // * must resolve these moduleIds to corresponding, existing filepaths + // * '../local/customResolverParent.js', + // * 'RESOLVECUSTOM/ + return ( + /RESOLVECUSTOM$/.test(moduleId) + ? parent + '../local/customResolverChild.js' + : parent + moduleId + ).replace(/\/tests-node\/\.\./, '') +} + +async function resolve (specifier, context, next) { + return next( + specifier === 'RESOLVECUSTOM' + ? resolverCustom(specifier, context.parentURL) + : specifier, context) +} + +module.register && module.register(` +data:text/javascript, +${encodeURIComponent(resolverCustom)} +export ${encodeURIComponent(resolve)}`.slice(1)) + +test('should use custom resolver', async () => { + if (!module.register) + return assert.ok('skip test') + + const customResolverParent = await esmock( + '../local/customResolverParent.js', {}, { + RESOLVECUSTOM: ({ isMocked: true }) + }, { + resolver: resolverCustom + }) + + assert.ok(customResolverParent.child.isCustomResolverChild) + assert.ok(customResolverParent.child.isMocked) +}) + +test('should not call custom resover with builtin moduleIds', async () => { + if (!module.register) + return assert.ok('skip test') + + const customResolverParent = await esmock( + '../local/customResolverParent.js', {}, { + RESOLVECUSTOM: ({ isMocked: true }), + path: { basename: () => 'basenametest' } + }, { + resolver: resolverCustom + }) + + assert.ok(customResolverParent.child.isCustomResolverChild) + assert.ok(customResolverParent.child.isMocked) + assert.strictEqual( + customResolverParent.pathbasenameresult, + 'basenametest') +})