Skip to content

Commit

Permalink
feat: add pending interceptor check functions to mock agent (nodejs#1358
Browse files Browse the repository at this point in the history
)
  • Loading branch information
theneva authored and metcoder95 committed Dec 26, 2022
1 parent 25a979f commit 1f773fb
Show file tree
Hide file tree
Showing 8 changed files with 434 additions and 22 deletions.
76 changes: 76 additions & 0 deletions docs/api/MockAgent.md
Expand Up @@ -445,3 +445,79 @@ mockAgent.disableNetConnect()
await request('http://example.com')
// Will throw
```

### `MockAgent.pendingInterceptors()`

This method returns any pending interceptors registered on a mock agent. A pending interceptor meets one of the following criteria:

- Is registered with neither `.times(<number>)` nor `.persist()`, and has not been invoked;
- Is persistent (i.e., registered with `.persist()`) and has not been invoked;
- Is registered with `.times(<number>)` and has not been invoked `<number>` of times.

Returns: `PendingInterceptor[]` (where `PendingInterceptor` is a `MockDispatch` with an additional `origin: string`)

#### Example - List all pending inteceptors

```js
const agent = new MockAgent()
agent.disableNetConnect()

agent
.get('https://example.com')
.intercept({ method: 'GET', path: '/' })
.reply(200, '')

const pendingInterceptors = agent.pendingInterceptors()
// Returns [
// {
// timesInvoked: 0,
// times: 1,
// persist: false,
// consumed: false,
// pending: true,
// path: '/',
// method: 'GET',
// body: undefined,
// headers: undefined,
// data: {
// error: null,
// statusCode: 200,
// data: '',
// headers: {},
// trailers: {}
// },
// origin: 'https://example.com'
// }
// ]
```

### `MockAgent.assertNoPendingInterceptors([options])`

This method throws if the mock agent has any pending interceptors. A pending interceptor meets one of the following criteria:

- Is registered with neither `.times(<number>)` nor `.persist()`, and has not been invoked;
- Is persistent (i.e., registered with `.persist()`) and has not been invoked;
- Is registered with `.times(<number>)` and has not been invoked `<number>` of times.

#### Example - Check that there are no pending interceptors

```js
const agent = new MockAgent()
agent.disableNetConnect()

agent
.get('https://example.com')
.intercept({ method: 'GET', path: '/' })
.reply(200, '')

