From 00dd6c56bb5623d4a181496ae61f17e679ab54f8 Mon Sep 17 00:00:00 2001 From: Martin Lehmann Date: Thu, 21 Apr 2022 22:59:53 +0200 Subject: [PATCH 01/10] feat: add pending interceptor check functions to mock agent --- docs/api/MockAgent.md | 68 +++++++++++ lib/mock/mock-agent.js | 91 ++++++++++++++- lib/mock/mock-symbols.js | 3 +- lib/mock/pluralizer.js | 34 ++++++ lib/mock/table-formatter.js | 24 ++++ test/mock-interceptor-unused-assertions.js | 126 +++++++++++++++++++++ test/types/mock-agent.test-d.ts | 21 ++-- types/mock-agent.d.ts | 9 +- 8 files changed, 366 insertions(+), 10 deletions(-) create mode 100644 lib/mock/pluralizer.js create mode 100644 lib/mock/table-formatter.js create mode 100644 test/mock-interceptor-unused-assertions.js diff --git a/docs/api/MockAgent.md b/docs/api/MockAgent.md index f94ae339f96..3ac565c1a9f 100644 --- a/docs/api/MockAgent.md +++ b/docs/api/MockAgent.md @@ -445,3 +445,71 @@ mockAgent.disableNetConnect() await request('http://example.com') // Will throw ``` + + +### `MockAgent.pendingInterceptors()` + +This method returns any pending (i.e., non-persistent and not fully consumed) interceptors registered on a mock agent. + +Returns: `MockDispatch[]` + +#### 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 = dispatcher.pendingInterceptors() +// Returns [ +// { +// times: null, +// persist: false, +// consumed: false, +// path: '/', +// method: 'GET', +// body: undefined, +// headers: undefined, +// data: { +// error: null, +// statusCode: 200, +// data: '', +// headers: {}, +// trailers: {} +// } +// } +// ] +``` + +### `MockAgent.assertNoUnusedInterceptors([options])` + +This method throws if the mock agent has any pending (i.e., non-persistent and not fully consumed) interceptors. + +#### 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, '') + +dispatcher.assertNoPendingInterceptors() +// Throws an Error with the following message: +// +// 1 interceptor was not consumed! +// (0 interceptors were consumed, and 0 were not counted because they are persistent.) +// +// This interceptor was not consumed: +// ┌─────────┬────────┬──────┬─────────────┬────────────┬─────────────────┐ +// │ (index) │ Method │ Path │ Status code │ Persistent │ Remaining calls │ +// ├─────────┼────────┼──────┼─────────────┼────────────┼─────────────────┤ +// │ 0 │ 'GET' │ '/' │ 200 │ '❌' │ 1 │ +// └─────────┴────────┴──────┴─────────────┴────────────┴─────────────────┘ +``` diff --git a/lib/mock/mock-agent.js b/lib/mock/mock-agent.js index 4ae47f657fe..454ac07d8cc 100644 --- a/lib/mock/mock-agent.js +++ b/lib/mock/mock-agent.js @@ -11,13 +11,16 @@ const { kNetConnect, kGetNetConnect, kOptions, - kFactory + kFactory, + kGetMockAgentClientsByConsumedStatus } = require('./mock-symbols') const MockClient = require('./mock-client') const MockPool = require('./mock-pool') const { matchValue, buildMockOptions } = require('./mock-utils') const { InvalidArgumentError } = require('../core/errors') const Dispatcher = require('../dispatcher') +const Pluralizer = require('./pluralizer') +const TableFormatter = require('./table-formatter') class FakeWeakRef { constructor (value) { @@ -134,6 +137,92 @@ class MockAgent extends Dispatcher { [kGetNetConnect] () { return this[kNetConnect] } + + [kGetMockAgentClientsByConsumedStatus] () { + const mockAgentClients = this[kClients] + + return Array.from(mockAgentClients.entries()) + .map(([origin, scopeWeakRef]) => { + const scope = scopeWeakRef.deref() + + if (scope == null) { + throw new Error('scope was null; this should not happen') + } + + return { origin, scope } + }) + .map(({ origin, scope }) => { + // @ts-expect-error TypeScript doesn't understand the symbol use + const clients = scope[kDispatches] + + const consumed = clients.filter(({ consumed }) => consumed) + const unconsumed = clients.filter(({ consumed }) => !consumed) + const persistent = unconsumed.filter(({ persist }) => persist) + const tooFewUses = unconsumed.filter(({ persist }) => !persist) + + return { origin, clients, consumed, persistent, tooFewUses } + }) + .reduce( + (all, current) => ({ + totals: { + consumed: all.totals.consumed.concat(current.consumed), + persistent: all.totals.persistent.concat(current.persistent), + tooFewUses: all.totals.tooFewUses.concat(current.tooFewUses) + }, + clients: all.clients.concat({ + origin: current.origin, + clients: current.clients + }) + }), + { + totals: { + consumed: [], + persistent: [], + tooFewUses: [] + }, + clients: [] + } + ) + } + + pendingInterceptors () { + return this[kGetMockAgentClientsByConsumedStatus]().totals.tooFewUses + } + + assertNoUnusedInterceptors (options = {}) { + const clients = this[kGetMockAgentClientsByConsumedStatus]() + + if (clients.totals.tooFewUses.length === 0) { + return clients + } + + const interceptorPluralizer = new Pluralizer('interceptor', 'interceptors') + const tooFew = interceptorPluralizer.pluralize( + clients.totals.tooFewUses.length + ) + const consumed = interceptorPluralizer.pluralize( + clients.totals.consumed.length + ) + const persistent = interceptorPluralizer.pluralize( + clients.totals.persistent.length + ) + + throw new Error(` +${tooFew.count} ${tooFew.noun} ${tooFew.was} not consumed! +(${consumed.count} ${consumed.noun} ${consumed.was} consumed, and ${persistent.count} ${persistent.was} not counted because ${persistent.pronoun} ${persistent.is} persistent.) + +${Pluralizer.capitalize(tooFew.this)} ${tooFew.noun} ${tooFew.was} not consumed: +${(options.tableFormatter ?? new TableFormatter()).formatTable( + clients.totals.tooFewUses.map( + ({ method, path, data: { statusCode }, persist, times }) => ({ + Method: method, + Path: path, + 'Status code': statusCode, + Persistent: persist ? '✅' : '❌', + 'Remaining calls': times ?? 1 + })))} +`.trim()) + } } module.exports = MockAgent diff --git a/lib/mock/mock-symbols.js b/lib/mock/mock-symbols.js index 8c4cbb60e16..a749c95273a 100644 --- a/lib/mock/mock-symbols.js +++ b/lib/mock/mock-symbols.js @@ -19,5 +19,6 @@ module.exports = { kIsMockActive: Symbol('is mock active'), kNetConnect: Symbol('net connect'), kGetNetConnect: Symbol('get net connect'), - kConnected: Symbol('connected') + kConnected: Symbol('connected'), + kGetMockAgentClientsByConsumedStatus: Symbol('get mock agent clients by consumed status') } diff --git a/lib/mock/pluralizer.js b/lib/mock/pluralizer.js new file mode 100644 index 00000000000..1fe6612e4e0 --- /dev/null +++ b/lib/mock/pluralizer.js @@ -0,0 +1,34 @@ +'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 } + } + + static capitalize (word) { + const firstCharacter = word.charAt(0) + return firstCharacter.toUpperCase() + word.slice(1) + } +} diff --git a/lib/mock/table-formatter.js b/lib/mock/table-formatter.js new file mode 100644 index 00000000000..0125857eb42 --- /dev/null +++ b/lib/mock/table-formatter.js @@ -0,0 +1,24 @@ +const { Transform } = require('stream') +const { Console } = require('console') + +module.exports = class TableFormatter { + 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 == null + } + }) + } + + formatTable (...args) { + this.logger.table(...args) + return (this.transform.read() || '').toString() + } +} diff --git a/test/mock-interceptor-unused-assertions.js b/test/mock-interceptor-unused-assertions.js new file mode 100644 index 00000000000..1374494a3e9 --- /dev/null +++ b/test/mock-interceptor-unused-assertions.js @@ -0,0 +1,126 @@ +'use strict' + +const { test, beforeEach, afterEach } = require('tap') +const { MockAgent, setGlobalDispatcher } = require('..') +const TableFormatter = require('../lib/mock/table-formatter') + +// Avoid colors in the output for inline snapshots. +const tableFormatter = new TableFormatter({ disableColors: true }) + +let originalGlobalDispatcher + +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 unconsumed interceptor', t => { + t.plan(2) + + const err = t.throws(() => mockAgentWithOneInterceptor().assertNoUnusedInterceptors({ tableFormatter })) + + t.same(err.message, ` +1 interceptor was not consumed! +(0 interceptors were consumed, and 0 were not counted because they are persistent.) + +This interceptor was not consumed: +┌─────────┬────────┬──────┬─────────────┬────────────┬─────────────────┐ +│ (index) │ Method │ Path │ Status code │ Persistent │ Remaining calls │ +├─────────┼────────┼──────┼─────────────┼────────────┼─────────────────┤ +│ 0 │ 'GET' │ '/' │ 200 │ '❌' │ 1 │ +└─────────┴────────┴──────┴─────────────┴────────────┴─────────────────┘ +`.trim()) +}) + +test('2 unconsumed interceptors', t => { + t.plan(2) + + const withTwoInterceptors = mockAgentWithOneInterceptor() + withTwoInterceptors + .get('https://localhost:9999') + .intercept({ method: 'get', path: '/some/path' }) + .reply(204, 'OK') + const err = t.throws(() => withTwoInterceptors.assertNoUnusedInterceptors({ tableFormatter })) + + t.same(err.message, ` +2 interceptors were not consumed! +(0 interceptors were consumed, and 0 were not counted because they are persistent.) + +These interceptors were not consumed: +┌─────────┬────────┬──────────────┬─────────────┬────────────┬─────────────────┐ +│ (index) │ Method │ Path │ Status code │ Persistent │ Remaining calls │ +├─────────┼────────┼──────────────┼─────────────┼────────────┼─────────────────┤ +│ 0 │ 'GET' │ '/' │ 200 │ '❌' │ 1 │ +│ 1 │ 'GET' │ '/some/path' │ 204 │ '❌' │ 1 │ +└─────────┴────────┴──────────────┴─────────────┴────────────┴─────────────────┘ +`.trim()) +}) + +test('works when no interceptors are registered', t => { + t.plan(1) + + const dispatcher = new MockAgent() + dispatcher.disableNetConnect() + + t.same(dispatcher.pendingInterceptors(), []) +}) + +test('defaults to rendering output with terminal color', t => { + t.plan(2) + + const err = t.throws( + () => mockAgentWithOneInterceptor().assertNoUnusedInterceptors()) + t.same(err.message, ` +1 interceptor was not consumed! +(0 interceptors were consumed, and 0 were not counted because they are persistent.) + +This interceptor was not consumed: +┌─────────┬────────┬──────┬─────────────┬────────────┬─────────────────┐ +│ (index) │ Method │ Path │ Status code │ Persistent │ Remaining calls │ +├─────────┼────────┼──────┼─────────────┼────────────┼─────────────────┤ +│ 0 │ 'GET' │ '/' │ 200 │ '❌' │ 1 │ +└─────────┴────────┴──────┴─────────────┴────────────┴─────────────────┘ +`.trim()) +}) + +test('returns unused interceptors', t => { + t.plan(1) + + t.same(mockAgentWithOneInterceptor().pendingInterceptors(), [ + { + times: null, + persist: false, + consumed: false, + path: '/', + method: 'GET', + body: undefined, + headers: undefined, + data: { + error: null, + statusCode: 200, + data: '', + headers: {}, + trailers: {} + } + } + ]) +}) diff --git a/test/types/mock-agent.test-d.ts b/test/types/mock-agent.test-d.ts index fc214b95d2e..b30cd58fcba 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,27 @@ 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('')) } + +{ + const agent = new MockAgent({agent: new Agent()}) + expectType<() => MockDispatch[]>(agent.pendingInterceptors) + expectType<(options?: { formatter?: { formatTable: typeof console.table } }) => void>(agent.assertNoUnusedInterceptors) +} diff --git a/types/mock-agent.d.ts b/types/mock-agent.d.ts index 2b673c48956..173d2281ea7 100644 --- a/types/mock-agent.d.ts +++ b/types/mock-agent.d.ts @@ -1,6 +1,7 @@ import Agent = require('./agent') import Dispatcher = require('./dispatcher') -import { Interceptable } from './mock-interceptor' +import {Interceptable, MockInterceptor, MockScope} from './mock-interceptor' +import MockDispatch = MockInterceptor.MockDispatch; export = MockAgent @@ -26,6 +27,12 @@ declare class MockAgent boolean)): void; /** Causes all requests to throw when requests are not matched in a MockAgent intercept. */ disableNetConnect(): void; + pendingInterceptors(): MockDispatch[]; + assertNoUnusedInterceptors(options?: {formatter?: UnusedInterceptorFormatter}): void; +} + +interface UnusedInterceptorFormatter { + formatTable: typeof console.table } declare namespace MockAgent { From e2419f7e83a1cd07324f4b5ad7c94be5daaec7b1 Mon Sep 17 00:00:00 2001 From: Martin Lehmann Date: Fri, 22 Apr 2022 14:05:33 +0200 Subject: [PATCH 02/10] fix: cleanup & various fixes --- docs/api/MockAgent.md | 4 ++-- lib/mock/mock-agent.js | 4 ++-- lib/mock/table-formatter.js | 7 ++++++- test/mock-interceptor-unused-assertions.js | 14 +++++++++++++- test/types/mock-agent.test-d.ts | 9 ++++++++- types/mock-agent.d.ts | 4 ++-- 6 files changed, 33 insertions(+), 9 deletions(-) diff --git a/docs/api/MockAgent.md b/docs/api/MockAgent.md index 3ac565c1a9f..2c339088888 100644 --- a/docs/api/MockAgent.md +++ b/docs/api/MockAgent.md @@ -464,7 +464,7 @@ agent .intercept({ method: 'GET', path: '/' }) .reply(200, '') -const pendingInterceptors = dispatcher.pendingInterceptors() +const pendingInterceptors = agent.pendingInterceptors() // Returns [ // { // times: null, @@ -500,7 +500,7 @@ agent .intercept({ method: 'GET', path: '/' }) .reply(200, '') -dispatcher.assertNoPendingInterceptors() +agent.assertNoPendingInterceptors() // Throws an Error with the following message: // // 1 interceptor was not consumed! diff --git a/lib/mock/mock-agent.js b/lib/mock/mock-agent.js index 454ac07d8cc..bfe3122df39 100644 --- a/lib/mock/mock-agent.js +++ b/lib/mock/mock-agent.js @@ -17,7 +17,7 @@ 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 TableFormatter = require('./table-formatter') @@ -207,7 +207,7 @@ class MockAgent extends Dispatcher { clients.totals.persistent.length ) - throw new Error(` + throw new UndiciError(` ${tooFew.count} ${tooFew.noun} ${tooFew.was} not consumed! (${consumed.count} ${consumed.noun} ${consumed.was} consumed, and ${persistent.count} ${persistent.was} not counted because ${persistent.pronoun} ${persistent.is} persistent.) diff --git a/lib/mock/table-formatter.js b/lib/mock/table-formatter.js index 0125857eb42..424c5d801da 100644 --- a/lib/mock/table-formatter.js +++ b/lib/mock/table-formatter.js @@ -1,6 +1,11 @@ +'use strict' + const { Transform } = require('stream') const { Console } = require('console') +/** + * Gets the output of `console.table(…)` as a string. + */ module.exports = class TableFormatter { constructor ({ disableColors } = {}) { this.transform = new Transform({ @@ -12,7 +17,7 @@ module.exports = class TableFormatter { this.logger = new Console({ stdout: this.transform, inspectOptions: { - colors: !disableColors && process.env.CI == null + colors: !disableColors && !process.env.CI } }) } diff --git a/test/mock-interceptor-unused-assertions.js b/test/mock-interceptor-unused-assertions.js index 1374494a3e9..44439f2a3ea 100644 --- a/test/mock-interceptor-unused-assertions.js +++ b/test/mock-interceptor-unused-assertions.js @@ -84,9 +84,13 @@ test('works when no interceptors are registered', t => { t.same(dispatcher.pendingInterceptors(), []) }) -test('defaults to rendering output with terminal color', t => { +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().assertNoUnusedInterceptors()) t.same(err.message, ` @@ -100,6 +104,14 @@ This interceptor was not consumed: │ 0 │ 'GET' │ '/' │ 200 │ '❌' │ 1 │ └─────────┴────────┴──────┴─────────────┴────────────┴─────────────────┘ `.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 => { diff --git a/test/types/mock-agent.test-d.ts b/test/types/mock-agent.test-d.ts index b30cd58fcba..3f13e3df36d 100644 --- a/test/types/mock-agent.test-d.ts +++ b/test/types/mock-agent.test-d.ts @@ -63,5 +63,12 @@ expectAssignable(new MockAgent({})) { const agent = new MockAgent({agent: new Agent()}) expectType<() => MockDispatch[]>(agent.pendingInterceptors) - expectType<(options?: { formatter?: { formatTable: typeof console.table } }) => void>(agent.assertNoUnusedInterceptors) + expectType<(options?: { + tableFormatter?: { + formatTable( + tabularData: any, + properties?: readonly string[] + ): string; + } + }) => void>(agent.assertNoUnusedInterceptors) } diff --git a/types/mock-agent.d.ts b/types/mock-agent.d.ts index 173d2281ea7..98dffe09d19 100644 --- a/types/mock-agent.d.ts +++ b/types/mock-agent.d.ts @@ -28,11 +28,11 @@ declare class MockAgent): string; } declare namespace MockAgent { From becb271c01c6b33d67a5c19635e6c47a8854236d Mon Sep 17 00:00:00 2001 From: Martin Lehmann Date: Fri, 22 Apr 2022 14:25:32 +0200 Subject: [PATCH 03/10] fix: don't use null coalescing --- lib/mock/mock-agent.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/mock/mock-agent.js b/lib/mock/mock-agent.js index bfe3122df39..4dedd8d75a2 100644 --- a/lib/mock/mock-agent.js +++ b/lib/mock/mock-agent.js @@ -212,14 +212,14 @@ ${tooFew.count} ${tooFew.noun} ${tooFew.was} not consumed! (${consumed.count} ${consumed.noun} ${consumed.was} consumed, and ${persistent.count} ${persistent.was} not counted because ${persistent.pronoun} ${persistent.is} persistent.) ${Pluralizer.capitalize(tooFew.this)} ${tooFew.noun} ${tooFew.was} not consumed: -${(options.tableFormatter ?? new TableFormatter()).formatTable( +${(options.tableFormatter != null ? options.tableFormatter : new TableFormatter()).formatTable( clients.totals.tooFewUses.map( ({ method, path, data: { statusCode }, persist, times }) => ({ Method: method, Path: path, 'Status code': statusCode, Persistent: persist ? '✅' : '❌', - 'Remaining calls': times ?? 1 + 'Remaining calls': times != null ? times : 1 })))} `.trim()) } From bdab4bc5ce0a83aa5bffc9b09c17e56d2432aefa Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Sun, 24 Apr 2022 11:13:42 +0200 Subject: [PATCH 04/10] Update docs/api/MockAgent.md Co-authored-by: Simen Bekkhus --- docs/api/MockAgent.md | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/api/MockAgent.md b/docs/api/MockAgent.md index 2c339088888..a499818023a 100644 --- a/docs/api/MockAgent.md +++ b/docs/api/MockAgent.md @@ -446,7 +446,6 @@ await request('http://example.com') // Will throw ``` - ### `MockAgent.pendingInterceptors()` This method returns any pending (i.e., non-persistent and not fully consumed) interceptors registered on a mock agent. From b8c8a76ce10fca19c027fc2b3cd332c9c8b4ea1f Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Sun, 24 Apr 2022 11:13:59 +0200 Subject: [PATCH 05/10] Update lib/mock/mock-agent.js Co-authored-by: Simen Bekkhus --- lib/mock/mock-agent.js | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/mock/mock-agent.js b/lib/mock/mock-agent.js index 4dedd8d75a2..2729575ba95 100644 --- a/lib/mock/mock-agent.js +++ b/lib/mock/mock-agent.js @@ -148,7 +148,6 @@ class MockAgent extends Dispatcher { if (scope == null) { throw new Error('scope was null; this should not happen') } - return { origin, scope } }) .map(({ origin, scope }) => { From adfb8e392ca9beb66f20f6fd4b9cfb0875cfd226 Mon Sep 17 00:00:00 2001 From: Martin Lehmann Date: Mon, 25 Apr 2022 10:54:31 +0200 Subject: [PATCH 06/10] don't return unnecessarily --- lib/mock/mock-agent.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/mock/mock-agent.js b/lib/mock/mock-agent.js index 2729575ba95..635f0d635f8 100644 --- a/lib/mock/mock-agent.js +++ b/lib/mock/mock-agent.js @@ -192,7 +192,7 @@ class MockAgent extends Dispatcher { const clients = this[kGetMockAgentClientsByConsumedStatus]() if (clients.totals.tooFewUses.length === 0) { - return clients + return } const interceptorPluralizer = new Pluralizer('interceptor', 'interceptors') From b8c1a0545a528adddc0a5b7a1297d8e1c75cfe22 Mon Sep 17 00:00:00 2001 From: Martin Lehmann Date: Mon, 25 Apr 2022 13:21:26 +0200 Subject: [PATCH 07/10] add tests for various permutations --- test/mock-interceptor-unused-assertions.js | 69 +++++++++++++++++++++- 1 file changed, 68 insertions(+), 1 deletion(-) diff --git a/test/mock-interceptor-unused-assertions.js b/test/mock-interceptor-unused-assertions.js index 44439f2a3ea..209dea702ee 100644 --- a/test/mock-interceptor-unused-assertions.js +++ b/test/mock-interceptor-unused-assertions.js @@ -9,6 +9,8 @@ const tableFormatter = new TableFormatter({ 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() @@ -56,7 +58,7 @@ test('2 unconsumed interceptors', t => { const withTwoInterceptors = mockAgentWithOneInterceptor() withTwoInterceptors - .get('https://localhost:9999') + .get(origin) .intercept({ method: 'get', path: '/some/path' }) .reply(204, 'OK') const err = t.throws(() => withTwoInterceptors.assertNoUnusedInterceptors({ tableFormatter })) @@ -75,6 +77,71 @@ These interceptors were not consumed: `.trim()) }) +test('Variations of persist(), times(), and consumed 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/consumed' }) + .reply(201, 'Created') + t.same((await agent.request({ origin, method: 'POST', path: '/transient/consumed' })).statusCode, 201) + + // Partially consumed 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 consumed with times() + agent.get(origin) + .intercept({ method: 'get', path: '/times/consumed' }) + .reply(200, 'OK') + .times(2) + t.same((await agent.request({ origin, method: 'GET', path: '/times/consumed' })).statusCode, 200) + t.same((await agent.request({ origin, method: 'GET', path: '/times/consumed' })).statusCode, 200) + + const err = t.throws(() => agent.assertNoUnusedInterceptors({ tableFormatter })) + + t.same(err.message, ` +3 interceptors were not consumed! +(0 interceptors were consumed, and 2 were not counted because they are persistent.) + +These interceptors were not consumed: +┌─────────┬────────┬──────────────────┬─────────────┬────────────┬─────────────────┐ +│ (index) │ Method │ Path │ Status code │ Persistent │ Remaining calls │ +├─────────┼────────┼──────────────────┼─────────────┼────────────┼─────────────────┤ +│ 0 │ 'GET' │ '/' │ 200 │ '❌' │ 1 │ +│ 1 │ 'GET' │ '/times/partial' │ 200 │ '❌' │ 4 │ +│ 2 │ 'GET' │ '/times/unused' │ 200 │ '❌' │ 2 │ +└─────────┴────────┴──────────────────┴─────────────┴────────────┴─────────────────┘ +`.trim()) +}) + test('works when no interceptors are registered', t => { t.plan(1) From 65bcea94c302fce407522a0874a3adcd56ce5806 Mon Sep 17 00:00:00 2001 From: Martin Lehmann Date: Mon, 25 Apr 2022 13:26:13 +0200 Subject: [PATCH 08/10] cover case where everything is fine --- test/mock-interceptor-unused-assertions.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/mock-interceptor-unused-assertions.js b/test/mock-interceptor-unused-assertions.js index 209dea702ee..30ae1a9062f 100644 --- a/test/mock-interceptor-unused-assertions.js +++ b/test/mock-interceptor-unused-assertions.js @@ -143,12 +143,13 @@ These interceptors were not consumed: }) test('works when no interceptors are registered', t => { - t.plan(1) + t.plan(2) const dispatcher = new MockAgent() dispatcher.disableNetConnect() t.same(dispatcher.pendingInterceptors(), []) + t.doesNotThrow(() => dispatcher.assertNoUnusedInterceptors()) }) test('defaults to rendering output with terminal color when process.env.CI is unset', t => { From 14517abaf905d32aed545b0bfc671670e63c74f1 Mon Sep 17 00:00:00 2001 From: Martin Lehmann Date: Mon, 25 Apr 2022 13:31:29 +0200 Subject: [PATCH 09/10] add test for all interceptors consumed --- test/mock-interceptor-unused-assertions.js | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/test/mock-interceptor-unused-assertions.js b/test/mock-interceptor-unused-assertions.js index 30ae1a9062f..49893b08bbb 100644 --- a/test/mock-interceptor-unused-assertions.js +++ b/test/mock-interceptor-unused-assertions.js @@ -145,11 +145,24 @@ These interceptors were not consumed: test('works when no interceptors are registered', t => { t.plan(2) - const dispatcher = new MockAgent() - dispatcher.disableNetConnect() + const agent = new MockAgent() + agent.disableNetConnect() + + t.same(agent.pendingInterceptors(), []) + t.doesNotThrow(() => agent.assertNoUnusedInterceptors()) +}) + +test('works when all interceptors are consumed', async t => { + t.plan(3) + + 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) - t.same(dispatcher.pendingInterceptors(), []) - t.doesNotThrow(() => dispatcher.assertNoUnusedInterceptors()) + t.same(agent.pendingInterceptors(), []) + t.doesNotThrow(() => agent.assertNoUnusedInterceptors()) }) test('defaults to rendering output with terminal color when process.env.CI is unset', t => { From 6af598dcec4a7c57ade8718614108a967815f4b7 Mon Sep 17 00:00:00 2001 From: Martin Lehmann Date: Mon, 25 Apr 2022 16:15:28 +0200 Subject: [PATCH 10/10] simplify & improve things --- docs/api/MockAgent.md | 39 ++++--- lib/mock/mock-agent.js | 86 ++------------- lib/mock/mock-symbols.js | 3 +- lib/mock/mock-utils.js | 21 ++-- lib/mock/pending-interceptors-formatter.js | 40 +++++++ lib/mock/pluralizer.js | 5 - lib/mock/table-formatter.js | 29 ----- test/mock-interceptor-unused-assertions.js | 121 ++++++++++----------- test/types/mock-agent.test-d.ts | 15 +-- types/mock-agent.d.ts | 16 ++- 10 files changed, 164 insertions(+), 211 deletions(-) create mode 100644 lib/mock/pending-interceptors-formatter.js delete mode 100644 lib/mock/table-formatter.js diff --git a/docs/api/MockAgent.md b/docs/api/MockAgent.md index a499818023a..04cfcb0e554 100644 --- a/docs/api/MockAgent.md +++ b/docs/api/MockAgent.md @@ -448,9 +448,13 @@ await request('http://example.com') ### `MockAgent.pendingInterceptors()` -This method returns any pending (i.e., non-persistent and not fully consumed) interceptors registered on a mock agent. +This method returns any pending interceptors registered on a mock agent. A pending interceptor meets one of the following criteria: -Returns: `MockDispatch[]` +- 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 @@ -466,9 +470,11 @@ agent const pendingInterceptors = agent.pendingInterceptors() // Returns [ // { -// times: null, +// timesInvoked: 0, +// times: 1, // persist: false, // consumed: false, +// pending: true, // path: '/', // method: 'GET', // body: undefined, @@ -479,14 +485,19 @@ const pendingInterceptors = agent.pendingInterceptors() // data: '', // headers: {}, // trailers: {} -// } +// }, +// origin: 'https://example.com' // } // ] ``` -### `MockAgent.assertNoUnusedInterceptors([options])` +### `MockAgent.assertNoPendingInterceptors([options])` + +This method throws if the mock agent has any pending interceptors. A pending interceptor meets one of the following criteria: -This method throws if the mock agent has any pending (i.e., non-persistent and not fully consumed) interceptors. +- 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 @@ -500,15 +511,13 @@ agent .reply(200, '') agent.assertNoPendingInterceptors() -// Throws an Error with the following message: +// Throws an UndiciError with the following message: // -// 1 interceptor was not consumed! -// (0 interceptors were consumed, and 0 were not counted because they are persistent.) +// 1 interceptor is pending: // -// This interceptor was not consumed: -// ┌─────────┬────────┬──────┬─────────────┬────────────┬─────────────────┐ -// │ (index) │ Method │ Path │ Status code │ Persistent │ Remaining calls │ -// ├─────────┼────────┼──────┼─────────────┼────────────┼─────────────────┤ -// │ 0 │ 'GET' │ '/' │ 200 │ '❌' │ 1 │ -// └─────────┴────────┴──────┴─────────────┴────────────┴─────────────────┘ +// ┌─────────┬────────┬───────────────────────┬──────┬─────────────┬────────────┬─────────────┬───────────┐ +// │ (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 635f0d635f8..093da5e50a9 100644 --- a/lib/mock/mock-agent.js +++ b/lib/mock/mock-agent.js @@ -11,8 +11,7 @@ const { kNetConnect, kGetNetConnect, kOptions, - kFactory, - kGetMockAgentClientsByConsumedStatus + kFactory } = require('./mock-symbols') const MockClient = require('./mock-client') const MockPool = require('./mock-pool') @@ -20,7 +19,7 @@ const { matchValue, buildMockOptions } = require('./mock-utils') const { InvalidArgumentError, UndiciError } = require('../core/errors') const Dispatcher = require('../dispatcher') const Pluralizer = require('./pluralizer') -const TableFormatter = require('./table-formatter') +const PendingInterceptorsFormatter = require('./pending-interceptors-formatter') class FakeWeakRef { constructor (value) { @@ -138,88 +137,27 @@ class MockAgent extends Dispatcher { return this[kNetConnect] } - [kGetMockAgentClientsByConsumedStatus] () { + pendingInterceptors () { const mockAgentClients = this[kClients] return Array.from(mockAgentClients.entries()) - .map(([origin, scopeWeakRef]) => { - const scope = scopeWeakRef.deref() - - if (scope == null) { - throw new Error('scope was null; this should not happen') - } - return { origin, scope } - }) - .map(({ origin, scope }) => { - // @ts-expect-error TypeScript doesn't understand the symbol use - const clients = scope[kDispatches] - - const consumed = clients.filter(({ consumed }) => consumed) - const unconsumed = clients.filter(({ consumed }) => !consumed) - const persistent = unconsumed.filter(({ persist }) => persist) - const tooFewUses = unconsumed.filter(({ persist }) => !persist) - - return { origin, clients, consumed, persistent, tooFewUses } - }) - .reduce( - (all, current) => ({ - totals: { - consumed: all.totals.consumed.concat(current.consumed), - persistent: all.totals.persistent.concat(current.persistent), - tooFewUses: all.totals.tooFewUses.concat(current.tooFewUses) - }, - clients: all.clients.concat({ - origin: current.origin, - clients: current.clients - }) - }), - { - totals: { - consumed: [], - persistent: [], - tooFewUses: [] - }, - clients: [] - } - ) + .flatMap(([origin, scope]) => scope.deref()[kDispatches].map(dispatch => ({ ...dispatch, origin }))) + .filter(({ pending }) => pending) } - pendingInterceptors () { - return this[kGetMockAgentClientsByConsumedStatus]().totals.tooFewUses - } + assertNoPendingInterceptors ({ pendingInterceptorsFormatter = new PendingInterceptorsFormatter() } = {}) { + const pending = this.pendingInterceptors() - assertNoUnusedInterceptors (options = {}) { - const clients = this[kGetMockAgentClientsByConsumedStatus]() - - if (clients.totals.tooFewUses.length === 0) { + if (pending.length === 0) { return } - const interceptorPluralizer = new Pluralizer('interceptor', 'interceptors') - const tooFew = interceptorPluralizer.pluralize( - clients.totals.tooFewUses.length - ) - const consumed = interceptorPluralizer.pluralize( - clients.totals.consumed.length - ) - const persistent = interceptorPluralizer.pluralize( - clients.totals.persistent.length - ) + const pluralizer = new Pluralizer('interceptor', 'interceptors').pluralize(pending.length) throw new UndiciError(` -${tooFew.count} ${tooFew.noun} ${tooFew.was} not consumed! -(${consumed.count} ${consumed.noun} ${consumed.was} consumed, and ${persistent.count} ${persistent.was} not counted because ${persistent.pronoun} ${persistent.is} persistent.) - -${Pluralizer.capitalize(tooFew.this)} ${tooFew.noun} ${tooFew.was} not consumed: -${(options.tableFormatter != null ? options.tableFormatter : new TableFormatter()).formatTable( - clients.totals.tooFewUses.map( - ({ method, path, data: { statusCode }, persist, times }) => ({ - Method: method, - Path: path, - 'Status code': statusCode, - Persistent: persist ? '✅' : '❌', - 'Remaining calls': times != null ? times : 1 - })))} +${pluralizer.count} ${pluralizer.noun} ${pluralizer.is} pending: + +${pendingInterceptorsFormatter.format(pending)} `.trim()) } } diff --git a/lib/mock/mock-symbols.js b/lib/mock/mock-symbols.js index a749c95273a..8c4cbb60e16 100644 --- a/lib/mock/mock-symbols.js +++ b/lib/mock/mock-symbols.js @@ -19,6 +19,5 @@ module.exports = { kIsMockActive: Symbol('is mock active'), kNetConnect: Symbol('net connect'), kGetNetConnect: Symbol('get net connect'), - kConnected: Symbol('connected'), - kGetMockAgentClientsByConsumedStatus: Symbol('get mock agent clients by consumed status') + kConnected: Symbol('connected') } 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 index 1fe6612e4e0..47f150bc27a 100644 --- a/lib/mock/pluralizer.js +++ b/lib/mock/pluralizer.js @@ -26,9 +26,4 @@ module.exports = class Pluralizer { const noun = one ? this.singular : this.plural return { ...keys, count, noun } } - - static capitalize (word) { - const firstCharacter = word.charAt(0) - return firstCharacter.toUpperCase() + word.slice(1) - } } diff --git a/lib/mock/table-formatter.js b/lib/mock/table-formatter.js deleted file mode 100644 index 424c5d801da..00000000000 --- a/lib/mock/table-formatter.js +++ /dev/null @@ -1,29 +0,0 @@ -'use strict' - -const { Transform } = require('stream') -const { Console } = require('console') - -/** - * Gets the output of `console.table(…)` as a string. - */ -module.exports = class TableFormatter { - 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 - } - }) - } - - formatTable (...args) { - this.logger.table(...args) - return (this.transform.read() || '').toString() - } -} diff --git a/test/mock-interceptor-unused-assertions.js b/test/mock-interceptor-unused-assertions.js index 49893b08bbb..7ad62904866 100644 --- a/test/mock-interceptor-unused-assertions.js +++ b/test/mock-interceptor-unused-assertions.js @@ -2,10 +2,10 @@ const { test, beforeEach, afterEach } = require('tap') const { MockAgent, setGlobalDispatcher } = require('..') -const TableFormatter = require('../lib/mock/table-formatter') +const PendingInterceptorsFormatter = require('../lib/mock/pending-interceptors-formatter') // Avoid colors in the output for inline snapshots. -const tableFormatter = new TableFormatter({ disableColors: true }) +const pendingInterceptorsFormatter = new PendingInterceptorsFormatter({ disableColors: true }) let originalGlobalDispatcher @@ -35,25 +35,23 @@ function mockAgentWithOneInterceptor () { return agent } -test('1 unconsumed interceptor', t => { +test('1 pending interceptor', t => { t.plan(2) - const err = t.throws(() => mockAgentWithOneInterceptor().assertNoUnusedInterceptors({ tableFormatter })) + const err = t.throws(() => mockAgentWithOneInterceptor().assertNoPendingInterceptors({ pendingInterceptorsFormatter })) t.same(err.message, ` -1 interceptor was not consumed! -(0 interceptors were consumed, and 0 were not counted because they are persistent.) - -This interceptor was not consumed: -┌─────────┬────────┬──────┬─────────────┬────────────┬─────────────────┐ -│ (index) │ Method │ Path │ Status code │ Persistent │ Remaining calls │ -├─────────┼────────┼──────┼─────────────┼────────────┼─────────────────┤ -│ 0 │ 'GET' │ '/' │ 200 │ '❌' │ 1 │ -└─────────┴────────┴──────┴─────────────┴────────────┴─────────────────┘ +1 interceptor is pending: + +┌─────────┬────────┬───────────────────────┬──────┬─────────────┬────────────┬─────────────┬───────────┐ +│ (index) │ Method │ Origin │ Path │ Status code │ Persistent │ Invocations │ Remaining │ +├─────────┼────────┼───────────────────────┼──────┼─────────────┼────────────┼─────────────┼───────────┤ +│ 0 │ 'GET' │ 'https://example.com' │ '/' │ 200 │ '❌' │ 0 │ 1 │ +└─────────┴────────┴───────────────────────┴──────┴─────────────┴────────────┴─────────────┴───────────┘ `.trim()) }) -test('2 unconsumed interceptors', t => { +test('2 pending interceptors', t => { t.plan(2) const withTwoInterceptors = mockAgentWithOneInterceptor() @@ -61,23 +59,21 @@ test('2 unconsumed interceptors', t => { .get(origin) .intercept({ method: 'get', path: '/some/path' }) .reply(204, 'OK') - const err = t.throws(() => withTwoInterceptors.assertNoUnusedInterceptors({ tableFormatter })) + const err = t.throws(() => withTwoInterceptors.assertNoPendingInterceptors({ pendingInterceptorsFormatter })) t.same(err.message, ` -2 interceptors were not consumed! -(0 interceptors were consumed, and 0 were not counted because they are persistent.) - -These interceptors were not consumed: -┌─────────┬────────┬──────────────┬─────────────┬────────────┬─────────────────┐ -│ (index) │ Method │ Path │ Status code │ Persistent │ Remaining calls │ -├─────────┼────────┼──────────────┼─────────────┼────────────┼─────────────────┤ -│ 0 │ 'GET' │ '/' │ 200 │ '❌' │ 1 │ -│ 1 │ 'GET' │ '/some/path' │ 204 │ '❌' │ 1 │ -└─────────┴────────┴──────────────┴─────────────┴────────────┴─────────────────┘ +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 consumed status', async t => { +test('Variations of persist(), times(), and pending status', async t => { t.plan(7) // Agent with unused interceptor @@ -100,11 +96,11 @@ test('Variations of persist(), times(), and consumed status', async t => { // Consumed without persist() agent.get(origin) - .intercept({ method: 'post', path: '/transient/consumed' }) + .intercept({ method: 'post', path: '/transient/pending' }) .reply(201, 'Created') - t.same((await agent.request({ origin, method: 'POST', path: '/transient/consumed' })).statusCode, 201) + t.same((await agent.request({ origin, method: 'POST', path: '/transient/pending' })).statusCode, 201) - // Partially consumed with times() + // Partially pending with times() agent.get(origin) .intercept({ method: 'get', path: '/times/partial' }) .reply(200, 'OK') @@ -117,28 +113,27 @@ test('Variations of persist(), times(), and consumed status', async t => { .reply(200, 'OK') .times(2) - // Fully consumed with times() + // Fully pending with times() agent.get(origin) - .intercept({ method: 'get', path: '/times/consumed' }) + .intercept({ method: 'get', path: '/times/pending' }) .reply(200, 'OK') .times(2) - t.same((await agent.request({ origin, method: 'GET', path: '/times/consumed' })).statusCode, 200) - t.same((await agent.request({ origin, method: 'GET', path: '/times/consumed' })).statusCode, 200) + 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.assertNoUnusedInterceptors({ tableFormatter })) + const err = t.throws(() => agent.assertNoPendingInterceptors({ pendingInterceptorsFormatter })) t.same(err.message, ` -3 interceptors were not consumed! -(0 interceptors were consumed, and 2 were not counted because they are persistent.) - -These interceptors were not consumed: -┌─────────┬────────┬──────────────────┬─────────────┬────────────┬─────────────────┐ -│ (index) │ Method │ Path │ Status code │ Persistent │ Remaining calls │ -├─────────┼────────┼──────────────────┼─────────────┼────────────┼─────────────────┤ -│ 0 │ 'GET' │ '/' │ 200 │ '❌' │ 1 │ -│ 1 │ 'GET' │ '/times/partial' │ 200 │ '❌' │ 4 │ -│ 2 │ 'GET' │ '/times/unused' │ 200 │ '❌' │ 2 │ -└─────────┴────────┴──────────────────┴─────────────┴────────────┴─────────────────┘ +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()) }) @@ -149,11 +144,11 @@ test('works when no interceptors are registered', t => { agent.disableNetConnect() t.same(agent.pendingInterceptors(), []) - t.doesNotThrow(() => agent.assertNoUnusedInterceptors()) + t.doesNotThrow(() => agent.assertNoPendingInterceptors()) }) -test('works when all interceptors are consumed', async t => { - t.plan(3) +test('works when all interceptors are pending', async t => { + t.plan(4) const agent = new MockAgent() agent.disableNetConnect() @@ -161,8 +156,11 @@ test('works when all interceptors are consumed', async t => { 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.assertNoUnusedInterceptors()) + t.doesNotThrow(() => agent.assertNoPendingInterceptors()) }) test('defaults to rendering output with terminal color when process.env.CI is unset', t => { @@ -173,17 +171,15 @@ test('defaults to rendering output with terminal color when process.env.CI is un delete process.env.CI const err = t.throws( - () => mockAgentWithOneInterceptor().assertNoUnusedInterceptors()) + () => mockAgentWithOneInterceptor().assertNoPendingInterceptors()) t.same(err.message, ` -1 interceptor was not consumed! -(0 interceptors were consumed, and 0 were not counted because they are persistent.) - -This interceptor was not consumed: -┌─────────┬────────┬──────┬─────────────┬────────────┬─────────────────┐ -│ (index) │ Method │ Path │ Status code │ Persistent │ Remaining calls │ -├─────────┼────────┼──────┼─────────────┼────────────┼─────────────────┤ -│ 0 │ 'GET' │ '/' │ 200 │ '❌' │ 1 │ -└─────────┴────────┴──────┴─────────────┴────────────┴─────────────────┘ +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. @@ -200,9 +196,11 @@ test('returns unused interceptors', t => { t.same(mockAgentWithOneInterceptor().pendingInterceptors(), [ { - times: null, + timesInvoked: 0, + times: 1, persist: false, consumed: false, + pending: true, path: '/', method: 'GET', body: undefined, @@ -213,7 +211,8 @@ test('returns unused interceptors', t => { 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 3f13e3df36d..5f7f9686494 100644 --- a/test/types/mock-agent.test-d.ts +++ b/test/types/mock-agent.test-d.ts @@ -61,14 +61,15 @@ expectAssignable(new MockAgent({})) } { + interface PendingInterceptor extends MockDispatch { + origin: string; + } + const agent = new MockAgent({agent: new Agent()}) - expectType<() => MockDispatch[]>(agent.pendingInterceptors) + expectType<() => PendingInterceptor[]>(agent.pendingInterceptors) expectType<(options?: { - tableFormatter?: { - formatTable( - tabularData: any, - properties?: readonly string[] - ): string; + pendingInterceptorsFormatter?: { + format(pendingInterceptors: readonly PendingInterceptor[]): string; } - }) => void>(agent.assertNoUnusedInterceptors) + }) => void>(agent.assertNoPendingInterceptors) } diff --git a/types/mock-agent.d.ts b/types/mock-agent.d.ts index 98dffe09d19..825d2aeff6d 100644 --- a/types/mock-agent.d.ts +++ b/types/mock-agent.d.ts @@ -1,10 +1,14 @@ import Agent = require('./agent') import Dispatcher = require('./dispatcher') -import {Interceptable, MockInterceptor, MockScope} 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) @@ -27,12 +31,14 @@ declare class MockAgent boolean)): void; /** Causes all requests to throw when requests are not matched in a MockAgent intercept. */ disableNetConnect(): void; - pendingInterceptors(): MockDispatch[]; - assertNoUnusedInterceptors(options?: {tableFormatter?: UnusedInterceptorFormatter}): void; + pendingInterceptors(): PendingInterceptor[]; + assertNoPendingInterceptors(options?: { + pendingInterceptorsFormatter?: PendingInterceptorsFormatter; + }): void; } -interface UnusedInterceptorFormatter { - formatTable(...args: Parameters): string; +interface PendingInterceptorsFormatter { + format(pendingInterceptors: readonly PendingInterceptor[]): string; } declare namespace MockAgent {