Skip to content

Commit

Permalink
mock: allow absolute deps, not node builtins
Browse files Browse the repository at this point in the history
Fix: #948
  • Loading branch information
isaacs committed Oct 8, 2023
1 parent 57c6847 commit 16f52fe
Show file tree
Hide file tree
Showing 6 changed files with 129 additions and 21 deletions.
24 changes: 21 additions & 3 deletions src/mock/src/index.ts
@@ -1,6 +1,7 @@
import { plugin as AfterPlugin } from '@tapjs/after'
import { TapPlugin, TestBase } from '@tapjs/core'
import * as stack from '@tapjs/stack'
import { isBuiltin } from 'node:module'
import { mockRequire } from './mock-require.js'
import { MockService } from './mock-service.js'

Expand All @@ -23,7 +24,10 @@ export class TapMock {
TapMock.#refs.set(t, this)
// inherit #allMock
const p = t.parent && TapMock.#refs.get(t.parent)
this.#allMock = Object.assign(Object.create(null), p ? p.#allMock : {})
this.#allMock = Object.assign(
Object.create(null),
p ? p.#allMock : {}
)
}

/**
Expand Down Expand Up @@ -132,7 +136,14 @@ export class TapMock {
*
* @group Spies, Mocks, and Fixtures
*/
mockImport(module: string, mocks: Record<string, any> = {}) {
async mockImport(module: string, mocks: Record<string, any> = {}) {
if (isBuiltin(module)) {
this.#t.t.currentAssert = this.mockImport
this.#t.t.fail(
'Node built-in modules cannot have their imports mocked'
)
return {}
}
mocks = Object.assign({}, this.#allMock, mocks)
if (!this.#didTeardown && this.#t.t.pluginLoaded(AfterPlugin)) {
this.#didTeardown = true
Expand All @@ -144,7 +155,7 @@ export class TapMock {
this.#t.t.mockImport
)
this.#mocks.push(service)
return import(service.module)
return Promise.resolve(service.module).then(s => import(s))
}

/**
Expand All @@ -167,6 +178,13 @@ export class TapMock {
* @group Spies, Mocks, and Fixtures
*/
mockRequire(module: string, mocks: Record<string, any> = {}) {
if (isBuiltin(module)) {
this.#t.t.currentAssert = this.mockRequire
this.#t.t.fail(
'Node built-in modules cannot have their imports mocked'
)
return {}
}
mocks = Object.assign({}, this.#allMock, mocks)
return mockRequire(module, mocks, this.#t.t.mockRequire)
}
Expand Down
22 changes: 13 additions & 9 deletions src/mock/src/mock-service.ts
Expand Up @@ -30,6 +30,7 @@ import { pathToFileURL } from 'url'
import { MessagePort } from 'worker_threads'
import { exportLine } from './export-line.js'
import { mungeMocks } from './munge-mocks.js'
import { resolveMockEntryPoint } from './resolve-mock-entry-point.js'
import { serviceKey } from './service-key.js'

const { hasOwnProperty } = Object.prototype
Expand Down Expand Up @@ -114,7 +115,7 @@ g[kInstances] = instances
const mockServiceCtorSymbol = Symbol('private')
export class MockService {
key: string = getKey()
module?: string
module?: string | Promise<string>
mocks?: Record<string, Record<string, any>>
caller?: {
path: string
Expand Down Expand Up @@ -227,7 +228,7 @@ export class MockService {
module: string,
mocks: Record<string, any> = {},
caller: Function | ((...a: any[]) => any) = MockService.create
): MockService & { module: string } {
): MockService & { module: string | Promise<string> } {
const ms = new MockService(mockServiceCtorSymbol)

/* c8 ignore start */
Expand All @@ -243,13 +244,18 @@ export class MockService {
if (!path) {
throw new Error('could not get current call site')
}

const dir = dirname(path)
const url = pathToFileURL(path)
const mockedModuleURL = new URL(module, url)
mockedModuleURL.searchParams.set(
'tapmock',
`${serviceKey}.${ms.key}`

const resolved = resolveMockEntryPoint(
url,
module,
serviceKey,
ms.key,
caller
)
resolved.then(s => (ms.module = s))

ms.mocks = mungeMocks(mocks, dir)
ms.caller = {
Expand All @@ -263,9 +269,7 @@ export class MockService {
const g = globalThis as typeof globalThis & {
[sym]?: MockService
}
return (g[sym] = Object.assign(ms, {
module: String(mockedModuleURL),
}))
return (g[sym] = Object.assign(ms, { module: resolved }))
}

unmock() {
Expand Down
30 changes: 30 additions & 0 deletions src/mock/src/resolve-mock-entry-point.ts
@@ -0,0 +1,30 @@
import { resolveImport } from 'resolve-import'

export const resolveMockEntryPoint = async (
url: URL,
module: string,
serviceKey: string,
key: string,
caller: Function | ((...a: any[]) => any)
): Promise<string> => {
let mockedModuleURL: URL
if (module.startsWith('./') || module.startsWith('../')) {
mockedModuleURL = new URL(module, url)
} else {
const res = (await resolveImport(module, url)) as URL
// caught at the exposed API, defense in depth only
// but the experience if it throws here is unhelpful.
/* c8 ignore start */
if (typeof res === 'string') {
const er = new TypeError(
'node builtins cannot be mock imported'
)
Error.captureStackTrace(er, caller)
throw er
}
/* c8 ignore stop */
mockedModuleURL = res
}
mockedModuleURL.searchParams.set('tapmock', `${serviceKey}.${key}`)
return String(mockedModuleURL)
}
28 changes: 27 additions & 1 deletion src/mock/test/index.ts
@@ -1,5 +1,5 @@
import { relative } from 'path'
import t from 'tap'
import t, { Test } from 'tap'
import { loader, plugin } from '../dist/esm/index.js'

const __dirname = fileURLToPath(new URL('.', import.meta.url))
Expand Down Expand Up @@ -172,3 +172,29 @@ t.test('mockAll editing', t => {
t.same(t.mockAll(null), {})
t.end()
})

t.test('cannot mock node builtins', async t => {
const tt = new Test({ name: 'failing mocker' })
tt.runMain(() => {})
tt.test('subtest', async t => {
await t.mockImport('fs')
t.mockRequire('fs')
})
tt.end()
const result = await tt.concat()
t.equal(tt.passing(), false)
t.match(
result,
`
await t.mockImport('fs')
------------^
`
)
t.match(
result,
`
t.mockRequire('fs')
------^
`
)
})
16 changes: 8 additions & 8 deletions src/mock/test/mock-service.ts
Expand Up @@ -132,7 +132,7 @@ t.test('generate some mock imports', async t => {
)
const expect = new URL(mod, import.meta.url)
expect.searchParams.set('tapmock', `${serviceKey}.${service.key}`)
t.equal(service.module, String(expect))
t.equal(await service.module, String(expect))
t.type(service.key, 'string')
t.match(service, {
mocks: {
Expand Down Expand Up @@ -167,7 +167,7 @@ t.test('generate some mock imports', async t => {
stack: String,
},
})
t.strictSame(await import(service.module), {
t.strictSame(await import(await service.module), {
__proto__: null,
foo: 'bar',
myFS: 'hello from fs',
Expand Down Expand Up @@ -242,39 +242,39 @@ t.test('create with no mocks, nothing to resolve', async t => {
action: 'resolve',
id: 'whatever',
url: './blah.mjs',
parentURL: service.module,
parentURL: await service.module,
}), String(expect))

t.equal(await MSCJS.resolve({
action: 'resolve',
id: 'whatever',
url: './blah.mjs',
parentURL: service.module,
parentURL: await service.module,
}), String(expect))

t.equal(await service.resolve({
action: 'resolve',
id: 'whatever',
url: './blah.mjs',
parentURL: service.module,
parentURL: await service.module,
}), String(expect))

t.equal(await service.resolve({
action: 'resolve',
id: 'another one',
url: './nonexistent.mjs',
parentURL: service.module,
parentURL: await service.module,
}), undefined)
t.equal(await MockService.resolve({
action: 'resolve',
id: 'another one',
url: './nonexistent.mjs',
parentURL: service.module,
parentURL: await service.module,
}), undefined)
t.equal(await MSCJS.resolve({
action: 'resolve',
id: 'another one',
url: './nonexistent.mjs',
parentURL: service.module,
parentURL: await service.module,
}), undefined)
})
30 changes: 30 additions & 0 deletions src/mock/test/resolve-mock-entry-point.ts
@@ -0,0 +1,30 @@
import { resolveImport } from 'resolve-import'
import t from 'tap'

import { resolveMockEntryPoint } from '../src/resolve-mock-entry-point.js'

t.equal(
await resolveMockEntryPoint(
new URL(import.meta.url),
'../dist/esm/hooks.mjs',
'service-key',
'mock-key',
() => {}
),
String(
await resolveImport('../dist/esm/hooks.mjs', import.meta.url)
) + '?tapmock=service-key.mock-key'
)

t.equal(
await resolveMockEntryPoint(
new URL(import.meta.url),
'@tapjs/synonyms',
'service-key',
'mock-key',
() => {}
),
String(
await resolveImport('@tapjs/synonyms', import.meta.url)
) + '?tapmock=service-key.mock-key'
)

0 comments on commit 16f52fe

Please sign in to comment.