- Fix ChatBubble to handle non-string content with String() wrapper - Fix API route to use generateText for non-streaming requests - Add @ai-sdk/openai-compatible for non-OpenAI providers (DeepSeek, etc.) - Use Chat Completions API instead of Responses API for compatible providers - Update ChatBubble tests and fix component exports to kebab-case - Remove stale PascalCase ChatBubble.tsx file
13 KiB
Intercept Network Call Utility
Principle
Intercept network requests with a single declarative call that returns a Promise. Automatically parse JSON responses, support both spy (observe) and stub (mock) patterns, and use powerful glob pattern matching for URL filtering.
Rationale
Vanilla Playwright's network interception requires multiple steps:
page.route()to setup,page.waitForResponse()to capture- Manual JSON parsing
- Verbose syntax for conditional handling
- Complex filter predicates
The interceptNetworkCall utility provides:
- Single declarative call: Setup and wait in one statement
- Automatic JSON parsing: Response pre-parsed, strongly typed
- Flexible URL patterns: Glob matching with picomatch
- Spy or stub modes: Observe real traffic or mock responses
- Concise API: Reduces boilerplate by 60-70%
Pattern Examples
Example 1: Spy on Network (Observe Real Traffic)
Context: Capture and inspect real API responses for validation.
Implementation:
import { test } from '@seontechnologies/playwright-utils/intercept-network-call/fixtures';
test('should spy on users API', async ({ page, interceptNetworkCall }) => {
// Setup interception BEFORE navigation
const usersCall = interceptNetworkCall({
url: '**/api/users', // Glob pattern
});
await page.goto('/dashboard');
// Wait for response and access parsed data
const { responseJson, status } = await usersCall;
expect(status).toBe(200);
expect(responseJson).toHaveLength(10);
expect(responseJson[0]).toHaveProperty('name');
});
Key Points:
- Intercept before navigation (critical for race-free tests)
- Returns Promise with
{ responseJson, status, requestBody } - Glob patterns (
**matches any path segment) - JSON automatically parsed
Example 2: Stub Network (Mock Response)
Context: Mock API responses for testing UI behavior without backend.
Implementation:
test('should stub users API', async ({ page, interceptNetworkCall }) => {
const mockUsers = [
{ id: 1, name: 'Test User 1' },
{ id: 2, name: 'Test User 2' },
];
const usersCall = interceptNetworkCall({
url: '**/api/users',
fulfillResponse: {
status: 200,
body: mockUsers,
},
});
await page.goto('/dashboard');
await usersCall;
// UI shows mocked data
await expect(page.getByText('Test User 1')).toBeVisible();
await expect(page.getByText('Test User 2')).toBeVisible();
});
Key Points:
fulfillResponsemocks the API- No backend needed
- Test UI logic in isolation
- Status code and body fully controllable
Example 3: Conditional Response Handling
Context: Different responses based on request method or parameters.
Implementation:
test('conditional mocking', async ({ page, interceptNetworkCall }) => {
await interceptNetworkCall({
url: '**/api/data',
handler: async (route, request) => {
if (request.method() === 'POST') {
// Mock POST success
await route.fulfill({
status: 201,
body: JSON.stringify({ id: 'new-id', success: true }),
});
} else if (request.method() === 'GET') {
// Mock GET with data
await route.fulfill({
status: 200,
body: JSON.stringify([{ id: 1, name: 'Item' }]),
});
} else {
// Let other methods through
await route.continue();
}
},
});
await page.goto('/data-page');
});
Key Points:
handlerfunction for complex logic- Access full
routeandrequestobjects - Can mock, continue, or abort
- Flexible for advanced scenarios
Example 4: Error Simulation
Context: Testing error handling in UI when API fails.
Implementation:
test('should handle API errors gracefully', async ({ page, interceptNetworkCall }) => {
// Simulate 500 error
const errorCall = interceptNetworkCall({
url: '**/api/users',
fulfillResponse: {
status: 500,
body: { error: 'Internal Server Error' },
},
});
await page.goto('/dashboard');
await errorCall;
// Verify UI shows error state
await expect(page.getByText('Failed to load users')).toBeVisible();
await expect(page.getByTestId('retry-button')).toBeVisible();
});
// Simulate network timeout
test('should handle timeout', async ({ page, interceptNetworkCall }) => {
await interceptNetworkCall({
url: '**/api/slow',
handler: async (route) => {
// Never respond - simulates timeout
await new Promise(() => {});
},
});
await page.goto('/slow-page');
// UI should show timeout error
await expect(page.getByText('Request timed out')).toBeVisible({ timeout: 10000 });
});
Key Points:
- Mock error statuses (4xx, 5xx)
- Test timeout scenarios
- Validate error UI states
- No real failures needed
Example 5: Order Matters - Intercept Before Navigate
Context: The interceptor must be set up before the network request occurs.
Implementation:
// INCORRECT - interceptor set up too late
await page.goto('https://example.com'); // Request already happened
const networkCall = interceptNetworkCall({ url: '**/api/data' });
await networkCall; // Will hang indefinitely!
// CORRECT - Set up interception first
const networkCall = interceptNetworkCall({ url: '**/api/data' });
await page.goto('https://example.com');
const result = await networkCall;
This pattern follows the classic test spy/stub pattern:
- Define the spy/stub (set up interception)
- Perform the action (trigger the network request)
- Assert on the spy/stub (await and verify the response)
Example 6: Multiple Intercepts
Context: Intercepting different endpoints in same test - setup order is critical.
Implementation:
test('multiple intercepts', async ({ page, interceptNetworkCall }) => {
// Setup all intercepts BEFORE navigation
const usersCall = interceptNetworkCall({ url: '**/api/users' });
const productsCall = interceptNetworkCall({ url: '**/api/products' });
const ordersCall = interceptNetworkCall({ url: '**/api/orders' });
// THEN navigate
await page.goto('/dashboard');
// Wait for all (or specific ones)
const [users, products] = await Promise.all([usersCall, productsCall]);
expect(users.responseJson).toHaveLength(10);
expect(products.responseJson).toHaveLength(50);
});
Key Points:
- Setup all intercepts before triggering actions
- Use
Promise.all()to wait for multiple calls - Order: intercept -> navigate -> await
- Prevents race conditions
Example 7: Capturing Multiple Requests to the Same Endpoint
Context: Each interceptNetworkCall captures only the first matching request.
Implementation:
// Capturing a known number of requests
const firstRequest = interceptNetworkCall({ url: '/api/data' });
const secondRequest = interceptNetworkCall({ url: '/api/data' });
await page.click('#load-data-button');
const firstResponse = await firstRequest;
const secondResponse = await secondRequest;
expect(firstResponse.status).toBe(200);
expect(secondResponse.status).toBe(200);
// Handling an unknown number of requests
const getDataRequestInterceptor = () =>
interceptNetworkCall({
url: '/api/data',
timeout: 1000, // Short timeout to detect when no more requests are coming
});
let currentInterceptor = getDataRequestInterceptor();
const allResponses = [];
await page.click('#load-multiple-data-button');
while (true) {
try {
const response = await currentInterceptor;
allResponses.push(response);
currentInterceptor = getDataRequestInterceptor();
} catch (error) {
// No more requests (timeout)
break;
}
}
console.log(`Captured ${allResponses.length} requests to /api/data`);
Example 8: Using Timeout
Context: Set a timeout for waiting on a network request.
Implementation:
const dataCall = interceptNetworkCall({
method: 'GET',
url: '/api/data-that-might-be-slow',
timeout: 5000, // 5 seconds timeout
});
await page.goto('/data-page');
try {
const { responseJson } = await dataCall;
console.log('Data loaded successfully:', responseJson);
} catch (error) {
if (error.message.includes('timeout')) {
console.log('Request timed out as expected');
} else {
throw error;
}
}
URL Pattern Matching
The utility uses picomatch for powerful glob pattern matching, dramatically simplifying URL targeting:
Supported glob patterns:
'**/api/users'; // Any path ending with /api/users
'/api/users'; // Exact match
'**/users/*'; // Any users sub-path
'**/api/{users,products}'; // Either users or products
'**/api/users?id=*'; // With query params
Comparison with vanilla Playwright:
// Vanilla Playwright - complex predicate
const predicate = (response) => {
const url = response.url();
return (
url.endsWith('/api/users') ||
url.match(/\/api\/users\/\d+/) ||
(url.includes('/api/users/') && url.includes('/profile'))
);
};
page.waitForResponse(predicate);
// With interceptNetworkCall - simple glob patterns
interceptNetworkCall({ url: '/api/users' }); // Exact endpoint
interceptNetworkCall({ url: '/api/users/*' }); // User by ID pattern
interceptNetworkCall({ url: '/api/users/*/profile' }); // Specific sub-paths
interceptNetworkCall({ url: '/api/users/**' }); // Match all
API Reference
interceptNetworkCall(options)
| Parameter | Type | Description |
|---|---|---|
page |
Page |
Required when using direct import (not needed with fixture) |
method |
string |
Optional: HTTP method to match (e.g., 'GET', 'POST') |
url |
string |
Optional: URL pattern to match (supports glob patterns via picomatch) |
fulfillResponse |
object |
Optional: Response to use when mocking |
handler |
function |
Optional: Custom handler function for the route |
timeout |
number |
Optional: Timeout in milliseconds for the network request |
fulfillResponse Object
| Property | Type | Description |
|---|---|---|
status |
number |
HTTP status code (default: 200) |
headers |
Record<string, string> |
Response headers |
body |
any |
Response body (will be JSON.stringified if an object) |
Return Value
Returns a Promise<NetworkCallResult> with:
| Property | Type | Description |
|---|---|---|
request |
Request |
The intercepted request |
response |
Response |
The response (null if mocked) |
responseJson |
any |
Parsed JSON response (if available) |
status |
number |
HTTP status code |
requestJson |
any |
Parsed JSON request body (if available) |
Comparison with Vanilla Playwright
| Vanilla Playwright | intercept-network-call |
|---|---|
await page.route('/api/users', route => route.continue()) |
const call = interceptNetworkCall({ url: '**/api/users' }) |
const resp = await page.waitForResponse('/api/users') |
(Combined in single statement) |
const json = await resp.json() |
const { responseJson } = await call |
const status = resp.status() |
const { status } = await call |
| Complex filter predicates | Simple glob patterns |
Reduction: ~5-7 lines -> ~2-3 lines per interception
Related Fragments
network-first.md- Core pattern: intercept before navigatenetwork-recorder.md- HAR-based offline testingoverview.md- Fixture composition basics
Anti-Patterns
DON'T intercept after navigation:
await page.goto('/dashboard'); // Navigation starts
const usersCall = interceptNetworkCall({ url: '**/api/users' }); // Too late!
DO intercept before navigate:
const usersCall = interceptNetworkCall({ url: '**/api/users' }); // First
await page.goto('/dashboard'); // Then navigate
const { responseJson } = await usersCall; // Then await
DON'T ignore the returned Promise:
interceptNetworkCall({ url: '**/api/users' }); // Not awaited!
await page.goto('/dashboard');
// No deterministic wait - race condition
DO always await the intercept:
const usersCall = interceptNetworkCall({ url: '**/api/users' });
await page.goto('/dashboard');
await usersCall; // Deterministic wait