test-proxy-recorder 0.1.4 → 0.1.6
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 +345 -152
- package/dist/{index-De4mgziH.d.cts → index-CBjvm5rb.d.cts} +5 -1
- package/dist/{index-De4mgziH.d.ts → index-CBjvm5rb.d.ts} +5 -1
- package/dist/index.cjs +210 -35
- package/dist/index.d.cts +4 -2
- package/dist/index.d.ts +4 -2
- package/dist/index.mjs +210 -35
- 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 +173 -34
- package/package.json +8 -3
package/dist/proxy.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
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';
|
|
@@ -38,7 +38,7 @@ function parseCliArgs() {
|
|
|
38
38
|
if (targets2.length === 0) {
|
|
39
39
|
program.help();
|
|
40
40
|
}
|
|
41
|
-
const recordingsDir2 =
|
|
41
|
+
const recordingsDir2 = path2.resolve(process.cwd(), options.recordingsDir);
|
|
42
42
|
return { targets: targets2, port: port2, recordingsDir: recordingsDir2 };
|
|
43
43
|
}
|
|
44
44
|
|
|
@@ -58,7 +58,7 @@ var Modes = {
|
|
|
58
58
|
};
|
|
59
59
|
var JSON_INDENT_SPACES = 2;
|
|
60
60
|
function getRecordingPath(recordingsDir2, id) {
|
|
61
|
-
return
|
|
61
|
+
return path2.join(recordingsDir2, `${id}.mock.json`);
|
|
62
62
|
}
|
|
63
63
|
async function loadRecordingSession(filePath) {
|
|
64
64
|
const fileContent = await fs.readFile(filePath, "utf8");
|
|
@@ -66,6 +66,8 @@ async function loadRecordingSession(filePath) {
|
|
|
66
66
|
}
|
|
67
67
|
async function saveRecordingSession(recordingsDir2, session) {
|
|
68
68
|
const filePath = getRecordingPath(recordingsDir2, session.id);
|
|
69
|
+
const dirPath = path2.dirname(filePath);
|
|
70
|
+
await fs.mkdir(dirPath, { recursive: true });
|
|
69
71
|
await fs.writeFile(
|
|
70
72
|
filePath,
|
|
71
73
|
JSON.stringify(session, null, JSON_INDENT_SPACES)
|
|
@@ -150,6 +152,7 @@ var ProxyServer = class {
|
|
|
150
152
|
this.handleUpgrade(req, socket, head);
|
|
151
153
|
});
|
|
152
154
|
server.listen(port2, () => {
|
|
155
|
+
process.env.TEST_PROXY_RECORDER_PORT = String(port2);
|
|
153
156
|
this.logServerStartup(port2);
|
|
154
157
|
});
|
|
155
158
|
return server;
|
|
@@ -158,14 +161,17 @@ var ProxyServer = class {
|
|
|
158
161
|
this.proxy.on("error", this.handleProxyError.bind(this));
|
|
159
162
|
this.proxy.on("proxyRes", this.handleProxyResponse.bind(this));
|
|
160
163
|
}
|
|
161
|
-
handleProxyError(err,
|
|
164
|
+
handleProxyError(err, req, res) {
|
|
162
165
|
console.error("Proxy error:", err);
|
|
163
166
|
if (!(res instanceof http.ServerResponse)) {
|
|
164
167
|
return;
|
|
165
168
|
}
|
|
166
169
|
if (!res.headersSent) {
|
|
170
|
+
const origin = req.headers.origin;
|
|
167
171
|
res.writeHead(HTTP_STATUS_BAD_GATEWAY, {
|
|
168
|
-
"Content-Type": "application/json"
|
|
172
|
+
"Content-Type": "application/json",
|
|
173
|
+
"Access-Control-Allow-Origin": origin || "*",
|
|
174
|
+
"Access-Control-Allow-Credentials": "true"
|
|
169
175
|
});
|
|
170
176
|
}
|
|
171
177
|
res.end(JSON.stringify({ error: "Proxy error", message: err.message }));
|
|
@@ -221,7 +227,7 @@ var ProxyServer = class {
|
|
|
221
227
|
async switchMode(mode, id) {
|
|
222
228
|
if (this.currentSession) {
|
|
223
229
|
console.log("Switching mode, saving current session first");
|
|
224
|
-
await this.saveCurrentSession();
|
|
230
|
+
await this.saveCurrentSession(true);
|
|
225
231
|
console.log("Session saved, continuing with mode switch");
|
|
226
232
|
}
|
|
227
233
|
switch (mode) {
|
|
@@ -276,13 +282,13 @@ var ProxyServer = class {
|
|
|
276
282
|
if (timeout && timeout > 0) {
|
|
277
283
|
this.modeTimeout = setTimeout(async () => {
|
|
278
284
|
console.log("Timeout reached, switching back to transparent mode");
|
|
279
|
-
await this.saveCurrentSession();
|
|
285
|
+
await this.saveCurrentSession(true);
|
|
280
286
|
this.switchToTransparentMode();
|
|
281
287
|
this.modeTimeout = null;
|
|
282
288
|
}, timeout);
|
|
283
289
|
}
|
|
284
290
|
}
|
|
285
|
-
async saveCurrentSession() {
|
|
291
|
+
async saveCurrentSession(filterIncomplete = false) {
|
|
286
292
|
if (!this.currentSession) {
|
|
287
293
|
console.log("No current session to save");
|
|
288
294
|
return;
|
|
@@ -291,13 +297,27 @@ var ProxyServer = class {
|
|
|
291
297
|
console.log("Session has no recordings, skipping save");
|
|
292
298
|
return;
|
|
293
299
|
}
|
|
300
|
+
if (filterIncomplete) {
|
|
301
|
+
const incompleteCount = this.currentSession.recordings.filter(
|
|
302
|
+
(r) => !r.response
|
|
303
|
+
).length;
|
|
304
|
+
if (incompleteCount > 0) {
|
|
305
|
+
console.log(
|
|
306
|
+
`Removing ${incompleteCount} incomplete recording(s) without responses`
|
|
307
|
+
);
|
|
308
|
+
this.currentSession.recordings = this.currentSession.recordings.filter(
|
|
309
|
+
(r) => r.response
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
294
313
|
console.log(
|
|
295
314
|
`Saving session with ${this.currentSession.recordings.length} HTTP and ${this.currentSession.websocketRecordings.length} WebSocket recordings`
|
|
296
315
|
);
|
|
297
316
|
await saveRecordingSession(this.recordingsDir, this.currentSession);
|
|
298
317
|
}
|
|
299
|
-
|
|
318
|
+
saveRequestRecordSync(req, body) {
|
|
300
319
|
if (!this.currentSession) {
|
|
320
|
+
console.log("saveRequestRecordSync: No current session");
|
|
301
321
|
return;
|
|
302
322
|
}
|
|
303
323
|
const key = getReqID(req);
|
|
@@ -315,6 +335,30 @@ var ProxyServer = class {
|
|
|
315
335
|
sequence: currentSequence
|
|
316
336
|
};
|
|
317
337
|
this.currentSession.recordings.push(record);
|
|
338
|
+
console.log(
|
|
339
|
+
// eslint-disable-next-line sonarjs/no-nested-template-literals
|
|
340
|
+
`saveRequestRecordSync: Saved ${req.method} ${req.url} (key: ${key}, seq: ${currentSequence}, body: ${body ? `${body.length} chars` : "null"}, total: ${this.currentSession.recordings.length}, sessionId: ${this.currentSession.id})`
|
|
341
|
+
);
|
|
342
|
+
}
|
|
343
|
+
updateRequestBodySync(req, body) {
|
|
344
|
+
if (!this.currentSession) {
|
|
345
|
+
console.log("updateRequestBodySync: No current session");
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
const key = getReqID(req);
|
|
349
|
+
const record = this.currentSession.recordings.findLast(
|
|
350
|
+
(r) => r.key === key && !r.response
|
|
351
|
+
);
|
|
352
|
+
if (!record) {
|
|
353
|
+
console.error(
|
|
354
|
+
`updateRequestBodySync: Could not find request record for ${req.method} ${req.url}`
|
|
355
|
+
);
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
record.request.body = body || null;
|
|
359
|
+
console.log(
|
|
360
|
+
`updateRequestBodySync: Updated body for ${req.method} ${req.url} (${body.length} chars)`
|
|
361
|
+
);
|
|
318
362
|
}
|
|
319
363
|
async recordResponse(req, proxyRes) {
|
|
320
364
|
if (!this.currentSession) {
|
|
@@ -343,49 +387,105 @@ var ProxyServer = class {
|
|
|
343
387
|
console.log(`Recorded: ${req.method} ${req.url}`);
|
|
344
388
|
});
|
|
345
389
|
}
|
|
390
|
+
async recordResponseData(req, proxyRes, body) {
|
|
391
|
+
if (!this.currentSession) {
|
|
392
|
+
console.log("recordResponseData: No current session");
|
|
393
|
+
return false;
|
|
394
|
+
}
|
|
395
|
+
const key = getReqID(req);
|
|
396
|
+
const record = this.currentSession.recordings.findLast(
|
|
397
|
+
(r) => r.key === key && !r.response
|
|
398
|
+
);
|
|
399
|
+
if (!record) {
|
|
400
|
+
const host = req.headers.host || "unknown";
|
|
401
|
+
const recordsWithKey = this.currentSession.recordings.filter(
|
|
402
|
+
(r) => r.key === key
|
|
403
|
+
);
|
|
404
|
+
console.error(
|
|
405
|
+
`Request record not found for response: ${key} at ${req.method} ${host}${req.url}`
|
|
406
|
+
);
|
|
407
|
+
console.error(
|
|
408
|
+
` Total recordings: ${this.currentSession.recordings.length}, with this key: ${recordsWithKey.length}`
|
|
409
|
+
);
|
|
410
|
+
console.error(
|
|
411
|
+
` Records with key:`,
|
|
412
|
+
recordsWithKey.map((r) => ({
|
|
413
|
+
seq: r.sequence,
|
|
414
|
+
hasResponse: !!r.response
|
|
415
|
+
}))
|
|
416
|
+
);
|
|
417
|
+
return false;
|
|
418
|
+
}
|
|
419
|
+
record.response = {
|
|
420
|
+
statusCode: proxyRes.statusCode,
|
|
421
|
+
headers: proxyRes.headers,
|
|
422
|
+
body: body || null
|
|
423
|
+
};
|
|
424
|
+
await this.saveCurrentSession();
|
|
425
|
+
console.log(
|
|
426
|
+
`recordResponseData: Recorded response for ${req.method} ${req.url}`
|
|
427
|
+
);
|
|
428
|
+
return true;
|
|
429
|
+
}
|
|
346
430
|
async handleReplayRequest(req, res) {
|
|
347
431
|
const key = getReqID(req);
|
|
348
432
|
const filePath = getRecordingPath(this.recordingsDir, this.replayId);
|
|
349
433
|
try {
|
|
350
434
|
const session = await loadRecordingSession(filePath);
|
|
351
|
-
const
|
|
352
|
-
const
|
|
353
|
-
(r) => r.key === key && r.
|
|
435
|
+
const host = req.headers.host || "unknown";
|
|
436
|
+
const recordsWithKey = session.recordings.filter(
|
|
437
|
+
(r) => r.key === key && r.response
|
|
354
438
|
);
|
|
355
|
-
if (
|
|
439
|
+
if (recordsWithKey.length === 0) {
|
|
356
440
|
throw new Error(
|
|
357
|
-
`No recording found for ${key}
|
|
441
|
+
`No recording found for ${key} at ${req.method} ${host}${req.url}`
|
|
358
442
|
);
|
|
359
443
|
}
|
|
444
|
+
const usageCount = this.replaySequenceMap.get(key) || 0;
|
|
445
|
+
const recordIndex = usageCount % recordsWithKey.length;
|
|
446
|
+
const record = recordsWithKey[recordIndex];
|
|
447
|
+
console.log(
|
|
448
|
+
`Replaying ${req.method} ${req.url} (usage: ${usageCount}, using recording ${recordIndex}/${recordsWithKey.length})`
|
|
449
|
+
);
|
|
450
|
+
this.replaySequenceMap.set(key, usageCount + 1);
|
|
360
451
|
if (!record.response) {
|
|
361
|
-
throw new Error(
|
|
452
|
+
throw new Error(
|
|
453
|
+
`No response recorded for this request: ${req.method} ${host}${req.url}`
|
|
454
|
+
);
|
|
362
455
|
}
|
|
363
|
-
this.replaySequenceMap.set(key, currentSequence + 1);
|
|
364
456
|
const { statusCode, headers, body } = record.response;
|
|
365
457
|
const origin = req.headers.origin;
|
|
366
458
|
const responseHeaders = {
|
|
367
459
|
...headers,
|
|
368
460
|
"access-control-allow-origin": origin || "*",
|
|
369
|
-
"access-control-allow-credentials": "true"
|
|
461
|
+
"access-control-allow-credentials": "true",
|
|
462
|
+
"access-control-allow-headers": req.headers["access-control-request-headers"] || "Origin, X-Requested-With, Content-Type, Accept, Authorization",
|
|
463
|
+
"access-control-allow-methods": "GET, POST, PUT, DELETE, PATCH, OPTIONS",
|
|
464
|
+
"access-control-expose-headers": "*"
|
|
370
465
|
};
|
|
371
466
|
res.writeHead(statusCode, responseHeaders);
|
|
372
467
|
res.end(body);
|
|
373
|
-
console.log(
|
|
374
|
-
`Replayed: ${req.method} ${req.url} (sequence: ${currentSequence})`
|
|
375
|
-
);
|
|
376
468
|
} catch (error) {
|
|
377
|
-
this.handleReplayError(res, error, key, filePath);
|
|
469
|
+
this.handleReplayError(req, res, error, key, filePath);
|
|
378
470
|
}
|
|
379
471
|
}
|
|
380
|
-
handleReplayError(res, err, key, filePath) {
|
|
472
|
+
handleReplayError(req, res, err, key, filePath) {
|
|
381
473
|
const isFileNotFound = err instanceof Error && "code" in err && err.code === "ENOENT";
|
|
382
474
|
console.error("Replay error:", err);
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
475
|
+
const origin = req.headers.origin;
|
|
476
|
+
res.writeHead(HTTP_STATUS_NOT_FOUND, {
|
|
477
|
+
"Content-Type": "application/json",
|
|
478
|
+
"Access-Control-Allow-Origin": origin || "*",
|
|
479
|
+
"Access-Control-Allow-Credentials": "true"
|
|
388
480
|
});
|
|
481
|
+
res.end(
|
|
482
|
+
JSON.stringify({
|
|
483
|
+
error: isFileNotFound ? "Recording file not found" : "Recording not found",
|
|
484
|
+
message: err instanceof Error ? err.message : "Unknown error",
|
|
485
|
+
key,
|
|
486
|
+
filePath
|
|
487
|
+
})
|
|
488
|
+
);
|
|
389
489
|
}
|
|
390
490
|
async handleRequest(req, res) {
|
|
391
491
|
if (req.method === "OPTIONS") {
|
|
@@ -415,6 +515,7 @@ var ProxyServer = class {
|
|
|
415
515
|
const target = this.getTarget();
|
|
416
516
|
console.log(`[${this.mode}] ${req.method} ${req.url} -> ${target}`);
|
|
417
517
|
if (this.mode === Modes.record) {
|
|
518
|
+
this.saveRequestRecordSync(req, null);
|
|
418
519
|
await this.bufferAndProxyRequest(req, res, target);
|
|
419
520
|
} else {
|
|
420
521
|
this.proxy.web(req, res, { target });
|
|
@@ -425,11 +526,20 @@ var ProxyServer = class {
|
|
|
425
526
|
req.on("data", (chunk) => {
|
|
426
527
|
chunks.push(chunk);
|
|
427
528
|
});
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
529
|
+
try {
|
|
530
|
+
await new Promise((resolve, reject) => {
|
|
531
|
+
req.on("end", () => resolve());
|
|
532
|
+
req.on("error", (err) => reject(err));
|
|
533
|
+
setTimeout(
|
|
534
|
+
() => reject(new Error("Request buffering timeout")),
|
|
535
|
+
3e4
|
|
536
|
+
);
|
|
537
|
+
});
|
|
538
|
+
} catch (error) {
|
|
539
|
+
console.error("Error buffering request:", error);
|
|
540
|
+
}
|
|
431
541
|
const body = Buffer.concat(chunks).toString("utf8");
|
|
432
|
-
|
|
542
|
+
this.updateRequestBodySync(req, body);
|
|
433
543
|
const targetUrl = new URL(target);
|
|
434
544
|
const isHttps = targetUrl.protocol === "https:";
|
|
435
545
|
const requestModule = isHttps ? https : http;
|
|
@@ -444,9 +554,38 @@ var ProxyServer = class {
|
|
|
444
554
|
},
|
|
445
555
|
(proxyRes) => {
|
|
446
556
|
this.addCorsHeaders(proxyRes, req);
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
557
|
+
const responseChunks = [];
|
|
558
|
+
proxyRes.on("data", (chunk) => {
|
|
559
|
+
responseChunks.push(chunk);
|
|
560
|
+
});
|
|
561
|
+
proxyRes.on("end", async () => {
|
|
562
|
+
const responseBody = Buffer.concat(responseChunks);
|
|
563
|
+
const recorded = await this.recordResponseData(
|
|
564
|
+
req,
|
|
565
|
+
proxyRes,
|
|
566
|
+
responseBody.toString("utf8")
|
|
567
|
+
);
|
|
568
|
+
const origin = req.headers.origin;
|
|
569
|
+
const responseHeaders = {
|
|
570
|
+
...proxyRes.headers,
|
|
571
|
+
"access-control-allow-origin": origin || "*",
|
|
572
|
+
"access-control-allow-credentials": "true",
|
|
573
|
+
"access-control-allow-headers": req.headers["access-control-request-headers"] || "Origin, X-Requested-With, Content-Type, Accept, Authorization",
|
|
574
|
+
"access-control-allow-methods": "GET, POST, PUT, DELETE, PATCH, OPTIONS",
|
|
575
|
+
"access-control-expose-headers": "*"
|
|
576
|
+
};
|
|
577
|
+
res.writeHead(proxyRes.statusCode || 200, responseHeaders);
|
|
578
|
+
res.end(responseBody);
|
|
579
|
+
if (recorded) {
|
|
580
|
+
console.log(`Recorded: ${req.method} ${req.url}`);
|
|
581
|
+
}
|
|
582
|
+
});
|
|
583
|
+
proxyRes.on("error", (err) => {
|
|
584
|
+
console.error("Proxy response error:", err);
|
|
585
|
+
if (!res.headersSent) {
|
|
586
|
+
this.handleProxyError(err, req, res);
|
|
587
|
+
}
|
|
588
|
+
});
|
|
450
589
|
}
|
|
451
590
|
);
|
|
452
591
|
proxyReq.on("error", (err) => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "test-proxy-recorder",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.6",
|
|
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",
|
|
@@ -32,6 +32,9 @@
|
|
|
32
32
|
"README.md",
|
|
33
33
|
"LICENSE"
|
|
34
34
|
],
|
|
35
|
+
"pnpm": {
|
|
36
|
+
"onlyBuiltDependencies": ["esbuild"]
|
|
37
|
+
},
|
|
35
38
|
"packageManager": "pnpm@10.20.0+sha512.cf9998222162dd85864d0a8102e7892e7ba4ceadebbf5a31f9c2fce48dfce317a9c53b9f6464d1ef9042cba2e02ae02a9f7c143a2b438cd93c91840f0192b9dd",
|
|
36
39
|
"scripts": {
|
|
37
40
|
"start": "node dist/proxy.js",
|
|
@@ -42,7 +45,8 @@
|
|
|
42
45
|
"lint:fix": "eslint src --ext .ts --fix",
|
|
43
46
|
"typecheck": "tsc --noEmit",
|
|
44
47
|
"test": "vitest",
|
|
45
|
-
"test:run": "vitest run"
|
|
48
|
+
"test:run": "vitest run",
|
|
49
|
+
"test:coverage": "vitest run --coverage"
|
|
46
50
|
},
|
|
47
51
|
"keywords": [
|
|
48
52
|
"playwright",
|
|
@@ -91,6 +95,7 @@
|
|
|
91
95
|
"@types/ws": "^8.18.1",
|
|
92
96
|
"@typescript-eslint/eslint-plugin": "^8.0.0",
|
|
93
97
|
"@typescript-eslint/parser": "^8.0.0",
|
|
98
|
+
"@vitest/coverage-v8": "^4.0.8",
|
|
94
99
|
"eslint": "^9.0.0",
|
|
95
100
|
"eslint-config-prettier": "^10.1.8",
|
|
96
101
|
"eslint-plugin-prettier": "^5.5.4",
|
|
@@ -101,7 +106,7 @@
|
|
|
101
106
|
"tsup": "^8.5.0",
|
|
102
107
|
"tsx": "^4.19.0",
|
|
103
108
|
"typescript": "^5.6.0",
|
|
104
|
-
"vitest": "^
|
|
109
|
+
"vitest": "^4.0.8",
|
|
105
110
|
"ws": "^8.18.3"
|
|
106
111
|
}
|
|
107
112
|
}
|