agent.assertNoPendingInterceptors()
// Throws an UndiciError with the following message:
//
// 1 interceptor is pending:
//
// ┌─────────┬────────┬───────────────────────┬──────┬─────────────┬────────────┬─────────────┬───────────┐
// │ (index) │ Method │ Origin │ Path │ Status code │ Persistent │ Invocations │ Remaining │
// ├─────────┼────────┼───────────────────────┼──────┼─────────────┼────────────┼─────────────┼───────────┤
// │ 0 │ 'GET' │ 'https://example.com' │ '/' │ 200 │ '❌' │ 0 │ 1 │
// └─────────┴────────┴───────────────────────┴──────┴─────────────┴────────────┴─────────────┴───────────┘
```
28 changes: 27 additions & 1 deletion lib/mock/mock-agent.js
Expand Up @@ -16,8 +16,10 @@ const {
const MockClient = require('./mock-client')
const MockPool = require('./mock-pool')
const { matchValue, buildMockOptions } = require('./mock-utils')
const { InvalidArgumentError } = require('../core/errors')
const { InvalidArgumentError, UndiciError } = require('../core/errors')
const Dispatcher = require('../dispatcher')
const Pluralizer = require('./pluralizer')
const PendingInterceptorsFormatter = require('./pending-interceptors-formatter')

class FakeWeakRef {
constructor (value) {
Expand Down Expand Up @@ -134,6 +136,30 @@ class MockAgent extends Dispatcher {
[kGetNetConnect] () {
return this[kNetConnect]
}

pendingInterceptors () {
const mockAgentClients = this[kClients]

return Array.from(mockAgentClients.entries())
.flatMap(([origin, scope]) => scope.deref()[kDispatches].map(dispatch => ({ ...dispatch, origin })))
.filter(({ pending }) => pending)
}

assertNoPendingInterceptors ({ pendingInterceptorsFormatter = new PendingInterceptorsFormatter() } = {}) {
const pending = this.pendingInterceptors()

if (pending.length === 0) {
return
}

const pluralizer = new Pluralizer('interceptor', 'interceptors').pluralize(pending.length)

throw new UndiciError(`
${pluralizer.count} ${pluralizer.noun} ${pluralizer.is} pending:
${pendingInterceptorsFormatter.format(pending)}
`.trim())
}
}

module.exports = MockAgent
21 changes: 8 additions & 13 deletions lib/mock/mock-utils.js
Expand Up @@ -107,9 +107,9 @@ function getMockDispatch (mockDispatches, key) {
}

function addMockDispatch (mockDispatches, key, data) {
const baseData = { times: null, persist: false, consumed: false }
const baseData = { timesInvoked: 0, times: 1, persist: false, consumed: false }
const replyData = typeof data === 'function' ? { callback: data } : { ...data }
const newMockDispatch = { ...baseData, ...key, data: { error: null, ...replyData } }
const newMockDispatch = { ...baseData, ...key, pending: true, data: { error: null, ...replyData } }
mockDispatches.push(newMockDispatch)
return newMockDispatch
}
Expand Down Expand Up @@ -230,25 +230,20 @@ function mockDispatch (opts, handler) {
const key = buildKey(opts)
const mockDispatch = getMockDispatch(this[kDispatches], key)

mockDispatch.timesInvoked++

// Here's where we resolve a callback if a callback is present for the dispatch data.
if (mockDispatch.data.callback) {
mockDispatch.data = { ...mockDispatch.data, ...mockDispatch.data.callback(opts) }
}

// Parse mockDispatch data
const { data: { statusCode, data, headers, trailers, error }, delay, persist } = mockDispatch
let { times } = mockDispatch
if (typeof times === 'number' && times > 0) {
times = --mockDispatch.times
}
const { timesInvoked, times } = mockDispatch

// If persist is true, skip
// Or if times is a number and > 0, skip
// Otherwise, mark as consumed

if (!(persist === true || (typeof times === 'number' && times > 0))) {
mockDispatch.consumed = true
}
// If it's used up and not persistent, mark as consumed
mockDispatch.consumed = !persist && timesInvoked >= times
mockDispatch.pending = timesInvoked < times

// If specified, trigger dispatch error
if (error !== null) {
Expand Down
40 changes: 40 additions & 0 deletions lib/mock/pending-interceptors-formatter.js
@@ -0,0 +1,40 @@
'use strict'

const { Transform } = require('stream')
const { Console } = require('console')

/**
* Gets the output of `console.table(…)` as a string.
*/
module.exports = class PendingInterceptorsFormatter {
constructor ({ disableColors } = {}) {
this.transform = new Transform({
transform (chunk, _enc, cb) {
cb(null, chunk)
}
})

this.logger = new Console({
stdout: this.transform,
inspectOptions: {
colors: !disableColors && !process.env.CI
}
})
}

format (pendingInterceptors) {
const withPrettyHeaders = pendingInterceptors.map(
({ method, path, data: { statusCode }, persist, times, timesInvoked, origin }) => ({
Method: method,
Origin: origin,
Path: path,
'Status code': statusCode,
Persistent: persist ? '✅' : '❌',
Invocations: timesInvoked,
Remaining: persist ? Infinity : times - timesInvoked
}))

this.logger.table(withPrettyHeaders)
return this.transform.read().toString()
}
}
29 changes: 29 additions & 0 deletions lib/mock/pluralizer.js
@@ -0,0 +1,29 @@
'use strict'

const singulars = {
pronoun: 'it',
is: 'is',
was: 'was',
this: 'this'
}

const plurals = {
pronoun: 'they',
is: 'are',
was: 'were',
this: 'these'
}

module.exports = class Pluralizer {
constructor (singular, plural) {
this.singular = singular
this.plural = plural
}

pluralize (count) {
const one = count === 1
const keys = one ? singulars : plurals
const noun = one ? this.singular : this.plural
return { ...keys, count, noun }
}
}

0 comments on commit 1f773fb

Please sign in to comment.