Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add support for custom resolvers #251

Merged
merged 11 commits into from
Oct 13, 2023
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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).
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
7 changes: 5 additions & 2 deletions src/esmock.d.ts
Original file line number Diff line number Diff line change
@@ -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 = {
Expand Down Expand Up @@ -90,5 +92,6 @@ export {
strictest,
type MockFunction,
type MockMap,
type Options
type Options,
type Resolver
}
4 changes: 3 additions & 1 deletion src/esmockArgs.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import resolver from 'resolvewithplus'

// extracts path or fileurl from stack,
// ' at <anonymous> (/root/test.js:11:31)' -> /root/test.js
// ' at Object.handler (file:///D:/a/test.js:11:31)' -> file:///D:/a/test.js
Expand All @@ -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
}
14 changes: 10 additions & 4 deletions src/esmockModule.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 \'#<Object>\''
Expand Down Expand Up @@ -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
Expand All @@ -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'))

Expand Down Expand Up @@ -108,14 +111,17 @@ 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]
mocks = mocks || []

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)

Expand All @@ -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)

Expand Down
7 changes: 7 additions & 0 deletions tests/local/customResolverChild.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
const isMocked = false
const isCustomResolverChild = true

export default {
isCustomResolverChild,
isMocked
}
9 changes: 9 additions & 0 deletions tests/local/customResolverParent.js
Original file line number Diff line number Diff line change
@@ -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
}
66 changes: 66 additions & 0 deletions tests/tests-node/esmock.node.resolver-custom.test.js
Original file line number Diff line number Diff line change
@@ -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')
})