UNPKG

@exodus/test

Version:
456 lines (398 loc) 17.5 kB
import { mockModule, assert, requireIsRelative, relativeRequire as require, baseFile, isTopLevelESM, builtinModules, syncBuiltinESMExports, } from './engine.js' import { haste } from './jest.config.js' import { jestfn } from './jest.fn.js' import { loadExpect } from './expect.cjs' import { loadPrettyFormat } from './pretty-format.cjs' import { makeEsbuildMockable, insideEsbuild, createCallerLocationHook } from './dark.cjs' const mapMocks = new Map() const mapActual = new Map() const nodeMocks = new Map() const overridenBuiltins = new Set() // TODO: support correct relative locations in other engines too (and bundles) const { getCallerLocation: getLoc } = createCallerLocationHook() export const jestModuleMocks = { mock(name, mock) { jestmock(name, mock, { override: true, loc: getLoc() }) return this }, doMock(name, mock) { jestmock(name, mock, { loc: getLoc() }) return this }, setMock(name, mock) { jestmock(name, () => mock, { loc: getLoc() }) // like doMock, does not hoist to top, tested return this }, unmock(name) { unmock(name, { loc: getLoc() }) return this }, createMockFromModule: (name) => mockClone(requireActual(name, { loc: getLoc() })), requireMock: (name) => requireMock(name, { loc: getLoc() }), requireActual: (name) => requireActual(name, { loc: getLoc() }), resetModules, } jestModuleMocks.dontMock = jestModuleMocks.unmock if (process.env.EXODUS_TEST_ENVIRONMENT === 'bundle') { globalThis.EXODUS_TEST_MOCK_BUILTINS = new Map() Object.assign(jestModuleMocks, { __mockBundle(name, builtin, actual, mock) { jestmock(name, mock, { actual, builtin, override: true }) return this }, __doMockBundle(name, builtin, actual, mock) { jestmock(name, mock, { actual, builtin }) return this }, __setMockBundle(name, builtin, actual, mock) { jestmock(name, () => mock, { actual, builtin }) return this }, }) } // For bundles const cjsSet = typeof __mocksCJSPossible === 'undefined' ? null : __mocksCJSPossible // eslint-disable-line no-undef const esmSet = typeof __mocksESMPossible === 'undefined' ? null : __mocksESMPossible // eslint-disable-line no-undef function resolveModule(name, loc) { if (process.env.EXODUS_TEST_ENVIRONMENT === 'bundle') { assert(name.startsWith('bundle:'), `Can't mock unresolved ${name} in bundle, use static syntax`) assert(cjsSet && esmSet, 'Module mocking not installed correctly in bundle') const id = name.replace(/^bundle:/u, '') assert(!cjsSet?.has(id) || !esmSet?.has(id), 'CJS/ESM conflict in bundle mock') assert(cjsSet?.has(id) || esmSet?.has(id), `Mock: can not find ${id} in bundle. Unused mock?`) const cjs = `${id}.exodus-test-mock.cjs` if (esmSet.has(id) && cjsSet.has(cjs)) { assert(!esmSet.has(cjs)) return cjs } return id } const unprefixed = name.replace(/^node:/, '') if (builtinModules.includes(unprefixed)) return unprefixed const canRequire = loc?.[2] || requireIsRelative || /^[@a-zA-Z]/u.test(name) assert(canRequire, 'Mocking relative paths is not possible') const properRequire = loc?.[2] ? require('node:module').createRequire(loc?.[2]) : require for (const suffix of haste()) { try { return properRequire.resolve(`${name}.${suffix}`) } catch {} } return properRequire.resolve(name) } function resolveImport(name, loc) { try { const { fileURLToPath, pathToFileURL } = require('node:url') let parent if (loc?.[2]) parent = loc[2].startsWith('file:') ? loc[2] : pathToFileURL(loc[2]) return fileURLToPath(import.meta.resolve(name, parent)) } catch { return null } } function requireActual(name, { loc } = {}) { const resolved = resolveModule(name, loc) if (mapActual.has(resolved)) return mapActual.get(resolved) if (!mapMocks.has(resolved)) return require(resolved) throw new Error('Module can not been loaded') } function requireMock(name, { loc } = {}) { const resolved = resolveModule(name, loc) assert(mapMocks.has(resolved), 'Module is not mocked') return mapMocks.get(resolved) } function resetModules() { for (const [, ctx] of nodeMocks) { if (mockModule) ctx.restore() } assert(process.env.EXODUS_TEST_ENVIRONMENT !== 'bundle', 'resetModules() unsupported from bundle') for (const resolved of Object.keys(require.cache)) { delete require.cache[resolved] mapMocks.delete(resolved) } } function unmock(name, { loc } = {}) { const resolved = resolveModule(name, loc) assert(mapMocks.has(resolved), 'Module is not mocked') if (mockModule) nodeMocks.get(resolved).restore() delete require.cache[resolved] delete require.cache[`node:${resolved}`] mapMocks.delete(resolved) nodeMocks.delete(resolved) assert( !overridenBuiltins.has(resolved), 'Built-in modules mocked with jest.mock can not be unmocked, use jest.doMock' ) } const isObject = (obj) => obj && [Object.prototype, null].includes(Object.getPrototypeOf(obj)) function overrideModule(resolved, lax = false) { const value = mapMocks.get(resolved) const current = mapActual.get(resolved) if (current === value) return assert(isObject(value), 'Overriding loaded or internal modules is possible with objects only') const clone = { ...current } Object.setPrototypeOf(clone, Object.getPrototypeOf(current)) mapActual.set(resolved, clone) for (const key of Object.keys(current)) { try { delete current[key] } catch {} } // We want to skip overriding frozen properties that already match, e.g. fs.constants const filtered = Object.entries(value).filter(([k, v]) => !(k in {}) && current[k] !== v) const access = { configurable: true, enumerable: true, writable: true } const definitions = Object.fromEntries(filtered.map(([k, value]) => [k, { value, ...access }])) Object.defineProperties(current, definitions) const proto = Object.getPrototypeOf(value) if (Object.getPrototypeOf(current) !== proto) Object.setPrototypeOf(current, proto) const checked = { ...current } // allow value.__esModule to be absent, allow value.__esModule to be non-enumerable // if we try to override an existing __esModule module with a manually passed obj, it means we are using named exports if (value.__esModule && current.__esModule === true) checked.__esModule = current.__esModule if (!lax) assert.deepEqual(checked, value) } function mockClone(obj, cache = new Map()) { if (!cache.has(obj)) cache.set(obj, mockCloneItem(obj, cache)) return cache.get(obj) } function mockCloneItem(obj, cache) { if ([Object.prototype, null].includes(obj)) return obj if (!obj || ['number', 'boolean', 'string', 'bigint'].includes(typeof obj)) return obj const TypedArray = Object.getPrototypeOf(Int8Array) if (Array.isArray(obj) || obj instanceof TypedArray) return [] // this is what jest does apparently if (obj instanceof RegExp) return new RegExp() // this is what jest does apparently // eslint-disable-next-line no-new-wrappers, unicorn/new-for-builtins if (obj instanceof String) return new String(obj) if (obj instanceof Function) { const res = jestfn() cache.set(obj, res) if (obj.prototype) res.prototype = mockClone(obj.prototype, cache) return res } if (typeof obj === 'object') { // Special path, as .default might be a getter and we want to unwrap it if (obj.__esModule === true) { const { __esModule, default: def, ...rest } = obj const proto = Object.getPrototypeOf(obj) const toClone = proto?.[Symbol.toStringTag] === 'Module' ? proto : { default: def, ...rest } // unwrap bun modules for proper cloning return { __esModule, ...mockClone(toClone, cache) } } const prototype = Object.getPrototypeOf(obj) const clone = Object.create(prototype === null ? null : Object.prototype) cache.set(obj, clone) const definitions = [] // Collect all property descriptors from the prototype chain, top-level last for correct overriding in fromEntries const stack = [] for (let c = obj; c && c !== Object.prototype; c = Object.getPrototypeOf(c)) stack.unshift(c) let modified = stack.length > 1 for (const level of stack) { const descriptors = Object.getOwnPropertyDescriptors(level) const entries = Object.entries(descriptors) for (const sym of [Symbol.toStringTag]) { if (sym && Object.hasOwn(descriptors, sym)) entries.push([sym, descriptors[sym]]) // Missed by Object.entries } for (const [name, desc] of entries) { if (name === 'constructor') continue for (const key of ['get', 'set', 'value']) { if (!desc[key]) continue const orig = desc[key] desc[key] = mockClone(desc[key], cache) if (orig !== desc[key]) modified = true } if (desc.value !== undefined || ((desc.get || desc.set) && desc.enumerable !== false)) { desc.enumerable = desc.configurable = true definitions.push([name, desc]) } } } Object.defineProperties(clone, Object.fromEntries(definitions)) return modified ? clone : obj } return null } // TODO: implement for bundles or add a guard against bundles if __mocks__ dir exists let loadMocksDirMock // Optimized out in 'bundle' env function installMockDirs() { const { existsSync, readdirSync, statSync } = require('node:fs') const { dirname, join, extname } = require('node:path') const dirs = [] let dir = baseFile ? dirname(baseFile) : undefined while (dir) { const file = join(dir, '__mocks__') if (existsSync(file)) dirs.push(file) if (dir === process.env.PROJECT_CWD) break // e.g. yarn sets this if (existsSync(join(dir, '.git'))) break // don't go higher than the repo root if (existsSync(join(dir, 'pnpm-workspace.yaml'))) break // pnpm workspace root const parent = dirname(dir) if (!parent || parent === dir) break dir = parent } const mocks = new Map() const shouldAutoMock = new Set() for (const dir of dirs) { for (const file of readdirSync(dir, { recursive: true })) { const ext = extname(file) if (!['.js', '.cjs', '.mjs', '.jsx'].includes(ext)) continue const absolute = join(dir, file) if (!statSync(absolute).isFile()) continue const name = file.slice(0, -ext.length) if (!mocks.has(name)) mocks.set(name, absolute) if (!builtinModules.includes(name)) shouldAutoMock.add(name) } } if (mocks.size > 0) { loadMocksDirMock = (name) => { if (name.startsWith('.') || !mocks.has(name)) return return require(mocks.get(name)) } } // Automock does't work on import() in jest anyway, so it's ok to let that require manual jest.mock if (shouldAutoMock.size > 0) { const { Module } = require('node:module') const _require = Module.prototype.require Module.prototype.require = function (...args) { if (shouldAutoMock.has(args[0])) { shouldAutoMock.delete(args[0]) jestmock(args[0]) } return _require.apply(this, args) } } } if (process.env.EXODUS_TEST_ENVIRONMENT !== 'bundle') installMockDirs() function jestmock(name, mocker, { override = false, actual, builtin, loc } = {}) { // Loaded ESM: isn't mocked // Loaded CJS: mocked via object overriding // Loaded built-ins: mocked via object overriding where possible // New CJS, doMock CJS: mocked via mock.module + require.cache // New ESM, doMock ESM: mocked via mock.module // New built-ins: mocked via mock.module // [Bundled] New CJS, doMock CJS: mocked via bundle hook // [Bundled] New ESM, doMock ESM: isn't mocked // [Bundled] New built-ins: mocked via bundle hook const mockFromMocks = mocker ? undefined : loadMocksDirMock?.(name) const resolved = resolveModule(name, loc) const isBuiltIn = builtinModules.includes(resolved) if (!mocker && mockFromMocks && mapMocks.get(resolved) === mockFromMocks) return assert(!mapMocks.has(resolved), 'Re-mocking the same module is not supported') assert( !overridenBuiltins.has(resolved), 'Built-in modules mocked with jest.mock can not be remocked, use jest.doMock' ) let havePrior if (process.env.EXODUS_TEST_ENVIRONMENT === 'bundle') { havePrior = __mocksCJSLoaded.has(resolved) || __mocksESMLoaded.has(resolved) // eslint-disable-line no-undef assert(actual) } else { havePrior = Object.hasOwn(require.cache, resolved) assert(!actual && !builtin) } // Attempt to load it // Jest also loads modules on mock // Can be ESM, so let it fail silently try { assert(!resolved.endsWith('.exodus-test-mock.cjs')) // actual() would attempt to load non-wrapped ESM here const shouldLoadActual = !mockFromMocks || havePrior || isBuiltIn if (shouldLoadActual) mapActual.set(resolved, actual ? actual() : require(resolved)) } catch { const reason = actual ? 'in bundle' : 'without --esbuild or newer Node.js' assert(mocker || mockFromMocks, `Can not auto-clone a native ESM module ${reason}`) } const expand = (obj) => (isObject(obj) ? { ...obj } : obj) const value = mockFromMocks ?? (mocker ? expand(mocker()) : mockClone(mapActual.get(resolved))) mapMocks.set(resolved, value) loadExpect('jest.mock') // we need to do this as we don't want mocks affecting expect loadPrettyFormat() // same reason if (process.env.EXODUS_TEST_ENVIRONMENT === 'bundle') { if (builtin) globalThis.EXODUS_TEST_MOCK_BUILTINS.set(builtin, value) if (havePrior && override) overrideModule(resolved) // This won't work on ESM if (cjsSet?.has(resolved)) { __mocksCJS.set(resolved, value) // eslint-disable-line no-undef } else if (esmSet?.has(resolved)) { throw new Error('ESM module mocks are not supported from bundle') // TODO: can we do something? } else { throw new Error('unreachable') } return this } const topESM = isTopLevelESM() let likelyESM = topESM && !insideEsbuild() && ![null, resolved].includes(resolveImport(name, loc)) let isOverridenBuiltinSynchedWithESM = false const isNodeCache = (x) => x && x.id && x.path && x.filename && x.children && x.paths && x.loaded if (isBuiltIn && !isNodeCache(require.cache[resolved])) { if (!value.default && !value.__esModule) { value.__esModule = true // allows esbuild to unwrap it to named mocks value.default = value } if (override) { overridenBuiltins.add(resolved) overrideModule(resolved, true) // Override builtin modules if (syncBuiltinESMExports) { try { syncBuiltinESMExports() } catch (err) { if (!globalThis.Deno) throw err // Deno throws on syncBuiltinESMExports, ignore for now } isOverridenBuiltinSynchedWithESM = true } } require.cache[resolved] = require.cache[`node:${resolved}`] = { exports: value } } else if (Object.hasOwn(require.cache, resolved)) { if (isNodeCache(require.cache[resolved]) || !require.cache[resolved].exports?.__esModule) { const { exports } = require.cache[resolved] assert.equal(mapActual.get(resolved), exports) if (exports?.[Symbol.toStringTag] === 'Module') likelyESM = true // required ESM in Node.js // If we did't have this prior but have now, it means we just loaded it and there are no leaked instances if (havePrior && override) overrideModule(resolved) require.cache[resolved].exports = value } else { // If it's non-Node.js and has __esModule tag, assume it's ESM likelyESM = true } } else if (mockFromMocks) { require.cache[resolved] = { exports: value } } else { // The module doesn't exist or is ESM likelyESM = true } const mocksNodeVersionNote = 'mocks are available only on Node.js >=20.18 <21 || >=22.3' if (likelyESM || (!isOverridenBuiltinSynchedWithESM && topESM)) { // Native module mocks is required if loading ESM or __from__ ESM // No good way to check the locations that import the module, but we can check top-level file // Built-in modules are fine though assert(mockModule, `ESM module ${mocksNodeVersionNote}`) } else if (isBuiltIn && name.startsWith('node:') && !override) { assert(mockModule, `Native non-overriding node:* ${mocksNodeVersionNote}`) } if (value?.[Symbol.toStringTag] === 'Module') value.__esModule = true const obj = { defaultExport: value } if (isBuiltIn && isObject(value)) obj.namedExports = value if (insideEsbuild()) { // esbuild handles unwrapping just default exports for us assert(!likelyESM) // should not be reachable if (isObject(value)) { const { default: defaultExport, __esModule, ...namedExports } = value // eslint-disable-line @typescript-eslint/no-unused-vars // Don't override defaultExport, as that's processed with esbuild // Add named exports though for further static named imports from that module // type:module and esbuild can be combined e.g. when testing typescript packages if (__esModule) obj.namedExports = namedExports } } else if (likelyESM && isObject(value) && value.__esModule === true) { // eslint-disable-next-line @typescript-eslint/no-unused-vars const { default: defaultExport, __esModule, ...namedExports } = value Object.assign(obj, { defaultExport, namedExports }) if (obj.defaultExport === undefined) delete obj.defaultExport } nodeMocks.set(resolved, mockModule?.(resolved, obj)) return this } makeEsbuildMockable()