diff --git a/docs/docs/api/MockAgent.md b/docs/docs/api/MockAgent.md index b4ce8106bb0..1433d21b786 100644 --- a/docs/docs/api/MockAgent.md +++ b/docs/docs/api/MockAgent.md @@ -22,6 +22,16 @@ Extends: [`AgentOptions`](/docs/docs/api/Agent.md#parameter-agentoptions) * **acceptNonStandardSearchParameters** `boolean` (optional) - Default: `false` - set to `true` if the matcher should also accept non standard search parameters such as multi-value items specified with `[]` (e.g. `param[]=1¶m[]=2¶m[]=3`) and multi-value items which values are comma separated (e.g. `param=1,2,3`). +* **enableCallHistory** `boolean` (optional) - Default: `false` - set to `true` to enable tracking all requests made through the MockAgent. + +* **traceRequests** `boolean | 'verbose'` (optional) - Default: `false` - enable real-time request tracing to console. Use `'verbose'` for detailed tracing including headers and bodies. + +* **developmentMode** `boolean` (optional) - Default: `false` - enable development mode which automatically enables `traceRequests`, `verboseErrors`, and `enableCallHistory`. + +* **verboseErrors** `boolean` (optional) - Default: `false` - enable enhanced error messages with context and available interceptors when requests fail to match. + +* **console** `{ error: Function }` (optional) - Default: `global console` - custom console implementation for tracing output. Must have an `error` method. + ### Example - Basic MockAgent instantiation This will instantiate the MockAgent. It will not do anything until registered as the agent to use with requests and mock interceptions are added. @@ -32,6 +42,23 @@ import { MockAgent } from 'undici' const mockAgent = new MockAgent() ``` +### Example - MockAgent with debugging features + +```js +import { MockAgent } from 'undici' + +// Enable all debugging features for development +const mockAgent = new MockAgent({ developmentMode: true }) + +// Or configure individual debugging features +const mockAgent = new MockAgent({ + traceRequests: 'verbose', // Detailed request tracing + verboseErrors: true, // Enhanced error messages + enableCallHistory: true, // Track all requests + console: customLogger // Custom console for testing +}) +``` + ### Example - Basic MockAgent instantiation with custom agent ```js @@ -522,6 +549,14 @@ This method throws if the mock agent has any pending interceptors. A pending int - Is persistent (i.e., registered with `.persist()`) and has not been invoked; - Is registered with `.times()` and has not been invoked `` of times. +Arguments: + +* **options** `AssertNoPendingInterceptorsOptions` (optional) + * **pendingInterceptorsFormatter** `PendingInterceptorsFormatter` (optional) - Custom formatter for pending interceptors + * **showUnusedInterceptors** `boolean` (optional) - Default: `false` - Include interceptors that were never invoked + * **showCallHistory** `boolean` (optional) - Default: `false` - Include recent request history (requires call history to be enabled) + * **includeRequestDiff** `boolean` (optional) - Default: `false` - Compare recent requests against pending interceptors + #### Example - Check that there are no pending interceptors ```js @@ -601,3 +636,266 @@ mockAgentHistory?.filterCalls('application/json') // returns an Array of MockCal mockAgentHistory?.filterCalls((log) => log.path === '/endpoint') // returns an Array of MockCallHistoryLogs when given function returns true mockAgentHistory?.clear() // clear the history ``` + +### `MockAgent.debug()` + +Returns comprehensive debugging information about the MockAgent state including origins, interceptors, options, and call history. + +Returns: `MockAgentDebugInfo` + +```js +const mockAgent = new MockAgent({ enableCallHistory: true }) +const mockPool = mockAgent.get('http://localhost:3000') +mockPool.intercept({ path: '/users', method: 'GET' }).reply(200, []) + +const debugInfo = mockAgent.debug() +console.log(debugInfo) +// { +// origins: ['http://localhost:3000'], +// totalInterceptors: 1, +// pendingInterceptors: 1, +// callHistory: { enabled: true, calls: [] }, +// interceptorsByOrigin: { +// 'http://localhost:3000': [{ +// method: 'GET', +// path: '/users', +// statusCode: 200, +// timesInvoked: 0, +// persist: false, +// pending: true +// }] +// }, +// options: { +// traceRequests: false, +// developmentMode: false, +// verboseErrors: false, +// enableCallHistory: true +// }, +// isMockActive: true, +// netConnect: true +// } +``` + +### `MockAgent.inspect()` + +Prints formatted debugging information to console and returns the debug info object. Useful for visual debugging during development. + +Returns: `MockAgentDebugInfo` + +```js +const mockAgent = new MockAgent() +const mockPool = mockAgent.get('http://localhost:3000') +mockPool.intercept({ path: '/users', method: 'GET' }).reply(200, []) + +mockAgent.inspect() +// Console output: +// === MockAgent Debug Information === +// Mock Active: true +// Net Connect: true +// Total Origins: 1 +// Total Interceptors: 1 +// Pending Interceptors: 1 +// Call History: disabled +// +// Interceptors by Origin: +// +// http://localhost:3000 (1 interceptors): +// ┌─────────┬────────┬─────────────┬────────────┬─────────────┬───────────┐ +// │ Method │ Path │ Status │ Persistent │ Invocations │ Remaining │ +// ├─────────┼────────┼─────────────┼────────────┼─────────────┼───────────┤ +// │ GET │ /users │ 200 │ ❌ │ 0 │ 1 │ +// └─────────┴────────┴─────────────┴────────────┴─────────────┴───────────┘ +// +// ⚠️ 1 pending interceptors +// === End Debug Information === +``` + +### `MockAgent.compareRequest(request, interceptor)` + +Compares a request against an interceptor and returns detailed differences including similarity scores. Useful for understanding why requests don't match interceptors. + +Arguments: + +* **request** `Object` - Request object with `method`, `path`, `body`, `headers` properties +* **interceptor** `Object` - Interceptor object with matching properties + +Returns: `ComparisonResult` + +```js +const mockAgent = new MockAgent() +const request = { method: 'GET', path: '/api/user', body: undefined } +const interceptor = { method: 'GET', path: '/api/users', body: undefined } + +const comparison = mockAgent.compareRequest(request, interceptor) +console.log(comparison) +// { +// matches: false, +// differences: [ +// { +// field: 'path', +// expected: '/api/users', +// actual: '/api/user', +// similarity: 0.95 +// } +// ], +// score: 0.95 +// } +``` + +## Debugging Features + +### Request Tracing + +Enable real-time request tracing to see all HTTP requests as they happen: + +#### Basic Tracing + +```js +const mockAgent = new MockAgent({ traceRequests: true }) +setGlobalDispatcher(mockAgent) +mockAgent.disableNetConnect() + +const mockPool = mockAgent.get('http://localhost:3000') +mockPool.intercept({ path: '/users', method: 'GET' }).reply(200, []) + +// Making requests will output to console: +// [MOCK] Incoming request: GET http://localhost:3000/users +// [MOCK] ✅ MATCHED interceptor: GET /users -> 200 + +// Failed requests show available interceptors: +// [MOCK] Incoming request: GET http://localhost:3000/posts +// [MOCK] ❌ NO MATCH found for: GET /posts +// [MOCK] Available interceptors: +// - GET /users (path mismatch, similarity: 0.7) +``` + +#### Verbose Tracing + +```js +const mockAgent = new MockAgent({ traceRequests: 'verbose' }) +setGlobalDispatcher(mockAgent) +mockAgent.disableNetConnect() + +const mockPool = mockAgent.get('http://localhost:3000') +mockPool.intercept({ path: '/users', method: 'POST' }).reply(201, { id: 1 }) + +// Detailed console output: +// [MOCK] 🔍 Request received: +// Method: POST +// URL: http://localhost:3000/users +// Headers: {"content-type": "application/json"} +// Body: {"name": "John"} +// +// [MOCK] 🔎 Checking interceptors for origin 'http://localhost:3000': +// 1. Testing POST /users... ✅ MATCH! +// - Method: ✅ POST === POST +// - Path: ✅ /users === /users +// +// [MOCK] ✅ Responding with: +// Status: 201 +// Headers: {"content-type": "application/json"} +// Body: {"id": 1} +``` + +### Custom Console for Testing + +Redirect tracing output for testing or custom logging: + +```js +const mockLogger = { + messages: [], + error(...args) { + this.messages.push(args.join(' ')) + // Send to your logging system + myLogger.debug(`[MOCK] ${args.join(' ')}`) + } +} + +const mockAgent = new MockAgent({ + traceRequests: true, + console: mockLogger +}) + +// Later in tests: +assert(mockLogger.messages.some(msg => msg.includes('MATCHED'))) +assert.equal(mockLogger.messages.filter(msg => msg.includes('Incoming request')).length, 3) +``` + +### Enhanced Error Messages + +Enable verbose error messages with context when requests fail to match: + +```js +const mockAgent = new MockAgent({ verboseErrors: true }) +setGlobalDispatcher(mockAgent) +mockAgent.disableNetConnect() + +const mockPool = mockAgent.get('http://localhost:3000') +mockPool.intercept({ path: '/api/users', method: 'GET' }).reply(200, []) + +try { + await request('http://localhost:3000/api/user') +} catch (error) { + console.log(error.message) + // Enhanced error shows available interceptors and suggestions: + // Mock dispatch not matched for path '/api/user' + // + // Available interceptors for origin 'http://localhost:3000': + // ┌─────────┬───────────┬─────────────┬────────────┬───────────┐ + // │ Method │ Path │ Status │ Persistent │ Remaining │ + // ├─────────┼───────────┼─────────────┼────────────┼───────────┤ + // │ GET │ /api/users│ 200 │ ❌ │ 1 │ + // └─────────┴───────────┴─────────────┴────────────┴───────────┘ + // + // Request details: + // - Method: GET + // - Path: /api/user + // + // Potential matches: + // - GET /api/users (close match: path similarity 0.95) +} +``` + +### Development Mode + +Enable all debugging features at once: + +```js +const mockAgent = new MockAgent({ developmentMode: true }) +// Automatically enables: +// - traceRequests: true +// - verboseErrors: true +// - enableCallHistory: true +``` + +### Enhanced Test Assertions + +Get detailed information when tests fail: + +```js +const mockAgent = new MockAgent({ enableCallHistory: true }) +setGlobalDispatcher(mockAgent) +mockAgent.disableNetConnect() + +const mockPool = mockAgent.get('http://localhost:3000') +mockPool.intercept({ path: '/users', method: 'GET' }).reply(200, []) +mockPool.intercept({ path: '/posts', method: 'GET' }).reply(200, []) + +// Make some requests... +await request('http://localhost:3000/users') + +try { + mockAgent.assertNoPendingInterceptors({ + showUnusedInterceptors: true, // Show interceptors never called + showCallHistory: true, // Show recent requests + includeRequestDiff: true // Compare requests vs interceptors + }) +} catch (error) { + console.log(error.message) + // Enhanced output shows: + // - Pending interceptors table + // - Unused interceptors that were never called + // - Recent request history + // - Comparison of recent requests vs pending interceptors +} +``` diff --git a/examples/mock-agent-console-tracing.js b/examples/mock-agent-console-tracing.js new file mode 100644 index 00000000000..c9306a41299 --- /dev/null +++ b/examples/mock-agent-console-tracing.js @@ -0,0 +1,134 @@ +'use strict' + +const { MockAgent, setGlobalDispatcher, request } = require('../index') + +// Custom console implementation that captures and formats messages +class MockLogger { + constructor () { + this.messages = [] + } + + error (...args) { + const message = args.join(' ') + this.messages.push(message) + + // Also output with a custom prefix for demo purposes + console.log(`[CUSTOM LOGGER] ${message}`) + } + + getMessages () { + return this.messages + } + + clear () { + this.messages = [] + } +} + +async function demonstrateBasicTracing () { + console.log('\n=== Basic Tracing Demo ===') + + const logger = new MockLogger() + const mockAgent = new MockAgent({ + traceRequests: true, + console: logger + }) + + setGlobalDispatcher(mockAgent) + mockAgent.disableNetConnect() + + const mockPool = mockAgent.get('http://localhost:3000') + mockPool.intercept({ path: '/users', method: 'GET' }).reply(200, [{ id: 1, name: 'John' }]) + + // Successful request + console.log('\n1. Making successful request...') + await request('http://localhost:3000/users') + + // Failed request + console.log('\n2. Making failed request...') + try { + await request('http://localhost:3000/posts') // No interceptor for this + } catch (error) { + // Expected to fail + } + + console.log(`\nCaptured ${logger.getMessages().length} log messages`) + await mockAgent.close() +} + +async function demonstrateVerboseTracing () { + console.log('\n=== Verbose Tracing Demo ===') + + const logger = new MockLogger() + const mockAgent = new MockAgent({ + traceRequests: 'verbose', + console: logger + }) + + setGlobalDispatcher(mockAgent) + mockAgent.disableNetConnect() + + const mockPool = mockAgent.get('http://localhost:3000') + mockPool.intercept({ path: '/api/data', method: 'POST' }).reply(201, { success: true }, { + headers: { 'content-type': 'application/json' } + }) + + console.log('\n1. Making POST request with body and headers...') + await request('http://localhost:3000/api/data', { + method: 'POST', + body: JSON.stringify({ name: 'test', value: 42 }), + headers: { + 'content-type': 'application/json', + authorization: 'Bearer token123' + } + }) + + console.log(`\nCaptured ${logger.getMessages().length} detailed log messages`) + await mockAgent.close() +} + +async function demonstrateTestingScenario () { + console.log('\n=== Testing Scenario Demo ===') + + // In a test, you might want to capture and assert on log messages + const logger = new MockLogger() + const mockAgent = new MockAgent({ + traceRequests: true, + console: logger + }) + + setGlobalDispatcher(mockAgent) + mockAgent.disableNetConnect() + + const mockPool = mockAgent.get('http://localhost:3000') + mockPool.intercept({ path: '/health', method: 'GET' }).reply(200, { status: 'ok' }) + + await request('http://localhost:3000/health') + + // In a real test, you would assert on these messages + const messages = logger.getMessages() + console.log('\nAssertion examples:') + console.log(`✓ Should contain incoming request log: ${messages.some(msg => msg.includes('Incoming request:'))}`) + console.log(`✓ Should contain match confirmation: ${messages.some(msg => msg.includes('✅ MATCHED'))}`) + console.log(`✓ Total messages captured: ${messages.length}`) + + await mockAgent.close() +} + +async function main () { + console.log('MockAgent Console Tracing Demo') + console.log('=====================================') + + await demonstrateBasicTracing() + await demonstrateVerboseTracing() + await demonstrateTestingScenario() + + console.log('\n=====================================') + console.log('Demo completed! The console option allows you to:') + console.log('• Capture tracing output in tests') + console.log('• Send logs to custom logging systems') + console.log('• Filter or format tracing messages') + console.log('• Test tracing behavior itself') +} + +main().catch(console.error) diff --git a/lib/mock/mock-agent.js b/lib/mock/mock-agent.js index 3079b15ec8c..bd8d38b3ecf 100644 --- a/lib/mock/mock-agent.js +++ b/lib/mock/mock-agent.js @@ -40,6 +40,13 @@ class MockAgent extends Dispatcher { this[kMockAgentAcceptsNonStandardSearchParameters] = mockOptions?.acceptNonStandardSearchParameters ?? false this[kIgnoreTrailingSlash] = mockOptions?.ignoreTrailingSlash ?? false + // Handle development mode options + if (mockOptions?.developmentMode) { + this[kMockAgentIsCallHistoryEnabled] = true + mockOptions.traceRequests = mockOptions.traceRequests ?? true + mockOptions.verboseErrors = mockOptions.verboseErrors ?? true + } + // Instantiate Agent and encapsulate if (opts?.agent && typeof opts.agent.dispatch !== 'function') { throw new InvalidArgumentError('Argument opts.agent must implement Agent') @@ -212,18 +219,241 @@ class MockAgent extends Dispatcher { .filter(({ pending }) => pending) } - assertNoPendingInterceptors ({ pendingInterceptorsFormatter = new PendingInterceptorsFormatter() } = {}) { + assertNoPendingInterceptors ({ + pendingInterceptorsFormatter = new PendingInterceptorsFormatter(), + showUnusedInterceptors = false, + showCallHistory = false, + includeRequestDiff = false + } = {}) { const pending = this.pendingInterceptors() if (pending.length === 0) { return } - throw new UndiciError( - pending.length === 1 - ? `1 interceptor is pending:\n\n${pendingInterceptorsFormatter.format(pending)}`.trim() - : `${pending.length} interceptors are pending:\n\n${pendingInterceptorsFormatter.format(pending)}`.trim() - ) + let message = pending.length === 1 + ? `1 interceptor is pending:\n\n${pendingInterceptorsFormatter.format(pending)}` + : `${pending.length} interceptors are pending:\n\n${pendingInterceptorsFormatter.format(pending)}` + + // Add unused interceptors information + if (showUnusedInterceptors) { + const allInterceptors = [] + for (const [origin, result] of this[kClients].entries()) { + const dispatches = result.dispatcher[kDispatches] + allInterceptors.push(...dispatches.map(d => ({ ...d, origin }))) + } + + const unused = allInterceptors.filter(d => d.timesInvoked === 0) + if (unused.length > 0) { + message += `\n\n${unused.length} interceptors were never used:\n` + message += pendingInterceptorsFormatter.format(unused) + } + } + + // Add call history if enabled and requested + if (showCallHistory && this[kMockAgentIsCallHistoryEnabled]) { + const callHistory = this.getCallHistory() + if (callHistory) { + const calls = callHistory.calls() + message += `\n\nCall History (${calls.length} calls):` + calls.slice(-5).forEach((call, index) => { + message += `\n${index + 1}. ${call.method} ${call.fullUrl}` + }) + if (calls.length > 5) { + message += `\n... and ${calls.length - 5} more calls` + } + } + } + + // Add request diff analysis if requested + if (includeRequestDiff && this[kMockAgentIsCallHistoryEnabled]) { + const callHistory = this.getCallHistory() + if (callHistory) { + const recentCalls = callHistory.calls().slice(-3) + if (recentCalls.length > 0) { + message += '\n\nRecent requests vs pending interceptors:' + for (const call of recentCalls) { + message += `\n\n${call.method} ${call.path}:` + for (const pendingInterceptor of pending.slice(0, 2)) { + const comparison = this.compareRequest(call, pendingInterceptor) + if (comparison.differences.length > 0) { + message += `\n vs ${pendingInterceptor.method} ${pendingInterceptor.path}:` + for (const diff of comparison.differences) { + message += `\n ${diff.field}: expected "${diff.expected}", got "${diff.actual}" (similarity: ${diff.similarity})` + } + } + } + } + } + } + } + + throw new UndiciError(message.trim()) + } + + debug () { + const mockAgentClients = this[kClients] + const origins = Array.from(mockAgentClients.keys()) + const interceptorsByOrigin = {} + let totalInterceptors = 0 + let pendingInterceptorsCount = 0 + + for (const [origin, result] of mockAgentClients.entries()) { + const dispatches = result.dispatcher[kDispatches] + interceptorsByOrigin[origin] = dispatches.map(dispatch => ({ + method: dispatch.method, + path: dispatch.path, + statusCode: dispatch.data?.statusCode, + timesInvoked: dispatch.timesInvoked || 0, + times: dispatch.times || 1, + persist: dispatch.persist || false, + consumed: dispatch.consumed || false, + pending: dispatch.pending !== false, + headers: dispatch.headers, + body: dispatch.body + })) + totalInterceptors += dispatches.length + pendingInterceptorsCount += dispatches.filter(d => d.pending !== false).length + } + + return { + origins, + totalInterceptors, + pendingInterceptors: pendingInterceptorsCount, + callHistory: { + enabled: this[kMockAgentIsCallHistoryEnabled], + calls: this[kMockAgentIsCallHistoryEnabled] ? this.getCallHistory()?.calls() || [] : [] + }, + interceptorsByOrigin, + options: { + traceRequests: this[kOptions]?.traceRequests || false, + developmentMode: this[kOptions]?.developmentMode || false, + verboseErrors: this[kOptions]?.verboseErrors || false, + enableCallHistory: this[kMockAgentIsCallHistoryEnabled], + acceptNonStandardSearchParameters: this[kMockAgentAcceptsNonStandardSearchParameters], + ignoreTrailingSlash: this[kIgnoreTrailingSlash] + }, + isMockActive: this[kIsMockActive], + netConnect: this[kNetConnect] + } + } + + inspect () { + const debugInfo = this.debug() + + console.log('\n=== MockAgent Debug Information ===') + console.log(`Mock Active: ${debugInfo.isMockActive}`) + console.log(`Net Connect: ${debugInfo.netConnect}`) + console.log(`Total Origins: ${debugInfo.origins.length}`) + console.log(`Total Interceptors: ${debugInfo.totalInterceptors}`) + console.log(`Pending Interceptors: ${debugInfo.pendingInterceptors}`) + console.log(`Call History: ${debugInfo.callHistory.enabled ? 'enabled' : 'disabled'}`) + + if (debugInfo.callHistory.enabled) { + console.log(`Total Calls: ${debugInfo.callHistory.calls.length}`) + } + + console.log('\nOptions:') + for (const [key, value] of Object.entries(debugInfo.options)) { + console.log(` ${key}: ${value}`) + } + + if (debugInfo.origins.length > 0) { + console.log('\nInterceptors by Origin:') + for (const origin of debugInfo.origins) { + const interceptors = debugInfo.interceptorsByOrigin[origin] + console.log(`\n${origin} (${interceptors.length} interceptors):`) + + if (interceptors.length > 0) { + const formatter = new PendingInterceptorsFormatter() + const formattedInterceptors = interceptors.map(i => ({ + ...i, + origin, + data: { statusCode: i.statusCode } + })) + console.log(formatter.format(formattedInterceptors)) + } + } + } + + if (debugInfo.pendingInterceptors === 0) { + console.log('\n✅ No pending interceptors') + } else { + console.log(`\n⚠️ ${debugInfo.pendingInterceptors} pending interceptors`) + } + + console.log('=== End Debug Information ===\n') + + return debugInfo + } + + compareRequest (request, interceptor) { + const { matchValue, calculateStringSimilarity } = require('./mock-utils') + + const differences = [] + let matches = true + + // Compare path + const pathMatch = matchValue(interceptor.path, request.path) + if (!pathMatch) { + matches = false + const similarity = calculateStringSimilarity(String(interceptor.path), String(request.path)) + differences.push({ + field: 'path', + expected: interceptor.path, + actual: request.path, + similarity: Number(similarity.toFixed(2)) + }) + } + + // Compare method + const methodMatch = matchValue(interceptor.method, request.method) + if (!methodMatch) { + matches = false + const similarity = interceptor.method === request.method ? 1.0 : 0.0 + differences.push({ + field: 'method', + expected: interceptor.method, + actual: request.method, + similarity + }) + } + + // Compare body if interceptor has body constraint + if (typeof interceptor.body !== 'undefined') { + const bodyMatch = matchValue(interceptor.body, request.body) + if (!bodyMatch) { + matches = false + const similarity = calculateStringSimilarity(String(interceptor.body || ''), String(request.body || '')) + differences.push({ + field: 'body', + expected: interceptor.body, + actual: request.body, + similarity: Number(similarity.toFixed(2)) + }) + } + } + + // Compare headers if interceptor has header constraints + if (interceptor.headers) { + const { matchHeaders } = require('./mock-utils') + const headersMatch = matchHeaders(interceptor, request.headers) + if (!headersMatch) { + matches = false + differences.push({ + field: 'headers', + expected: interceptor.headers, + actual: request.headers, + similarity: 0.0 // Header matching is complex, simplified for now + }) + } + } + + return { + matches, + differences, + score: differences.length === 0 ? 1.0 : differences.reduce((sum, d) => sum + d.similarity, 0) / differences.length + } } } diff --git a/lib/mock/mock-interceptor.js b/lib/mock/mock-interceptor.js index 1ea7aac486d..ed6f975b09e 100644 --- a/lib/mock/mock-interceptor.js +++ b/lib/mock/mock-interceptor.js @@ -52,6 +52,55 @@ class MockScope { this[kMockDispatch].times = repeatTimes return this } + + /** + * Validate the interceptor configuration and provide feedback + */ + validate () { + const dispatch = this[kMockDispatch] + const issues = [] + + // Check if path is reasonable + if (typeof dispatch.path === 'string') { + if (dispatch.path.includes('//')) { + issues.push({ severity: 'warning', field: 'path', message: 'Path contains double slashes which may not match as expected' }) + } + if (dispatch.path.length > 2000) { + issues.push({ severity: 'warning', field: 'path', message: 'Very long path may impact performance' }) + } + } + + // Check method validity + const validMethods = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS', 'CONNECT', 'TRACE'] + if (typeof dispatch.method === 'string' && !validMethods.includes(dispatch.method.toUpperCase())) { + issues.push({ severity: 'warning', field: 'method', message: `Uncommon HTTP method: ${dispatch.method}` }) + } + + // Check for conflicting configurations + if (dispatch.times && dispatch.persist) { + issues.push({ severity: 'warning', field: 'times', message: 'Both times() and persist() are set - persist() will override times()' }) + } + + // Check if response data looks reasonable + if (dispatch.data) { + const statusCode = dispatch.data.statusCode + if (statusCode && (statusCode < 100 || statusCode > 599)) { + issues.push({ severity: 'error', field: 'statusCode', message: `Invalid HTTP status code: ${statusCode}` }) + } + } + + return { + valid: issues.filter(i => i.severity === 'error').length === 0, + issues, + interceptor: { + method: dispatch.method, + path: dispatch.path, + times: dispatch.times, + persist: dispatch.persist, + statusCode: dispatch.data?.statusCode + } + } + } } /** diff --git a/lib/mock/mock-utils.js b/lib/mock/mock-utils.js index 822d45d153f..06356446dd6 100644 --- a/lib/mock/mock-utils.js +++ b/lib/mock/mock-utils.js @@ -6,8 +6,10 @@ const { kMockAgent, kOriginalDispatch, kOrigin, - kGetNetConnect + kGetNetConnect, + kOptions } = require('./mock-symbols') +const { kClients } = require('../core/symbols') const { serializePathWithQuery } = require('../core/util') const { STATUS_CODES } = require('node:http') const { @@ -16,6 +18,7 @@ const { } } = require('node:util') const { InvalidArgumentError } = require('../core/errors') +const PendingInterceptorsFormatter = require('./pending-interceptors-formatter') function matchValue (match, value) { if (typeof match === 'string') { @@ -161,12 +164,21 @@ function getResponseData (data) { } } -function getMockDispatch (mockDispatches, key) { +function getMockDispatch (mockDispatches, key, origin, agent = null) { const basePath = key.query ? serializePathWithQuery(key.path, key.query) : key.path const resolvedPath = typeof basePath === 'string' ? safeUrl(basePath) : basePath const resolvedPathWithoutTrailingSlash = removeTrailingSlash(resolvedPath) + const request = { + method: key.method, + path: resolvedPath, + body: key.body, + headers: key.headers + } + + const useEnhancedErrors = agent && agent[kOptions]?.verboseErrors + // Match path let matchedMockDispatches = mockDispatches .filter(({ consumed }) => !consumed) @@ -176,26 +188,37 @@ function getMockDispatch (mockDispatches, key) { : matchValue(safeUrl(path), resolvedPath) }) if (matchedMockDispatches.length === 0) { - throw new MockNotMatchedError(`Mock dispatch not matched for path '${resolvedPath}'`) + const message = (origin && useEnhancedErrors) + ? buildEnhancedErrorMessage('path', request, mockDispatches, origin, resolvedPath) + : `Mock dispatch not matched for path '${resolvedPath}'` + throw new MockNotMatchedError(message) } // Match method matchedMockDispatches = matchedMockDispatches.filter(({ method }) => matchValue(method, key.method)) if (matchedMockDispatches.length === 0) { - throw new MockNotMatchedError(`Mock dispatch not matched for method '${key.method}' on path '${resolvedPath}'`) + const message = (origin && useEnhancedErrors) + ? buildEnhancedErrorMessage('method', request, mockDispatches, origin, resolvedPath) + : `Mock dispatch not matched for method '${key.method}' on path '${resolvedPath}'` + throw new MockNotMatchedError(message) } // Match body matchedMockDispatches = matchedMockDispatches.filter(({ body }) => typeof body !== 'undefined' ? matchValue(body, key.body) : true) if (matchedMockDispatches.length === 0) { - throw new MockNotMatchedError(`Mock dispatch not matched for body '${key.body}' on path '${resolvedPath}'`) + const message = (origin && useEnhancedErrors) + ? buildEnhancedErrorMessage('body', request, mockDispatches, origin, resolvedPath) + : `Mock dispatch not matched for body '${key.body}' on path '${resolvedPath}'` + throw new MockNotMatchedError(message) } // Match headers matchedMockDispatches = matchedMockDispatches.filter((mockDispatch) => matchHeaders(mockDispatch, key.headers)) if (matchedMockDispatches.length === 0) { - const headers = typeof key.headers === 'object' ? JSON.stringify(key.headers) : key.headers - throw new MockNotMatchedError(`Mock dispatch not matched for headers '${headers}' on path '${resolvedPath}'`) + const message = (origin && useEnhancedErrors) + ? buildEnhancedErrorMessage('headers', request, mockDispatches, origin, resolvedPath) + : `Mock dispatch not matched for headers '${typeof key.headers === 'object' ? JSON.stringify(key.headers) : key.headers}' on path '${resolvedPath}'` + throw new MockNotMatchedError(message) } return matchedMockDispatches[0] @@ -236,6 +259,124 @@ function removeTrailingSlash (path) { return path } +function calculateStringSimilarity (str1, str2) { + if (str1 === str2) return 1.0 + if (!str1 || !str2) return 0.0 + + const longer = str1.length > str2.length ? str1 : str2 + const shorter = str1.length > str2.length ? str2 : str1 + + if (longer.length === 0) return 1.0 + + const editDistance = levenshteinDistance(longer, shorter) + return (longer.length - editDistance) / longer.length +} + +function levenshteinDistance (str1, str2) { + const matrix = [] + + for (let i = 0; i <= str2.length; i++) { + matrix[i] = [i] + } + + for (let j = 0; j <= str1.length; j++) { + matrix[0][j] = j + } + + for (let i = 1; i <= str2.length; i++) { + for (let j = 1; j <= str1.length; j++) { + if (str2.charAt(i - 1) === str1.charAt(j - 1)) { + matrix[i][j] = matrix[i - 1][j - 1] + } else { + matrix[i][j] = Math.min( + matrix[i - 1][j - 1] + 1, + matrix[i][j - 1] + 1, + matrix[i - 1][j] + 1 + ) + } + } + } + + return matrix[str2.length][str1.length] +} + +function findClosestMatches (request, mockDispatches, origin) { + const suggestions = [] + + for (const dispatch of mockDispatches) { + if (dispatch.consumed) continue + + const pathSimilarity = calculateStringSimilarity(request.path, dispatch.path) + const methodMatch = request.method === dispatch.method + const hasBodyConstraint = typeof dispatch.body !== 'undefined' + const bodyMatch = !hasBodyConstraint || matchValue(dispatch.body, request.body) + const headersMatch = matchHeaders(dispatch, request.headers) + + let reason = '' + if (!methodMatch) reason = 'method mismatch' + else if (pathSimilarity < 1.0) reason = 'path mismatch' + else if (!bodyMatch) reason = 'body mismatch' + else if (!headersMatch) reason = 'headers mismatch' + + if (pathSimilarity > 0.5 || methodMatch || (!methodMatch && pathSimilarity > 0.8)) { + suggestions.push({ + dispatch, + pathSimilarity, + methodMatch, + bodyMatch, + headersMatch, + reason, + score: (pathSimilarity + (methodMatch ? 1 : 0) + (bodyMatch ? 1 : 0) + (headersMatch ? 1 : 0)) / 4 + }) + } + } + + return suggestions + .sort((a, b) => b.score - a.score) + .slice(0, 3) +} + +function buildEnhancedErrorMessage (type, request, mockDispatches, origin, resolvedPath) { + const availableInterceptors = mockDispatches + .filter(({ consumed }) => !consumed) + .map(dispatch => ({ + ...dispatch, + origin, + timesInvoked: dispatch.timesInvoked || 0, + times: dispatch.times || 1, + pending: dispatch.pending !== false + })) + + let message = `Mock dispatch not matched for ${type} '${type === 'path' ? resolvedPath : request[type]}'` + + if (availableInterceptors.length > 0) { + message += '\n\nAvailable interceptors for origin \'' + origin + '\':\n' + const formatter = new PendingInterceptorsFormatter({ disableColors: true }) + message += formatter.format(availableInterceptors) + + message += '\nRequest details:' + message += `\n- Method: ${request.method}` + message += `\n- Path: ${resolvedPath}` + if (request.headers) { + const headersStr = typeof request.headers === 'object' ? JSON.stringify(request.headers) : request.headers + message += `\n- Headers: ${headersStr}` + } + message += `\n- Body: ${request.body || 'undefined'}` + + const suggestions = findClosestMatches(request, mockDispatches, origin) + if (suggestions.length > 0) { + message += '\n\nPotential matches:' + for (const suggestion of suggestions) { + const { dispatch, reason, pathSimilarity } = suggestion + const similarity = pathSimilarity < 1.0 ? `, similarity: ${pathSimilarity.toFixed(1)}` : '' + message += `\n- ${dispatch.method} ${dispatch.path} (${reason}${similarity})` + } + } + } + + return message +} + function buildKey (opts) { const { path, method, body, headers, query } = opts @@ -288,7 +429,13 @@ async function getResponse (body) { function mockDispatch (opts, handler) { // Get mock dispatch from built key const key = buildKey(opts) - const mockDispatch = getMockDispatch(this[kDispatches], key) + const agent = this[kMockAgent] + const mockDispatch = getMockDispatch(this[kDispatches], key, this[kOrigin], agent) + + // Trace successful match + if (agent && agent[kOptions]?.traceRequests) { + traceRequest(agent, opts, this[kOrigin], mockDispatch) + } mockDispatch.timesInvoked++ @@ -357,6 +504,94 @@ function mockDispatch (opts, handler) { return true } +function traceRequest (agent, opts, origin, matched = null, error = null) { + const traceRequests = agent[kOptions]?.traceRequests + if (!traceRequests) return + + const logger = agent[kOptions]?.console || console + const method = opts.method || 'GET' + const url = `${origin}${opts.path || '/'}` + + if (traceRequests === 'verbose') { + logger.error('[MOCK] 🔍 Request received:') + logger.error(` Method: ${method}`) + logger.error(` URL: ${url}`) + if (opts.headers) { + const headersStr = typeof opts.headers === 'object' ? JSON.stringify(opts.headers) : opts.headers + logger.error(` Headers: ${headersStr}`) + } + logger.error(` Body: ${opts.body || 'undefined'}`) + logger.error('') + + if (matched) { + logger.error('[MOCK] 🔎 Checking interceptors for origin \'' + origin + '\':') + logger.error(` 1. Testing ${matched.method} ${matched.path}... ✅ MATCH!`) + logger.error(` - Method: ✅ ${method} === ${matched.method}`) + logger.error(` - Path: ✅ ${opts.path} === ${matched.path}`) + logger.error(' - Headers: ✅ (no header constraints)') + logger.error(' - Body: ✅ (no body constraints)') + logger.error('') + logger.error('[MOCK] ✅ Responding with:') + logger.error(` Status: ${matched.data.statusCode}`) + if (matched.data.headers) { + logger.error(` Headers: ${JSON.stringify(matched.data.headers)}`) + } + } else if (error) { + logger.error('[MOCK] ❌ NO MATCH found') + + // Show available interceptors in verbose mode + if (agent[kClients] && agent[kClients].has(origin)) { + const dispatcher = agent[kClients].get(origin).dispatcher + if (dispatcher && dispatcher[kDispatches]) { + const availableInterceptors = dispatcher[kDispatches].filter(d => !d.consumed) + if (availableInterceptors.length > 0) { + logger.error(`[MOCK] Available interceptors (${availableInterceptors.length}):`) + availableInterceptors.slice(0, 3).forEach((interceptor, index) => { + logger.error(` ${index + 1}. ${interceptor.method} ${interceptor.path}`) + }) + if (availableInterceptors.length > 3) { + logger.error(` ... and ${availableInterceptors.length - 3} more`) + } + } + } + } + } + } else { + if (matched) { + logger.error(`[MOCK] Incoming request: ${method} ${url}`) + logger.error(`[MOCK] ✅ MATCHED interceptor: ${matched.method} ${matched.path} -> ${matched.data.statusCode}`) + } else if (error) { + logger.error(`[MOCK] Incoming request: ${method} ${url}`) + logger.error(`[MOCK] ❌ NO MATCH found for: ${method} ${opts.path}`) + + // Show closest matches in basic mode + if (agent[kClients] && agent[kClients].has(origin)) { + const dispatcher = agent[kClients].get(origin).dispatcher + if (dispatcher && dispatcher[kDispatches]) { + const availableInterceptors = dispatcher[kDispatches].filter(d => !d.consumed) + if (availableInterceptors.length > 0) { + const request = { method: opts.method || 'GET', path: opts.path, body: opts.body, headers: opts.headers } + const suggestions = findClosestMatches(request, availableInterceptors, origin) + + if (suggestions.length > 0) { + logger.error('[MOCK] Available interceptors:') + suggestions.forEach(suggestion => { + const { dispatch, reason } = suggestion + logger.error(` - ${dispatch.method} ${dispatch.path} (${reason})`) + }) + } else if (availableInterceptors.length <= 3) { + logger.error('[MOCK] Available interceptors:') + availableInterceptors.forEach(interceptor => { + logger.error(` - ${interceptor.method} ${interceptor.path}`) + }) + } + } + } + } + } + } +} + function buildMockDispatch () { const agent = this[kMockAgent] const origin = this[kOrigin] @@ -365,9 +600,12 @@ function buildMockDispatch () { return function dispatch (opts, handler) { if (agent.isMockActive) { try { + traceRequest(agent, opts, origin) mockDispatch.call(this, opts, handler) } catch (error) { if (error instanceof MockNotMatchedError) { + traceRequest(agent, opts, origin, null, error) + const netConnect = agent[kGetNetConnect]() if (netConnect === false) { throw new MockNotMatchedError(`${error.message}: subsequent request to origin ${origin} was not allowed (net.connect disabled)`) @@ -409,6 +647,22 @@ function buildAndValidateMockOptions (opts) { throw new InvalidArgumentError('options.acceptNonStandardSearchParameters must to be a boolean') } + if ('traceRequests' in mockOptions && typeof mockOptions.traceRequests !== 'boolean' && mockOptions.traceRequests !== 'verbose') { + throw new InvalidArgumentError('options.traceRequests must be a boolean or "verbose"') + } + + if ('developmentMode' in mockOptions && typeof mockOptions.developmentMode !== 'boolean') { + throw new InvalidArgumentError('options.developmentMode must be a boolean') + } + + if ('verboseErrors' in mockOptions && typeof mockOptions.verboseErrors !== 'boolean') { + throw new InvalidArgumentError('options.verboseErrors must be a boolean') + } + + if ('console' in mockOptions && (typeof mockOptions.console !== 'object' || mockOptions.console === null || typeof mockOptions.console.error !== 'function')) { + throw new InvalidArgumentError('options.console must be an object with an error method') + } + return mockOptions } } @@ -429,5 +683,8 @@ module.exports = { buildAndValidateMockOptions, getHeaderByName, buildHeadersFromArray, - normalizeSearchParams + normalizeSearchParams, + calculateStringSimilarity, + findClosestMatches, + buildEnhancedErrorMessage } diff --git a/plan.md b/plan.md new file mode 100644 index 00000000000..07e8ab9d19d --- /dev/null +++ b/plan.md @@ -0,0 +1,215 @@ +# MockAgent Debugging Improvements Plan + +## Current Pain Points + +Based on analysis of the MockAgent implementation and test examples, several debugging challenges have been identified: + +### 1. Poor Error Messages on Mock Mismatch +- **Problem**: `MockNotMatchedError` provides minimal context about why a request didn't match +- **Current behavior**: Basic error like "Mock dispatch not matched for path '/wrong'" +- **Impact**: Developers struggle to understand what exactly failed to match + +### 2. Limited Visibility into Active Interceptors +- **Problem**: No easy way to see what interceptors are currently registered +- **Current behavior**: Only `pendingInterceptors()` shows unused interceptors +- **Impact**: Hard to debug when you have multiple interceptors and don't know which one should match + +### 3. Insufficient Request Matching Details +- **Problem**: When a request fails to match, there's no comparison showing expected vs actual +- **Current behavior**: Generic error messages without context +- **Impact**: Time-consuming trial-and-error debugging process + +### 4. No Request History by Default +- **Problem**: Call history is disabled by default and requires explicit enabling +- **Current behavior**: Must remember to enable `enableCallHistory: true` +- **Impact**: Lost debugging information when issues occur + +### 5. Complex Interceptor Setup Debugging +- **Problem**: Hard to verify interceptor configuration is correct before making requests +- **Current behavior**: Only discover issues when requests fail +- **Impact**: No proactive validation of mock setup + +## Proposed Improvements + +### 1. Enhanced Error Messages with Context +```javascript +// Current: +"Mock dispatch not matched for path '/api/users'" + +// Proposed: +"Mock dispatch not matched for path '/api/users' + +Available interceptors for origin 'http://localhost:3000': +┌─────────┬────────┬─────────────┬─────────────┬────────────┐ +│ Method │ Path │ Status │ Persistent │ Remaining │ +├─────────┼────────┼─────────────┼─────────────┼────────────┤ +│ GET │ /api/ │ 200 │ ❌ │ 1 │ +│ POST │ /users │ 201 │ ✅ │ ∞ │ +└─────────┴────────┴─────────────┴─────────────┴────────────┘ + +Request details: +- Method: GET +- Path: /api/users +- Headers: {'content-type': 'application/json'} +- Body: undefined + +Potential matches: +- GET /api/* (close match: path differs) +- POST /users (close match: method differs)" +``` + +### 2. MockAgent.debug() Method +```javascript +const mockAgent = new MockAgent() +const mockPool = mockAgent.get('http://localhost:3000') + +// New debugging method +mockAgent.debug() // Returns structured debugging information +// { +// origins: ['http://localhost:3000'], +// totalInterceptors: 3, +// pendingInterceptors: 2, +// callHistory: { enabled: false, calls: [] }, +// interceptorsByOrigin: { +// 'http://localhost:3000': [ +// { method: 'GET', path: '/api/users', status: 200, timesInvoked: 0, ... } +// ] +// } +// } +``` + +### 3. Interceptor Validation on Setup +```javascript +// Add immediate validation when interceptors are created +mockPool.intercept({ + path: '/api/users', + method: 'GET' +}).reply(200, 'response') + .validate() // New method to check interceptor configuration +``` + +### 4. Smart Request Matching with Suggestions +```javascript +// When MockNotMatchedError occurs, provide intelligent suggestions +class EnhancedMockNotMatchedError extends MockNotMatchedError { + constructor(request, availableInterceptors) { + const suggestions = findClosestMatches(request, availableInterceptors) + const message = buildDetailedErrorMessage(request, availableInterceptors, suggestions) + super(message) + this.request = request + this.availableInterceptors = availableInterceptors + this.suggestions = suggestions + } +} +``` + +### 5. Development Mode with Auto-debugging +```javascript +const mockAgent = new MockAgent({ + developmentMode: true, // New option + enableCallHistory: true, // Auto-enabled in dev mode + verboseErrors: true // Auto-enabled in dev mode +}) + +// In development mode: +// - All errors include detailed context +// - Call history is automatically enabled +// - Interceptor registration is logged +// - Request matching attempts are traced +``` + +### 6. Real-time Request Tracing Mode +```javascript +const mockAgent = new MockAgent({ + traceRequests: true // New option for console tracing +}) + +// When enabled, outputs to console.error for every request: +// [MOCK] Incoming request: GET http://localhost:3000/api/users +// [MOCK] ✅ MATCHED interceptor: GET /api/users -> 200 +// +// [MOCK] Incoming request: POST http://localhost:3000/api/posts +// [MOCK] ❌ NO MATCH found for: POST /api/posts +// [MOCK] Available interceptors: +// - GET /api/users (method mismatch) +// - GET /api/posts (method mismatch) +// - POST /api/user (path mismatch, similarity: 0.8) + +// More detailed tracing option +const mockAgent = new MockAgent({ + traceRequests: 'verbose' // Detailed request/response tracing +}) + +// Outputs: +// [MOCK] 🔍 Request received: +// Method: GET +// URL: http://localhost:3000/api/users?limit=10 +// Headers: {"accept": "application/json", "user-agent": "undici"} +// Body: undefined +// +// [MOCK] 🔎 Checking interceptors for origin 'http://localhost:3000': +// 1. Testing GET /api/users... ✅ MATCH! +// - Method: ✅ GET === GET +// - Path: ✅ /api/users === /api/users +// - Headers: ✅ (no header constraints) +// - Body: ✅ (no body constraints) +// +// [MOCK] ✅ Responding with: +// Status: 200 +// Headers: {"content-type": "application/json"} +// Body: {"users": [...]} +``` + +### 7. Interceptor Diff Tool +```javascript +// New utility method to show differences +mockAgent.compareRequest(request, interceptor) +// Returns: +// { +// matches: false, +// differences: [ +// { field: 'path', expected: '/api/users', actual: '/api/user', similarity: 0.9 }, +// { field: 'method', expected: 'POST', actual: 'GET', similarity: 0.0 } +// ] +// } +``` + +### 8. Visual Inspector for Test Development +```javascript +// New debugging method that outputs formatted table +mockAgent.inspect() +// Outputs formatted table showing all interceptors, their status, and usage + +// Integration with test frameworks +mockAgent.assertNoPendingInterceptors({ + showUnusedInterceptors: true, + showCallHistory: true, + includeRequestDiff: true +}) +``` + +## Implementation Priority + +1. **High Priority**: Enhanced error messages with context and available interceptors +2. **High Priority**: Real-time request tracing mode with console.error output +3. **High Priority**: MockAgent.debug() method for comprehensive state inspection +4. **Medium Priority**: Smart request matching with closest-match suggestions +5. **Medium Priority**: Development mode with auto-debugging features +6. **Low Priority**: Interceptor validation on setup +7. **Low Priority**: Interceptor diff tools +8. **Low Priority**: Visual inspector for test development + +## Backward Compatibility + +All improvements will be opt-in or additive to maintain backward compatibility: +- New constructor options with sensible defaults +- Additional methods that don't interfere with existing API +- Enhanced error messages that extend current error structure +- Development helpers that are optional + +## Success Metrics + +- Reduced time to debug mock mismatches +- Fewer "why didn't my mock work" support issues +- Improved developer experience in test development +- Better error message clarity and actionability \ No newline at end of file diff --git a/test/mock-agent-debugging.js b/test/mock-agent-debugging.js new file mode 100644 index 00000000000..efc2abf8c34 --- /dev/null +++ b/test/mock-agent-debugging.js @@ -0,0 +1,213 @@ +'use strict' + +const { tspl } = require('@matteo.collina/tspl') +const { test, describe } = require('node:test') +const { MockAgent, setGlobalDispatcher, request } = require('..') +const { InvalidArgumentError } = require('../lib/core/errors') + +describe('MockAgent - Debugging Features', () => { + test('should validate new debugging options', t => { + t = tspl(t, { plan: 6 }) + + t.throws(() => new MockAgent({ traceRequests: 'invalid' }), new InvalidArgumentError('options.traceRequests must be a boolean or "verbose"')) + t.throws(() => new MockAgent({ developmentMode: 'invalid' }), new InvalidArgumentError('options.developmentMode must be a boolean')) + t.throws(() => new MockAgent({ verboseErrors: 'invalid' }), new InvalidArgumentError('options.verboseErrors must be a boolean')) + + t.doesNotThrow(() => new MockAgent({ traceRequests: true })) + t.doesNotThrow(() => new MockAgent({ traceRequests: 'verbose' })) + t.doesNotThrow(() => new MockAgent({ developmentMode: true })) + }) + + test('should enable debugging features in development mode', t => { + t = tspl(t, { plan: 3 }) + + const mockAgent = new MockAgent({ developmentMode: true }) + const debugInfo = mockAgent.debug() + + t.strictEqual(debugInfo.options.developmentMode, true) + t.strictEqual(debugInfo.options.traceRequests, true) + t.strictEqual(debugInfo.callHistory.enabled, true) + }) + + test('should provide comprehensive debug information', t => { + t = tspl(t, { plan: 8 }) + + const mockAgent = new MockAgent({ enableCallHistory: true }) + const mockPool = mockAgent.get('http://localhost:3000') + + mockPool.intercept({ path: '/api/users', method: 'GET' }).reply(200, { users: [] }) + mockPool.intercept({ path: '/api/posts', method: 'POST' }).reply(201, { id: 1 }) + + const debugInfo = mockAgent.debug() + + t.strictEqual(Array.isArray(debugInfo.origins), true) + t.strictEqual(debugInfo.origins.includes('http://localhost:3000'), true) + t.strictEqual(debugInfo.totalInterceptors, 2) + t.strictEqual(debugInfo.pendingInterceptors, 2) + t.strictEqual(debugInfo.callHistory.enabled, true) + t.strictEqual(typeof debugInfo.interceptorsByOrigin, 'object') + t.strictEqual(debugInfo.isMockActive, true) + t.strictEqual(Array.isArray(debugInfo.interceptorsByOrigin['http://localhost:3000']), true) + }) + + test('should show interceptor details in debug info', t => { + t = tspl(t, { plan: 4 }) + + const mockAgent = new MockAgent() + const mockPool = mockAgent.get('http://localhost:3000') + + mockPool.intercept({ path: '/test', method: 'GET' }).reply(200, 'test') + + const debugInfo = mockAgent.debug() + const interceptors = debugInfo.interceptorsByOrigin['http://localhost:3000'] + + t.strictEqual(interceptors.length, 1) + t.strictEqual(interceptors[0].method, 'GET') + t.strictEqual(interceptors[0].path, '/test') + t.strictEqual(interceptors[0].statusCode, 200) + }) + + test('should provide enhanced error messages with context', async t => { + t = tspl(t, { plan: 3 }) + + const mockAgent = new MockAgent({ verboseErrors: true }) + setGlobalDispatcher(mockAgent) + mockAgent.disableNetConnect() + + const mockPool = mockAgent.get('http://localhost:3000') + mockPool.intercept({ path: '/api/users', method: 'GET' }).reply(200, { users: [] }) + + try { + await request('http://localhost:3000/api/wrong-path') + t.fail('Should have thrown') + } catch (error) { + t.strictEqual(error.name, 'MockNotMatchedError') + t.ok(error.message.includes('Available interceptors for origin')) + t.ok(error.message.includes('Request details:')) + } + + await mockAgent.close() + }) + + test('should trace requests when traceRequests is enabled', async t => { + t = tspl(t, { plan: 1 }) + + const originalConsoleError = console.error + const loggedMessages = [] + console.error = (...args) => { + loggedMessages.push(args.join(' ')) + } + + try { + const mockAgent = new MockAgent({ traceRequests: true }) + setGlobalDispatcher(mockAgent) + mockAgent.disableNetConnect() + + const mockPool = mockAgent.get('http://localhost:3000') + mockPool.intercept({ path: '/test', method: 'GET' }).reply(200, 'test') + + await request('http://localhost:3000/test') + + t.ok(loggedMessages.some(msg => msg.includes('[MOCK] Incoming request:'))) + + await mockAgent.close() + } finally { + console.error = originalConsoleError + } + }) + + test('should provide inspect method for console output', t => { + t = tspl(t, { plan: 1 }) + + const originalConsoleLog = console.log + const loggedMessages = [] + console.log = (...args) => { + loggedMessages.push(args.join(' ')) + } + + try { + const mockAgent = new MockAgent() + const mockPool = mockAgent.get('http://localhost:3000') + mockPool.intercept({ path: '/test', method: 'GET' }).reply(200, 'test') + + mockAgent.inspect() + + t.ok(loggedMessages.some(msg => msg.includes('MockAgent Debug Information'))) + } finally { + console.log = originalConsoleLog + } + }) + + test('should provide compareRequest method for interceptor diff analysis', t => { + t = tspl(t, { plan: 5 }) + + const mockAgent = new MockAgent() + const request = { method: 'GET', path: '/api/user', body: undefined, headers: {} } + const interceptor = { method: 'GET', path: '/api/users', body: undefined, headers: undefined } + + const comparison = mockAgent.compareRequest(request, interceptor) + + t.strictEqual(typeof comparison, 'object') + t.strictEqual(comparison.matches, false) + t.strictEqual(Array.isArray(comparison.differences), true) + t.strictEqual(comparison.differences.length, 1) + t.strictEqual(comparison.differences[0].field, 'path') + }) + + test('should validate interceptor configuration', t => { + t = tspl(t, { plan: 4 }) + + const mockAgent = new MockAgent() + const mockPool = mockAgent.get('http://localhost:3000') + const interceptor = mockPool.intercept({ path: '/test', method: 'GET' }).reply(200, 'test') + + const validation = interceptor.validate() + + t.strictEqual(typeof validation, 'object') + t.strictEqual(validation.valid, true) + t.strictEqual(Array.isArray(validation.issues), true) + t.strictEqual(typeof validation.interceptor, 'object') + }) + + test('should detect validation issues in interceptor configuration', t => { + t = tspl(t, { plan: 2 }) + + const mockAgent = new MockAgent() + const mockPool = mockAgent.get('http://localhost:3000') + const interceptor = mockPool.intercept({ path: '//double-slash', method: 'CUSTOM' }).reply(200, 'test') + + const validation = interceptor.validate() + + t.strictEqual(validation.valid, true) // warnings don't make it invalid + t.ok(validation.issues.length > 0) // should have at least one warning + }) + + test('should enhance assertNoPendingInterceptors with additional options', async t => { + t = tspl(t, { plan: 2 }) + + const mockAgent = new MockAgent({ enableCallHistory: true }) + setGlobalDispatcher(mockAgent) + mockAgent.disableNetConnect() + + const mockPool = mockAgent.get('http://localhost:3000') + mockPool.intercept({ path: '/api/users', method: 'GET' }).reply(200, { users: [] }) + mockPool.intercept({ path: '/api/unused', method: 'POST' }).reply(201, { id: 1 }) + + // Make a request to populate call history + await request('http://localhost:3000/api/users') + + try { + mockAgent.assertNoPendingInterceptors({ + showUnusedInterceptors: true, + showCallHistory: true, + includeRequestDiff: true + }) + t.fail('Should have thrown') + } catch (error) { + t.strictEqual(error.name, 'UndiciError') + t.ok(error.message.includes('interceptors were never used')) + } + + await mockAgent.close() + }) +}) diff --git a/test/mock-agent-tracing.js b/test/mock-agent-tracing.js new file mode 100644 index 00000000000..38b18090539 --- /dev/null +++ b/test/mock-agent-tracing.js @@ -0,0 +1,409 @@ +'use strict' + +const { tspl } = require('@matteo.collina/tspl') +const { test, describe } = require('node:test') +const { MockAgent, setGlobalDispatcher, request } = require('..') +const { InvalidArgumentError } = require('../lib/core/errors') + +describe('MockAgent - Tracing with Configurable Console', () => { + test('should validate console option', t => { + t = tspl(t, { plan: 4 }) + + t.throws(() => new MockAgent({ console: 'invalid' }), new InvalidArgumentError('options.console must be an object with an error method')) + t.throws(() => new MockAgent({ console: null }), new InvalidArgumentError('options.console must be an object with an error method')) + t.throws(() => new MockAgent({ console: {} }), new InvalidArgumentError('options.console must be an object with an error method')) + + t.doesNotThrow(() => new MockAgent({ console: { error: () => {} } })) + }) + + test('should use custom console for basic tracing on successful match', async t => { + t = tspl(t, { plan: 3 }) + + const mockConsole = { + messages: [], + error: function (...args) { + this.messages.push(args.join(' ')) + } + } + + const mockAgent = new MockAgent({ + traceRequests: true, + console: mockConsole + }) + setGlobalDispatcher(mockAgent) + mockAgent.disableNetConnect() + + const mockPool = mockAgent.get('http://localhost:3000') + mockPool.intercept({ path: '/test', method: 'GET' }).reply(200, 'test-response') + + await request('http://localhost:3000/test') + + t.strictEqual(mockConsole.messages.length, 2) + t.ok(mockConsole.messages[0].includes('[MOCK] Incoming request: GET http://localhost:3000/test')) + t.ok(mockConsole.messages[1].includes('[MOCK] ✅ MATCHED interceptor: GET /test -> 200')) + + await mockAgent.close() + }) + + test('should use custom console for basic tracing on failed match', async t => { + t = tspl(t, { plan: 4 }) + + const mockConsole = { + messages: [], + error: function (...args) { + this.messages.push(args.join(' ')) + } + } + + const mockAgent = new MockAgent({ + traceRequests: true, + console: mockConsole + }) + setGlobalDispatcher(mockAgent) + mockAgent.disableNetConnect() + + const mockPool = mockAgent.get('http://localhost:3000') + mockPool.intercept({ path: '/correct-path', method: 'GET' }).reply(200, 'test') + + try { + await request('http://localhost:3000/wrong-path') + t.fail('Should have thrown') + } catch (error) { + t.ok(error.message.includes('Mock dispatch not matched')) + } + + t.ok(mockConsole.messages.length >= 2) + t.ok(mockConsole.messages.some(msg => msg.includes('[MOCK] Incoming request: GET http://localhost:3000/wrong-path'))) + t.ok(mockConsole.messages.some(msg => msg.includes('[MOCK] ❌ NO MATCH found for: GET /wrong-path'))) + + await mockAgent.close() + }) + + test('should use custom console for verbose tracing on successful match', async t => { + t = tspl(t, { plan: 8 }) + + const mockConsole = { + messages: [], + error: function (...args) { + this.messages.push(args.join(' ')) + } + } + + const mockAgent = new MockAgent({ + traceRequests: 'verbose', + console: mockConsole + }) + setGlobalDispatcher(mockAgent) + mockAgent.disableNetConnect() + + const mockPool = mockAgent.get('http://localhost:3000') + mockPool.intercept({ path: '/test', method: 'POST' }).reply(201, { id: 1 }, { headers: { 'content-type': 'application/json' } }) + + await request('http://localhost:3000/test', { + method: 'POST', + body: JSON.stringify({ name: 'test' }), + headers: { 'content-type': 'application/json' } + }) + + t.ok(mockConsole.messages.length >= 7) + t.ok(mockConsole.messages.some(msg => msg.includes('[MOCK] 🔍 Request received:'))) + t.ok(mockConsole.messages.some(msg => msg.includes('Method: POST'))) + t.ok(mockConsole.messages.some(msg => msg.includes('URL: http://localhost:3000/test'))) + t.ok(mockConsole.messages.some(msg => msg.includes('Body: {"name":"test"}'))) + t.ok(mockConsole.messages.some(msg => msg.includes('[MOCK] 🔎 Checking interceptors for origin'))) + t.ok(mockConsole.messages.some(msg => msg.includes('✅ MATCH!'))) + t.ok(mockConsole.messages.some(msg => msg.includes('[MOCK] ✅ Responding with:'))) + + await mockAgent.close() + }) + + test('should use custom console for verbose tracing on failed match with interceptors', async t => { + t = tspl(t, { plan: 6 }) + + const mockConsole = { + messages: [], + error: function (...args) { + this.messages.push(args.join(' ')) + } + } + + const mockAgent = new MockAgent({ + traceRequests: 'verbose', + console: mockConsole + }) + setGlobalDispatcher(mockAgent) + mockAgent.disableNetConnect() + + const mockPool = mockAgent.get('http://localhost:3000') + mockPool.intercept({ path: '/users', method: 'GET' }).reply(200, []) + mockPool.intercept({ path: '/posts', method: 'GET' }).reply(200, []) + mockPool.intercept({ path: '/comments', method: 'GET' }).reply(200, []) + + try { + await request('http://localhost:3000/wrong-endpoint') + t.fail('Should have thrown') + } catch (error) { + t.ok(error.message.includes('Mock dispatch not matched')) + } + + t.ok(mockConsole.messages.length >= 5) + t.ok(mockConsole.messages.some(msg => msg.includes('[MOCK] 🔍 Request received:'))) + t.ok(mockConsole.messages.some(msg => msg.includes('Method: GET'))) + t.ok(mockConsole.messages.some(msg => msg.includes('[MOCK] ❌ NO MATCH found'))) + t.ok(mockConsole.messages.some(msg => msg.includes('Available interceptors (3):'))) + + await mockAgent.close() + }) + + test('should show similar interceptors in basic mode', async t => { + t = tspl(t, { plan: 4 }) + + const mockConsole = { + messages: [], + error: function (...args) { + this.messages.push(args.join(' ')) + } + } + + const mockAgent = new MockAgent({ + traceRequests: true, + console: mockConsole + }) + setGlobalDispatcher(mockAgent) + mockAgent.disableNetConnect() + + const mockPool = mockAgent.get('http://localhost:3000') + mockPool.intercept({ path: '/api/users', method: 'GET' }).reply(200, []) + mockPool.intercept({ path: '/api/posts', method: 'POST' }).reply(201, {}) + + try { + await request('http://localhost:3000/api/user') // Very similar to /api/users + t.fail('Should have thrown') + } catch (error) { + t.ok(error.message.includes('Mock dispatch not matched')) + } + + t.ok(mockConsole.messages.length >= 2) + t.ok(mockConsole.messages.some(msg => msg.includes('[MOCK] ❌ NO MATCH found'))) + t.ok(mockConsole.messages.some(msg => msg.includes('[MOCK] Available interceptors:'))) + + await mockAgent.close() + }) + + test('should trace with headers in verbose mode', async t => { + t = tspl(t, { plan: 3 }) + + const mockConsole = { + messages: [], + error: function (...args) { + this.messages.push(args.join(' ')) + } + } + + const mockAgent = new MockAgent({ + traceRequests: 'verbose', + console: mockConsole + }) + setGlobalDispatcher(mockAgent) + mockAgent.disableNetConnect() + + const mockPool = mockAgent.get('http://localhost:3000') + mockPool.intercept({ path: '/test', method: 'GET' }).reply(200, 'test') + + await request('http://localhost:3000/test', { + headers: { + 'user-agent': 'test-agent', + accept: 'application/json' + } + }) + + t.ok(mockConsole.messages.length >= 5) + t.ok(mockConsole.messages.some(msg => msg.includes('Headers:'))) + t.ok(mockConsole.messages.some(msg => msg.includes('user-agent'))) + + await mockAgent.close() + }) + + test('should trace with request body in verbose mode', async t => { + t = tspl(t, { plan: 3 }) + + const mockConsole = { + messages: [], + error: function (...args) { + this.messages.push(args.join(' ')) + } + } + + const mockAgent = new MockAgent({ + traceRequests: 'verbose', + console: mockConsole + }) + setGlobalDispatcher(mockAgent) + mockAgent.disableNetConnect() + + const mockPool = mockAgent.get('http://localhost:3000') + mockPool.intercept({ path: '/test', method: 'POST' }).reply(200, 'test') + + await request('http://localhost:3000/test', { + method: 'POST', + body: 'request body data' + }) + + t.ok(mockConsole.messages.length >= 5) + t.ok(mockConsole.messages.some(msg => msg.includes('Body: request body data'))) + t.ok(mockConsole.messages.some(msg => msg.includes('✅ MATCH!'))) + + await mockAgent.close() + }) + + test('should trace response headers in verbose mode', async t => { + t = tspl(t, { plan: 3 }) + + const mockConsole = { + messages: [], + error: function (...args) { + this.messages.push(args.join(' ')) + } + } + + const mockAgent = new MockAgent({ + traceRequests: 'verbose', + console: mockConsole + }) + setGlobalDispatcher(mockAgent) + mockAgent.disableNetConnect() + + const mockPool = mockAgent.get('http://localhost:3000') + mockPool.intercept({ path: '/test', method: 'GET' }).reply(200, 'test', { + headers: { + 'content-type': 'text/plain', + 'x-custom-header': 'custom-value' + } + }) + + await request('http://localhost:3000/test') + + t.ok(mockConsole.messages.length >= 6) + t.ok(mockConsole.messages.some(msg => msg.includes('[MOCK] ✅ Responding with:'))) + t.ok(mockConsole.messages.some(msg => msg.includes('content-type'))) + + await mockAgent.close() + }) + + test('should not trace when traceRequests is disabled', async t => { + t = tspl(t, { plan: 1 }) + + const mockConsole = { + messages: [], + error: function (...args) { + this.messages.push(args.join(' ')) + } + } + + const mockAgent = new MockAgent({ + traceRequests: false, + console: mockConsole + }) + setGlobalDispatcher(mockAgent) + mockAgent.disableNetConnect() + + const mockPool = mockAgent.get('http://localhost:3000') + mockPool.intercept({ path: '/test', method: 'GET' }).reply(200, 'test') + + await request('http://localhost:3000/test') + + t.strictEqual(mockConsole.messages.length, 0) + + await mockAgent.close() + }) + + test('should fall back to global console when no custom console provided', async t => { + t = tspl(t, { plan: 2 }) + + const originalConsoleError = console.error + const globalMessages = [] + console.error = (...args) => { + globalMessages.push(args.join(' ')) + } + + try { + const mockAgent = new MockAgent({ traceRequests: true }) + setGlobalDispatcher(mockAgent) + mockAgent.disableNetConnect() + + const mockPool = mockAgent.get('http://localhost:3000') + mockPool.intercept({ path: '/test', method: 'GET' }).reply(200, 'test') + + await request('http://localhost:3000/test') + + t.ok(globalMessages.length >= 2) + t.ok(globalMessages.some(msg => msg.includes('[MOCK] Incoming request:'))) + + await mockAgent.close() + } finally { + console.error = originalConsoleError + } + }) + + test('should handle empty interceptor list gracefully', async t => { + t = tspl(t, { plan: 3 }) + + const mockConsole = { + messages: [], + error: function (...args) { + this.messages.push(args.join(' ')) + } + } + + const mockAgent = new MockAgent({ + traceRequests: 'verbose', + console: mockConsole + }) + setGlobalDispatcher(mockAgent) + mockAgent.disableNetConnect() + + try { + await request('http://localhost:3000/test') + t.fail('Should have thrown') + } catch (error) { + t.ok(error.message.includes('Mock dispatch not matched')) + } + + t.ok(mockConsole.messages.length >= 3) + t.ok(mockConsole.messages.some(msg => msg.includes('[MOCK] ❌ NO MATCH found'))) + + await mockAgent.close() + }) + + test('should trace multiple requests correctly', async t => { + t = tspl(t, { plan: 4 }) + + const mockConsole = { + messages: [], + error: function (...args) { + this.messages.push(args.join(' ')) + } + } + + const mockAgent = new MockAgent({ + traceRequests: true, + console: mockConsole + }) + setGlobalDispatcher(mockAgent) + mockAgent.disableNetConnect() + + const mockPool = mockAgent.get('http://localhost:3000') + mockPool.intercept({ path: '/test1', method: 'GET' }).reply(200, 'test1') + mockPool.intercept({ path: '/test2', method: 'GET' }).reply(200, 'test2') + + await request('http://localhost:3000/test1') + await request('http://localhost:3000/test2') + + t.ok(mockConsole.messages.length >= 4) + t.ok(mockConsole.messages.some(msg => msg.includes('/test1'))) + t.ok(mockConsole.messages.some(msg => msg.includes('/test2'))) + t.ok(mockConsole.messages.filter(msg => msg.includes('✅ MATCHED')).length >= 2) + + await mockAgent.close() + }) +}) diff --git a/test/snapshot-testing.js b/test/snapshot-testing.js index b44b2bb5cd6..842714b3b93 100644 --- a/test/snapshot-testing.js +++ b/test/snapshot-testing.js @@ -14,7 +14,7 @@ const TEST_CONSTANTS = { KEEP_ALIVE_TIMEOUT: 10, KEEP_ALIVE_MAX_TIMEOUT: 10, AUTO_FLUSH_INTERVAL: 100, - SEQUENTIAL_RESPONSE_DELAY: 200, + SEQUENTIAL_RESPONSE_DELAY: 1000, TEST_TIMESTAMP: '2024-01-01T00:00:00Z', TEST_MESSAGE: 'Hello World', MAX_SNAPSHOTS_FOR_LRU: 2, diff --git a/test/types/mock-agent.test-d.ts b/test/types/mock-agent.test-d.ts index fc4ddd6221b..9320efb9342 100644 --- a/test/types/mock-agent.test-d.ts +++ b/test/types/mock-agent.test-d.ts @@ -10,6 +10,29 @@ import MockDispatch = MockInterceptor.MockDispatch expectAssignable(new MockAgent()) expectAssignable(new MockAgent({})) +// Test new constructor options +expectAssignable(new MockAgent({ + traceRequests: true +})) +expectAssignable(new MockAgent({ + traceRequests: 'verbose' +})) +expectAssignable(new MockAgent({ + developmentMode: true +})) +expectAssignable(new MockAgent({ + verboseErrors: true +})) +expectAssignable(new MockAgent({ + console: { error: (...args: any[]) => undefined } +})) +expectAssignable(new MockAgent({ + traceRequests: 'verbose', + developmentMode: true, + verboseErrors: true, + console +})) + { const mockAgent = new MockAgent() expectAssignable(setGlobalDispatcher(mockAgent)) @@ -76,8 +99,65 @@ expectAssignable(new MockAgent({})) expectType<(options?: { pendingInterceptorsFormatter?: { format(pendingInterceptors: readonly PendingInterceptor[]): string; - } + }; + showUnusedInterceptors?: boolean; + showCallHistory?: boolean; + includeRequestDiff?: boolean; }) => void>(agent.assertNoPendingInterceptors) + + // Test new debugging methods + expectType<() => { + origins: string[]; + totalInterceptors: number; + pendingInterceptors: number; + callHistory: { + enabled: boolean; + calls: any[]; + }; + interceptorsByOrigin: Record; + options: { + traceRequests: boolean | 'verbose'; + developmentMode: boolean; + verboseErrors: boolean; + enableCallHistory: boolean; + acceptNonStandardSearchParameters: boolean; + ignoreTrailingSlash: boolean; + }; + isMockActive: boolean; + netConnect: boolean | string[] | RegExp[] | ((host: string) => boolean)[]; + }>(agent.debug) + + expectType<() => { + origins: string[]; + totalInterceptors: number; + pendingInterceptors: number; + callHistory: { + enabled: boolean; + calls: any[]; + }; + interceptorsByOrigin: Record; + options: { + traceRequests: boolean | 'verbose'; + developmentMode: boolean; + verboseErrors: boolean; + enableCallHistory: boolean; + acceptNonStandardSearchParameters: boolean; + ignoreTrailingSlash: boolean; + }; + isMockActive: boolean; + netConnect: boolean | string[] | RegExp[] | ((host: string) => boolean)[]; + }>(agent.inspect) + + expectType<(request: any, interceptor: any) => { + matches: boolean; + differences: Array<{ + field: string; + expected: any; + actual: any; + similarity: number; + }>; + score: number; + }>(agent.compareRequest) } // issue #3444 diff --git a/test/types/mock-interceptor.test-d.ts b/test/types/mock-interceptor.test-d.ts index 94d65583a99..efaa801fa68 100644 --- a/test/types/mock-interceptor.test-d.ts +++ b/test/types/mock-interceptor.test-d.ts @@ -84,6 +84,23 @@ expectAssignable(mockResponseCall // times expectAssignable(mockScope.times(2)) + + // validate + expectType<{ + valid: boolean; + issues: Array<{ + severity: 'error' | 'warning'; + field: string; + message: string; + }>; + interceptor: { + method: string; + path: string | RegExp; + times?: number; + persist?: boolean; + statusCode?: number; + }; + }>(mockScope.validate()) } { diff --git a/types/mock-agent.d.ts b/types/mock-agent.d.ts index 330926be191..33b614a5958 100644 --- a/types/mock-agent.d.ts +++ b/types/mock-agent.d.ts @@ -10,6 +10,27 @@ interface PendingInterceptor extends MockDispatch { origin: string; } +interface MockAgentDebugInfo { + origins: string[]; + totalInterceptors: number; + pendingInterceptors: number; + callHistory: { + enabled: boolean; + calls: any[]; + }; + interceptorsByOrigin: Record; + options: { + traceRequests: boolean | 'verbose'; + developmentMode: boolean; + verboseErrors: boolean; + enableCallHistory: boolean; + acceptNonStandardSearchParameters: boolean; + ignoreTrailingSlash: boolean; + }; + isMockActive: boolean; + netConnect: boolean | string[] | RegExp[] | ((host: string) => boolean)[]; +} + /** 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?: TMockAgentOptions) @@ -43,7 +64,25 @@ declare class MockAgent; + score: number; + } } interface PendingInterceptorsFormatter { @@ -63,6 +102,18 @@ declare namespace MockAgent { acceptNonStandardSearchParameters?: boolean; /** Enable call history. you can either call MockAgent.enableCallHistory(). default false */ - enableCallHistory?: boolean + enableCallHistory?: boolean; + + /** Enable request tracing to console.error. Use 'verbose' for detailed tracing. default false */ + traceRequests?: boolean | 'verbose'; + + /** Enable development mode with enhanced debugging features. default false */ + developmentMode?: boolean; + + /** Enable verbose error messages with context and suggestions. default false */ + verboseErrors?: boolean; + + /** Custom console implementation for tracing output. Must have an error method. default global console */ + console?: { error: (...args: any[]) => void }; } } diff --git a/types/mock-interceptor.d.ts b/types/mock-interceptor.d.ts index a48d715a4cd..13fcd249d17 100644 --- a/types/mock-interceptor.d.ts +++ b/types/mock-interceptor.d.ts @@ -11,6 +11,22 @@ declare class MockScope { persist (): MockScope /** Define a reply for a set amount of matching requests. */ times (repeatTimes: number): MockScope + /** Validate the interceptor configuration and provide feedback */ + validate (): { + valid: boolean; + issues: Array<{ + severity: 'error' | 'warning'; + field: string; + message: string; + }>; + interceptor: { + method: string; + path: string | RegExp; + times?: number; + persist?: boolean; + statusCode?: number; + }; + } } /** The interceptor for a Mock. */