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/dist/proxy.js CHANGED
@@ -1,4 +1,4 @@
1
- import path from 'path';
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 = path.resolve(process.cwd(), options.recordingsDir);
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 path.join(recordingsDir2, `${id}.json`);
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, _req, res) {
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
- async saveRequestRecord(req, body) {
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 currentSequence = this.replaySequenceMap.get(key) || 0;
352
- const record = session.recordings.find(
353
- (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
354
438
  );
355
- if (!record) {
439
+ if (recordsWithKey.length === 0) {
356
440
  throw new Error(
357
- `No recording found for ${key} with sequence ${currentSequence}`
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("No response recorded for this request");
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
- sendJsonResponse(res, HTTP_STATUS_NOT_FOUND, {
384
- error: isFileNotFound ? "Recording file not found" : "Recording not found",
385
- message: err instanceof Error ? err.message : "Unknown error",
386
- key,
387
- 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"
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
- await new Promise((resolve) => {
429
- req.on("end", () => resolve());
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
- await this.saveRequestRecord(req, body);
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
- this.recordResponse(req, proxyRes);
448
- res.writeHead(proxyRes.statusCode || 200, proxyRes.headers);
449
- 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
+ });
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.4",
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
  }