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/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, _req, res) {
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
- async saveRequestRecord(req, body) {
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 currentSequence = this.replaySequenceMap.get(key) || 0;
355
- const record = session.recordings.find(
356
- (r) => r.key === key && r.sequence === currentSequence
435
+ const host = req.headers.host || "unknown";
436
+ const recordsWithKey = session.recordings.filter(
437
+ (r) => r.key === key && r.response
357
438
  );
358
- if (!record) {
439
+ if (recordsWithKey.length === 0) {
359
440
  throw new Error(
360
- `No recording found for ${key} with sequence ${currentSequence}`
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("No response recorded for this request");
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
- sendJsonResponse(res, HTTP_STATUS_NOT_FOUND, {
387
- error: isFileNotFound ? "Recording file not found" : "Recording not found",
388
- message: err instanceof Error ? err.message : "Unknown error",
389
- key,
390
- filePath
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
- await new Promise((resolve) => {
432
- req.on("end", () => resolve());
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
- await this.saveRequestRecord(req, body);
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
- this.recordResponse(req, proxyRes);
451
- res.writeHead(proxyRes.statusCode || 200, proxyRes.headers);
452
- proxyRes.pipe(res);
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.5",
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": "^3.2.4",
109
+ "vitest": "^4.0.8",
105
110
  "ws": "^8.18.3"
106
111
  }
107
112
  }