Skip to content

Commit d7b2997

Browse files
tsemachhjunie-agent
andcommitted
feat: add Playwright recording JSON integration docs, tests, and fixtures
Co-authored-by: Junie <junie@jetbrains.com>
1 parent b7d4325 commit d7b2997

7 files changed

Lines changed: 465 additions & 0 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,4 @@ mockit.iml
1010
!.idea/runConfigurations/
1111

1212
.DS_Store
13+
/test-results/.last-run.json

README.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,43 @@ Useful scripts:
6262
- `CONTRIBUTING.md`
6363
- `SECURITY.md`
6464
- `PRIVACY.md`
65+
- `docs/playwright-integration.md`
66+
67+
## Use recordings in Playwright
68+
69+
You can reuse exported `API Replay` JSON files directly in Playwright tests.
70+
71+
```ts
72+
import path from 'node:path';
73+
import { test, expect } from '@playwright/test';
74+
import { applyRecordingMocks } from './tests/helpers/recording-mock';
75+
76+
test('uses an exported recording as API mocks', async ({ context, page }) => {
77+
const recordingPath = path.resolve(
78+
process.cwd(),
79+
'tests/fixtures/recordings/playwright-demo-recording.json'
80+
);
81+
82+
const mock = await applyRecordingMocks(context, recordingPath, {
83+
fallbackMatching: true,
84+
strictUnmatched: false
85+
});
86+
87+
try {
88+
await page.goto('https://example.test');
89+
const body = await page.evaluate(async () => {
90+
const response = await fetch('https://api.example.com/users/me');
91+
return response.json();
92+
});
93+
94+
expect(body).toEqual({ id: 'u_123', name: 'API Replay' });
95+
} finally {
96+
await mock.dispose();
97+
}
98+
});
99+
```
100+
101+
See `docs/playwright-integration.md` for full workflow and strict-mode guidance.
65102

66103
## License
67104

docs/playwright-integration.md

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
## Playwright integration with exported recordings
2+
3+
This guide explains how to use exported `API Replay` recording JSON files as deterministic API mocks in Playwright tests.
4+
5+
### 1) Export and store a recording fixture
6+
7+
1. Use extension UI to export a recording JSON.
8+
2. Commit it under `tests/fixtures/recordings/`.
9+
10+
Example fixture path used in this repo:
11+
12+
- `tests/fixtures/recordings/playwright-demo-recording.json`
13+
14+
### 2) Apply mocks in a Playwright test
15+
16+
Use the helper from `tests/helpers/recording-mock.ts`.
17+
18+
```ts
19+
import path from 'node:path';
20+
import { test, expect } from '@playwright/test';
21+
import { applyRecordingMocks } from '../helpers/recording-mock';
22+
23+
test('reuses recording JSON in Playwright', async ({ context, page }) => {
24+
const recordingPath = path.resolve(
25+
process.cwd(),
26+
'tests/fixtures/recordings/playwright-demo-recording.json'
27+
);
28+
29+
const mock = await applyRecordingMocks(context, recordingPath, {
30+
fallbackMatching: true,
31+
strictUnmatched: false
32+
});
33+
34+
try {
35+
const result = await page.evaluate(async () => {
36+
const response = await fetch('https://api.example.com/users/me');
37+
return {
38+
status: response.status,
39+
body: await response.json()
40+
};
41+
});
42+
43+
expect(result.status).toBe(200);
44+
expect(result.body).toEqual({ id: 'u_123', name: 'API Replay' });
45+
} finally {
46+
await mock.dispose();
47+
}
48+
});
49+
```
50+
51+
### 3) Matching behavior
52+
53+
- Exact mode (default): method + full URL must match.
54+
- Fallback mode (`fallbackMatching: true`): method + path/query fallback when host differs.
55+
- Disabled entries (`enabled === false`) are skipped and continue to real network.
56+
57+
### 4) Strict unmatched mode
58+
59+
When `strictUnmatched: true`, unmatched requests throw with a clear error:
60+
61+
- `No recording match for <METHOD> <URL>`
62+
63+
Use this in CI when tests should fail on unexpected traffic.
64+
65+
### 5) Debug unmatched requests
66+
67+
Pass `debug` callback to inspect misses without failing:
68+
69+
```ts
70+
await applyRecordingMocks(context, recordingPath, {
71+
debug: (message) => console.log(`[recording-mock] ${message}`)
72+
});
73+
```
74+
75+
### Best practices for stable CI
76+
77+
- Keep fixtures versioned in git and review JSON diffs in PRs.
78+
- Start with non-strict mode while stabilizing tests, then move to strict mode in CI.
79+
- Avoid over-mocking entire domains; keep fixtures focused on required flows.
80+
- Prefer explicit fixture names tied to scenarios (for example, `checkout-success.json`).

