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

feat: add pending interceptor check functions to mock agent #1358

Merged
merged 10 commits into from Apr 29, 2022
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
```

mcollina marked this conversation as resolved.
Show resolved Hide resolved
### `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)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the pluralizer API ends up being a little more complicated than it maybe needs to now that we only track one array (instead of consumed, tooFewUses, and persistent as we did before), but I think it's still a nice little utility.


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 }
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we default times to 1 here so we don't have to do null checking everywhere

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 } }
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All interceptors are pending as they are first registered. I think consumed belongs here as well (and not in baseData), but… meh. I suppose it might be possible to register an interceptor that should not be pending by registering it with .times(0), but that sounds too illogical to account for.

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++
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We now increase timesInvoked instead of decreasing times (which is now constant).

(Is invoked a good name, or would something like timesIntercepted be better?)


// 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
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

consumed is the same as before, but pending is new.


// 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
}))
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we moved the header name mapping here so that the formatter gets proper lowerCamelCased variable names passed in. I think that makes it less annoying to build a new formatter. Otherwise, this does the same thing as before


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 }
}
}