test-proxy-recorder 0.1.5 → 0.1.7

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,16 @@ 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 corsHeaders = this.getCorsHeaders(req);
170
171
  res.writeHead(HTTP_STATUS_BAD_GATEWAY, {
171
- "Content-Type": "application/json"
172
+ "Content-Type": "application/json",
173
+ ...corsHeaders
172
174
  });
173
175
  }
174
176
  res.end(JSON.stringify({ error: "Proxy error", message: err.message }));
@@ -179,24 +181,51 @@ var ProxyServer = class {
179
181
  this.recordResponse(req, proxyRes);
180
182
  }
181
183
  }
182
- addCorsHeaders(proxyRes, req) {
184
+ /**
185
+ * Get CORS headers for a given request
186
+ * @param req The incoming HTTP request
187
+ * @returns An object containing CORS headers
188
+ */
189
+ getCorsHeaders(req) {
183
190
  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"] = "*";
191
+ return {
192
+ "access-control-allow-origin": origin || "*",
193
+ "access-control-allow-credentials": "true",
194
+ "access-control-allow-headers": req.headers["access-control-request-headers"] || "Origin, X-Requested-With, Content-Type, Accept, Authorization",
195
+ "access-control-allow-methods": "GET, POST, PUT, DELETE, PATCH, OPTIONS",
196
+ "access-control-expose-headers": "*"
197
+ };
198
+ }
199
+ addCorsHeaders(proxyRes, req) {
200
+ const corsHeaders = this.getCorsHeaders(req);
201
+ Object.assign(proxyRes.headers, corsHeaders);
189
202
  }
190
203
  getTarget() {
191
204
  const target = this.targets[this.currentTargetIndex];
192
205
  this.currentTargetIndex = (this.currentTargetIndex + 1) % this.targets.length;
193
206
  return target;
194
207
  }
208
+ parseGetParams(req) {
209
+ const url = new URL(req.url || "", `http://${req.headers.host}`);
210
+ const mode = url.searchParams.get("mode");
211
+ const id = url.searchParams.get("id") || void 0;
212
+ const timeoutParam = url.searchParams.get("timeout");
213
+ const timeout = timeoutParam ? Number.parseInt(timeoutParam, 10) : void 0;
214
+ if (!mode) {
215
+ throw new Error("Mode parameter is required");
216
+ }
217
+ return { mode, id, timeout };
218
+ }
195
219
  async handleControlRequest(req, res) {
196
220
  try {
197
- const body = await readRequestBody(req);
198
- console.log("MODE CHANGE", body);
199
- const data = JSON.parse(body);
221
+ let data;
222
+ if (req.method === "GET") {
223
+ data = this.parseGetParams(req);
224
+ } else {
225
+ const body = await readRequestBody(req);
226
+ console.log("MODE CHANGE (POST)", body);
227
+ data = JSON.parse(body);
228
+ }
200
229
  const { mode, id, timeout: requestTimeout } = data;
201
230
  const timeout = requestTimeout ?? DEFAULT_TIMEOUT_MS;
202
231
  this.clearModeTimeout();
@@ -224,7 +253,7 @@ var ProxyServer = class {
224
253
  async switchMode(mode, id) {
225
254
  if (this.currentSession) {
226
255
  console.log("Switching mode, saving current session first");
227
- await this.saveCurrentSession();
256
+ await this.saveCurrentSession(true);
228
257
  console.log("Session saved, continuing with mode switch");
229
258
  }
230
259
  switch (mode) {
@@ -279,33 +308,41 @@ var ProxyServer = class {
279
308
  if (timeout && timeout > 0) {
280
309
  this.modeTimeout = setTimeout(async () => {
281
310
  console.log("Timeout reached, switching back to transparent mode");
282
- await this.saveCurrentSession();
311
+ await this.saveCurrentSession(true);
283
312
  this.switchToTransparentMode();
284
313
  this.modeTimeout = null;
285
314
  }, timeout);
286
315
  }
287
316
  }
288
- async saveCurrentSession() {
317
+ async saveCurrentSession(filterIncomplete = false) {
289
318
  if (!this.currentSession) {
290
319
  console.log("No current session to save");
291
320
  return;
292
321
  }
293
- if (this.currentSession.recordings.length === 0 && this.currentSession.websocketRecordings.length === 0) {
294
- console.log("Session has no recordings, skipping save");
295
- return;
322
+ if (filterIncomplete) {
323
+ const incompleteCount = this.currentSession.recordings.filter(
324
+ (r) => !r.response
325
+ ).length;
326
+ if (incompleteCount > 0) {
327
+ console.log(
328
+ `Removing ${incompleteCount} incomplete recording(s) without responses`
329
+ );
330
+ this.currentSession.recordings = this.currentSession.recordings.filter(
331
+ (r) => r.response
332
+ );
333
+ }
296
334
  }
297
335
  console.log(
298
336
  `Saving session with ${this.currentSession.recordings.length} HTTP and ${this.currentSession.websocketRecordings.length} WebSocket recordings`
299
337
  );
300
338
  await saveRecordingSession(this.recordingsDir, this.currentSession);
301
339
  }
302
- async saveRequestRecord(req, body) {
340
+ saveRequestRecordSync(req, body) {
303
341
  if (!this.currentSession) {
342
+ console.log("saveRequestRecordSync: No current session");
304
343
  return;
305
344
  }
306
345
  const key = getReqID(req);
307
- const currentSequence = this.requestSequenceMap.get(key) || 0;
308
- this.requestSequenceMap.set(key, currentSequence + 1);
309
346
  const record = {
310
347
  request: {
311
348
  method: req.method,
@@ -315,9 +352,34 @@ var ProxyServer = class {
315
352
  },
316
353
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
317
354
  key,
318
- sequence: currentSequence
355
+ sequence: -1
356
+ // Temporary, will be set when response arrives
319
357
  };
320
358
  this.currentSession.recordings.push(record);
359
+ console.log(
360
+ // eslint-disable-next-line sonarjs/no-nested-template-literals
361
+ `saveRequestRecordSync: Saved ${req.method} ${req.url} (key: ${key}, body: ${body ? `${body.length} chars` : "null"}, total: ${this.currentSession.recordings.length}, sessionId: ${this.currentSession.id})`
362
+ );
363
+ }
364
+ updateRequestBodySync(req, body) {
365
+ if (!this.currentSession) {
366
+ console.log("updateRequestBodySync: No current session");
367
+ return;
368
+ }
369
+ const key = getReqID(req);
370
+ const record = this.currentSession.recordings.findLast(
371
+ (r) => r.key === key && !r.response
372
+ );
373
+ if (!record) {
374
+ console.error(
375
+ `updateRequestBodySync: Could not find request record for ${req.method} ${req.url}`
376
+ );
377
+ return;
378
+ }
379
+ record.request.body = body || null;
380
+ console.log(
381
+ `updateRequestBodySync: Updated body for ${req.method} ${req.url} (${body.length} chars)`
382
+ );
321
383
  }
322
384
  async recordResponse(req, proxyRes) {
323
385
  if (!this.currentSession) {
@@ -342,59 +404,115 @@ var ProxyServer = class {
342
404
  headers: proxyRes.headers,
343
405
  body: body || null
344
406
  };
345
- await this.saveCurrentSession();
346
407
  console.log(`Recorded: ${req.method} ${req.url}`);
347
408
  });
348
409
  }
410
+ async recordResponseData(req, proxyRes, body) {
411
+ if (!this.currentSession) {
412
+ console.log("recordResponseData: No current session");
413
+ return false;
414
+ }
415
+ const key = getReqID(req);
416
+ const record = this.currentSession.recordings.findLast(
417
+ (r) => r.key === key && !r.response
418
+ );
419
+ if (!record) {
420
+ const host = req.headers.host || "unknown";
421
+ const recordsWithKey = this.currentSession.recordings.filter(
422
+ (r) => r.key === key
423
+ );
424
+ console.error(
425
+ `Request record not found for response: ${key} at ${req.method} ${host}${req.url}`
426
+ );
427
+ console.error(
428
+ ` Total recordings: ${this.currentSession.recordings.length}, with this key: ${recordsWithKey.length}`
429
+ );
430
+ console.error(
431
+ ` Records with key:`,
432
+ recordsWithKey.map((r) => ({
433
+ seq: r.sequence,
434
+ hasResponse: !!r.response
435
+ }))
436
+ );
437
+ return false;
438
+ }
439
+ record.response = {
440
+ statusCode: proxyRes.statusCode,
441
+ headers: proxyRes.headers,
442
+ body: body || null
443
+ };
444
+ record.timestamp = (/* @__PURE__ */ new Date()).toISOString();
445
+ const currentSequence = this.requestSequenceMap.get(key) || 0;
446
+ record.sequence = currentSequence;
447
+ this.requestSequenceMap.set(key, currentSequence + 1);
448
+ console.log(
449
+ `recordResponseData: Recorded response for ${req.method} ${req.url} (seq: ${record.sequence})`
450
+ );
451
+ return true;
452
+ }
349
453
  async handleReplayRequest(req, res) {
350
454
  const key = getReqID(req);
351
455
  const filePath = getRecordingPath(this.recordingsDir, this.replayId);
352
456
  try {
353
457
  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
357
- );
358
- if (!record) {
458
+ const host = req.headers.host || "unknown";
459
+ const recordsWithKey = session.recordings.filter((r) => r.key === key && r.response).toSorted((a, b) => a.sequence - b.sequence);
460
+ if (recordsWithKey.length === 0) {
359
461
  throw new Error(
360
- `No recording found for ${key} with sequence ${currentSequence}`
462
+ `No recording found for ${key} at ${req.method} ${host}${req.url}`
361
463
  );
362
464
  }
465
+ const usageCount = this.replaySequenceMap.get(key) || 0;
466
+ let record;
467
+ if (recordsWithKey.length > 1) {
468
+ record = recordsWithKey[recordsWithKey.length - 1];
469
+ } else {
470
+ const recordIndex = usageCount % recordsWithKey.length;
471
+ record = recordsWithKey[recordIndex];
472
+ }
473
+ console.log(
474
+ `Replaying ${req.method} ${req.url} (usage: ${usageCount}, sequence: ${record.sequence}, body_len: ${record.response?.body?.length || 0})`
475
+ );
476
+ this.replaySequenceMap.set(key, usageCount + 1);
363
477
  if (!record.response) {
364
- throw new Error("No response recorded for this request");
478
+ throw new Error(
479
+ `No response recorded for this request: ${req.method} ${host}${req.url}`
480
+ );
365
481
  }
366
- this.replaySequenceMap.set(key, currentSequence + 1);
367
482
  const { statusCode, headers, body } = record.response;
368
- const origin = req.headers.origin;
369
483
  const responseHeaders = {
370
484
  ...headers,
371
- "access-control-allow-origin": origin || "*",
372
- "access-control-allow-credentials": "true"
485
+ ...this.getCorsHeaders(req)
373
486
  };
374
487
  res.writeHead(statusCode, responseHeaders);
375
488
  res.end(body);
376
- console.log(
377
- `Replayed: ${req.method} ${req.url} (sequence: ${currentSequence})`
378
- );
379
489
  } catch (error) {
380
- this.handleReplayError(res, error, key, filePath);
490
+ this.handleReplayError(req, res, error, key, filePath);
381
491
  }
382
492
  }
383
- handleReplayError(res, err, key, filePath) {
493
+ handleReplayError(req, res, err, key, filePath) {
384
494
  const isFileNotFound = err instanceof Error && "code" in err && err.code === "ENOENT";
385
495
  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
496
+ const corsHeaders = this.getCorsHeaders(req);
497
+ res.writeHead(HTTP_STATUS_NOT_FOUND, {
498
+ "Content-Type": "application/json",
499
+ ...corsHeaders
391
500
  });
501
+ res.end(
502
+ JSON.stringify({
503
+ error: isFileNotFound ? "Recording file not found" : "Recording not found",
504
+ message: err instanceof Error ? err.message : "Unknown error",
505
+ key,
506
+ filePath
507
+ })
508
+ );
392
509
  }
393
510
  async handleRequest(req, res) {
394
511
  if (req.method === "OPTIONS") {
395
512
  return this.handleCorsPreflightRequest(req, res);
396
513
  }
397
- if (req.url === CONTROL_ENDPOINT) {
514
+ const urlPath = req.url?.split("?")[0] || "";
515
+ if (urlPath === CONTROL_ENDPOINT) {
398
516
  return this.handleControlRequest(req, res);
399
517
  }
400
518
  if (this.mode === Modes.replay) {
@@ -403,12 +521,9 @@ var ProxyServer = class {
403
521
  await this.handleProxyRequest(req, res);
404
522
  }
405
523
  handleCorsPreflightRequest(req, res) {
406
- const origin = req.headers.origin;
524
+ const corsHeaders = this.getCorsHeaders(req);
407
525
  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",
526
+ ...corsHeaders,
412
527
  "Access-Control-Max-Age": "86400"
413
528
  // 24 hours
414
529
  });
@@ -418,6 +533,7 @@ var ProxyServer = class {
418
533
  const target = this.getTarget();
419
534
  console.log(`[${this.mode}] ${req.method} ${req.url} -> ${target}`);
420
535
  if (this.mode === Modes.record) {
536
+ this.saveRequestRecordSync(req, null);
421
537
  await this.bufferAndProxyRequest(req, res, target);
422
538
  } else {
423
539
  this.proxy.web(req, res, { target });
@@ -428,11 +544,20 @@ var ProxyServer = class {
428
544
  req.on("data", (chunk) => {
429
545
  chunks.push(chunk);
430
546
  });
431
- await new Promise((resolve) => {
432
- req.on("end", () => resolve());
433
- });
547
+ try {
548
+ await new Promise((resolve, reject) => {
549
+ req.on("end", () => resolve());
550
+ req.on("error", (err) => reject(err));
551
+ setTimeout(
552
+ () => reject(new Error("Request buffering timeout")),
553
+ 3e4
554
+ );
555
+ });
556
+ } catch (error) {
557
+ console.error("Error buffering request:", error);
558
+ }
434
559
  const body = Buffer.concat(chunks).toString("utf8");
435
- await this.saveRequestRecord(req, body);
560
+ this.updateRequestBodySync(req, body);
436
561
  const targetUrl = new URL(target);
437
562
  const isHttps = targetUrl.protocol === "https:";
438
563
  const requestModule = isHttps ? https : http;
@@ -447,9 +572,33 @@ var ProxyServer = class {
447
572
  },
448
573
  (proxyRes) => {
449
574
  this.addCorsHeaders(proxyRes, req);
450
- this.recordResponse(req, proxyRes);
451
- res.writeHead(proxyRes.statusCode || 200, proxyRes.headers);
452
- proxyRes.pipe(res);
575
+ const responseChunks = [];
576
+ proxyRes.on("data", (chunk) => {
577
+ responseChunks.push(chunk);
578
+ });
579
+ proxyRes.on("end", async () => {
580
+ const responseBody = Buffer.concat(responseChunks);
581
+ const recorded = await this.recordResponseData(
582
+ req,
583
+ proxyRes,
584
+ responseBody.toString("utf8")
585
+ );
586
+ const responseHeaders = {
587
+ ...proxyRes.headers,
588
+ ...this.getCorsHeaders(req)
589
+ };
590
+ res.writeHead(proxyRes.statusCode || 200, responseHeaders);
591
+ res.end(responseBody);
592
+ if (recorded) {
593
+ console.log(`Recorded: ${req.method} ${req.url}`);
594
+ }
595
+ });
596
+ proxyRes.on("error", (err) => {
597
+ console.error("Proxy response error:", err);
598
+ if (!res.headersSent) {
599
+ this.handleProxyError(err, req, res);
600
+ }
601
+ });
453
602
  }
454
603
  );
455
604
  proxyReq.on("error", (err) => {
@@ -501,9 +650,6 @@ var ProxyServer = class {
501
650
  if (backendWs.readyState === WebSocket.OPEN) {
502
651
  backendWs.send(message);
503
652
  }
504
- this.saveCurrentSession().catch((error) => {
505
- console.error("Failed to save WebSocket recording:", error);
506
- });
507
653
  });
508
654
  backendWs.on("message", (data) => {
509
655
  const message = data.toString();
@@ -515,9 +661,6 @@ var ProxyServer = class {
515
661
  if (clientWs.readyState === WebSocket.OPEN) {
516
662
  clientWs.send(message);
517
663
  }
518
- this.saveCurrentSession().catch((error) => {
519
- console.error("Failed to save WebSocket recording:", error);
520
- });
521
664
  });
522
665
  clientWs.on("error", (err) => {
523
666
  console.error("Client WebSocket 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.7",
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
  }