diff --git a/docs/api/MockAgent.md b/docs/api/MockAgent.md index f94ae339f96..04cfcb0e554 100644 --- a/docs/api/MockAgent.md +++ b/docs/api/MockAgent.md @@ -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()` nor `.persist()`, and has not been invoked; +- Is persistent (i.e., registered with `.persist()`) and has not been invoked; +- Is registered with `.times()` and has not been invoked `` 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()` nor `.persist()`, and has not been invoked; +- Is persistent (i.e., registered with `.persist()`) and has not been invoked; +- Is registered with `.times()` and has not been invoked `` 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 │ +// └─────────┴────────┴───────────────────────┴──────┴─────────────┴────────────┴─────────────┴───────────┘ +``` diff --git a/lib/mock/mock-agent.js b/lib/mock/mock-agent.js index 4ae47f657fe..093da5e50a9 100644 --- a/lib/mock/mock-agent.js +++ b/lib/mock/mock-agent.js @@ -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) { @@ -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 diff --git a/lib/mock/mock-utils.js b/lib/mock/mock-utils.js index 65e21c6bf5c..ed7e6c3432f 100644 --- a/lib/mock/mock-utils.js +++ b/lib/mock/mock-utils.js @@ -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 } @@ -230,6 +230,8 @@ 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) } @@ -237,18 +239,11 @@ function mockDispatch (opts, handler) { // 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) { diff --git a/lib/mock/pending-interceptors-formatter.js b/lib/mock/pending-interceptors-formatter.js new file mode 100644 index 00000000000..1bc7539fd2e --- /dev/null +++ b/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() + } +} diff --git a/lib/mock/pluralizer.js b/lib/mock/pluralizer.js new file mode 100644 index 00000000000..47f150bc27a --- /dev/null +++ b/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 } + } +} diff --git a/test/mock-interceptor-unused-assertions.js b/test/mock-interceptor-unused-assertions.js new file mode 100644 index 00000000000..7ad62904866 --- /dev/null +++ b/test/mock-interceptor-unused-assertions.js @@ -0,0 +1,218 @@ +'use strict' + +const { test, beforeEach, afterEach } = require('tap') +const { MockAgent, setGlobalDispatcher } = require('..') +const PendingInterceptorsFormatter = require('../lib/mock/pending-interceptors-formatter') + +// Avoid colors in the output for inline snapshots. +const pendingInterceptorsFormatter = new PendingInterceptorsFormatter({ disableColors: true }) + +let originalGlobalDispatcher + +const origin = 'https://localhost:9999' + +beforeEach(() => { + // Disallow all network activity by default by using a mock agent as the global dispatcher + const globalDispatcher = new MockAgent() + globalDispatcher.disableNetConnect() + setGlobalDispatcher(globalDispatcher) + originalGlobalDispatcher = globalDispatcher +}) + +afterEach(() => { + setGlobalDispatcher(originalGlobalDispatcher) +}) + +function mockAgentWithOneInterceptor () { + const agent = new MockAgent() + agent.disableNetConnect() + + agent + .get('https://example.com') + .intercept({ method: 'GET', path: '/' }) + .reply(200, '') + + return agent +} + +test('1 pending interceptor', t => { + t.plan(2) + + const err = t.throws(() => mockAgentWithOneInterceptor().assertNoPendingInterceptors({ pendingInterceptorsFormatter })) + + t.same(err.message, ` +1 interceptor is pending: + +┌─────────┬────────┬───────────────────────┬──────┬─────────────┬────────────┬─────────────┬───────────┐ +│ (index) │ Method │ Origin │ Path │ Status code │ Persistent │ Invocations │ Remaining │ +├─────────┼────────┼───────────────────────┼──────┼─────────────┼────────────┼─────────────┼───────────┤ +│ 0 │ 'GET' │ 'https://example.com' │ '/' │ 200 │ '❌' │ 0 │ 1 │ +└─────────┴────────┴───────────────────────┴──────┴─────────────┴────────────┴─────────────┴───────────┘ +`.trim()) +}) + +test('2 pending interceptors', t => { + t.plan(2) + + const withTwoInterceptors = mockAgentWithOneInterceptor() + withTwoInterceptors + .get(origin) + .intercept({ method: 'get', path: '/some/path' }) + .reply(204, 'OK') + const err = t.throws(() => withTwoInterceptors.assertNoPendingInterceptors({ pendingInterceptorsFormatter })) + + t.same(err.message, ` +2 interceptors are pending: + +┌─────────┬────────┬──────────────────────────┬──────────────┬─────────────┬────────────┬─────────────┬───────────┐ +│ (index) │ Method │ Origin │ Path │ Status code │ Persistent │ Invocations │ Remaining │ +├─────────┼────────┼──────────────────────────┼──────────────┼─────────────┼────────────┼─────────────┼───────────┤ +│ 0 │ 'GET' │ 'https://example.com' │ '/' │ 200 │ '❌' │ 0 │ 1 │ +│ 1 │ 'GET' │ 'https://localhost:9999' │ '/some/path' │ 204 │ '❌' │ 0 │ 1 │ +└─────────┴────────┴──────────────────────────┴──────────────┴─────────────┴────────────┴─────────────┴───────────┘ +`.trim()) +}) + +test('Variations of persist(), times(), and pending status', async t => { + t.plan(7) + + // Agent with unused interceptor + const agent = mockAgentWithOneInterceptor() + + // Unused with persist() + agent + .get(origin) + .intercept({ method: 'get', path: '/persistent/unused' }) + .reply(200, 'OK') + .persist() + + // Used with persist() + agent + .get(origin) + .intercept({ method: 'GET', path: '/persistent/used' }) + .reply(200, 'OK') + .persist() + t.same((await agent.request({ origin, method: 'GET', path: '/persistent/used' })).statusCode, 200) + + // Consumed without persist() + agent.get(origin) + .intercept({ method: 'post', path: '/transient/pending' }) + .reply(201, 'Created') + t.same((await agent.request({ origin, method: 'POST', path: '/transient/pending' })).statusCode, 201) + + // Partially pending with times() + agent.get(origin) + .intercept({ method: 'get', path: '/times/partial' }) + .reply(200, 'OK') + .times(5) + t.same((await agent.request({ origin, method: 'GET', path: '/times/partial' })).statusCode, 200) + + // Unused with times() + agent.get(origin) + .intercept({ method: 'get', path: '/times/unused' }) + .reply(200, 'OK') + .times(2) + + // Fully pending with times() + agent.get(origin) + .intercept({ method: 'get', path: '/times/pending' }) + .reply(200, 'OK') + .times(2) + t.same((await agent.request({ origin, method: 'GET', path: '/times/pending' })).statusCode, 200) + t.same((await agent.request({ origin, method: 'GET', path: '/times/pending' })).statusCode, 200) + + const err = t.throws(() => agent.assertNoPendingInterceptors({ pendingInterceptorsFormatter })) + + t.same(err.message, ` +4 interceptors are pending: + +┌─────────┬────────┬──────────────────────────┬──────────────────────┬─────────────┬────────────┬─────────────┬───────────┐ +│ (index) │ Method │ Origin │ Path │ Status code │ Persistent │ Invocations │ Remaining │ +├─────────┼────────┼──────────────────────────┼──────────────────────┼─────────────┼────────────┼─────────────┼───────────┤ +│ 0 │ 'GET' │ 'https://example.com' │ '/' │ 200 │ '❌' │ 0 │ 1 │ +│ 1 │ 'GET' │ 'https://localhost:9999' │ '/persistent/unused' │ 200 │ '✅' │ 0 │ Infinity │ +│ 2 │ 'GET' │ 'https://localhost:9999' │ '/times/partial' │ 200 │ '❌' │ 1 │ 4 │ +│ 3 │ 'GET' │ 'https://localhost:9999' │ '/times/unused' │ 200 │ '❌' │ 0 │ 2 │ +└─────────┴────────┴──────────────────────────┴──────────────────────┴─────────────┴────────────┴─────────────┴───────────┘ +`.trim()) +}) + +test('works when no interceptors are registered', t => { + t.plan(2) + + const agent = new MockAgent() + agent.disableNetConnect() + + t.same(agent.pendingInterceptors(), []) + t.doesNotThrow(() => agent.assertNoPendingInterceptors()) +}) + +test('works when all interceptors are pending', async t => { + t.plan(4) + + const agent = new MockAgent() + agent.disableNetConnect() + + agent.get(origin).intercept({ method: 'get', path: '/' }).reply(200, 'OK') + t.same((await agent.request({ origin, method: 'GET', path: '/' })).statusCode, 200) + + agent.get(origin).intercept({ method: 'get', path: '/persistent' }).reply(200, 'OK') + t.same((await agent.request({ origin, method: 'GET', path: '/persistent' })).statusCode, 200) + + t.same(agent.pendingInterceptors(), []) + t.doesNotThrow(() => agent.assertNoPendingInterceptors()) +}) + +test('defaults to rendering output with terminal color when process.env.CI is unset', t => { + t.plan(2) + + // This ensures that the test works in an environment where the CI env var is set. + const oldCiEnvVar = process.env.CI + delete process.env.CI + + const err = t.throws( + () => mockAgentWithOneInterceptor().assertNoPendingInterceptors()) + t.same(err.message, ` +1 interceptor is pending: + +┌─────────┬────────┬───────────────────────┬──────┬─────────────┬────────────┬─────────────┬───────────┐ +│ (index) │ Method │ Origin │ Path │ Status code │ Persistent │ Invocations │ Remaining │ +├─────────┼────────┼───────────────────────┼──────┼─────────────┼────────────┼─────────────┼───────────┤ +│ 0 │ \u001b[32m'GET'\u001b[39m │ \u001b[32m'https://example.com'\u001b[39m │ \u001b[32m'/'\u001b[39m │ \u001b[33m200\u001b[39m │ \u001b[32m'❌'\u001b[39m │ \u001b[33m0\u001b[39m │ \u001b[33m1\u001b[39m │ +└─────────┴────────┴───────────────────────┴──────┴─────────────┴────────────┴─────────────┴───────────┘ +`.trim()) + + // Re-set the CI env var if it were set. + // Assigning `undefined` does not work, + // because reading the env var afterwards yields the string 'undefined', + // so we need to re-set it conditionally. + if (oldCiEnvVar != null) { + process.env.CI = oldCiEnvVar + } +}) + +test('returns unused interceptors', t => { + t.plan(1) + + t.same(mockAgentWithOneInterceptor().pendingInterceptors(), [ + { + 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' + } + ]) +}) diff --git a/test/types/mock-agent.test-d.ts b/test/types/mock-agent.test-d.ts index fc214b95d2e..5f7f9686494 100644 --- a/test/types/mock-agent.test-d.ts +++ b/test/types/mock-agent.test-d.ts @@ -1,6 +1,7 @@ -import { expectAssignable } from 'tsd' -import { MockAgent, MockPool, MockClient, Agent, setGlobalDispatcher, Dispatcher } from '../..' -import { MockInterceptor } from '../../types/mock-interceptor' +import {expectAssignable, expectType} from 'tsd' +import {Agent, Dispatcher, MockAgent, MockClient, MockPool, setGlobalDispatcher} from '../..' +import {MockInterceptor} from '../../types/mock-interceptor' +import MockDispatch = MockInterceptor.MockDispatch; expectAssignable(new MockAgent()) expectAssignable(new MockAgent({})) @@ -40,21 +41,35 @@ expectAssignable(new MockAgent({})) expectAssignable(mockAgent.disableNetConnect()) // dispatch - expectAssignable(mockAgent.dispatch({ origin: '', path: '', method: 'GET' }, {})) + expectAssignable(mockAgent.dispatch({origin: '', path: '', method: 'GET'}, {})) // intercept - expectAssignable((mockAgent.get('foo')).intercept({ path: '', method: 'GET' })) + expectAssignable((mockAgent.get('foo')).intercept({path: '', method: 'GET'})) } { - const mockAgent = new MockAgent({ connections: 1 }) + const mockAgent = new MockAgent({connections: 1}) expectAssignable(setGlobalDispatcher(mockAgent)) expectAssignable(mockAgent.get('')) } { const agent = new Agent() - const mockAgent = new MockAgent({ agent }) + const mockAgent = new MockAgent({agent}) expectAssignable(setGlobalDispatcher(mockAgent)) expectAssignable(mockAgent.get('')) } + +{ + interface PendingInterceptor extends MockDispatch { + origin: string; + } + + const agent = new MockAgent({agent: new Agent()}) + expectType<() => PendingInterceptor[]>(agent.pendingInterceptors) + expectType<(options?: { + pendingInterceptorsFormatter?: { + format(pendingInterceptors: readonly PendingInterceptor[]): string; + } + }) => void>(agent.assertNoPendingInterceptors) +} diff --git a/types/mock-agent.d.ts b/types/mock-agent.d.ts index 2b673c48956..825d2aeff6d 100644 --- a/types/mock-agent.d.ts +++ b/types/mock-agent.d.ts @@ -1,9 +1,14 @@ import Agent = require('./agent') import Dispatcher = require('./dispatcher') -import { Interceptable } from './mock-interceptor' +import { Interceptable, MockInterceptor } from './mock-interceptor' +import MockDispatch = MockInterceptor.MockDispatch; export = MockAgent +interface PendingInterceptor extends MockDispatch { + origin: string; +} + /** A mocked Agent class that implements the Agent API. It allows one to intercept HTTP requests made through undici and return mocked responses instead. */ declare class MockAgent extends Dispatcher { constructor(options?: MockAgent.Options) @@ -26,6 +31,14 @@ declare class MockAgent boolean)): void; /** Causes all requests to throw when requests are not matched in a MockAgent intercept. */ disableNetConnect(): void; + pendingInterceptors(): PendingInterceptor[]; + assertNoPendingInterceptors(options?: { + pendingInterceptorsFormatter?: PendingInterceptorsFormatter; + }): void; +} + +interface PendingInterceptorsFormatter { + format(pendingInterceptors: readonly PendingInterceptor[]): string; } declare namespace MockAgent {