tests/e2e/recording-mock.spec.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { expect, test } from '@playwright/test';
2+
import path from 'node:path';
3+
import { applyRecordingMocks } from '../helpers/recording-mock';
4+
5+
const recordingPath = path.resolve(
6+
process.cwd(),
7+
'tests/fixtures/recordings/playwright-demo-recording.json'
8+
);
9+
10+
test.describe('recording mock helper', () => {
11+
test('mocks exact method + URL matches from recording JSON', async ({ context, page }) => {
12+
const mock = await applyRecordingMocks(context, recordingPath);
13+
14+
try {
15+
const responseData = await page.evaluate(async () => {
16+
const response = await fetch('https://api.example.com/users/me');
17+
return {
18+
status: response.status,
19+
body: await response.json()
20+
};
21+
});
22+
23+
expect(responseData.status).toBe(200);
24+
expect(responseData.body).toEqual({ id: 'u_123', name: 'API Replay' });
25+
} finally {
26+
await mock.dispose();
27+
}
28+
});
29+
30+
test('supports fallback matching when hosts differ', async ({ context, page }) => {
31+
const mock = await applyRecordingMocks(context, recordingPath, {
32+
fallbackMatching: true,
33+
urlBase: 'https://staging.example.test'
34+
});
35+
36+
try {
37+
const responseData = await page.evaluate(async () => {
38+
const response = await fetch('https://api.example.com/users/me');
39+
return {
40+
status: response.status,
41+
body: await response.json()
42+
};
43+
});
44+
45+
expect(responseData.status).toBe(200);
46+
expect(responseData.body).toEqual({ id: 'u_123', name: 'API Replay' });
47+
} finally {
48+
await mock.dispose();
49+
}
50+
});
51+
});
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
{
2+
"name": "Playwright Demo Recording",
3+
"filter": [
4+
"/api"
5+
],
6+
"requests": {
7+
"GET https://api.example.com/users/me": {
8+
"url": "https://api.example.com/users/me",
9+
"method": "GET",
10+
"status": 200,
11+
"responseHeaders": {
12+
"content-type": "application/json"
13+
},
14+
"responseBody": "{\"id\":\"u_123\",\"name\":\"API Replay\"}",
15+
"enabled": true
16+
},
17+
"GET https://api.example.com/todos": {
18+
"url": "https://api.example.com/todos",
19+
"method": "GET",
20+
"status": 200,
21+
"responseHeaders": {
22+
"content-type": "application/json"
23+
},
24+
"responseBody": "[{\"id\":1,\"title\":\"Ship Playwright helper\"}]",
25+
"enabled": false
26+
}
27+
},
28+
"metadata": {
29+
"totalRequests": 2,
30+
"responsesWithBody": 2
31+
}
32+
}

