test-proxy-recorder 0.1.3 → 0.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +76 -10
- package/dist/{index-DfFpm8mB.d.cts → index-CBjvm5rb.d.cts} +7 -2
- package/dist/{index-DfFpm8mB.d.ts → index-CBjvm5rb.d.ts} +7 -2
- package/dist/index.cjs +136 -16
- package/dist/index.d.cts +6 -2
- package/dist/index.d.ts +6 -2
- package/dist/index.mjs +135 -16
- package/dist/playwright/index.cjs +39 -3
- package/dist/playwright/index.d.cts +1 -1
- package/dist/playwright/index.d.ts +1 -1
- package/dist/playwright/index.mjs +39 -3
- package/dist/proxy.js +98 -15
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -191,7 +191,7 @@ interface ControlRequest {
|
|
|
191
191
|
### Playwright Integration API
|
|
192
192
|
|
|
193
193
|
```typescript
|
|
194
|
-
import { playwrightProxy } from 'test-proxy-recorder';
|
|
194
|
+
import { playwrightProxy, setProxyMode } from 'test-proxy-recorder';
|
|
195
195
|
|
|
196
196
|
// Main helper object for use with Playwright tests
|
|
197
197
|
const playwrightProxy = {
|
|
@@ -203,14 +203,84 @@ const playwrightProxy = {
|
|
|
203
203
|
};
|
|
204
204
|
```
|
|
205
205
|
|
|
206
|
-
|
|
206
|
+
### Global Teardown and Hooks Setup (Recommended)
|
|
207
|
+
|
|
208
|
+
For robust test setups, it's recommended to configure global teardown and afterEach hooks to ensure the proxy is properly reset even when tests fail. This prevents the proxy from staying in record/replay mode, which could affect subsequent test runs.
|
|
209
|
+
|
|
210
|
+
#### 1. Create Global Teardown File
|
|
211
|
+
|
|
212
|
+
Create `e2e/global-teardown.ts` to reset the proxy mode after all tests complete:
|
|
213
|
+
|
|
214
|
+
```typescript
|
|
215
|
+
import { setProxyMode } from 'test-proxy-recorder';
|
|
216
|
+
|
|
217
|
+
async function globalTeardown() {
|
|
218
|
+
await setProxyMode('transparent');
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export default globalTeardown;
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
#### 2. Create Global Hooks File
|
|
225
|
+
|
|
226
|
+
Create `e2e/global-hooks.ts` to ensure proxy cleanup happens after each test, even on failure:
|
|
207
227
|
|
|
208
|
-
|
|
228
|
+
```typescript
|
|
229
|
+
import { test } from '@playwright/test';
|
|
230
|
+
import { playwrightProxy } from 'test-proxy-recorder';
|
|
209
231
|
|
|
232
|
+
/**
|
|
233
|
+
* Global afterEach hook to ensure proxy cleanup happens even when tests fail.
|
|
234
|
+
* This will run after every test across all test files.
|
|
235
|
+
*/
|
|
236
|
+
test.afterEach(async ({}, testInfo) => {
|
|
237
|
+
try {
|
|
238
|
+
await playwrightProxy.after(testInfo);
|
|
239
|
+
} catch (error) {
|
|
240
|
+
console.error('Error during proxy cleanup:', error);
|
|
241
|
+
// Don't throw - we want cleanup to continue even if this fails
|
|
242
|
+
}
|
|
243
|
+
});
|
|
210
244
|
```
|
|
245
|
+
|
|
246
|
+
#### 3. Configure Playwright
|
|
247
|
+
|
|
248
|
+
Update your `playwright.config.ts` to include the global teardown:
|
|
249
|
+
|
|
250
|
+
```typescript
|
|
251
|
+
import { defineConfig } from '@playwright/test';
|
|
252
|
+
|
|
253
|
+
export default defineConfig({
|
|
254
|
+
testDir: './e2e',
|
|
255
|
+
globalTeardown: './e2e/global-teardown.ts',
|
|
256
|
+
// ... rest of your config
|
|
257
|
+
});
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
#### 4. Import Global Hooks in Your Base Page or Test Setup
|
|
261
|
+
|
|
262
|
+
Import the global hooks file in your base test file or base page to register the afterEach hook:
|
|
263
|
+
|
|
264
|
+
```typescript
|
|
265
|
+
// In your e2e/basePage.ts or similar base test file
|
|
266
|
+
import { test as base } from '@playwright/test';
|
|
267
|
+
|
|
268
|
+
// Import global hooks to register afterEach for proxy cleanup
|
|
269
|
+
import './global-hooks';
|
|
270
|
+
|
|
271
|
+
export const test = base.extend({
|
|
272
|
+
// your fixtures
|
|
273
|
+
});
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
## Recording Format
|
|
277
|
+
|
|
278
|
+
Recordings are stored as JSON files with `.mock.json` extension in the recordings directory:
|
|
279
|
+
|
|
280
|
+
```text
|
|
211
281
|
recordings/
|
|
212
|
-
├── test-session-1.json
|
|
213
|
-
├── test-session-2.json
|
|
282
|
+
├── test-session-1.mock.json
|
|
283
|
+
├── test-session-2.mock.json
|
|
214
284
|
└── ...
|
|
215
285
|
```
|
|
216
286
|
|
|
@@ -228,11 +298,7 @@ Each recording contains:
|
|
|
228
298
|
test-proxy-recorder http://localhost:8000 --port 8100
|
|
229
299
|
```
|
|
230
300
|
|
|
231
|
-
2. **Configure your app** to use the proxy
|
|
232
|
-
|
|
233
|
-
```bash
|
|
234
|
-
export EXTERNAL_API_URL=http://localhost:8100 yarn dev
|
|
235
|
-
```
|
|
301
|
+
2. **Configure your app** to use the proxy (point your app to the proxy port, e.g., 8100)
|
|
236
302
|
|
|
237
303
|
3. **Record responses** (first run):
|
|
238
304
|
|
|
@@ -28,6 +28,7 @@ interface Recording {
|
|
|
28
28
|
response?: RecordedResponse;
|
|
29
29
|
timestamp: string;
|
|
30
30
|
key: string;
|
|
31
|
+
sequence: number;
|
|
31
32
|
}
|
|
32
33
|
interface WebSocketMessage {
|
|
33
34
|
direction: 'client-to-server' | 'server-to-client';
|
|
@@ -46,16 +47,20 @@ interface RecordingSession {
|
|
|
46
47
|
websocketRecordings: WebSocketRecording[];
|
|
47
48
|
}
|
|
48
49
|
|
|
49
|
-
type PlaywrightTestInfo = Pick<TestInfo, 'title'>;
|
|
50
|
+
type PlaywrightTestInfo = Pick<TestInfo, 'title' | 'titlePath'>;
|
|
50
51
|
/**
|
|
51
52
|
* Set the proxy mode for a given session
|
|
52
53
|
* @param mode - The proxy mode to set (recording, replay, transparent)
|
|
53
54
|
* @param sessionId - Unique identifier for the session
|
|
54
55
|
* @param timeout - Optional timeout in milliseconds
|
|
55
56
|
*/
|
|
56
|
-
declare function setProxyMode(mode: Mode, sessionId
|
|
57
|
+
declare function setProxyMode(mode: Mode, sessionId?: string, timeout?: number): Promise<void>;
|
|
57
58
|
/**
|
|
58
59
|
* Generate a session ID from test info
|
|
60
|
+
* Uses titlePath to create folder structure with test file name
|
|
61
|
+
* Supports both .spec.ts and .test.ts extensions
|
|
62
|
+
* Example: ['jobs/Create.spec.ts', 'create a job'] becomes 'jobs/Create__create-a-job'
|
|
63
|
+
* Example: ['users/Auth.test.ts', 'login test'] becomes 'users/Auth__login-test'
|
|
59
64
|
* @param testInfo - Playwright test info object
|
|
60
65
|
*/
|
|
61
66
|
declare function generateSessionId(testInfo: PlaywrightTestInfo): string;
|
|
@@ -28,6 +28,7 @@ interface Recording {
|
|
|
28
28
|
response?: RecordedResponse;
|
|
29
29
|
timestamp: string;
|
|
30
30
|
key: string;
|
|
31
|
+
sequence: number;
|
|
31
32
|
}
|
|
32
33
|
interface WebSocketMessage {
|
|
33
34
|
direction: 'client-to-server' | 'server-to-client';
|
|
@@ -46,16 +47,20 @@ interface RecordingSession {
|
|
|
46
47
|
websocketRecordings: WebSocketRecording[];
|
|
47
48
|
}
|
|
48
49
|
|
|
49
|
-
type PlaywrightTestInfo = Pick<TestInfo, 'title'>;
|
|
50
|
+
type PlaywrightTestInfo = Pick<TestInfo, 'title' | 'titlePath'>;
|
|
50
51
|
/**
|
|
51
52
|
* Set the proxy mode for a given session
|
|
52
53
|
* @param mode - The proxy mode to set (recording, replay, transparent)
|
|
53
54
|
* @param sessionId - Unique identifier for the session
|
|
54
55
|
* @param timeout - Optional timeout in milliseconds
|
|
55
56
|
*/
|
|
56
|
-
declare function setProxyMode(mode: Mode, sessionId
|
|
57
|
+
declare function setProxyMode(mode: Mode, sessionId?: string, timeout?: number): Promise<void>;
|
|
57
58
|
/**
|
|
58
59
|
* Generate a session ID from test info
|
|
60
|
+
* Uses titlePath to create folder structure with test file name
|
|
61
|
+
* Supports both .spec.ts and .test.ts extensions
|
|
62
|
+
* Example: ['jobs/Create.spec.ts', 'create a job'] becomes 'jobs/Create__create-a-job'
|
|
63
|
+
* Example: ['users/Auth.test.ts', 'login test'] becomes 'users/Auth__login-test'
|
|
59
64
|
* @param testInfo - Playwright test info object
|
|
60
65
|
*/
|
|
61
66
|
declare function generateSessionId(testInfo: PlaywrightTestInfo): string;
|
package/dist/index.cjs
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
var fs = require('fs/promises');
|
|
4
4
|
var http = require('http');
|
|
5
|
+
var https = require('https');
|
|
5
6
|
var httpProxy = require('http-proxy');
|
|
6
7
|
var ws = require('ws');
|
|
7
8
|
var path = require('path');
|
|
@@ -11,6 +12,7 @@ function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
|
|
|
11
12
|
|
|
12
13
|
var fs__default = /*#__PURE__*/_interopDefault(fs);
|
|
13
14
|
var http__default = /*#__PURE__*/_interopDefault(http);
|
|
15
|
+
var https__default = /*#__PURE__*/_interopDefault(https);
|
|
14
16
|
var httpProxy__default = /*#__PURE__*/_interopDefault(httpProxy);
|
|
15
17
|
var path__default = /*#__PURE__*/_interopDefault(path);
|
|
16
18
|
var filenamify__default = /*#__PURE__*/_interopDefault(filenamify);
|
|
@@ -33,7 +35,7 @@ var Modes = {
|
|
|
33
35
|
};
|
|
34
36
|
var JSON_INDENT_SPACES = 2;
|
|
35
37
|
function getRecordingPath(recordingsDir, id) {
|
|
36
|
-
return path__default.default.join(recordingsDir, `${id}.json`);
|
|
38
|
+
return path__default.default.join(recordingsDir, `${id}.mock.json`);
|
|
37
39
|
}
|
|
38
40
|
async function loadRecordingSession(filePath) {
|
|
39
41
|
const fileContent = await fs__default.default.readFile(filePath, "utf8");
|
|
@@ -41,6 +43,8 @@ async function loadRecordingSession(filePath) {
|
|
|
41
43
|
}
|
|
42
44
|
async function saveRecordingSession(recordingsDir, session) {
|
|
43
45
|
const filePath = getRecordingPath(recordingsDir, session.id);
|
|
46
|
+
const dirPath = path__default.default.dirname(filePath);
|
|
47
|
+
await fs__default.default.mkdir(dirPath, { recursive: true });
|
|
44
48
|
await fs__default.default.writeFile(
|
|
45
49
|
filePath,
|
|
46
50
|
JSON.stringify(session, null, JSON_INDENT_SPACES)
|
|
@@ -93,6 +97,10 @@ var ProxyServer = class {
|
|
|
93
97
|
proxy;
|
|
94
98
|
currentSession;
|
|
95
99
|
recordingsDir;
|
|
100
|
+
requestSequenceMap;
|
|
101
|
+
// Track sequence per request key
|
|
102
|
+
replaySequenceMap;
|
|
103
|
+
// Track replay position per request key
|
|
96
104
|
constructor(targets, recordingsDir) {
|
|
97
105
|
this.targets = targets;
|
|
98
106
|
this.currentTargetIndex = 0;
|
|
@@ -102,6 +110,8 @@ var ProxyServer = class {
|
|
|
102
110
|
this.modeTimeout = null;
|
|
103
111
|
this.currentSession = null;
|
|
104
112
|
this.recordingsDir = recordingsDir;
|
|
113
|
+
this.requestSequenceMap = /* @__PURE__ */ new Map();
|
|
114
|
+
this.replaySequenceMap = /* @__PURE__ */ new Map();
|
|
105
115
|
this.proxy = httpProxy__default.default.createProxyServer({
|
|
106
116
|
secure: false,
|
|
107
117
|
changeOrigin: true
|
|
@@ -119,6 +129,7 @@ var ProxyServer = class {
|
|
|
119
129
|
this.handleUpgrade(req, socket, head);
|
|
120
130
|
});
|
|
121
131
|
server.listen(port, () => {
|
|
132
|
+
process.env.TEST_PROXY_RECORDER_PORT = String(port);
|
|
122
133
|
this.logServerStartup(port);
|
|
123
134
|
});
|
|
124
135
|
return server;
|
|
@@ -140,10 +151,19 @@ var ProxyServer = class {
|
|
|
140
151
|
res.end(JSON.stringify({ error: "Proxy error", message: err.message }));
|
|
141
152
|
}
|
|
142
153
|
handleProxyResponse(proxyRes, req) {
|
|
154
|
+
this.addCorsHeaders(proxyRes, req);
|
|
143
155
|
if (this.mode === Modes.record && this.recordingId) {
|
|
144
156
|
this.recordResponse(req, proxyRes);
|
|
145
157
|
}
|
|
146
158
|
}
|
|
159
|
+
addCorsHeaders(proxyRes, req) {
|
|
160
|
+
const origin = req.headers.origin;
|
|
161
|
+
proxyRes.headers["access-control-allow-origin"] = origin || "*";
|
|
162
|
+
proxyRes.headers["access-control-allow-credentials"] = "true";
|
|
163
|
+
proxyRes.headers["access-control-allow-headers"] = req.headers["access-control-request-headers"] || "Origin, X-Requested-With, Content-Type, Accept, Authorization";
|
|
164
|
+
proxyRes.headers["access-control-allow-methods"] = "GET, POST, PUT, DELETE, PATCH, OPTIONS";
|
|
165
|
+
proxyRes.headers["access-control-expose-headers"] = "*";
|
|
166
|
+
}
|
|
147
167
|
getTarget() {
|
|
148
168
|
const target = this.targets[this.currentTargetIndex];
|
|
149
169
|
this.currentTargetIndex = (this.currentTargetIndex + 1) % this.targets.length;
|
|
@@ -218,6 +238,7 @@ var ProxyServer = class {
|
|
|
218
238
|
this.recordingId = id;
|
|
219
239
|
this.replayId = null;
|
|
220
240
|
this.currentSession = { id, recordings: [], websocketRecordings: [] };
|
|
241
|
+
this.requestSequenceMap.clear();
|
|
221
242
|
console.log(`Switched to record mode with ID: ${id}`);
|
|
222
243
|
}
|
|
223
244
|
switchToReplayMode(id) {
|
|
@@ -228,6 +249,7 @@ var ProxyServer = class {
|
|
|
228
249
|
this.replayId = id;
|
|
229
250
|
this.recordingId = null;
|
|
230
251
|
this.currentSession = null;
|
|
252
|
+
this.replaySequenceMap.clear();
|
|
231
253
|
console.log(`Switched to replay mode with ID: ${id}`);
|
|
232
254
|
}
|
|
233
255
|
setupModeTimeout(timeout) {
|
|
@@ -259,6 +281,8 @@ var ProxyServer = class {
|
|
|
259
281
|
return;
|
|
260
282
|
}
|
|
261
283
|
const key = getReqID(req);
|
|
284
|
+
const currentSequence = this.requestSequenceMap.get(key) || 0;
|
|
285
|
+
this.requestSequenceMap.set(key, currentSequence + 1);
|
|
262
286
|
const record = {
|
|
263
287
|
request: {
|
|
264
288
|
method: req.method,
|
|
@@ -267,7 +291,8 @@ var ProxyServer = class {
|
|
|
267
291
|
body: body || null
|
|
268
292
|
},
|
|
269
293
|
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
270
|
-
key
|
|
294
|
+
key,
|
|
295
|
+
sequence: currentSequence
|
|
271
296
|
};
|
|
272
297
|
this.currentSession.recordings.push(record);
|
|
273
298
|
}
|
|
@@ -276,7 +301,9 @@ var ProxyServer = class {
|
|
|
276
301
|
return;
|
|
277
302
|
}
|
|
278
303
|
const key = getReqID(req);
|
|
279
|
-
const record = this.currentSession.recordings.
|
|
304
|
+
const record = this.currentSession.recordings.findLast(
|
|
305
|
+
(r) => r.key === key && !r.response
|
|
306
|
+
);
|
|
280
307
|
if (!record) {
|
|
281
308
|
console.error("Request record not found for response:", key);
|
|
282
309
|
return;
|
|
@@ -301,17 +328,31 @@ var ProxyServer = class {
|
|
|
301
328
|
const filePath = getRecordingPath(this.recordingsDir, this.replayId);
|
|
302
329
|
try {
|
|
303
330
|
const session = await loadRecordingSession(filePath);
|
|
304
|
-
const
|
|
331
|
+
const currentSequence = this.replaySequenceMap.get(key) || 0;
|
|
332
|
+
const record = session.recordings.find(
|
|
333
|
+
(r) => r.key === key && r.sequence === currentSequence
|
|
334
|
+
);
|
|
305
335
|
if (!record) {
|
|
306
|
-
throw new Error(
|
|
336
|
+
throw new Error(
|
|
337
|
+
`No recording found for ${key} with sequence ${currentSequence}`
|
|
338
|
+
);
|
|
307
339
|
}
|
|
308
340
|
if (!record.response) {
|
|
309
341
|
throw new Error("No response recorded for this request");
|
|
310
342
|
}
|
|
343
|
+
this.replaySequenceMap.set(key, currentSequence + 1);
|
|
311
344
|
const { statusCode, headers, body } = record.response;
|
|
312
|
-
|
|
345
|
+
const origin = req.headers.origin;
|
|
346
|
+
const responseHeaders = {
|
|
347
|
+
...headers,
|
|
348
|
+
"access-control-allow-origin": origin || "*",
|
|
349
|
+
"access-control-allow-credentials": "true"
|
|
350
|
+
};
|
|
351
|
+
res.writeHead(statusCode, responseHeaders);
|
|
313
352
|
res.end(body);
|
|
314
|
-
console.log(
|
|
353
|
+
console.log(
|
|
354
|
+
`Replayed: ${req.method} ${req.url} (sequence: ${currentSequence})`
|
|
355
|
+
);
|
|
315
356
|
} catch (error) {
|
|
316
357
|
this.handleReplayError(res, error, key, filePath);
|
|
317
358
|
}
|
|
@@ -327,6 +368,9 @@ var ProxyServer = class {
|
|
|
327
368
|
});
|
|
328
369
|
}
|
|
329
370
|
async handleRequest(req, res) {
|
|
371
|
+
if (req.method === "OPTIONS") {
|
|
372
|
+
return this.handleCorsPreflightRequest(req, res);
|
|
373
|
+
}
|
|
330
374
|
if (req.url === CONTROL_ENDPOINT) {
|
|
331
375
|
return this.handleControlRequest(req, res);
|
|
332
376
|
}
|
|
@@ -335,23 +379,63 @@ var ProxyServer = class {
|
|
|
335
379
|
}
|
|
336
380
|
await this.handleProxyRequest(req, res);
|
|
337
381
|
}
|
|
382
|
+
handleCorsPreflightRequest(req, res) {
|
|
383
|
+
const origin = req.headers.origin;
|
|
384
|
+
res.writeHead(HTTP_STATUS_OK, {
|
|
385
|
+
"Access-Control-Allow-Origin": origin || "*",
|
|
386
|
+
"Access-Control-Allow-Credentials": "true",
|
|
387
|
+
"Access-Control-Allow-Headers": req.headers["access-control-request-headers"] || "Origin, X-Requested-With, Content-Type, Accept, Authorization",
|
|
388
|
+
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, PATCH, OPTIONS",
|
|
389
|
+
"Access-Control-Max-Age": "86400"
|
|
390
|
+
// 24 hours
|
|
391
|
+
});
|
|
392
|
+
res.end();
|
|
393
|
+
}
|
|
338
394
|
async handleProxyRequest(req, res) {
|
|
339
395
|
const target = this.getTarget();
|
|
340
396
|
console.log(`[${this.mode}] ${req.method} ${req.url} -> ${target}`);
|
|
341
397
|
if (this.mode === Modes.record) {
|
|
342
|
-
await this.
|
|
398
|
+
await this.bufferAndProxyRequest(req, res, target);
|
|
399
|
+
} else {
|
|
400
|
+
this.proxy.web(req, res, { target });
|
|
343
401
|
}
|
|
344
|
-
this.proxy.web(req, res, { target });
|
|
345
402
|
}
|
|
346
|
-
async
|
|
403
|
+
async bufferAndProxyRequest(req, res, target) {
|
|
347
404
|
const chunks = [];
|
|
348
405
|
req.on("data", (chunk) => {
|
|
349
406
|
chunks.push(chunk);
|
|
350
407
|
});
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
await this.saveRequestRecord(req, body);
|
|
408
|
+
await new Promise((resolve) => {
|
|
409
|
+
req.on("end", () => resolve());
|
|
354
410
|
});
|
|
411
|
+
const body = Buffer.concat(chunks).toString("utf8");
|
|
412
|
+
await this.saveRequestRecord(req, body);
|
|
413
|
+
const targetUrl = new URL(target);
|
|
414
|
+
const isHttps = targetUrl.protocol === "https:";
|
|
415
|
+
const requestModule = isHttps ? https__default.default : http__default.default;
|
|
416
|
+
const defaultPort = isHttps ? 443 : 80;
|
|
417
|
+
const proxyReq = requestModule.request(
|
|
418
|
+
{
|
|
419
|
+
hostname: targetUrl.hostname,
|
|
420
|
+
port: targetUrl.port || defaultPort,
|
|
421
|
+
path: req.url,
|
|
422
|
+
method: req.method,
|
|
423
|
+
headers: req.headers
|
|
424
|
+
},
|
|
425
|
+
(proxyRes) => {
|
|
426
|
+
this.addCorsHeaders(proxyRes, req);
|
|
427
|
+
this.recordResponse(req, proxyRes);
|
|
428
|
+
res.writeHead(proxyRes.statusCode || 200, proxyRes.headers);
|
|
429
|
+
proxyRes.pipe(res);
|
|
430
|
+
}
|
|
431
|
+
);
|
|
432
|
+
proxyReq.on("error", (err) => {
|
|
433
|
+
this.handleProxyError(err, req, res);
|
|
434
|
+
});
|
|
435
|
+
if (chunks.length > 0) {
|
|
436
|
+
proxyReq.write(Buffer.concat(chunks));
|
|
437
|
+
}
|
|
438
|
+
proxyReq.end();
|
|
355
439
|
}
|
|
356
440
|
handleUpgrade(req, socket, head) {
|
|
357
441
|
if (this.mode === Modes.replay) {
|
|
@@ -524,15 +608,25 @@ var ProxyServer = class {
|
|
|
524
608
|
};
|
|
525
609
|
|
|
526
610
|
// src/playwright/index.ts
|
|
527
|
-
|
|
611
|
+
function getProxyPort() {
|
|
612
|
+
const envPort = process.env.TEST_PROXY_RECORDER_PORT;
|
|
613
|
+
if (envPort) {
|
|
614
|
+
const parsed = Number.parseInt(envPort, 10);
|
|
615
|
+
if (!Number.isNaN(parsed)) {
|
|
616
|
+
return parsed;
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
return 8100;
|
|
620
|
+
}
|
|
528
621
|
async function setProxyMode(mode, sessionId, timeout) {
|
|
622
|
+
const proxyPort = getProxyPort();
|
|
529
623
|
try {
|
|
530
624
|
const body = {
|
|
531
625
|
mode,
|
|
532
626
|
id: sessionId,
|
|
533
627
|
...timeout && { timeout }
|
|
534
628
|
};
|
|
535
|
-
const response = await fetch(
|
|
629
|
+
const response = await fetch(`http://127.0.0.1:${proxyPort}/__control`, {
|
|
536
630
|
method: "POST",
|
|
537
631
|
headers: { "Content-Type": "application/json" },
|
|
538
632
|
body: JSON.stringify(body)
|
|
@@ -549,8 +643,34 @@ async function setProxyMode(mode, sessionId, timeout) {
|
|
|
549
643
|
throw error;
|
|
550
644
|
}
|
|
551
645
|
}
|
|
646
|
+
function parseSpecFilePath(specPath) {
|
|
647
|
+
const folderMatch = specPath.match(/^(.+?)\/([^/]+)\.(spec|test)\.ts$/);
|
|
648
|
+
if (folderMatch) {
|
|
649
|
+
return { folder: folderMatch[1], fileName: folderMatch[2] };
|
|
650
|
+
}
|
|
651
|
+
const fileMatch = specPath.match(/^([^/]+)\.(spec|test)\.ts$/);
|
|
652
|
+
if (fileMatch) {
|
|
653
|
+
return { folder: null, fileName: fileMatch[1] };
|
|
654
|
+
}
|
|
655
|
+
return { folder: null, fileName: null };
|
|
656
|
+
}
|
|
657
|
+
function buildSessionPath(folder, fileName, testName) {
|
|
658
|
+
if (folder && fileName) {
|
|
659
|
+
return `${folder}/${fileName}__${testName}`;
|
|
660
|
+
}
|
|
661
|
+
if (fileName) {
|
|
662
|
+
return `${fileName}__${testName}`;
|
|
663
|
+
}
|
|
664
|
+
return testName;
|
|
665
|
+
}
|
|
552
666
|
function generateSessionId(testInfo) {
|
|
553
|
-
|
|
667
|
+
const { titlePath } = testInfo;
|
|
668
|
+
if (!titlePath || titlePath.length === 0) {
|
|
669
|
+
return testInfo.title.toLowerCase().replaceAll(/\s+/g, "-");
|
|
670
|
+
}
|
|
671
|
+
const { folder, fileName } = parseSpecFilePath(titlePath[0]);
|
|
672
|
+
const testName = titlePath.at(-1).toLowerCase().replaceAll(/\s+/g, "-");
|
|
673
|
+
return buildSessionPath(folder, fileName, testName);
|
|
554
674
|
}
|
|
555
675
|
async function startRecording(testInfo) {
|
|
556
676
|
const sessionId = generateSessionId(testInfo);
|
package/dist/index.d.cts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import http from 'node:http';
|
|
2
|
-
export { C as ControlRequest, M as Mode, P as PlaywrightTestInfo, R as Recording, a as RecordingSession, W as WebSocketRecording, g as generateSessionId, p as playwrightProxy, s as setProxyMode, b as startRecording, c as startReplay, d as stopProxy } from './index-
|
|
2
|
+
export { C as ControlRequest, M as Mode, P as PlaywrightTestInfo, R as Recording, a as RecordingSession, W as WebSocketRecording, g as generateSessionId, p as playwrightProxy, s as setProxyMode, b as startRecording, c as startReplay, d as stopProxy } from './index-CBjvm5rb.cjs';
|
|
3
3
|
import '@playwright/test';
|
|
4
4
|
|
|
5
5
|
declare class ProxyServer {
|
|
@@ -12,12 +12,15 @@ declare class ProxyServer {
|
|
|
12
12
|
private proxy;
|
|
13
13
|
private currentSession;
|
|
14
14
|
private recordingsDir;
|
|
15
|
+
private requestSequenceMap;
|
|
16
|
+
private replaySequenceMap;
|
|
15
17
|
constructor(targets: string[], recordingsDir: string);
|
|
16
18
|
init(): Promise<void>;
|
|
17
19
|
listen(port: number): http.Server;
|
|
18
20
|
private setupProxyEventHandlers;
|
|
19
21
|
private handleProxyError;
|
|
20
22
|
private handleProxyResponse;
|
|
23
|
+
private addCorsHeaders;
|
|
21
24
|
private getTarget;
|
|
22
25
|
private handleControlRequest;
|
|
23
26
|
private clearModeTimeout;
|
|
@@ -32,8 +35,9 @@ declare class ProxyServer {
|
|
|
32
35
|
private handleReplayRequest;
|
|
33
36
|
private handleReplayError;
|
|
34
37
|
private handleRequest;
|
|
38
|
+
private handleCorsPreflightRequest;
|
|
35
39
|
private handleProxyRequest;
|
|
36
|
-
private
|
|
40
|
+
private bufferAndProxyRequest;
|
|
37
41
|
private handleUpgrade;
|
|
38
42
|
private handleRecordWebSocket;
|
|
39
43
|
private handleReplayWebSocket;
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import http from 'node:http';
|
|
2
|
-
export { C as ControlRequest, M as Mode, P as PlaywrightTestInfo, R as Recording, a as RecordingSession, W as WebSocketRecording, g as generateSessionId, p as playwrightProxy, s as setProxyMode, b as startRecording, c as startReplay, d as stopProxy } from './index-
|
|
2
|
+
export { C as ControlRequest, M as Mode, P as PlaywrightTestInfo, R as Recording, a as RecordingSession, W as WebSocketRecording, g as generateSessionId, p as playwrightProxy, s as setProxyMode, b as startRecording, c as startReplay, d as stopProxy } from './index-CBjvm5rb.js';
|
|
3
3
|
import '@playwright/test';
|
|
4
4
|
|
|
5
5
|
declare class ProxyServer {
|
|
@@ -12,12 +12,15 @@ declare class ProxyServer {
|
|
|
12
12
|
private proxy;
|
|
13
13
|
private currentSession;
|
|
14
14
|
private recordingsDir;
|
|
15
|
+
private requestSequenceMap;
|
|
16
|
+
private replaySequenceMap;
|
|
15
17
|
constructor(targets: string[], recordingsDir: string);
|
|
16
18
|
init(): Promise<void>;
|
|
17
19
|
listen(port: number): http.Server;
|
|
18
20
|
private setupProxyEventHandlers;
|
|
19
21
|
private handleProxyError;
|
|
20
22
|
private handleProxyResponse;
|
|
23
|
+
private addCorsHeaders;
|
|
21
24
|
private getTarget;
|
|
22
25
|
private handleControlRequest;
|
|
23
26
|
private clearModeTimeout;
|
|
@@ -32,8 +35,9 @@ declare class ProxyServer {
|
|
|
32
35
|
private handleReplayRequest;
|
|
33
36
|
private handleReplayError;
|
|
34
37
|
private handleRequest;
|
|
38
|
+
private handleCorsPreflightRequest;
|
|
35
39
|
private handleProxyRequest;
|
|
36
|
-
private
|
|
40
|
+
private bufferAndProxyRequest;
|
|
37
41
|
private handleUpgrade;
|
|
38
42
|
private handleRecordWebSocket;
|
|
39
43
|
private handleReplayWebSocket;
|
package/dist/index.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import fs from 'fs/promises';
|
|
2
2
|
import http from 'http';
|
|
3
|
+
import https from 'https';
|
|
3
4
|
import httpProxy from 'http-proxy';
|
|
4
5
|
import { WebSocket, WebSocketServer } from 'ws';
|
|
5
6
|
import path from 'path';
|
|
@@ -23,7 +24,7 @@ var Modes = {
|
|
|
23
24
|
};
|
|
24
25
|
var JSON_INDENT_SPACES = 2;
|
|
25
26
|
function getRecordingPath(recordingsDir, id) {
|
|
26
|
-
return path.join(recordingsDir, `${id}.json`);
|
|
27
|
+
return path.join(recordingsDir, `${id}.mock.json`);
|
|
27
28
|
}
|
|
28
29
|
async function loadRecordingSession(filePath) {
|
|
29
30
|
const fileContent = await fs.readFile(filePath, "utf8");
|
|
@@ -31,6 +32,8 @@ async function loadRecordingSession(filePath) {
|
|
|
31
32
|
}
|
|
32
33
|
async function saveRecordingSession(recordingsDir, session) {
|
|
33
34
|
const filePath = getRecordingPath(recordingsDir, session.id);
|
|
35
|
+
const dirPath = path.dirname(filePath);
|
|
36
|
+
await fs.mkdir(dirPath, { recursive: true });
|
|
34
37
|
await fs.writeFile(
|
|
35
38
|
filePath,
|
|
36
39
|
JSON.stringify(session, null, JSON_INDENT_SPACES)
|
|
@@ -83,6 +86,10 @@ var ProxyServer = class {
|
|
|
83
86
|
proxy;
|
|
84
87
|
currentSession;
|
|
85
88
|
recordingsDir;
|
|
89
|
+
requestSequenceMap;
|
|
90
|
+
// Track sequence per request key
|
|
91
|
+
replaySequenceMap;
|
|
92
|
+
// Track replay position per request key
|
|
86
93
|
constructor(targets, recordingsDir) {
|
|
87
94
|
this.targets = targets;
|
|
88
95
|
this.currentTargetIndex = 0;
|
|
@@ -92,6 +99,8 @@ var ProxyServer = class {
|
|
|
92
99
|
this.modeTimeout = null;
|
|
93
100
|
this.currentSession = null;
|
|
94
101
|
this.recordingsDir = recordingsDir;
|
|
102
|
+
this.requestSequenceMap = /* @__PURE__ */ new Map();
|
|
103
|
+
this.replaySequenceMap = /* @__PURE__ */ new Map();
|
|
95
104
|
this.proxy = httpProxy.createProxyServer({
|
|
96
105
|
secure: false,
|
|
97
106
|
changeOrigin: true
|
|
@@ -109,6 +118,7 @@ var ProxyServer = class {
|
|
|
109
118
|
this.handleUpgrade(req, socket, head);
|
|
110
119
|
});
|
|
111
120
|
server.listen(port, () => {
|
|
121
|
+
process.env.TEST_PROXY_RECORDER_PORT = String(port);
|
|
112
122
|
this.logServerStartup(port);
|
|
113
123
|
});
|
|
114
124
|
return server;
|
|
@@ -130,10 +140,19 @@ var ProxyServer = class {
|
|
|
130
140
|
res.end(JSON.stringify({ error: "Proxy error", message: err.message }));
|
|
131
141
|
}
|
|
132
142
|
handleProxyResponse(proxyRes, req) {
|
|
143
|
+
this.addCorsHeaders(proxyRes, req);
|
|
133
144
|
if (this.mode === Modes.record && this.recordingId) {
|
|
134
145
|
this.recordResponse(req, proxyRes);
|
|
135
146
|
}
|
|
136
147
|
}
|
|
148
|
+
addCorsHeaders(proxyRes, req) {
|
|
149
|
+
const origin = req.headers.origin;
|
|
150
|
+
proxyRes.headers["access-control-allow-origin"] = origin || "*";
|
|
151
|
+
proxyRes.headers["access-control-allow-credentials"] = "true";
|
|
152
|
+
proxyRes.headers["access-control-allow-headers"] = req.headers["access-control-request-headers"] || "Origin, X-Requested-With, Content-Type, Accept, Authorization";
|
|
153
|
+
proxyRes.headers["access-control-allow-methods"] = "GET, POST, PUT, DELETE, PATCH, OPTIONS";
|
|
154
|
+
proxyRes.headers["access-control-expose-headers"] = "*";
|
|
155
|
+
}
|
|
137
156
|
getTarget() {
|
|
138
157
|
const target = this.targets[this.currentTargetIndex];
|
|
139
158
|
this.currentTargetIndex = (this.currentTargetIndex + 1) % this.targets.length;
|
|
@@ -208,6 +227,7 @@ var ProxyServer = class {
|
|
|
208
227
|
this.recordingId = id;
|
|
209
228
|
this.replayId = null;
|
|
210
229
|
this.currentSession = { id, recordings: [], websocketRecordings: [] };
|
|
230
|
+
this.requestSequenceMap.clear();
|
|
211
231
|
console.log(`Switched to record mode with ID: ${id}`);
|
|
212
232
|
}
|
|
213
233
|
switchToReplayMode(id) {
|
|
@@ -218,6 +238,7 @@ var ProxyServer = class {
|
|
|
218
238
|
this.replayId = id;
|
|
219
239
|
this.recordingId = null;
|
|
220
240
|
this.currentSession = null;
|
|
241
|
+
this.replaySequenceMap.clear();
|
|
221
242
|
console.log(`Switched to replay mode with ID: ${id}`);
|
|
222
243
|
}
|
|
223
244
|
setupModeTimeout(timeout) {
|
|
@@ -249,6 +270,8 @@ var ProxyServer = class {
|
|
|
249
270
|
return;
|
|
250
271
|
}
|
|
251
272
|
const key = getReqID(req);
|
|
273
|
+
const currentSequence = this.requestSequenceMap.get(key) || 0;
|
|
274
|
+
this.requestSequenceMap.set(key, currentSequence + 1);
|
|
252
275
|
const record = {
|
|
253
276
|
request: {
|
|
254
277
|
method: req.method,
|
|
@@ -257,7 +280,8 @@ var ProxyServer = class {
|
|
|
257
280
|
body: body || null
|
|
258
281
|
},
|
|
259
282
|
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
260
|
-
key
|
|
283
|
+
key,
|
|
284
|
+
sequence: currentSequence
|
|
261
285
|
};
|
|
262
286
|
this.currentSession.recordings.push(record);
|
|
263
287
|
}
|
|
@@ -266,7 +290,9 @@ var ProxyServer = class {
|
|
|
266
290
|
return;
|
|
267
291
|
}
|
|
268
292
|
const key = getReqID(req);
|
|
269
|
-
const record = this.currentSession.recordings.
|
|
293
|
+
const record = this.currentSession.recordings.findLast(
|
|
294
|
+
(r) => r.key === key && !r.response
|
|
295
|
+
);
|
|
270
296
|
if (!record) {
|
|
271
297
|
console.error("Request record not found for response:", key);
|
|
272
298
|
return;
|
|
@@ -291,17 +317,31 @@ var ProxyServer = class {
|
|
|
291
317
|
const filePath = getRecordingPath(this.recordingsDir, this.replayId);
|
|
292
318
|
try {
|
|
293
319
|
const session = await loadRecordingSession(filePath);
|
|
294
|
-
const
|
|
320
|
+
const currentSequence = this.replaySequenceMap.get(key) || 0;
|
|
321
|
+
const record = session.recordings.find(
|
|
322
|
+
(r) => r.key === key && r.sequence === currentSequence
|
|
323
|
+
);
|
|
295
324
|
if (!record) {
|
|
296
|
-
throw new Error(
|
|
325
|
+
throw new Error(
|
|
326
|
+
`No recording found for ${key} with sequence ${currentSequence}`
|
|
327
|
+
);
|
|
297
328
|
}
|
|
298
329
|
if (!record.response) {
|
|
299
330
|
throw new Error("No response recorded for this request");
|
|
300
331
|
}
|
|
332
|
+
this.replaySequenceMap.set(key, currentSequence + 1);
|
|
301
333
|
const { statusCode, headers, body } = record.response;
|
|
302
|
-
|
|
334
|
+
const origin = req.headers.origin;
|
|
335
|
+
const responseHeaders = {
|
|
336
|
+
...headers,
|
|
337
|
+
"access-control-allow-origin": origin || "*",
|
|
338
|
+
"access-control-allow-credentials": "true"
|
|
339
|
+
};
|
|
340
|
+
res.writeHead(statusCode, responseHeaders);
|
|
303
341
|
res.end(body);
|
|
304
|
-
console.log(
|
|
342
|
+
console.log(
|
|
343
|
+
`Replayed: ${req.method} ${req.url} (sequence: ${currentSequence})`
|
|
344
|
+
);
|
|
305
345
|
} catch (error) {
|
|
306
346
|
this.handleReplayError(res, error, key, filePath);
|
|
307
347
|
}
|
|
@@ -317,6 +357,9 @@ var ProxyServer = class {
|
|
|
317
357
|
});
|
|
318
358
|
}
|
|
319
359
|
async handleRequest(req, res) {
|
|
360
|
+
if (req.method === "OPTIONS") {
|
|
361
|
+
return this.handleCorsPreflightRequest(req, res);
|
|
362
|
+
}
|
|
320
363
|
if (req.url === CONTROL_ENDPOINT) {
|
|
321
364
|
return this.handleControlRequest(req, res);
|
|
322
365
|
}
|
|
@@ -325,23 +368,63 @@ var ProxyServer = class {
|
|
|
325
368
|
}
|
|
326
369
|
await this.handleProxyRequest(req, res);
|
|
327
370
|
}
|
|
371
|
+
handleCorsPreflightRequest(req, res) {
|
|
372
|
+
const origin = req.headers.origin;
|
|
373
|
+
res.writeHead(HTTP_STATUS_OK, {
|
|
374
|
+
"Access-Control-Allow-Origin": origin || "*",
|
|
375
|
+
"Access-Control-Allow-Credentials": "true",
|
|
376
|
+
"Access-Control-Allow-Headers": req.headers["access-control-request-headers"] || "Origin, X-Requested-With, Content-Type, Accept, Authorization",
|
|
377
|
+
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, PATCH, OPTIONS",
|
|
378
|
+
"Access-Control-Max-Age": "86400"
|
|
379
|
+
// 24 hours
|
|
380
|
+
});
|
|
381
|
+
res.end();
|
|
382
|
+
}
|
|
328
383
|
async handleProxyRequest(req, res) {
|
|
329
384
|
const target = this.getTarget();
|
|
330
385
|
console.log(`[${this.mode}] ${req.method} ${req.url} -> ${target}`);
|
|
331
386
|
if (this.mode === Modes.record) {
|
|
332
|
-
await this.
|
|
387
|
+
await this.bufferAndProxyRequest(req, res, target);
|
|
388
|
+
} else {
|
|
389
|
+
this.proxy.web(req, res, { target });
|
|
333
390
|
}
|
|
334
|
-
this.proxy.web(req, res, { target });
|
|
335
391
|
}
|
|
336
|
-
async
|
|
392
|
+
async bufferAndProxyRequest(req, res, target) {
|
|
337
393
|
const chunks = [];
|
|
338
394
|
req.on("data", (chunk) => {
|
|
339
395
|
chunks.push(chunk);
|
|
340
396
|
});
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
await this.saveRequestRecord(req, body);
|
|
397
|
+
await new Promise((resolve) => {
|
|
398
|
+
req.on("end", () => resolve());
|
|
344
399
|
});
|
|
400
|
+
const body = Buffer.concat(chunks).toString("utf8");
|
|
401
|
+
await this.saveRequestRecord(req, body);
|
|
402
|
+
const targetUrl = new URL(target);
|
|
403
|
+
const isHttps = targetUrl.protocol === "https:";
|
|
404
|
+
const requestModule = isHttps ? https : http;
|
|
405
|
+
const defaultPort = isHttps ? 443 : 80;
|
|
406
|
+
const proxyReq = requestModule.request(
|
|
407
|
+
{
|
|
408
|
+
hostname: targetUrl.hostname,
|
|
409
|
+
port: targetUrl.port || defaultPort,
|
|
410
|
+
path: req.url,
|
|
411
|
+
method: req.method,
|
|
412
|
+
headers: req.headers
|
|
413
|
+
},
|
|
414
|
+
(proxyRes) => {
|
|
415
|
+
this.addCorsHeaders(proxyRes, req);
|
|
416
|
+
this.recordResponse(req, proxyRes);
|
|
417
|
+
res.writeHead(proxyRes.statusCode || 200, proxyRes.headers);
|
|
418
|
+
proxyRes.pipe(res);
|
|
419
|
+
}
|
|
420
|
+
);
|
|
421
|
+
proxyReq.on("error", (err) => {
|
|
422
|
+
this.handleProxyError(err, req, res);
|
|
423
|
+
});
|
|
424
|
+
if (chunks.length > 0) {
|
|
425
|
+
proxyReq.write(Buffer.concat(chunks));
|
|
426
|
+
}
|
|
427
|
+
proxyReq.end();
|
|
345
428
|
}
|
|
346
429
|
handleUpgrade(req, socket, head) {
|
|
347
430
|
if (this.mode === Modes.replay) {
|
|
@@ -514,15 +597,25 @@ var ProxyServer = class {
|
|
|
514
597
|
};
|
|
515
598
|
|
|
516
599
|
// src/playwright/index.ts
|
|
517
|
-
|
|
600
|
+
function getProxyPort() {
|
|
601
|
+
const envPort = process.env.TEST_PROXY_RECORDER_PORT;
|
|
602
|
+
if (envPort) {
|
|
603
|
+
const parsed = Number.parseInt(envPort, 10);
|
|
604
|
+
if (!Number.isNaN(parsed)) {
|
|
605
|
+
return parsed;
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
return 8100;
|
|
609
|
+
}
|
|
518
610
|
async function setProxyMode(mode, sessionId, timeout) {
|
|
611
|
+
const proxyPort = getProxyPort();
|
|
519
612
|
try {
|
|
520
613
|
const body = {
|
|
521
614
|
mode,
|
|
522
615
|
id: sessionId,
|
|
523
616
|
...timeout && { timeout }
|
|
524
617
|
};
|
|
525
|
-
const response = await fetch(
|
|
618
|
+
const response = await fetch(`http://127.0.0.1:${proxyPort}/__control`, {
|
|
526
619
|
method: "POST",
|
|
527
620
|
headers: { "Content-Type": "application/json" },
|
|
528
621
|
body: JSON.stringify(body)
|
|
@@ -539,8 +632,34 @@ async function setProxyMode(mode, sessionId, timeout) {
|
|
|
539
632
|
throw error;
|
|
540
633
|
}
|
|
541
634
|
}
|
|
635
|
+
function parseSpecFilePath(specPath) {
|
|
636
|
+
const folderMatch = specPath.match(/^(.+?)\/([^/]+)\.(spec|test)\.ts$/);
|
|
637
|
+
if (folderMatch) {
|
|
638
|
+
return { folder: folderMatch[1], fileName: folderMatch[2] };
|
|
639
|
+
}
|
|
640
|
+
const fileMatch = specPath.match(/^([^/]+)\.(spec|test)\.ts$/);
|
|
641
|
+
if (fileMatch) {
|
|
642
|
+
return { folder: null, fileName: fileMatch[1] };
|
|
643
|
+
}
|
|
644
|
+
return { folder: null, fileName: null };
|
|
645
|
+
}
|
|
646
|
+
function buildSessionPath(folder, fileName, testName) {
|
|
647
|
+
if (folder && fileName) {
|
|
648
|
+
return `${folder}/${fileName}__${testName}`;
|
|
649
|
+
}
|
|
650
|
+
if (fileName) {
|
|
651
|
+
return `${fileName}__${testName}`;
|
|
652
|
+
}
|
|
653
|
+
return testName;
|
|
654
|
+
}
|
|
542
655
|
function generateSessionId(testInfo) {
|
|
543
|
-
|
|
656
|
+
const { titlePath } = testInfo;
|
|
657
|
+
if (!titlePath || titlePath.length === 0) {
|
|
658
|
+
return testInfo.title.toLowerCase().replaceAll(/\s+/g, "-");
|
|
659
|
+
}
|
|
660
|
+
const { folder, fileName } = parseSpecFilePath(titlePath[0]);
|
|
661
|
+
const testName = titlePath.at(-1).toLowerCase().replaceAll(/\s+/g, "-");
|
|
662
|
+
return buildSessionPath(folder, fileName, testName);
|
|
544
663
|
}
|
|
545
664
|
async function startRecording(testInfo) {
|
|
546
665
|
const sessionId = generateSessionId(testInfo);
|
|
@@ -8,15 +8,25 @@ var Modes = {
|
|
|
8
8
|
};
|
|
9
9
|
|
|
10
10
|
// src/playwright/index.ts
|
|
11
|
-
|
|
11
|
+
function getProxyPort() {
|
|
12
|
+
const envPort = process.env.TEST_PROXY_RECORDER_PORT;
|
|
13
|
+
if (envPort) {
|
|
14
|
+
const parsed = Number.parseInt(envPort, 10);
|
|
15
|
+
if (!Number.isNaN(parsed)) {
|
|
16
|
+
return parsed;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
return 8100;
|
|
20
|
+
}
|
|
12
21
|
async function setProxyMode(mode, sessionId, timeout) {
|
|
22
|
+
const proxyPort = getProxyPort();
|
|
13
23
|
try {
|
|
14
24
|
const body = {
|
|
15
25
|
mode,
|
|
16
26
|
id: sessionId,
|
|
17
27
|
...timeout && { timeout }
|
|
18
28
|
};
|
|
19
|
-
const response = await fetch(
|
|
29
|
+
const response = await fetch(`http://127.0.0.1:${proxyPort}/__control`, {
|
|
20
30
|
method: "POST",
|
|
21
31
|
headers: { "Content-Type": "application/json" },
|
|
22
32
|
body: JSON.stringify(body)
|
|
@@ -33,8 +43,34 @@ async function setProxyMode(mode, sessionId, timeout) {
|
|
|
33
43
|
throw error;
|
|
34
44
|
}
|
|
35
45
|
}
|
|
46
|
+
function parseSpecFilePath(specPath) {
|
|
47
|
+
const folderMatch = specPath.match(/^(.+?)\/([^/]+)\.(spec|test)\.ts$/);
|
|
48
|
+
if (folderMatch) {
|
|
49
|
+
return { folder: folderMatch[1], fileName: folderMatch[2] };
|
|
50
|
+
}
|
|
51
|
+
const fileMatch = specPath.match(/^([^/]+)\.(spec|test)\.ts$/);
|
|
52
|
+
if (fileMatch) {
|
|
53
|
+
return { folder: null, fileName: fileMatch[1] };
|
|
54
|
+
}
|
|
55
|
+
return { folder: null, fileName: null };
|
|
56
|
+
}
|
|
57
|
+
function buildSessionPath(folder, fileName, testName) {
|
|
58
|
+
if (folder && fileName) {
|
|
59
|
+
return `${folder}/${fileName}__${testName}`;
|
|
60
|
+
}
|
|
61
|
+
if (fileName) {
|
|
62
|
+
return `${fileName}__${testName}`;
|
|
63
|
+
}
|
|
64
|
+
return testName;
|
|
65
|
+
}
|
|
36
66
|
function generateSessionId(testInfo) {
|
|
37
|
-
|
|
67
|
+
const { titlePath } = testInfo;
|
|
68
|
+
if (!titlePath || titlePath.length === 0) {
|
|
69
|
+
return testInfo.title.toLowerCase().replaceAll(/\s+/g, "-");
|
|
70
|
+
}
|
|
71
|
+
const { folder, fileName } = parseSpecFilePath(titlePath[0]);
|
|
72
|
+
const testName = titlePath.at(-1).toLowerCase().replaceAll(/\s+/g, "-");
|
|
73
|
+
return buildSessionPath(folder, fileName, testName);
|
|
38
74
|
}
|
|
39
75
|
async function startRecording(testInfo) {
|
|
40
76
|
const sessionId = generateSessionId(testInfo);
|
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
import '@playwright/test';
|
|
2
|
-
export { P as PlaywrightTestInfo, g as generateSessionId, p as playwrightProxy, s as setProxyMode, b as startRecording, c as startReplay, d as stopProxy } from '../index-
|
|
2
|
+
export { P as PlaywrightTestInfo, g as generateSessionId, p as playwrightProxy, s as setProxyMode, b as startRecording, c as startReplay, d as stopProxy } from '../index-CBjvm5rb.cjs';
|
|
3
3
|
import 'node:http';
|
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
import '@playwright/test';
|
|
2
|
-
export { P as PlaywrightTestInfo, g as generateSessionId, p as playwrightProxy, s as setProxyMode, b as startRecording, c as startReplay, d as stopProxy } from '../index-
|
|
2
|
+
export { P as PlaywrightTestInfo, g as generateSessionId, p as playwrightProxy, s as setProxyMode, b as startRecording, c as startReplay, d as stopProxy } from '../index-CBjvm5rb.js';
|
|
3
3
|
import 'node:http';
|
|
@@ -6,15 +6,25 @@ var Modes = {
|
|
|
6
6
|
};
|
|
7
7
|
|
|
8
8
|
// src/playwright/index.ts
|
|
9
|
-
|
|
9
|
+
function getProxyPort() {
|
|
10
|
+
const envPort = process.env.TEST_PROXY_RECORDER_PORT;
|
|
11
|
+
if (envPort) {
|
|
12
|
+
const parsed = Number.parseInt(envPort, 10);
|
|
13
|
+
if (!Number.isNaN(parsed)) {
|
|
14
|
+
return parsed;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
return 8100;
|
|
18
|
+
}
|
|
10
19
|
async function setProxyMode(mode, sessionId, timeout) {
|
|
20
|
+
const proxyPort = getProxyPort();
|
|
11
21
|
try {
|
|
12
22
|
const body = {
|
|
13
23
|
mode,
|
|
14
24
|
id: sessionId,
|
|
15
25
|
...timeout && { timeout }
|
|
16
26
|
};
|
|
17
|
-
const response = await fetch(
|
|
27
|
+
const response = await fetch(`http://127.0.0.1:${proxyPort}/__control`, {
|
|
18
28
|
method: "POST",
|
|
19
29
|
headers: { "Content-Type": "application/json" },
|
|
20
30
|
body: JSON.stringify(body)
|
|
@@ -31,8 +41,34 @@ async function setProxyMode(mode, sessionId, timeout) {
|
|
|
31
41
|
throw error;
|
|
32
42
|
}
|
|
33
43
|
}
|
|
44
|
+
function parseSpecFilePath(specPath) {
|
|
45
|
+
const folderMatch = specPath.match(/^(.+?)\/([^/]+)\.(spec|test)\.ts$/);
|
|
46
|
+
if (folderMatch) {
|
|
47
|
+
return { folder: folderMatch[1], fileName: folderMatch[2] };
|
|
48
|
+
}
|
|
49
|
+
const fileMatch = specPath.match(/^([^/]+)\.(spec|test)\.ts$/);
|
|
50
|
+
if (fileMatch) {
|
|
51
|
+
return { folder: null, fileName: fileMatch[1] };
|
|
52
|
+
}
|
|
53
|
+
return { folder: null, fileName: null };
|
|
54
|
+
}
|
|
55
|
+
function buildSessionPath(folder, fileName, testName) {
|
|
56
|
+
if (folder && fileName) {
|
|
57
|
+
return `${folder}/${fileName}__${testName}`;
|
|
58
|
+
}
|
|
59
|
+
if (fileName) {
|
|
60
|
+
return `${fileName}__${testName}`;
|
|
61
|
+
}
|
|
62
|
+
return testName;
|
|
63
|
+
}
|
|
34
64
|
function generateSessionId(testInfo) {
|
|
35
|
-
|
|
65
|
+
const { titlePath } = testInfo;
|
|
66
|
+
if (!titlePath || titlePath.length === 0) {
|
|
67
|
+
return testInfo.title.toLowerCase().replaceAll(/\s+/g, "-");
|
|
68
|
+
}
|
|
69
|
+
const { folder, fileName } = parseSpecFilePath(titlePath[0]);
|
|
70
|
+
const testName = titlePath.at(-1).toLowerCase().replaceAll(/\s+/g, "-");
|
|
71
|
+
return buildSessionPath(folder, fileName, testName);
|
|
36
72
|
}
|
|
37
73
|
async function startRecording(testInfo) {
|
|
38
74
|
const sessionId = generateSessionId(testInfo);
|
package/dist/proxy.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import
|
|
1
|
+
import path2 from 'path';
|
|
2
2
|
import { Command } from 'commander';
|
|
3
3
|
import fs from 'fs/promises';
|
|
4
4
|
import http from 'http';
|
|
5
|
+
import https from 'https';
|
|
5
6
|
import httpProxy from 'http-proxy';
|
|
6
7
|
import { WebSocket, WebSocketServer } from 'ws';
|
|
7
8
|
import filenamify from 'filenamify';
|
|
@@ -37,7 +38,7 @@ function parseCliArgs() {
|
|
|
37
38
|
if (targets2.length === 0) {
|
|
38
39
|
program.help();
|
|
39
40
|
}
|
|
40
|
-
const recordingsDir2 =
|
|
41
|
+
const recordingsDir2 = path2.resolve(process.cwd(), options.recordingsDir);
|
|
41
42
|
return { targets: targets2, port: port2, recordingsDir: recordingsDir2 };
|
|
42
43
|
}
|
|
43
44
|
|
|
@@ -57,7 +58,7 @@ var Modes = {
|
|
|
57
58
|
};
|
|
58
59
|
var JSON_INDENT_SPACES = 2;
|
|
59
60
|
function getRecordingPath(recordingsDir2, id) {
|
|
60
|
-
return
|
|
61
|
+
return path2.join(recordingsDir2, `${id}.mock.json`);
|
|
61
62
|
}
|
|
62
63
|
async function loadRecordingSession(filePath) {
|
|
63
64
|
const fileContent = await fs.readFile(filePath, "utf8");
|
|
@@ -65,6 +66,8 @@ async function loadRecordingSession(filePath) {
|
|
|
65
66
|
}
|
|
66
67
|
async function saveRecordingSession(recordingsDir2, session) {
|
|
67
68
|
const filePath = getRecordingPath(recordingsDir2, session.id);
|
|
69
|
+
const dirPath = path2.dirname(filePath);
|
|
70
|
+
await fs.mkdir(dirPath, { recursive: true });
|
|
68
71
|
await fs.writeFile(
|
|
69
72
|
filePath,
|
|
70
73
|
JSON.stringify(session, null, JSON_INDENT_SPACES)
|
|
@@ -117,6 +120,10 @@ var ProxyServer = class {
|
|
|
117
120
|
proxy;
|
|
118
121
|
currentSession;
|
|
119
122
|
recordingsDir;
|
|
123
|
+
requestSequenceMap;
|
|
124
|
+
// Track sequence per request key
|
|
125
|
+
replaySequenceMap;
|
|
126
|
+
// Track replay position per request key
|
|
120
127
|
constructor(targets2, recordingsDir2) {
|
|
121
128
|
this.targets = targets2;
|
|
122
129
|
this.currentTargetIndex = 0;
|
|
@@ -126,6 +133,8 @@ var ProxyServer = class {
|
|
|
126
133
|
this.modeTimeout = null;
|
|
127
134
|
this.currentSession = null;
|
|
128
135
|
this.recordingsDir = recordingsDir2;
|
|
136
|
+
this.requestSequenceMap = /* @__PURE__ */ new Map();
|
|
137
|
+
this.replaySequenceMap = /* @__PURE__ */ new Map();
|
|
129
138
|
this.proxy = httpProxy.createProxyServer({
|
|
130
139
|
secure: false,
|
|
131
140
|
changeOrigin: true
|
|
@@ -143,6 +152,7 @@ var ProxyServer = class {
|
|
|
143
152
|
this.handleUpgrade(req, socket, head);
|
|
144
153
|
});
|
|
145
154
|
server.listen(port2, () => {
|
|
155
|
+
process.env.TEST_PROXY_RECORDER_PORT = String(port2);
|
|
146
156
|
this.logServerStartup(port2);
|
|
147
157
|
});
|
|
148
158
|
return server;
|
|
@@ -164,10 +174,19 @@ var ProxyServer = class {
|
|
|
164
174
|
res.end(JSON.stringify({ error: "Proxy error", message: err.message }));
|
|
165
175
|
}
|
|
166
176
|
handleProxyResponse(proxyRes, req) {
|
|
177
|
+
this.addCorsHeaders(proxyRes, req);
|
|
167
178
|
if (this.mode === Modes.record && this.recordingId) {
|
|
168
179
|
this.recordResponse(req, proxyRes);
|
|
169
180
|
}
|
|
170
181
|
}
|
|
182
|
+
addCorsHeaders(proxyRes, req) {
|
|
183
|
+
const origin = req.headers.origin;
|
|
184
|
+
proxyRes.headers["access-control-allow-origin"] = origin || "*";
|
|
185
|
+
proxyRes.headers["access-control-allow-credentials"] = "true";
|
|
186
|
+
proxyRes.headers["access-control-allow-headers"] = req.headers["access-control-request-headers"] || "Origin, X-Requested-With, Content-Type, Accept, Authorization";
|
|
187
|
+
proxyRes.headers["access-control-allow-methods"] = "GET, POST, PUT, DELETE, PATCH, OPTIONS";
|
|
188
|
+
proxyRes.headers["access-control-expose-headers"] = "*";
|
|
189
|
+
}
|
|
171
190
|
getTarget() {
|
|
172
191
|
const target = this.targets[this.currentTargetIndex];
|
|
173
192
|
this.currentTargetIndex = (this.currentTargetIndex + 1) % this.targets.length;
|
|
@@ -242,6 +261,7 @@ var ProxyServer = class {
|
|
|
242
261
|
this.recordingId = id;
|
|
243
262
|
this.replayId = null;
|
|
244
263
|
this.currentSession = { id, recordings: [], websocketRecordings: [] };
|
|
264
|
+
this.requestSequenceMap.clear();
|
|
245
265
|
console.log(`Switched to record mode with ID: ${id}`);
|
|
246
266
|
}
|
|
247
267
|
switchToReplayMode(id) {
|
|
@@ -252,6 +272,7 @@ var ProxyServer = class {
|
|
|
252
272
|
this.replayId = id;
|
|
253
273
|
this.recordingId = null;
|
|
254
274
|
this.currentSession = null;
|
|
275
|
+
this.replaySequenceMap.clear();
|
|
255
276
|
console.log(`Switched to replay mode with ID: ${id}`);
|
|
256
277
|
}
|
|
257
278
|
setupModeTimeout(timeout) {
|
|
@@ -283,6 +304,8 @@ var ProxyServer = class {
|
|
|
283
304
|
return;
|
|
284
305
|
}
|
|
285
306
|
const key = getReqID(req);
|
|
307
|
+
const currentSequence = this.requestSequenceMap.get(key) || 0;
|
|
308
|
+
this.requestSequenceMap.set(key, currentSequence + 1);
|
|
286
309
|
const record = {
|
|
287
310
|
request: {
|
|
288
311
|
method: req.method,
|
|
@@ -291,7 +314,8 @@ var ProxyServer = class {
|
|
|
291
314
|
body: body || null
|
|
292
315
|
},
|
|
293
316
|
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
294
|
-
key
|
|
317
|
+
key,
|
|
318
|
+
sequence: currentSequence
|
|
295
319
|
};
|
|
296
320
|
this.currentSession.recordings.push(record);
|
|
297
321
|
}
|
|
@@ -300,7 +324,9 @@ var ProxyServer = class {
|
|
|
300
324
|
return;
|
|
301
325
|
}
|
|
302
326
|
const key = getReqID(req);
|
|
303
|
-
const record = this.currentSession.recordings.
|
|
327
|
+
const record = this.currentSession.recordings.findLast(
|
|
328
|
+
(r) => r.key === key && !r.response
|
|
329
|
+
);
|
|
304
330
|
if (!record) {
|
|
305
331
|
console.error("Request record not found for response:", key);
|
|
306
332
|
return;
|
|
@@ -325,17 +351,31 @@ var ProxyServer = class {
|
|
|
325
351
|
const filePath = getRecordingPath(this.recordingsDir, this.replayId);
|
|
326
352
|
try {
|
|
327
353
|
const session = await loadRecordingSession(filePath);
|
|
328
|
-
const
|
|
354
|
+
const currentSequence = this.replaySequenceMap.get(key) || 0;
|
|
355
|
+
const record = session.recordings.find(
|
|
356
|
+
(r) => r.key === key && r.sequence === currentSequence
|
|
357
|
+
);
|
|
329
358
|
if (!record) {
|
|
330
|
-
throw new Error(
|
|
359
|
+
throw new Error(
|
|
360
|
+
`No recording found for ${key} with sequence ${currentSequence}`
|
|
361
|
+
);
|
|
331
362
|
}
|
|
332
363
|
if (!record.response) {
|
|
333
364
|
throw new Error("No response recorded for this request");
|
|
334
365
|
}
|
|
366
|
+
this.replaySequenceMap.set(key, currentSequence + 1);
|
|
335
367
|
const { statusCode, headers, body } = record.response;
|
|
336
|
-
|
|
368
|
+
const origin = req.headers.origin;
|
|
369
|
+
const responseHeaders = {
|
|
370
|
+
...headers,
|
|
371
|
+
"access-control-allow-origin": origin || "*",
|
|
372
|
+
"access-control-allow-credentials": "true"
|
|
373
|
+
};
|
|
374
|
+
res.writeHead(statusCode, responseHeaders);
|
|
337
375
|
res.end(body);
|
|
338
|
-
console.log(
|
|
376
|
+
console.log(
|
|
377
|
+
`Replayed: ${req.method} ${req.url} (sequence: ${currentSequence})`
|
|
378
|
+
);
|
|
339
379
|
} catch (error) {
|
|
340
380
|
this.handleReplayError(res, error, key, filePath);
|
|
341
381
|
}
|
|
@@ -351,6 +391,9 @@ var ProxyServer = class {
|
|
|
351
391
|
});
|
|
352
392
|
}
|
|
353
393
|
async handleRequest(req, res) {
|
|
394
|
+
if (req.method === "OPTIONS") {
|
|
395
|
+
return this.handleCorsPreflightRequest(req, res);
|
|
396
|
+
}
|
|
354
397
|
if (req.url === CONTROL_ENDPOINT) {
|
|
355
398
|
return this.handleControlRequest(req, res);
|
|
356
399
|
}
|
|
@@ -359,23 +402,63 @@ var ProxyServer = class {
|
|
|
359
402
|
}
|
|
360
403
|
await this.handleProxyRequest(req, res);
|
|
361
404
|
}
|
|
405
|
+
handleCorsPreflightRequest(req, res) {
|
|
406
|
+
const origin = req.headers.origin;
|
|
407
|
+
res.writeHead(HTTP_STATUS_OK, {
|
|
408
|
+
"Access-Control-Allow-Origin": origin || "*",
|
|
409
|
+
"Access-Control-Allow-Credentials": "true",
|
|
410
|
+
"Access-Control-Allow-Headers": req.headers["access-control-request-headers"] || "Origin, X-Requested-With, Content-Type, Accept, Authorization",
|
|
411
|
+
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, PATCH, OPTIONS",
|
|
412
|
+
"Access-Control-Max-Age": "86400"
|
|
413
|
+
// 24 hours
|
|
414
|
+
});
|
|
415
|
+
res.end();
|
|
416
|
+
}
|
|
362
417
|
async handleProxyRequest(req, res) {
|
|
363
418
|
const target = this.getTarget();
|
|
364
419
|
console.log(`[${this.mode}] ${req.method} ${req.url} -> ${target}`);
|
|
365
420
|
if (this.mode === Modes.record) {
|
|
366
|
-
await this.
|
|
421
|
+
await this.bufferAndProxyRequest(req, res, target);
|
|
422
|
+
} else {
|
|
423
|
+
this.proxy.web(req, res, { target });
|
|
367
424
|
}
|
|
368
|
-
this.proxy.web(req, res, { target });
|
|
369
425
|
}
|
|
370
|
-
async
|
|
426
|
+
async bufferAndProxyRequest(req, res, target) {
|
|
371
427
|
const chunks = [];
|
|
372
428
|
req.on("data", (chunk) => {
|
|
373
429
|
chunks.push(chunk);
|
|
374
430
|
});
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
await this.saveRequestRecord(req, body);
|
|
431
|
+
await new Promise((resolve) => {
|
|
432
|
+
req.on("end", () => resolve());
|
|
378
433
|
});
|
|
434
|
+
const body = Buffer.concat(chunks).toString("utf8");
|
|
435
|
+
await this.saveRequestRecord(req, body);
|
|
436
|
+
const targetUrl = new URL(target);
|
|
437
|
+
const isHttps = targetUrl.protocol === "https:";
|
|
438
|
+
const requestModule = isHttps ? https : http;
|
|
439
|
+
const defaultPort = isHttps ? 443 : 80;
|
|
440
|
+
const proxyReq = requestModule.request(
|
|
441
|
+
{
|
|
442
|
+
hostname: targetUrl.hostname,
|
|
443
|
+
port: targetUrl.port || defaultPort,
|
|
444
|
+
path: req.url,
|
|
445
|
+
method: req.method,
|
|
446
|
+
headers: req.headers
|
|
447
|
+
},
|
|
448
|
+
(proxyRes) => {
|
|
449
|
+
this.addCorsHeaders(proxyRes, req);
|
|
450
|
+
this.recordResponse(req, proxyRes);
|
|
451
|
+
res.writeHead(proxyRes.statusCode || 200, proxyRes.headers);
|
|
452
|
+
proxyRes.pipe(res);
|
|
453
|
+
}
|
|
454
|
+
);
|
|
455
|
+
proxyReq.on("error", (err) => {
|
|
456
|
+
this.handleProxyError(err, req, res);
|
|
457
|
+
});
|
|
458
|
+
if (chunks.length > 0) {
|
|
459
|
+
proxyReq.write(Buffer.concat(chunks));
|
|
460
|
+
}
|
|
461
|
+
proxyReq.end();
|
|
379
462
|
}
|
|
380
463
|
handleUpgrade(req, socket, head) {
|
|
381
464
|
if (this.mode === Modes.replay) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "test-proxy-recorder",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.5",
|
|
4
4
|
"description": "HTTP proxy server for recording and replaying network requests in testing. Works seamlessly with Playwright testing framework.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.mjs",
|