Skip to content
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
57c0103
feat: add SnapshotAgent for HTTP request recording and playback
mcollina Jun 8, 2025
a90fe15
Merge branch 'main' into feature/snapshot-testing
mcollina Jul 19, 2025
8084eac
feat: implement Phase 1 of SnapshotAgent enhancements
mcollina Jul 19, 2025
4401261
docs: update PLAN.md to reflect Phase 1 completion
mcollina Jul 19, 2025
65e53a1
feat: implement Phase 2 - Enhanced Request Matching
mcollina Jul 19, 2025
0e8046e
feat: implement Phase 3 - Advanced Playback Features for SnapshotAgent
mcollina Jul 19, 2025
0a50c9b
docs: update PLAN.md to reflect completion of all primary objectives
mcollina Jul 19, 2025
f379bab
feat: update TypeScript definitions and add comprehensive tsd tests
mcollina Jul 19, 2025
0372422
Merge remote-tracking branch 'origin/main' into feature/snapshot-testing
mcollina Jul 20, 2025
ffaaa48
feat: implement Phase 4 optional enhancements for SnapshotAgent
mcollina Jul 20, 2025
ac43299
fix: resolve flaky sequential response test
mcollina Jul 20, 2025
d40eaf0
chore: remove PLAN.md file
mcollina Jul 20, 2025
83c4f7e
removed PR_DESCRIPTION.md
mcollina Jul 24, 2025
4c1d77c
docs: update snapshot agent documentation and implementation
mcollina Jul 28, 2025
559d936
fixup
mcollina Jul 29, 2025
01fdcc0
test: add redirect interceptor integration test and fix race condition
mcollina Jul 29, 2025
fe03c68
fix: make SnapshotAgent work properly with redirect interceptor
mcollina Jul 29, 2025
d7d9024
fix: complete SnapshotAgent redirect interceptor integration
mcollina Jul 29, 2025
27e1c49
fixup
mcollina Jul 29, 2025
2664559
fixup
mcollina Jul 29, 2025
a9d7cb7
fix: clean up console.logs and improve SnapshotAgent experimental war…
mcollina Jul 29, 2025
c968233
test: add test case for SnapshotAgent with pre-existing array responses
mcollina Jul 29, 2025
5db08e8
docs: simplify snapshot testing example to single working demo
mcollina Jul 29, 2025
14a70bb
remove spurious console.error
mcollina Jul 30, 2025
f61bb5a
clean: remove phase mentions and fix t.after() placement
mcollina Jul 30, 2025
3b460af
refactor: convert snapshot tests to use describe blocks and top-level…
mcollina Jul 30, 2025
4edfca0
fix: ensure agent.close() method is always awaited in tests
mcollina Jul 31, 2025
4cc196c
feat: add async close() method to SnapshotRecorder that saves recordings
mcollina Jul 31, 2025
f3945d4
fixup
mcollina Jul 31, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
405 changes: 405 additions & 0 deletions docs/docs/api/SnapshotAgent.md

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions docs/docsify/sidebar.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
* [MockClient](/docs/api/MockClient.md "Undici API - MockClient")
* [MockPool](/docs/api/MockPool.md "Undici API - MockPool")
* [MockAgent](/docs/api/MockAgent.md "Undici API - MockAgent")
* [SnapshotAgent](/docs/api/SnapshotAgent.md "Undici API - SnapshotAgent")
* [MockCallHistory](/docs/api/MockCallHistory.md "Undici API - MockCallHistory")
* [MockCallHistoryLog](/docs/api/MockCallHistoryLog.md "Undici API - MockCallHistoryLog")
* [MockErrors](/docs/api/MockErrors.md "Undici API - MockErrors")
Expand Down
257 changes: 257 additions & 0 deletions docs/examples/snapshot-testing.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,257 @@
const { SnapshotAgent, setGlobalDispatcher, getGlobalDispatcher, fetch } = require('../../index.js')
const { test } = require('node:test')
const assert = require('node:assert')

/**
* Example: Snapshot Testing with External APIs
*
* This example demonstrates how to use SnapshotAgent to record real API
* interactions and replay them in tests for consistent, offline testing.
*/

// Example 1: Recording API interactions
async function recordApiInteractions () {
console.log('📹 Recording API interactions...')

const agent = new SnapshotAgent({
mode: 'record',
snapshotPath: './examples/snapshots/github-api.json'
})

const originalDispatcher = getGlobalDispatcher()
setGlobalDispatcher(agent)

try {
// Record interactions with GitHub API
const response = await fetch('https://api.github.com/repos/nodejs/undici')
const repo = await response.json()

console.log(`✅ Recorded response for ${repo.full_name}`)
console.log(` Stars: ${repo.stargazers_count}`)
console.log(` Language: ${repo.language}`)

// Save the snapshots
await agent.saveSnapshots()
console.log('💾 Snapshots saved successfully')
} finally {
setGlobalDispatcher(originalDispatcher)
}
}

// Example 2: Using snapshots in tests
test('GitHub API integration test', async (t) => {
console.log('🧪 Running test with recorded snapshots...')

const agent = new SnapshotAgent({
mode: 'playback',
snapshotPath: './examples/snapshots/github-api.json'
})

const originalDispatcher = getGlobalDispatcher()
setGlobalDispatcher(agent)
t.after(() => setGlobalDispatcher(originalDispatcher))

// This will use the recorded response instead of making a real request
const response = await fetch('https://api.github.com/repos/nodejs/undici')
const repo = await response.json()

// Test the recorded data
assert.strictEqual(repo.name, 'undici')
assert.strictEqual(repo.owner.login, 'nodejs')
assert.strictEqual(typeof repo.stargazers_count, 'number')
assert(repo.stargazers_count > 0)

console.log('✅ Test passed using recorded data')
})

