From 98de374d3fdf697a93420f9492000dca9c0c6de6 Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Sat, 2 Aug 2025 11:34:03 +0200 Subject: [PATCH 1/8] docs: add MockAgent debugging improvements plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add comprehensive plan to improve MockAgent debugging experience including enhanced error messages, real-time request tracing, and development mode features. πŸ€– Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude Signed-off-by: Matteo Collina --- plan.md | 215 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 215 insertions(+) create mode 100644 plan.md 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 From 8aa7810553a2b614a0e5078d75b06d65f750bcef Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Sat, 2 Aug 2025 11:42:24 +0200 Subject: [PATCH 2/8] feat: implement MockAgent debugging improvements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add comprehensive debugging features to MockAgent: - Enhanced error messages with interceptor context and suggestions - Real-time request tracing with console.error output - MockAgent.debug() method for state inspection - MockAgent.inspect() method for formatted console output - New constructor options: traceRequests, developmentMode, verboseErrors - Development mode that auto-enables debugging features - Smart request matching with similarity scoring - Updated TypeScript definitions - Comprehensive test coverage Features improve debugging experience by providing detailed context when mocks don't match, real-time request visibility, and easy state inspection tools. πŸ€– Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude Signed-off-by: Matteo Collina --- lib/mock/mock-agent.js | 103 ++++++++++++++++ lib/mock/mock-utils.js | 220 +++++++++++++++++++++++++++++++++-- test/mock-agent-debugging.js | 140 ++++++++++++++++++++++ types/mock-agent.d.ts | 36 +++++- 4 files changed, 490 insertions(+), 9 deletions(-) create mode 100644 test/mock-agent-debugging.js diff --git a/lib/mock/mock-agent.js b/lib/mock/mock-agent.js index 3079b15ec8c..7f471731158 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') @@ -225,6 +232,102 @@ class MockAgent extends Dispatcher { : `${pending.length} interceptors are pending:\n\n${pendingInterceptorsFormatter.format(pending)}`.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 + } } module.exports = MockAgent diff --git a/lib/mock/mock-utils.js b/lib/mock/mock-utils.js index 822d45d153f..ab74263ff98 100644 --- a/lib/mock/mock-utils.js +++ b/lib/mock/mock-utils.js @@ -6,7 +6,8 @@ const { kMockAgent, kOriginalDispatch, kOrigin, - kGetNetConnect + kGetNetConnect, + kOptions } = require('./mock-symbols') const { serializePathWithQuery } = require('../core/util') const { STATUS_CODES } = require('node:http') @@ -16,6 +17,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 +163,19 @@ function getResponseData (data) { } } -function getMockDispatch (mockDispatches, key) { +function getMockDispatch (mockDispatches, key, origin) { 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 + } + // Match path let matchedMockDispatches = mockDispatches .filter(({ consumed }) => !consumed) @@ -176,26 +185,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 + ? 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 + ? 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 + ? 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 + ? 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 +256,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 +426,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 mockDispatch = getMockDispatch(this[kDispatches], key, this[kOrigin]) + + // Trace successful match + const agent = this[kMockAgent] + if (agent && agent[kOptions]?.traceRequests) { + traceRequest(agent, opts, this[kOrigin], mockDispatch) + } mockDispatch.timesInvoked++ @@ -357,6 +501,51 @@ function mockDispatch (opts, handler) { return true } +function traceRequest (agent, opts, origin, matched = null, error = null) { + const traceRequests = agent[kOptions]?.traceRequests + if (!traceRequests) return + + const method = opts.method || 'GET' + const url = `${origin}${opts.path || '/'}` + + if (traceRequests === 'verbose') { + console.error('[MOCK] πŸ” Request received:') + console.error(` Method: ${method}`) + console.error(` URL: ${url}`) + if (opts.headers) { + const headersStr = typeof opts.headers === 'object' ? JSON.stringify(opts.headers) : opts.headers + console.error(` Headers: ${headersStr}`) + } + console.error(` Body: ${opts.body || 'undefined'}`) + console.error('') + + if (matched) { + console.error('[MOCK] πŸ”Ž Checking interceptors for origin \'' + origin + '\':') + console.error(` 1. Testing ${matched.method} ${matched.path}... βœ… MATCH!`) + console.error(` - Method: βœ… ${method} === ${matched.method}`) + console.error(` - Path: βœ… ${opts.path} === ${matched.path}`) + console.error(' - Headers: βœ… (no header constraints)') + console.error(' - Body: βœ… (no body constraints)') + console.error('') + console.error('[MOCK] βœ… Responding with:') + console.error(` Status: ${matched.data.statusCode}`) + if (matched.data.headers) { + console.error(` Headers: ${JSON.stringify(matched.data.headers)}`) + } + } else if (error) { + console.error('[MOCK] ❌ NO MATCH found') + } + } else { + if (matched) { + console.error(`[MOCK] Incoming request: ${method} ${url}`) + console.error(`[MOCK] βœ… MATCHED interceptor: ${matched.method} ${matched.path} -> ${matched.data.statusCode}`) + } else if (error) { + console.error(`[MOCK] Incoming request: ${method} ${url}`) + console.error(`[MOCK] ❌ NO MATCH found for: ${method} ${opts.path}`) + } + } +} + function buildMockDispatch () { const agent = this[kMockAgent] const origin = this[kOrigin] @@ -365,9 +554,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 +601,18 @@ 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') + } + return mockOptions } } diff --git a/test/mock-agent-debugging.js b/test/mock-agent-debugging.js new file mode 100644 index 00000000000..d693acb8daa --- /dev/null +++ b/test/mock-agent-debugging.js @@ -0,0 +1,140 @@ +'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() + 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 + } + }) +}) diff --git a/types/mock-agent.d.ts b/types/mock-agent.d.ts index 330926be191..da8080d46b2 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) @@ -44,6 +65,10 @@ declare class MockAgent Date: Sat, 2 Aug 2025 11:44:06 +0200 Subject: [PATCH 3/8] feat: add compareRequest method for interceptor diff analysis MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add MockAgent.compareRequest() method that compares a request against an interceptor and returns detailed difference analysis: - Field-by-field comparison (path, method, body, headers) - Similarity scoring for each field - Overall match score calculation - Detailed difference objects with expected vs actual values This helps developers understand exactly why a request doesn't match a specific interceptor, making debugging more precise. πŸ€– Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude Signed-off-by: Matteo Collina --- lib/mock/mock-agent.js | 69 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/lib/mock/mock-agent.js b/lib/mock/mock-agent.js index 7f471731158..69e29e98d20 100644 --- a/lib/mock/mock-agent.js +++ b/lib/mock/mock-agent.js @@ -328,6 +328,75 @@ class MockAgent extends Dispatcher { 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 + } + } } module.exports = MockAgent From 188db3a75e202113c880880d7e7f812d47316ffc Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Sat, 2 Aug 2025 11:51:47 +0200 Subject: [PATCH 4/8] feat: complete MockAgent debugging improvements implementation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add comprehensive debugging features to complete the plan: **Interceptor Validation:** - Add MockScope.validate() method for interceptor configuration validation - Detect common issues like double slashes, unusual methods, conflicting settings - Provide detailed feedback with severity levels (warning/error) **Enhanced assertNoPendingInterceptors:** - Add showUnusedInterceptors option to show interceptors never called - Add showCallHistory option to include recent call history in errors - Add includeRequestDiff option for detailed request vs interceptor comparison **Smart Request Tracing:** - Enhanced tracing with closest match suggestions in basic mode - Show available interceptors when requests don't match - Intelligent similarity-based recommendations **Utility Functions:** - Export calculateStringSimilarity, findClosestMatches, buildEnhancedErrorMessage - Complete TypeScript definitions for all new methods and options - Comprehensive test coverage for all features All features maintain backward compatibility and follow existing code patterns. The implementation significantly improves MockAgent debugging experience with actionable insights when mocks don't work as expected. πŸ€– Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude Signed-off-by: Matteo Collina --- lib/mock/mock-agent.js | 70 +++++++++++++++++++++++++++++++--- lib/mock/mock-interceptor.js | 49 ++++++++++++++++++++++++ lib/mock/mock-utils.js | 48 +++++++++++++++++++++++- test/mock-agent-debugging.js | 73 ++++++++++++++++++++++++++++++++++++ types/mock-agent.d.ts | 14 +++++++ types/mock-interceptor.d.ts | 16 ++++++++ 6 files changed, 263 insertions(+), 7 deletions(-) diff --git a/lib/mock/mock-agent.js b/lib/mock/mock-agent.js index 69e29e98d20..bd8d38b3ecf 100644 --- a/lib/mock/mock-agent.js +++ b/lib/mock/mock-agent.js @@ -219,18 +219,76 @@ 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 () { 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 ab74263ff98..a3e1b8e854b 100644 --- a/lib/mock/mock-utils.js +++ b/lib/mock/mock-utils.js @@ -9,6 +9,7 @@ const { kGetNetConnect, kOptions } = require('./mock-symbols') +const { kClients } = require('../core/symbols') const { serializePathWithQuery } = require('../core/util') const { STATUS_CODES } = require('node:http') const { @@ -534,6 +535,23 @@ function traceRequest (agent, opts, origin, matched = null, error = null) { } } else if (error) { console.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) { + console.error(`[MOCK] Available interceptors (${availableInterceptors.length}):`) + availableInterceptors.slice(0, 3).forEach((interceptor, index) => { + console.error(` ${index + 1}. ${interceptor.method} ${interceptor.path}`) + }) + if (availableInterceptors.length > 3) { + console.error(` ... and ${availableInterceptors.length - 3} more`) + } + } + } + } } } else { if (matched) { @@ -542,6 +560,31 @@ function traceRequest (agent, opts, origin, matched = null, error = null) { } else if (error) { console.error(`[MOCK] Incoming request: ${method} ${url}`) console.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) { + console.error('[MOCK] Available interceptors:') + suggestions.forEach(suggestion => { + const { dispatch, reason } = suggestion + console.error(` - ${dispatch.method} ${dispatch.path} (${reason})`) + }) + } else if (availableInterceptors.length <= 3) { + console.error('[MOCK] Available interceptors:') + availableInterceptors.forEach(interceptor => { + console.error(` - ${interceptor.method} ${interceptor.path}`) + }) + } + } + } + } } } } @@ -633,5 +676,8 @@ module.exports = { buildAndValidateMockOptions, getHeaderByName, buildHeadersFromArray, - normalizeSearchParams + normalizeSearchParams, + calculateStringSimilarity, + findClosestMatches, + buildEnhancedErrorMessage } diff --git a/test/mock-agent-debugging.js b/test/mock-agent-debugging.js index d693acb8daa..7688589ce13 100644 --- a/test/mock-agent-debugging.js +++ b/test/mock-agent-debugging.js @@ -137,4 +137,77 @@ describe('MockAgent - Debugging Features', () => { 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/types/mock-agent.d.ts b/types/mock-agent.d.ts index da8080d46b2..9781f92ab94 100644 --- a/types/mock-agent.d.ts +++ b/types/mock-agent.d.ts @@ -64,11 +64,25 @@ declare class MockAgent; + score: number; + } } interface PendingInterceptorsFormatter { 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. */ From 2da62f4e4f20326bfde8248957e3bcfcc3e5499e Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Sat, 2 Aug 2025 12:13:24 +0200 Subject: [PATCH 5/8] feat: add console option to MockAgent tracing with comprehensive test coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add configurable console option to MockAgent for custom tracing output - Implement comprehensive test suite for all tracing scenarios (basic, verbose, custom console) - Fix enhanced error messages to be opt-in via verboseErrors option to maintain backward compatibility - Update TypeScript definitions to include console option - Add example demonstrating custom console usage for tracing - Ensure all 92 existing MockAgent tests continue to pass The console option allows developers to: - Capture tracing output in tests for assertions - Send logs to custom logging systems - Filter or format tracing messages - Test tracing behavior itself πŸ€– Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude Signed-off-by: Matteo Collina --- examples/mock-agent-console-tracing.js | 134 ++++++++ lib/mock/mock-utils.js | 77 ++--- test/mock-agent-debugging.js | 2 +- test/mock-agent-tracing.js | 409 +++++++++++++++++++++++++ types/mock-agent.d.ts | 3 + 5 files changed, 589 insertions(+), 36 deletions(-) create mode 100644 examples/mock-agent-console-tracing.js create mode 100644 test/mock-agent-tracing.js 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-utils.js b/lib/mock/mock-utils.js index a3e1b8e854b..06356446dd6 100644 --- a/lib/mock/mock-utils.js +++ b/lib/mock/mock-utils.js @@ -164,7 +164,7 @@ function getResponseData (data) { } } -function getMockDispatch (mockDispatches, key, origin) { +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 @@ -177,6 +177,8 @@ function getMockDispatch (mockDispatches, key, origin) { headers: key.headers } + const useEnhancedErrors = agent && agent[kOptions]?.verboseErrors + // Match path let matchedMockDispatches = mockDispatches .filter(({ consumed }) => !consumed) @@ -186,7 +188,7 @@ function getMockDispatch (mockDispatches, key, origin) { : matchValue(safeUrl(path), resolvedPath) }) if (matchedMockDispatches.length === 0) { - const message = origin + const message = (origin && useEnhancedErrors) ? buildEnhancedErrorMessage('path', request, mockDispatches, origin, resolvedPath) : `Mock dispatch not matched for path '${resolvedPath}'` throw new MockNotMatchedError(message) @@ -195,7 +197,7 @@ function getMockDispatch (mockDispatches, key, origin) { // Match method matchedMockDispatches = matchedMockDispatches.filter(({ method }) => matchValue(method, key.method)) if (matchedMockDispatches.length === 0) { - const message = origin + 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) @@ -204,7 +206,7 @@ function getMockDispatch (mockDispatches, key, origin) { // Match body matchedMockDispatches = matchedMockDispatches.filter(({ body }) => typeof body !== 'undefined' ? matchValue(body, key.body) : true) if (matchedMockDispatches.length === 0) { - const message = origin + 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) @@ -213,7 +215,7 @@ function getMockDispatch (mockDispatches, key, origin) { // Match headers matchedMockDispatches = matchedMockDispatches.filter((mockDispatch) => matchHeaders(mockDispatch, key.headers)) if (matchedMockDispatches.length === 0) { - const message = origin + 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) @@ -427,10 +429,10 @@ async function getResponse (body) { function mockDispatch (opts, handler) { // Get mock dispatch from built key const key = buildKey(opts) - const mockDispatch = getMockDispatch(this[kDispatches], key, this[kOrigin]) + const agent = this[kMockAgent] + const mockDispatch = getMockDispatch(this[kDispatches], key, this[kOrigin], agent) // Trace successful match - const agent = this[kMockAgent] if (agent && agent[kOptions]?.traceRequests) { traceRequest(agent, opts, this[kOrigin], mockDispatch) } @@ -506,35 +508,36 @@ 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') { - console.error('[MOCK] πŸ” Request received:') - console.error(` Method: ${method}`) - console.error(` URL: ${url}`) + 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 - console.error(` Headers: ${headersStr}`) + logger.error(` Headers: ${headersStr}`) } - console.error(` Body: ${opts.body || 'undefined'}`) - console.error('') + logger.error(` Body: ${opts.body || 'undefined'}`) + logger.error('') if (matched) { - console.error('[MOCK] πŸ”Ž Checking interceptors for origin \'' + origin + '\':') - console.error(` 1. Testing ${matched.method} ${matched.path}... βœ… MATCH!`) - console.error(` - Method: βœ… ${method} === ${matched.method}`) - console.error(` - Path: βœ… ${opts.path} === ${matched.path}`) - console.error(' - Headers: βœ… (no header constraints)') - console.error(' - Body: βœ… (no body constraints)') - console.error('') - console.error('[MOCK] βœ… Responding with:') - console.error(` Status: ${matched.data.statusCode}`) + 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) { - console.error(` Headers: ${JSON.stringify(matched.data.headers)}`) + logger.error(` Headers: ${JSON.stringify(matched.data.headers)}`) } } else if (error) { - console.error('[MOCK] ❌ NO MATCH found') + logger.error('[MOCK] ❌ NO MATCH found') // Show available interceptors in verbose mode if (agent[kClients] && agent[kClients].has(origin)) { @@ -542,12 +545,12 @@ function traceRequest (agent, opts, origin, matched = null, error = null) { if (dispatcher && dispatcher[kDispatches]) { const availableInterceptors = dispatcher[kDispatches].filter(d => !d.consumed) if (availableInterceptors.length > 0) { - console.error(`[MOCK] Available interceptors (${availableInterceptors.length}):`) + logger.error(`[MOCK] Available interceptors (${availableInterceptors.length}):`) availableInterceptors.slice(0, 3).forEach((interceptor, index) => { - console.error(` ${index + 1}. ${interceptor.method} ${interceptor.path}`) + logger.error(` ${index + 1}. ${interceptor.method} ${interceptor.path}`) }) if (availableInterceptors.length > 3) { - console.error(` ... and ${availableInterceptors.length - 3} more`) + logger.error(` ... and ${availableInterceptors.length - 3} more`) } } } @@ -555,11 +558,11 @@ function traceRequest (agent, opts, origin, matched = null, error = null) { } } else { if (matched) { - console.error(`[MOCK] Incoming request: ${method} ${url}`) - console.error(`[MOCK] βœ… MATCHED interceptor: ${matched.method} ${matched.path} -> ${matched.data.statusCode}`) + logger.error(`[MOCK] Incoming request: ${method} ${url}`) + logger.error(`[MOCK] βœ… MATCHED interceptor: ${matched.method} ${matched.path} -> ${matched.data.statusCode}`) } else if (error) { - console.error(`[MOCK] Incoming request: ${method} ${url}`) - console.error(`[MOCK] ❌ NO MATCH found for: ${method} ${opts.path}`) + 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)) { @@ -571,15 +574,15 @@ function traceRequest (agent, opts, origin, matched = null, error = null) { const suggestions = findClosestMatches(request, availableInterceptors, origin) if (suggestions.length > 0) { - console.error('[MOCK] Available interceptors:') + logger.error('[MOCK] Available interceptors:') suggestions.forEach(suggestion => { const { dispatch, reason } = suggestion - console.error(` - ${dispatch.method} ${dispatch.path} (${reason})`) + logger.error(` - ${dispatch.method} ${dispatch.path} (${reason})`) }) } else if (availableInterceptors.length <= 3) { - console.error('[MOCK] Available interceptors:') + logger.error('[MOCK] Available interceptors:') availableInterceptors.forEach(interceptor => { - console.error(` - ${interceptor.method} ${interceptor.path}`) + logger.error(` - ${interceptor.method} ${interceptor.path}`) }) } } @@ -656,6 +659,10 @@ function buildAndValidateMockOptions (opts) { 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 } } diff --git a/test/mock-agent-debugging.js b/test/mock-agent-debugging.js index 7688589ce13..efc2abf8c34 100644 --- a/test/mock-agent-debugging.js +++ b/test/mock-agent-debugging.js @@ -70,7 +70,7 @@ describe('MockAgent - Debugging Features', () => { test('should provide enhanced error messages with context', async t => { t = tspl(t, { plan: 3 }) - const mockAgent = new MockAgent() + const mockAgent = new MockAgent({ verboseErrors: true }) setGlobalDispatcher(mockAgent) mockAgent.disableNetConnect() 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/types/mock-agent.d.ts b/types/mock-agent.d.ts index 9781f92ab94..33b614a5958 100644 --- a/types/mock-agent.d.ts +++ b/types/mock-agent.d.ts @@ -112,5 +112,8 @@ declare namespace MockAgent { /** 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 }; } } From 0dc4c58c6f96e4596f26ce7d14391124e46bbf86 Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Sat, 2 Aug 2025 13:07:55 +0200 Subject: [PATCH 6/8] fix: update TypeScript tests for new MockAgent debugging features MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix assertNoPendingInterceptors signature to include new options - Add type tests for debug(), inspect(), and compareRequest() methods - Add type tests for new constructor options (traceRequests, developmentMode, verboseErrors, console) - Add type tests for MockScope.validate() method - Ensure all TypeScript tests pass with proper type coverage πŸ€– Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude Signed-off-by: Matteo Collina --- test/types/mock-agent.test-d.ts | 82 ++++++++++++++++++++++++++- test/types/mock-interceptor.test-d.ts | 17 ++++++ 2 files changed, 98 insertions(+), 1 deletion(-) 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()) } { From 068878ac15b6f2e04ed1a1d6c8fdc896f5d10cd4 Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Sat, 2 Aug 2025 15:58:49 +0200 Subject: [PATCH 7/8] fixup Signed-off-by: Matteo Collina --- test/snapshot-testing.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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, From e81e8cd8621305040e4afc87c8df9622ca0ebd13 Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Sun, 3 Aug 2025 12:58:27 +0200 Subject: [PATCH 8/8] docs: add comprehensive MockAgent debugging features documentation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add detailed documentation for the new MockAgent debugging capabilities: - New constructor options: traceRequests, developmentMode, verboseErrors, console - Request tracing with basic and verbose modes - Enhanced error messages with interceptor context - debug() and inspect() methods for state inspection - compareRequest() method for interceptor analysis - Enhanced assertNoPendingInterceptors() options - Custom console support for testing - Development mode for streamlined debugging setup πŸ€– Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude Signed-off-by: Matteo Collina --- docs/docs/api/MockAgent.md | 298 +++++++++++++++++++++++++++++++++++++ 1 file changed, 298 insertions(+) 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 +} +```