Record network traffic to HAR files during test execution, then play back from disk for offline testing. Enables frontend tests to run in complete isolation from backend services with intelligent stateful CRUD detection for realistic API behavior.
Traditional E2E tests require live backend services:
HAR-based recording/playback provides:
// Set mode to 'record' to capture network traffic
process.env.PW_NET_MODE = 'record';
test('should add, edit and delete a movie', async ({ page, context, networkRecorder }) => {
// Setup network recorder - it will record all network traffic
await networkRecorder.setup(context);
// Your normal test code
await page.goto('/');
await page.fill('#movie-name', 'Inception');
await page.click('#add-movie');
// Network traffic is automatically saved to HAR file
});
// Set mode to 'playback' to use recorded traffic
process.env.PW_NET_MODE = 'playback';
test('should add, edit and delete a movie', async ({ page, context, networkRecorder }) => {
// Setup network recorder - it will replay from HAR file
await networkRecorder.setup(context);
// Same test code runs without hitting real backend!
await page.goto('/');
await page.fill('#movie-name', 'Inception');
await page.click('#add-movie');
});
That's it! Your tests now run completely offline using recorded network traffic.
Context: The fundamental pattern - record traffic once, play back for all subsequent runs.
Implementation:
import { test } from '@seontechnologies/playwright-utils/network-recorder/fixtures';
// Set mode in test file (recommended)
process.env.PW_NET_MODE = 'playback'; // or 'record'
test('CRUD operations work offline', async ({ page, context, networkRecorder }) => {
// Setup recorder (records or plays back based on PW_NET_MODE)
await networkRecorder.setup(context);
await page.goto('/');
// First time (record mode): Records all network traffic to HAR
// Subsequent runs (playback mode): Plays back from HAR (no backend!)
await page.fill('#movie-name', 'Inception');
await page.click('#add-movie');
// Intelligent CRUD detection makes this work offline!
await expect(page.getByText('Inception')).toBeVisible();
});
Key Points:
PW_NET_MODE=record captures traffic to HAR filesPW_NET_MODE=playback replays from HAR filesContext: Full create-read-update-delete flow that works completely offline.
Implementation:
process.env.PW_NET_MODE = 'playback';
test.describe('Movie CRUD - offline with network recorder', () => {
test.beforeEach(async ({ page, networkRecorder, context }) => {
await networkRecorder.setup(context);
await page.goto('/');
});
test('should add, edit, delete movie browser-only', async ({ page, interceptNetworkCall }) => {
// Create
await page.fill('#movie-name', 'Inception');
await page.fill('#year', '2010');
await page.click('#add-movie');
// Verify create (reads from stateful HAR)
await expect(page.getByText('Inception')).toBeVisible();
// Update
await page.getByText('Inception').click();
await page.fill('#movie-name', "Inception Director's Cut");
const updateCall = interceptNetworkCall({
method: 'PUT',
url: '/movies/*',
});
await page.click('#save');
await updateCall; // Wait for update
// Verify update (HAR reflects state change!)
await page.click('#back');
await expect(page.getByText("Inception Director's Cut")).toBeVisible();
// Delete
await page.click(`[data-testid="delete-Inception Director's Cut"]`);
// Verify delete (HAR reflects removal!)
await expect(page.getByText("Inception Director's Cut")).not.toBeVisible();
});
});
Key Points:
interceptNetworkCall for deterministic waitsRecording Only API Calls:
await networkRecorder.setup(context, {
recording: {
urlFilter: /\/api\// // Only record API calls, ignore static assets
}
});
Playback with Fallback:
await networkRecorder.setup(context, {
playback: {
fallback: true // Fall back to live requests if HAR entry missing
}
});
Custom HAR File Location:
await networkRecorder.setup(context, {
harFile: {
harDir: 'recordings/api-calls',
baseName: 'user-journey',
organizeByTestFile: false // Optional: flatten directory structure
}
});
Directory Organization:
organizeByTestFile: true (default): har-files/test-file-name/baseName-test-title.harorganizeByTestFile: false: har-files/baseName-test-title.harContext: Choose how response content is stored in HAR files.
embed (Default - Recommended):
await networkRecorder.setup(context, {
recording: {
content: 'embed' // Store content inline (default)
}
});
Pros:
Cons:
attach (Alternative):
await networkRecorder.setup(context, {
recording: {
content: 'attach' // Store content separately
}
});
Pros:
Cons:
When to Use Each:
Use embed (default) when |
Use attach when |
|---|---|
| Recording API responses (JSON, XML) | Recording large images, videos |
| Small to medium HTML pages | HAR file size >50MB |
| You want a single, portable file | Maximum disk efficiency needed |
| Sharing HAR files with team | Working with ZIP archive output |
Context: Record in dev environment, play back in CI with different base URLs.
The Problem: HAR files contain URLs for the recording environment (e.g., dev.example.com). Playing back on a different environment fails.
Simple Hostname Mapping:
await networkRecorder.setup(context, {
playback: {
urlMapping: {
hostMapping: {
'preview.example.com': 'dev.example.com',
'staging.example.com': 'dev.example.com',
'localhost:3000': 'dev.example.com'
}
}
}
});
Pattern-Based Mapping (Recommended):
await networkRecorder.setup(context, {
playback: {
urlMapping: {
patterns: [
// Map any preview-XXXX subdomain to dev
{ match: /preview-\d+\.example\.com/, replace: 'dev.example.com' }
]
}
}
});
Custom Function:
await networkRecorder.setup(context, {
playback: {
urlMapping: {
mapUrl: (url) => url.replace('staging.example.com', 'dev.example.com')
}
}
});
Complex Multi-Environment Example:
await networkRecorder.setup(context, {
playback: {
urlMapping: {
hostMapping: {
'localhost:3000': 'admin.seondev.space',
'admin-staging.seon.io': 'admin.seondev.space',
'admin.seon.io': 'admin.seondev.space',
},
patterns: [
{ match: /admin-\d+\.seondev\.space/, replace: 'admin.seondev.space' },
{ match: /admin-staging-pr-\w+-\d\.seon\.io/, replace: 'admin.seondev.space' }
]
}
}
});
Benefits:
LOG_LEVEL=debug npm run testNative Playwright (routeFromHAR) |
network-recorder Utility |
|---|---|
| ~80 lines setup boilerplate | ~5 lines total |
| Manual HAR file management | Automatic file organization |
| Complex setup/teardown | Automatic cleanup via fixtures |
| Read-only tests only | Full CRUD support |
| Stateless | Stateful mocking |
| Manual URL mapping | Automatic environment mapping |
The game-changer: Stateful CRUD detection
Native Playwright HAR playback is stateless - a POST create followed by GET list won't show the created item. This utility intelligently tracks CRUD operations in memory to reflect state changes, making offline tests behave like real APIs.
When in playback mode, the Network Recorder automatically analyzes your HAR file to detect CRUD patterns. If it finds:
/movies)It automatically switches from static HAR playback to an intelligent stateful mock that:
This happens automatically - no configuration needed!
| Method | Return Type | Description |
|---|---|---|
setup(context) |
Promise<void> |
Sets up recording/playback on browser context |
cleanup() |
Promise<void> |
Flushes data to disk and cleans up memory |
getContext() |
NetworkRecorderContext |
Gets current recorder context information |
getStatusMessage() |
string |
Gets human-readable status message |
getHarStats() |
Promise<HarFileStats> |
Gets HAR file statistics and metadata |
cleanup()The cleanup() method performs memory and resource cleanup - it does NOT delete HAR files:
What it does:
What it does NOT do:
type NetworkRecorderConfig = {
harFile?: {
harDir?: string // Directory for HAR files (default: 'har-files')
baseName?: string // Base name for HAR files (default: 'network-traffic')
organizeByTestFile?: boolean // Organize by test file (default: true)
}
recording?: {
content?: 'embed' | 'attach' // Response content handling (default: 'embed')
urlFilter?: string | RegExp // URL filter for recording
update?: boolean // Update existing HAR files (default: false)
}
playback?: {
fallback?: boolean // Fall back to live requests (default: false)
urlFilter?: string | RegExp // URL filter for playback
updateMode?: boolean // Update mode during playback (default: false)
}
forceMode?: 'record' | 'playback' | 'disabled'
}
Control the recording mode using the PW_NET_MODE environment variable:
# Record mode - captures network traffic to HAR files
PW_NET_MODE=record npm run test:pw
# Playback mode - replays network traffic from HAR files
PW_NET_MODE=playback npm run test:pw
# Disabled mode - no network recording/playback
PW_NET_MODE=disabled npm run test:pw
# Default behavior (when PW_NET_MODE is empty/unset) - same as disabled
npm run test:pw
Tip: We recommend setting process.env.PW_NET_MODE directly in your test file for better control.
If you see "HAR file not found" errors during playback:
PW_NET_MODE=recordhar-files/)playback: { fallback: true }The network recorder works seamlessly with authentication:
test('Authenticated recording', async ({ page, context, authSession, networkRecorder }) => {
// First authenticate
await authSession.login('testuser', 'password');
// Then setup network recording with authenticated context
await networkRecorder.setup(context);
// Test authenticated flows
await page.goto('/dashboard');
});
The recorder includes built-in file locking for safe parallel execution. Each test gets its own HAR file based on the test name.
With interceptNetworkCall (deterministic waits):
test('use both utilities', async ({ page, context, networkRecorder, interceptNetworkCall }) => {
await networkRecorder.setup(context);
const createCall = interceptNetworkCall({
method: 'POST',
url: '/api/movies',
});
await page.click('#add-movie');
await createCall; // Wait for create (works with HAR!)
// Network recorder provides playback, intercept provides determinism
});
overview.md - Installation and fixture patternsintercept-network-call.md - Combine for deterministic offline testsauth-session.md - Record authenticated trafficnetwork-first.md - Core pattern for intercept-before-navigateDON'T mix record and playback in same test:
process.env.PW_NET_MODE = 'record';
// ... some test code ...
process.env.PW_NET_MODE = 'playback'; // Don't switch mid-test
DO use one mode per test:
process.env.PW_NET_MODE = 'playback'; // Set once at top
test('my test', async ({ page, context, networkRecorder }) => {
await networkRecorder.setup(context);
// Entire test uses playback mode
});
DON'T forget to call setup:
test('broken', async ({ page, networkRecorder }) => {
await page.goto('/'); // HAR not active!
});
DO always call setup before navigation:
test('correct', async ({ page, context, networkRecorder }) => {
await networkRecorder.setup(context); // Must setup first
await page.goto('/'); // Now HAR is active
});