// Example 3: POST request with body
async function recordPostRequest () {
console.log('📹 Recording POST request...')

const agent = new SnapshotAgent({
mode: 'record',
snapshotPath: './examples/snapshots/post-example.json'
})

const originalDispatcher = getGlobalDispatcher()
setGlobalDispatcher(agent)

try {
// Record a POST request to JSONPlaceholder API
const response = await fetch('https://jsonplaceholder.typicode.com/posts', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
title: 'Test Post',
body: 'This is a test post created by undici snapshot testing',
userId: 1
})
})

const createdPost = await response.json()
console.log(`✅ Recorded POST response - Created post ID: ${createdPost.id}`)

await agent.saveSnapshots()
} finally {
setGlobalDispatcher(originalDispatcher)
}
}

// Example 4: Update mode (record new, use existing)
async function demonstrateUpdateMode () {
console.log('🔄 Demonstrating update mode...')

const agent = new SnapshotAgent({
mode: 'update',
snapshotPath: './examples/snapshots/update-example.json'
})

const originalDispatcher = getGlobalDispatcher()
setGlobalDispatcher(agent)

try {
// First request - will be recorded if not exists
const response1 = await fetch('https://httpbin.org/uuid')
const uuid1 = await response1.json()
console.log(`Request 1 UUID: ${uuid1.uuid}`)

// Second request to same endpoint - will use recorded response
const response2 = await fetch('https://httpbin.org/uuid')
const uuid2 = await response2.json()
console.log(`Request 2 UUID: ${uuid2.uuid}`)

// They should be the same because the second uses the snapshot
console.log(`UUIDs match: ${uuid1.uuid === uuid2.uuid}`)

await agent.saveSnapshots()
} finally {
setGlobalDispatcher(originalDispatcher)
}
}

// Example 5: Error handling
test('snapshot error handling', async (t) => {
console.log('🚨 Testing error handling...')

const agent = new SnapshotAgent({
mode: 'playback',
snapshotPath: './examples/snapshots/nonexistent.json'
})

const originalDispatcher = getGlobalDispatcher()
setGlobalDispatcher(agent)
t.after(() => setGlobalDispatcher(originalDispatcher))

try {
await fetch('https://api.example.com/not-recorded')
assert.fail('Should have thrown an error')
} catch (error) {
assert(error.message.includes('No snapshot found'))
console.log('✅ Correctly threw error for missing snapshot')
}
})

// Example 6: Working with different request methods
async function recordMultipleRequestTypes () {
console.log('📹 Recording multiple request types...')

const agent = new SnapshotAgent({
mode: 'record',
snapshotPath: './examples/snapshots/http-methods.json'
})

const originalDispatcher = getGlobalDispatcher()
setGlobalDispatcher(agent)

try {
const baseUrl = 'https://httpbin.org'

// GET request
const getResponse = await fetch(`${baseUrl}/get?param=value`)
const getData = await getResponse.json()
console.log(`✅ Recorded GET: ${getData.url}`)

// POST request
const postResponse = await fetch(`${baseUrl}/post`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ test: 'data' })
})
const postData = await postResponse.json()
console.log(`✅ Recorded POST: ${postData.url}`)

// PUT request
const putResponse = await fetch(`${baseUrl}/put`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ update: 'value' })
})
const putData = await putResponse.json()
console.log(`✅ Recorded PUT: ${putData.url}`)

await agent.saveSnapshots()
console.log('💾 All HTTP methods recorded')
} finally {
setGlobalDispatcher(originalDispatcher)
}
}

// Helper function to create snapshot directory
async function ensureSnapshotDirectory () {
const { mkdir } = require('node:fs/promises')
try {
await mkdir('./examples/snapshots', { recursive: true })
} catch (error) {
// Directory might already exist
}
}

// Main execution
async function main () {
console.log('🚀 Undici SnapshotAgent Examples\n')

await ensureSnapshotDirectory()

// Only record if we're in record mode
if (process.env.SNAPSHOT_MODE === 'record') {
console.log('🔴 RECORD MODE - Making real API calls\n')

try {
await recordApiInteractions()
console.log()

await recordPostRequest()
console.log()

await recordMultipleRequestTypes()
console.log()
} catch (error) {
console.error('❌ Error during recording:', error.message)
}
}

// Always run the demonstrations
console.log('▶️ Running demonstrations and tests\n')

try {
await demonstrateUpdateMode()
console.log()
} catch (error) {
console.error('❌ Error during demonstrations:', error.message)
}
}

// Export for testing
module.exports = {
recordApiInteractions,
recordPostRequest,
demonstrateUpdateMode,
recordMultipleRequestTypes
}

// Run if called directly
if (require.main === module) {
main().catch(console.error)
}
2 changes: 2 additions & 0 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const MockClient = require('./lib/mock/mock-client')
const { MockCallHistory, MockCallHistoryLog } = require('./lib/mock/mock-call-history')
const MockAgent = require('./lib/mock/mock-agent')
const MockPool = require('./lib/mock/mock-pool')
const SnapshotAgent = require('./lib/mock/snapshot-agent')
const mockErrors = require('./lib/mock/mock-errors')
const RetryHandler = require('./lib/handler/retry-handler')
const { getGlobalDispatcher, setGlobalDispatcher } = require('./lib/global')
Expand Down Expand Up @@ -178,6 +179,7 @@ module.exports.MockCallHistory = MockCallHistory
module.exports.MockCallHistoryLog = MockCallHistoryLog
module.exports.MockPool = MockPool
module.exports.MockAgent = MockAgent
module.exports.SnapshotAgent = SnapshotAgent
module.exports.mockErrors = mockErrors

const { EventSource } = require('./lib/web/eventsource/eventsource')
Expand Down
Loading
Loading