test-proxy-recorder 0.1.8 → 0.1.10

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
@@ -5,6 +5,7 @@ import http from 'http';
5
5
  import https from 'https';
6
6
  import httpProxy from 'http-proxy';
7
7
  import { WebSocket, WebSocketServer } from 'ws';
8
+ import crypto from 'crypto';
8
9
  import filenamify from 'filenamify';
9
10
 
10
11
  // src/cli.ts
@@ -76,7 +77,6 @@ async function saveRecordingSession(recordingsDir2, session) {
76
77
  `Saved ${session.recordings.length} HTTP recordings and ${session.websocketRecordings?.length || 0} WebSocket recordings to ${filePath}`
77
78
  );
78
79
  }
79
- var QUERY_HASH_LENGTH = 8;
80
80
  function getReqID(req) {
81
81
  const urlParts = req.url.split("?");
82
82
  const pathname = urlParts[0];
@@ -91,7 +91,7 @@ function generateQueryHash(query) {
91
91
  if (!query) {
92
92
  return "";
93
93
  }
94
- const hash = Buffer.from(query).toString("base64").replaceAll(/[^a-zA-Z0-9]/g, "").slice(0, Math.max(0, QUERY_HASH_LENGTH));
94
+ const hash = crypto.createHash("md5").update(query).digest("hex").slice(0, 16);
95
95
  return `_${hash}`;
96
96
  }
97
97
 
@@ -120,24 +120,25 @@ var ProxyServer = class {
120
120
  proxy;
121
121
  currentSession;
122
122
  recordingsDir;
123
- requestSequenceMap;
124
- // Track sequence per request key
125
- replaySequenceMap;
126
- // Track replay position per request key
123
+ recordingIdCounter;
124
+ // Unique ID for each recording entry
125
+ replaySessions;
126
+ // Track multiple concurrent replay sessions by recording ID
127
127
  constructor(targets2, recordingsDir2) {
128
128
  this.targets = targets2;
129
129
  this.currentTargetIndex = 0;
130
130
  this.mode = Modes.transparent;
131
131
  this.recordingId = null;
132
+ this.recordingIdCounter = 0;
132
133
  this.replayId = null;
133
134
  this.modeTimeout = null;
134
135
  this.currentSession = null;
135
136
  this.recordingsDir = recordingsDir2;
136
- this.requestSequenceMap = /* @__PURE__ */ new Map();
137
- this.replaySequenceMap = /* @__PURE__ */ new Map();
137
+ this.replaySessions = /* @__PURE__ */ new Map();
138
138
  this.proxy = httpProxy.createProxyServer({
139
139
  secure: false,
140
- changeOrigin: true
140
+ changeOrigin: true,
141
+ ws: true
141
142
  });
142
143
  this.setupProxyEventHandlers();
143
144
  }
@@ -205,6 +206,43 @@ var ProxyServer = class {
205
206
  this.currentTargetIndex = (this.currentTargetIndex + 1) % this.targets.length;
206
207
  return target;
207
208
  }
209
+ /**
210
+ * Extract recording ID from request cookie
211
+ * Used for concurrent replay session routing
212
+ * @param req The incoming HTTP request
213
+ * @returns The recording ID from cookie, or null if not found
214
+ */
215
+ getRecordingIdFromCookie(req) {
216
+ const cookies = req.headers.cookie;
217
+ if (!cookies) {
218
+ return null;
219
+ }
220
+ const match = cookies.match(/proxy-recording-id=([^;]+)/);
221
+ return match ? decodeURIComponent(match[1]) : null;
222
+ }
223
+ /**
224
+ * Get or create a replay session state for a given recording ID
225
+ * @param recordingId The recording ID to get/create session for
226
+ * @returns The replay session state
227
+ */
228
+ getOrCreateReplaySession(recordingId) {
229
+ let session = this.replaySessions.get(recordingId);
230
+ if (session) {
231
+ session.lastAccessTime = Date.now();
232
+ } else {
233
+ session = {
234
+ recordingId,
235
+ servedRecordingIdsByKey: /* @__PURE__ */ new Map(),
236
+ loadedSession: null,
237
+ lastAccessTime: Date.now()
238
+ };
239
+ this.replaySessions.set(recordingId, session);
240
+ console.log(
241
+ `[CONCURRENT REPLAY] Created new session for recording: ${recordingId}`
242
+ );
243
+ }
244
+ return session;
245
+ }
208
246
  parseGetParams(req) {
209
247
  const url = new URL(req.url || "", `http://${req.headers.host}`);
210
248
  const mode = url.searchParams.get("mode");
@@ -221,16 +259,25 @@ var ProxyServer = class {
221
259
  let data;
222
260
  if (req.method === "GET") {
223
261
  data = this.parseGetParams(req);
224
- } else {
262
+ } else if (req.method === "POST") {
225
263
  const body = await readRequestBody(req);
226
- console.log("MODE CHANGE (POST)", body);
264
+ console.log(`MODE CHANGE (${req.method})`, body);
227
265
  data = JSON.parse(body);
266
+ } else {
267
+ return;
228
268
  }
229
269
  const { mode, id, timeout: requestTimeout } = data;
230
270
  const timeout = requestTimeout ?? DEFAULT_TIMEOUT_MS;
231
271
  this.clearModeTimeout();
232
272
  await this.switchMode(mode, id);
233
273
  this.setupModeTimeout(timeout);
274
+ if (mode === Modes.replay && id) {
275
+ res.setHeader(
276
+ "Set-Cookie",
277
+ `proxy-recording-id=${encodeURIComponent(id)}; HttpOnly; Path=/; SameSite=Lax`
278
+ );
279
+ console.log(`[CONCURRENT REPLAY] Set cookie for recording: ${id}`);
280
+ }
234
281
  sendJsonResponse(res, HTTP_STATUS_OK, {
235
282
  success: true,
236
283
  mode: this.mode,
@@ -245,14 +292,12 @@ var ProxyServer = class {
245
292
  }
246
293
  }
247
294
  clearModeTimeout() {
248
- if (this.modeTimeout) {
249
- clearTimeout(this.modeTimeout);
250
- this.modeTimeout = null;
251
- }
295
+ clearTimeout(this.modeTimeout || 0);
296
+ this.modeTimeout = null;
252
297
  }
253
298
  async switchMode(mode, id) {
254
- if (this.currentSession) {
255
- console.log("Switching mode, saving current session first");
299
+ console.log(`Switching to ${mode.toUpperCase()} mode`);
300
+ if (this.currentSession && this.mode === Modes.record) {
256
301
  await this.saveCurrentSession(true);
257
302
  console.log("Session saved, continuing with mode switch");
258
303
  }
@@ -262,11 +307,17 @@ var ProxyServer = class {
262
307
  break;
263
308
  }
264
309
  case Modes.record: {
310
+ if (!id) {
311
+ throw new Error("Record ID is required");
312
+ }
265
313
  this.switchToRecordMode(id);
266
314
  break;
267
315
  }
268
316
  case Modes.replay: {
269
- this.switchToReplayMode(id);
317
+ if (!id) {
318
+ throw new Error("Replay ID is required");
319
+ }
320
+ await this.switchToReplayMode(id);
270
321
  break;
271
322
  }
272
323
  default: {
@@ -283,36 +334,33 @@ var ProxyServer = class {
283
334
  console.log("Switched to transparent mode");
284
335
  }
285
336
  switchToRecordMode(id) {
286
- if (!id) {
287
- throw new Error("Record ID is required");
288
- }
289
337
  this.mode = Modes.record;
290
338
  this.recordingId = id;
291
339
  this.replayId = null;
292
340
  this.currentSession = { id, recordings: [], websocketRecordings: [] };
293
- this.requestSequenceMap.clear();
294
341
  console.log(`Switched to record mode with ID: ${id}`);
295
342
  }
296
- switchToReplayMode(id) {
297
- if (!id) {
298
- throw new Error("Replay ID is required");
299
- }
343
+ async switchToReplayMode(id) {
300
344
  this.mode = Modes.replay;
301
345
  this.replayId = id;
302
346
  this.recordingId = null;
303
347
  this.currentSession = null;
304
- this.replaySequenceMap.clear();
348
+ const session = this.replaySessions.get(id);
349
+ if (session) {
350
+ session.servedRecordingIdsByKey.clear();
351
+ console.log(`Reset served recordings tracker for session: ${id}`);
352
+ } else {
353
+ this.getOrCreateReplaySession(id);
354
+ }
305
355
  console.log(`Switched to replay mode with ID: ${id}`);
306
356
  }
307
357
  setupModeTimeout(timeout) {
308
- if (timeout && timeout > 0) {
309
- this.modeTimeout = setTimeout(async () => {
310
- console.log("Timeout reached, switching back to transparent mode");
311
- await this.saveCurrentSession(true);
312
- this.switchToTransparentMode();
313
- this.modeTimeout = null;
314
- }, timeout);
315
- }
358
+ this.modeTimeout = setTimeout(async () => {
359
+ console.log("Timeout reached, switching back to transparent mode");
360
+ await this.saveCurrentSession(true);
361
+ this.switchToTransparentMode();
362
+ this.modeTimeout = null;
363
+ }, timeout);
316
364
  }
317
365
  async saveCurrentSession(filterIncomplete = false) {
318
366
  if (!this.currentSession) {
@@ -338,6 +386,8 @@ var ProxyServer = class {
338
386
  return;
339
387
  }
340
388
  const key = getReqID(req);
389
+ const recordingId = this.recordingIdCounter++;
390
+ req.__recordingId = recordingId;
341
391
  const record = {
342
392
  request: {
343
393
  method: req.method,
@@ -347,44 +397,57 @@ var ProxyServer = class {
347
397
  },
348
398
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
349
399
  key,
350
- sequence: -1
351
- // Temporary, will be set when response arrives
400
+ recordingId
352
401
  };
353
402
  this.currentSession.recordings.push(record);
354
403
  console.log(
355
404
  // eslint-disable-next-line sonarjs/no-nested-template-literals
356
- `saveRequestRecordSync: Saved ${req.method} ${req.url} (key: ${key}, body: ${body ? `${body.length} chars` : "null"}, total: ${this.currentSession.recordings.length}, sessionId: ${this.currentSession.id})`
405
+ `saveRequestRecordSync: Saved ${req.method} ${req.url} (key: ${key}, recordingId: ${recordingId}, body: ${body ? `${body.length} chars` : "null"}, total: ${this.currentSession.recordings.length}, sessionId: ${this.currentSession.id})`
357
406
  );
358
407
  }
359
408
  updateRequestBodySync(req, body) {
360
409
  if (!this.currentSession) {
361
410
  return;
362
411
  }
363
- const key = getReqID(req);
364
- const record = this.currentSession.recordings.findLast(
365
- (r) => r.key === key && !r.response
412
+ const recordingId = req.__recordingId;
413
+ if (recordingId === void 0) {
414
+ console.error(
415
+ `updateRequestBodySync: No recording ID found on request ${req.method} ${req.url}`
416
+ );
417
+ return;
418
+ }
419
+ const record = this.currentSession.recordings.find(
420
+ (r) => r.recordingId === recordingId
366
421
  );
367
422
  if (!record) {
368
423
  console.error(
369
- `updateRequestBodySync: Could not find request record for ${req.method} ${req.url}`
424
+ `updateRequestBodySync: Could not find recording with ID ${recordingId} for ${req.method} ${req.url}`
370
425
  );
371
426
  return;
372
427
  }
373
428
  record.request.body = body || null;
374
429
  console.log(
375
- `updateRequestBodySync: Updated body for ${req.method} ${req.url} (${body.length} chars)`
430
+ `updateRequestBodySync: Updated body for ${req.method} ${req.url} (${body.length} chars, recordingId: ${recordingId})`
376
431
  );
377
432
  }
378
433
  async recordResponse(req, proxyRes) {
379
434
  if (!this.currentSession) {
380
435
  return;
381
436
  }
382
- const key = getReqID(req);
383
- const record = this.currentSession.recordings.findLast(
384
- (r) => r.key === key && !r.response
437
+ const recordingId = req.__recordingId;
438
+ if (recordingId === void 0) {
439
+ console.error(
440
+ `recordResponse: No recording ID found on request ${req.method} ${req.url}`
441
+ );
442
+ return;
443
+ }
444
+ const record = this.currentSession.recordings.find(
445
+ (r) => r.recordingId === recordingId
385
446
  );
386
447
  if (!record) {
387
- console.error("Request record not found for response:", key);
448
+ console.error(
449
+ `recordResponse: Could not find recording with ID ${recordingId} for ${req.method} ${req.url}`
450
+ );
388
451
  return;
389
452
  }
390
453
  const chunks = [];
@@ -398,34 +461,28 @@ var ProxyServer = class {
398
461
  headers: proxyRes.headers,
399
462
  body: body || null
400
463
  };
401
- console.log(`Recorded: ${req.method} ${req.url}`);
464
+ console.log(
465
+ `Recorded: ${req.method} ${req.url} (recordingId: ${recordingId})`
466
+ );
402
467
  });
403
468
  }
404
469
  async recordResponseData(req, proxyRes, body) {
405
470
  if (!this.currentSession) {
406
471
  return false;
407
472
  }
408
- const key = getReqID(req);
409
- const record = this.currentSession.recordings.findLast(
410
- (r) => r.key === key && !r.response
411
- );
412
- if (!record) {
413
- const host = req.headers.host || "unknown";
414
- const recordsWithKey = this.currentSession.recordings.filter(
415
- (r) => r.key === key
416
- );
473
+ const recordingId = req.__recordingId;
474
+ if (recordingId === void 0) {
417
475
  console.error(
418
- `Request record not found for response: ${key} at ${req.method} ${host}${req.url}`
419
- );
420
- console.error(
421
- ` Total recordings: ${this.currentSession.recordings.length}, with this key: ${recordsWithKey.length}`
476
+ `recordResponseData: No recording ID found on request ${req.method} ${req.url}`
422
477
  );
478
+ return false;
479
+ }
480
+ const record = this.currentSession.recordings.find(
481
+ (r) => r.recordingId === recordingId
482
+ );
483
+ if (!record) {
423
484
  console.error(
424
- ` Records with key:`,
425
- recordsWithKey.map((r) => ({
426
- seq: r.sequence,
427
- hasResponse: !!r.response
428
- }))
485
+ `recordResponseData: Could not find recording with ID ${recordingId} for ${req.method} ${req.url}`
429
486
  );
430
487
  return false;
431
488
  }
@@ -434,34 +491,78 @@ var ProxyServer = class {
434
491
  headers: proxyRes.headers,
435
492
  body: body || null
436
493
  };
437
- record.timestamp = (/* @__PURE__ */ new Date()).toISOString();
438
- const currentSequence = this.requestSequenceMap.get(key) || 0;
439
- record.sequence = currentSequence;
440
- this.requestSequenceMap.set(key, currentSequence + 1);
441
494
  console.log(
442
- `recordResponseData: Recorded response for ${req.method} ${req.url} (seq: ${record.sequence})`
495
+ `recordResponseData: Recorded response for ${req.method} ${req.url} (recordingId: ${recordingId})`
443
496
  );
444
497
  return true;
445
498
  }
446
499
  async handleReplayRequest(req, res) {
500
+ const recordingId = this.getRecordingIdFromCookie(req) || this.replayId;
501
+ if (!recordingId) {
502
+ const corsHeaders = this.getCorsHeaders(req);
503
+ res.writeHead(HTTP_STATUS_BAD_REQUEST, {
504
+ "Content-Type": "application/json",
505
+ ...corsHeaders
506
+ });
507
+ res.end(JSON.stringify({ error: "No replay session active" }));
508
+ return;
509
+ }
447
510
  const key = getReqID(req);
448
- const filePath = getRecordingPath(this.recordingsDir, this.replayId);
511
+ const filePath = getRecordingPath(this.recordingsDir, recordingId);
449
512
  try {
450
- const session = await loadRecordingSession(filePath);
513
+ const sessionState = this.getOrCreateReplaySession(recordingId);
514
+ if (!sessionState.loadedSession) {
515
+ sessionState.loadedSession = await loadRecordingSession(filePath);
516
+ console.log(`[REPLAY] Loaded recording session: ${recordingId}`);
517
+ }
518
+ const session = sessionState.loadedSession;
519
+ if (!sessionState.servedRecordingIdsByKey.has(key)) {
520
+ sessionState.servedRecordingIdsByKey.set(key, /* @__PURE__ */ new Set());
521
+ }
522
+ const servedForThisKey = sessionState.servedRecordingIdsByKey.get(key);
451
523
  const host = req.headers.host || "unknown";
452
- const recordsWithKey = session.recordings.filter((r) => r.key === key && r.response).toSorted((a, b) => a.sequence - b.sequence);
524
+ const recordsWithKey = session.recordings.filter((r) => r.key === key && r.response).toSorted((a, b) => a.recordingId - b.recordingId);
453
525
  if (recordsWithKey.length === 0) {
454
- throw new Error(
455
- `No recording found for ${key} at ${req.method} ${host}${req.url}`
526
+ const errorMsg = `No recording found for ${key} at ${req.method} ${host}${req.url}`;
527
+ console.error(`[REPLAY ERROR] ${errorMsg} (session: ${recordingId})`);
528
+ console.error(
529
+ `[REPLAY ERROR] This request was not made during recording - possible test non-determinism`
530
+ );
531
+ const errorResponse = {
532
+ error: "No recording found",
533
+ message: errorMsg,
534
+ key,
535
+ sessionId: recordingId
536
+ };
537
+ const corsHeaders = this.getCorsHeaders(req);
538
+ res.writeHead(HTTP_STATUS_NOT_FOUND, {
539
+ "Content-Type": "application/json",
540
+ ...corsHeaders
541
+ });
542
+ res.end(JSON.stringify(errorResponse));
543
+ return;
544
+ }
545
+ const requestCount = servedForThisKey.size + 1;
546
+ console.log(
547
+ `[REPLAY REQUEST #${requestCount}] ${req.method} ${req.url} (session: ${recordingId}, total: ${recordsWithKey.length}, served: ${servedForThisKey.size})`
548
+ );
549
+ let record;
550
+ for (const rec of recordsWithKey) {
551
+ if (!servedForThisKey.has(rec.recordingId)) {
552
+ record = rec;
553
+ break;
554
+ }
555
+ }
556
+ if (!record) {
557
+ console.log(
558
+ `[REPLAY WARNING] All ${recordsWithKey.length} recordings already served for ${key} (session: ${recordingId}), reusing last one`
456
559
  );
560
+ record = recordsWithKey[recordsWithKey.length - 1];
457
561
  }
458
- const usageCount = this.replaySequenceMap.get(key) || 0;
459
- const recordIndex = usageCount % recordsWithKey.length;
460
- const record = recordsWithKey[recordIndex];
562
+ servedForThisKey.add(record.recordingId);
461
563
  console.log(
462
- `Replaying ${req.method} ${req.url} (usage: ${usageCount}, sequence: ${record.sequence}, body_len: ${record.response?.body?.length || 0})`
564
+ `[REPLAY SERVING] recordingId: ${record.recordingId}, session: ${recordingId}, body_len: ${record.response?.body?.length || 0}`
463
565
  );
464
- this.replaySequenceMap.set(key, usageCount + 1);
465
566
  if (!record.response) {
466
567
  throw new Error(
467
568
  `No response recorded for this request: ${req.method} ${host}${req.url}`
@@ -527,6 +628,7 @@ var ProxyServer = class {
527
628
  this.proxy.web(req, res, { target });
528
629
  }
529
630
  }
631
+ // TODO: check if can handle streaming requests
530
632
  async bufferAndProxyRequest(req, res, target) {
531
633
  const chunks = [];
532
634
  req.on("data", (chunk) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "test-proxy-recorder",
3
- "version": "0.1.8",
3
+ "version": "0.1.10",
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",