diff --git a/lib/mock/snapshot-agent.js b/lib/mock/snapshot-agent.js index 31faebc38ca..80280111921 100644 --- a/lib/mock/snapshot-agent.js +++ b/lib/mock/snapshot-agent.js @@ -64,7 +64,9 @@ class SnapshotAgent extends MockAgent { this[kSnapshotLoaded] = false // For recording/update mode, we need a real agent to make actual requests - if (this[kSnapshotMode] === 'record' || this[kSnapshotMode] === 'update') { + // For playback mode, we need a real agent if there are excluded URLs + if (this[kSnapshotMode] === 'record' || this[kSnapshotMode] === 'update' || + (this[kSnapshotMode] === 'playback' && opts.excludeUrls && opts.excludeUrls.length > 0)) { this[kRealAgent] = new Agent(opts) } @@ -80,6 +82,12 @@ class SnapshotAgent extends MockAgent { handler = WrapHandler.wrap(handler) const mode = this[kSnapshotMode] + // Check if URL should be excluded (pass through without mocking/recording) + if (this[kSnapshotRecorder].isUrlExcluded(opts)) { + // Real agent is guaranteed by constructor when excludeUrls is configured + return this[kRealAgent].dispatch(opts, handler) + } + if (mode === 'playback' || mode === 'update') { // Ensure snapshots are loaded if (!this[kSnapshotLoaded]) { diff --git a/lib/mock/snapshot-recorder.js b/lib/mock/snapshot-recorder.js index e810fe79507..b5d07fae381 100644 --- a/lib/mock/snapshot-recorder.js +++ b/lib/mock/snapshot-recorder.js @@ -283,8 +283,7 @@ class SnapshotRecorder { } // Check URL exclusion patterns - const url = new URL(requestOpts.path, requestOpts.origin).toString() - if (this.#isUrlExcluded(url)) { + if (this.isUrlExcluded(requestOpts)) { return // Skip recording } @@ -330,6 +329,16 @@ class SnapshotRecorder { } } + /** + * Checks if a URL should be excluded from recording/playback + * @param {SnapshotRequestOptions} requestOpts - Request options to check + * @returns {boolean} - True if URL is excluded + */ + isUrlExcluded (requestOpts) { + const url = new URL(requestOpts.path, requestOpts.origin).toString() + return this.#isUrlExcluded(url) + } + /** * Finds a matching snapshot for the given request * Returns the appropriate response based on call count for sequential responses @@ -344,8 +353,7 @@ class SnapshotRecorder { } // Check URL exclusion patterns - const url = new URL(requestOpts.path, requestOpts.origin).toString() - if (this.#isUrlExcluded(url)) { + if (this.isUrlExcluded(requestOpts)) { return undefined // Skip playback } diff --git a/test/snapshot-testing.js b/test/snapshot-testing.js index c81620d38ea..10f8439f51e 100644 --- a/test/snapshot-testing.js +++ b/test/snapshot-testing.js @@ -1293,6 +1293,63 @@ describe('SnapshotAgent - Filtering', () => { assert.strictEqual(snapshots[0].request.method, 'GET', 'Recorded request should have GET method') }) + + it('excluded URLs should not error in playback mode', async (t) => { + const server = createTestServer((req, res) => { + res.writeHead(200, { 'content-type': 'text/plain' }) + res.end(`Response from ${req.url}`) + }) + + const { origin } = await setupServer(server) + const snapshotPath = createSnapshotPath('exclude-playback-bug') + + setupCleanup(t, { server, snapshotPath }) + + // Record mode: record one request, exclude another + const recordingAgent = new SnapshotAgent({ + mode: 'record', + snapshotPath, + excludeUrls: [`${origin}/excluded`] + }) + + const originalDispatcher = getGlobalDispatcher() + setupCleanup(t, { agent: recordingAgent, originalDispatcher }) + setGlobalDispatcher(recordingAgent) + + // Request to included endpoint - should be recorded + const res1 = await request(`${origin}/included`) + await res1.body.text() + + // Request to excluded endpoint - should NOT be recorded + const res2 = await request(`${origin}/excluded`) + await res2.body.text() + + await recordingAgent.saveSnapshots() + + const recorder = recordingAgent.getRecorder() + assert.strictEqual(recorder.size(), 1, 'Should have recorded only the included request') + + // Playback mode: should allow excluded URL to pass through without error + const playbackAgent = new SnapshotAgent({ + mode: 'playback', + snapshotPath, + excludeUrls: [`${origin}/excluded`] + }) + + setupCleanup(t, { agent: playbackAgent }) + setGlobalDispatcher(playbackAgent) + + // This should work - replays from snapshot + const res3 = await request(`${origin}/included`) + await res3.body.text() + assert.strictEqual(res3.statusCode, 200, 'Included request should replay successfully') + + // Excluded URL should pass through to real server + const res4 = await request(`${origin}/excluded`) + const body4 = await res4.body.text() + assert.strictEqual(res4.statusCode, 200, 'Excluded request should pass through to real server') + assert.strictEqual(body4, 'Response from /excluded', 'Should get live response from server') + }) }) describe('SnapshotAgent - Close Method', () => {