tests/helpers/recording-mock.ts

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import type { BrowserContext, Request, Route } from '@playwright/test';
2+
import { readFile } from 'node:fs/promises';
3+
import path from 'node:path';
4+
import { normalizeRecording } from '../../src/shared/schema';
5+
6+
type RecordedRequest = {
7+
key: string;
8+
method: string;
9+
url: string;
10+
path: string;
11+
status: number;
12+
headers: Record<string, string>;
13+
body: string;
14+
};
15+
16+
export interface RecordingMockOptions {
17+
fallbackMatching?: boolean;
18+
strictUnmatched?: boolean;
19+
urlBase?: string;
20+
debug?: (message: string) => void;
21+
}
22+
23+
export async function applyRecordingMocks(
24+
context: BrowserContext,
25+
recordingPath: string,
26+
options: RecordingMockOptions = {}
27+
): Promise<{ dispose: () => Promise<void> }> {
28+
const raw = await readFile(path.resolve(recordingPath), 'utf-8');
29+
const parsed = JSON.parse(raw);
30+
const recording = normalizeRecording(parsed);
31+
32+
if (!recording) {
33+
throw new Error(`Unable to parse recording JSON at ${recordingPath}`);
34+
}
35+
36+
const enabledRequests = Object.entries(recording.requests)
37+
.filter(([, request]) => request.enabled !== false)
38+
.map(([key, request]): RecordedRequest => {
39+
const requestUrl = normalizeUrl(request.url, options.urlBase);
40+
const requestPath = getPathnameAndSearch(requestUrl);
41+
return {
42+
key,
43+
method: request.method.toUpperCase(),
44+
url: requestUrl,
45+
path: requestPath,
46+
status: request.status ?? 200,
47+
headers: {
48+
'content-type': 'application/json',
49+
...(request.responseHeaders ?? {})
50+
},
51+
body: request.responseBody ?? ''
52+
};
53+
});
54+
55+
const exactIndex = new Map<string, RecordedRequest>();
56+
const fallbackIndex = new Map<string, RecordedRequest>();
57+
58+
for (const request of enabledRequests) {
59+
exactIndex.set(toExactKey(request.method, request.url), request);
60+
if (!fallbackIndex.has(toFallbackKey(request.method, request.path))) {
61+
fallbackIndex.set(toFallbackKey(request.method, request.path), request);
62+
}
63+
}
64+
65+
const routeHandler = async (route: Route, request: Request) => {
66+
const method = request.method().toUpperCase();
67+
const url = request.url();
68+
const exactMatch = exactIndex.get(toExactKey(method, url));
69+
70+
const fallbackMatch =
71+
!exactMatch && options.fallbackMatching
72+
? fallbackIndex.get(toFallbackKey(method, getPathnameAndSearch(url)))
73+
: undefined;
74+
75+
const matchedRequest = exactMatch ?? fallbackMatch;
76+
77+
if (!matchedRequest) {
78+
const message = `No recording match for ${method} ${url}`;
79+
options.debug?.(message);
80+
if (options.strictUnmatched) {
81+
throw new Error(message);
82+
}
83+
await route.continue();
84+
return;
85+
}
86+
87+
await route.fulfill({
88+
status: matchedRequest.status,
89+
headers: matchedRequest.headers,
90+
body: matchedRequest.body
91+
});
92+
};
93+
94+
await context.route('**/*', routeHandler);
95+
96+
return {
97+
dispose: async () => {
98+
await context.unroute('**/*', routeHandler);
99+
}
100+
};
101+
}
102+
103+
function toExactKey(method: string, url: string): string {
104+
return `${method} ${url}`;
105+
}
106+
107+
function toFallbackKey(method: string, pathValue: string): string {
108+
return `${method} ${pathValue}`;
109+
}
110+
111+
function getPathnameAndSearch(urlValue: string): string {
112+
const url = new URL(urlValue);
113+
return `${url.pathname}${url.search}`;
114+
}
115+
116+
function normalizeUrl(urlValue: string, urlBase?: string): string {
117+
if (!urlBase) {
118+
return urlValue;
119+
}
120+
121+
try {
122+
const parsed = new URL(urlValue);
123+
const base = new URL(urlBase);
124+
parsed.protocol = base.protocol;
125+
parsed.host = base.host;
126+
return parsed.toString();
127+
} catch {
128+
return new URL(urlValue, urlBase).toString();
129+
}
130+
}

0 commit comments

Comments
 (0)