test-proxy-recorder 0.1.5 → 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 +334 -207
- package/dist/index.cjs +167 -31
- package/dist/index.d.cts +3 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.mjs +167 -31
- package/dist/proxy.js +167 -31
- package/package.json +8 -3
package/dist/proxy.js
CHANGED
|
@@ -161,14 +161,17 @@ var ProxyServer = class {
|
|
|
161
161
|
this.proxy.on("error", this.handleProxyError.bind(this));
|
|
162
162
|
this.proxy.on("proxyRes", this.handleProxyResponse.bind(this));
|
|
163
163
|
}
|
|
164
|
-
handleProxyError(err,
|
|
164
|
+
handleProxyError(err, req, res) {
|
|
165
165
|
console.error("Proxy error:", err);
|
|
166
166
|
if (!(res instanceof http.ServerResponse)) {
|
|
167
167
|
return;
|
|
168
168
|
}
|
|
169
169
|
if (!res.headersSent) {
|
|
170
|
+
const origin = req.headers.origin;
|
|
170
171
|
res.writeHead(HTTP_STATUS_BAD_GATEWAY, {
|
|
171
|
-
"Content-Type": "application/json"
|
|
172
|
+
"Content-Type": "application/json",
|
|
173
|
+
"Access-Control-Allow-Origin": origin || "*",
|
|
174
|
+
"Access-Control-Allow-Credentials": "true"
|
|
172
175
|
});
|
|
173
176
|
}
|
|
174
177
|
res.end(JSON.stringify({ error: "Proxy error", message: err.message }));
|
|
@@ -224,7 +227,7 @@ var ProxyServer = class {
|
|
|
224
227
|
async switchMode(mode, id) {
|
|
225
228
|
if (this.currentSession) {
|
|
226
229
|
console.log("Switching mode, saving current session first");
|
|
227
|
-
await this.saveCurrentSession();
|
|
230
|
+
await this.saveCurrentSession(true);
|
|
228
231
|
console.log("Session saved, continuing with mode switch");
|
|
229
232
|
}
|
|
230
233
|
switch (mode) {
|
|
@@ -279,13 +282,13 @@ var ProxyServer = class {
|
|
|
279
282
|
if (timeout && timeout > 0) {
|
|
280
283
|
this.modeTimeout = setTimeout(async () => {
|
|
281
284
|
console.log("Timeout reached, switching back to transparent mode");
|
|
282
|
-
await this.saveCurrentSession();
|
|
285
|
+
await this.saveCurrentSession(true);
|
|
283
286
|
this.switchToTransparentMode();
|
|
284
287
|
this.modeTimeout = null;
|
|
285
288
|
}, timeout);
|
|
286
289
|
}
|
|
287
290
|
}
|
|
288
|
-
async saveCurrentSession() {
|
|
291
|
+
async saveCurrentSession(filterIncomplete = false) {
|
|
289
292
|
if (!this.currentSession) {
|
|
290
293
|
console.log("No current session to save");
|
|
291
294
|
return;
|
|
@@ -294,13 +297,27 @@ var ProxyServer = class {
|
|
|
294
297
|
console.log("Session has no recordings, skipping save");
|
|
295
298
|
return;
|
|
296
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
|
+
}
|
|
297
313
|
console.log(
|
|
298
314
|
`Saving session with ${this.currentSession.recordings.length} HTTP and ${this.currentSession.websocketRecordings.length} WebSocket recordings`
|
|
299
315
|
);
|
|
300
316
|
await saveRecordingSession(this.recordingsDir, this.currentSession);
|
|
301
317
|
}
|
|
302
|
-
|
|
318
|
+
saveRequestRecordSync(req, body) {
|
|
303
319
|
if (!this.currentSession) {
|
|
320
|
+
console.log("saveRequestRecordSync: No current session");
|
|
304
321
|
return;
|
|
305
322
|
}
|
|
306
323
|
const key = getReqID(req);
|
|
@@ -318,6 +335,30 @@ var ProxyServer = class {
|
|
|
318
335
|
sequence: currentSequence
|
|
319
336
|
};
|
|
320
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
|
+
);
|
|
321
362
|
}
|
|
322
363
|
async recordResponse(req, proxyRes) {
|
|
323
364
|
if (!this.currentSession) {
|
|
@@ -346,49 +387,105 @@ var ProxyServer = class {
|
|
|
346
387
|
console.log(`Recorded: ${req.method} ${req.url}`);
|
|
347
388
|
});
|
|
348
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
|
+
}
|
|
349
430
|
async handleReplayRequest(req, res) {
|
|
350
431
|
const key = getReqID(req);
|
|
351
432
|
const filePath = getRecordingPath(this.recordingsDir, this.replayId);
|
|
352
433
|
try {
|
|
353
434
|
const session = await loadRecordingSession(filePath);
|
|
354
|
-
const
|
|
355
|
-
const
|
|
356
|
-
(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
|
|
357
438
|
);
|
|
358
|
-
if (
|
|
439
|
+
if (recordsWithKey.length === 0) {
|
|
359
440
|
throw new Error(
|
|
360
|
-
`No recording found for ${key}
|
|
441
|
+
`No recording found for ${key} at ${req.method} ${host}${req.url}`
|
|
361
442
|
);
|
|
362
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);
|
|
363
451
|
if (!record.response) {
|
|
364
|
-
throw new Error(
|
|
452
|
+
throw new Error(
|
|
453
|
+
`No response recorded for this request: ${req.method} ${host}${req.url}`
|
|
454
|
+
);
|
|
365
455
|
}
|
|
366
|
-
this.replaySequenceMap.set(key, currentSequence + 1);
|
|
367
456
|
const { statusCode, headers, body } = record.response;
|
|
368
457
|
const origin = req.headers.origin;
|
|
369
458
|
const responseHeaders = {
|
|
370
459
|
...headers,
|
|
371
460
|
"access-control-allow-origin": origin || "*",
|
|
372
|
-
"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": "*"
|
|
373
465
|
};
|
|
374
466
|
res.writeHead(statusCode, responseHeaders);
|
|
375
467
|
res.end(body);
|
|
376
|
-
console.log(
|
|
377
|
-
`Replayed: ${req.method} ${req.url} (sequence: ${currentSequence})`
|
|
378
|
-
);
|
|
379
468
|
} catch (error) {
|
|
380
|
-
this.handleReplayError(res, error, key, filePath);
|
|
469
|
+
this.handleReplayError(req, res, error, key, filePath);
|
|
381
470
|
}
|
|
382
471
|
}
|
|
383
|
-
handleReplayError(res, err, key, filePath) {
|
|
472
|
+
handleReplayError(req, res, err, key, filePath) {
|
|
384
473
|
const isFileNotFound = err instanceof Error && "code" in err && err.code === "ENOENT";
|
|
385
474
|
console.error("Replay error:", err);
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
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"
|
|
391
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
|
+
);
|
|
392
489
|
}
|
|
393
490
|
async handleRequest(req, res) {
|
|
394
491
|
if (req.method === "OPTIONS") {
|
|
@@ -418,6 +515,7 @@ var ProxyServer = class {
|
|
|
418
515
|
const target = this.getTarget();
|
|
419
516
|
console.log(`[${this.mode}] ${req.method} ${req.url} -> ${target}`);
|
|
420
517
|
if (this.mode === Modes.record) {
|
|
518
|
+
this.saveRequestRecordSync(req, null);
|
|
421
519
|
await this.bufferAndProxyRequest(req, res, target);
|
|
422
520
|
} else {
|
|
423
521
|
this.proxy.web(req, res, { target });
|
|
@@ -428,11 +526,20 @@ var ProxyServer = class {
|
|
|
428
526
|
req.on("data", (chunk) => {
|
|
429
527
|
chunks.push(chunk);
|
|
430
528
|
});
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
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
|
+
}
|
|
434
541
|
const body = Buffer.concat(chunks).toString("utf8");
|
|
435
|
-
|
|
542
|
+
this.updateRequestBodySync(req, body);
|
|
436
543
|
const targetUrl = new URL(target);
|
|
437
544
|
const isHttps = targetUrl.protocol === "https:";
|
|
438
545
|
const requestModule = isHttps ? https : http;
|
|
@@ -447,9 +554,38 @@ var ProxyServer = class {
|
|
|
447
554
|
},
|
|
448
555
|
(proxyRes) => {
|
|
449
556
|
this.addCorsHeaders(proxyRes, req);
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
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
|
+
});
|
|
453
589
|
}
|
|
454
590
|
);
|
|
455
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
|
}
|