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/index.mjs CHANGED
@@ -4,6 +4,7 @@ import https from 'https';
4
4
  import httpProxy from 'http-proxy';
5
5
  import { WebSocket, WebSocketServer } from 'ws';
6
6
  import path from 'path';
7
+ import crypto from 'crypto';
7
8
  import filenamify from 'filenamify';
8
9
 
9
10
  // src/ProxyServer.ts
@@ -42,7 +43,6 @@ async function saveRecordingSession(recordingsDir, session) {
42
43
  `Saved ${session.recordings.length} HTTP recordings and ${session.websocketRecordings?.length || 0} WebSocket recordings to ${filePath}`
43
44
  );
44
45
  }
45
- var QUERY_HASH_LENGTH = 8;
46
46
  function getReqID(req) {
47
47
  const urlParts = req.url.split("?");
48
48
  const pathname = urlParts[0];
@@ -57,7 +57,7 @@ function generateQueryHash(query) {
57
57
  if (!query) {
58
58
  return "";
59
59
  }
60
- const hash = Buffer.from(query).toString("base64").replaceAll(/[^a-zA-Z0-9]/g, "").slice(0, Math.max(0, QUERY_HASH_LENGTH));
60
+ const hash = crypto.createHash("md5").update(query).digest("hex").slice(0, 16);
61
61
  return `_${hash}`;
62
62
  }
63
63
 
@@ -86,24 +86,25 @@ var ProxyServer = class {
86
86
  proxy;
87
87
  currentSession;
88
88
  recordingsDir;
89
- requestSequenceMap;
90
- // Track sequence per request key
91
- replaySequenceMap;
92
- // Track replay position per request key
89
+ recordingIdCounter;
90
+ // Unique ID for each recording entry
91
+ replaySessions;
92
+ // Track multiple concurrent replay sessions by recording ID
93
93
  constructor(targets, recordingsDir) {
94
94
  this.targets = targets;
95
95
  this.currentTargetIndex = 0;
96
96
  this.mode = Modes.transparent;
97
97
  this.recordingId = null;
98
+ this.recordingIdCounter = 0;
98
99
  this.replayId = null;
99
100
  this.modeTimeout = null;
100
101
  this.currentSession = null;
101
102
  this.recordingsDir = recordingsDir;
102
- this.requestSequenceMap = /* @__PURE__ */ new Map();
103
- this.replaySequenceMap = /* @__PURE__ */ new Map();
103
+ this.replaySessions = /* @__PURE__ */ new Map();
104
104
  this.proxy = httpProxy.createProxyServer({
105
105
  secure: false,
106
- changeOrigin: true
106
+ changeOrigin: true,
107
+ ws: true
107
108
  });
108
109
  this.setupProxyEventHandlers();
109
110
  }
@@ -171,6 +172,43 @@ var ProxyServer = class {
171
172
  this.currentTargetIndex = (this.currentTargetIndex + 1) % this.targets.length;
172
173
  return target;
173
174
  }
175
+ /**
176
+ * Extract recording ID from request cookie
177
+ * Used for concurrent replay session routing
178
+ * @param req The incoming HTTP request
179
+ * @returns The recording ID from cookie, or null if not found
180
+ */
181
+ getRecordingIdFromCookie(req) {
182
+ const cookies = req.headers.cookie;
183
+ if (!cookies) {
184
+ return null;
185
+ }
186
+ const match = cookies.match(/proxy-recording-id=([^;]+)/);
187
+ return match ? decodeURIComponent(match[1]) : null;
188
+ }
189
+ /**
190
+ * Get or create a replay session state for a given recording ID
191
+ * @param recordingId The recording ID to get/create session for
192
+ * @returns The replay session state
193
+ */
194
+ getOrCreateReplaySession(recordingId) {
195
+ let session = this.replaySessions.get(recordingId);
196
+ if (session) {
197
+ session.lastAccessTime = Date.now();
198
+ } else {
199
+ session = {
200
+ recordingId,
201
+ servedRecordingIdsByKey: /* @__PURE__ */ new Map(),
202
+ loadedSession: null,
203
+ lastAccessTime: Date.now()
204
+ };
205
+ this.replaySessions.set(recordingId, session);
206
+ console.log(
207
+ `[CONCURRENT REPLAY] Created new session for recording: ${recordingId}`
208
+ );
209
+ }
210
+ return session;
211
+ }
174
212
  parseGetParams(req) {
175
213
  const url = new URL(req.url || "", `http://${req.headers.host}`);
176
214
  const mode = url.searchParams.get("mode");
@@ -187,16 +225,25 @@ var ProxyServer = class {
187
225
  let data;
188
226
  if (req.method === "GET") {
189
227
  data = this.parseGetParams(req);
190
- } else {
228
+ } else if (req.method === "POST") {
191
229
  const body = await readRequestBody(req);
192
- console.log("MODE CHANGE (POST)", body);
230
+ console.log(`MODE CHANGE (${req.method})`, body);
193
231
  data = JSON.parse(body);
232
+ } else {
233
+ return;
194
234
  }
195
235
  const { mode, id, timeout: requestTimeout } = data;
196
236
  const timeout = requestTimeout ?? DEFAULT_TIMEOUT_MS;
197
237
  this.clearModeTimeout();
198
238
  await this.switchMode(mode, id);
199
239
  this.setupModeTimeout(timeout);
240
+ if (mode === Modes.replay && id) {
241
+ res.setHeader(
242
+ "Set-Cookie",
243
+ `proxy-recording-id=${encodeURIComponent(id)}; HttpOnly; Path=/; SameSite=Lax`
244
+ );
245
+ console.log(`[CONCURRENT REPLAY] Set cookie for recording: ${id}`);
246
+ }
200
247
  sendJsonResponse(res, HTTP_STATUS_OK, {
201
248
  success: true,
202
249
  mode: this.mode,
@@ -211,14 +258,12 @@ var ProxyServer = class {
211
258
  }
212
259
  }
213
260
  clearModeTimeout() {
214
- if (this.modeTimeout) {
215
- clearTimeout(this.modeTimeout);
216
- this.modeTimeout = null;
217
- }
261
+ clearTimeout(this.modeTimeout || 0);
262
+ this.modeTimeout = null;
218
263
  }
219
264
  async switchMode(mode, id) {
220
- if (this.currentSession) {
221
- console.log("Switching mode, saving current session first");
265
+ console.log(`Switching to ${mode.toUpperCase()} mode`);
266
+ if (this.currentSession && this.mode === Modes.record) {
222
267
  await this.saveCurrentSession(true);
223
268
  console.log("Session saved, continuing with mode switch");
224
269
  }
@@ -228,11 +273,17 @@ var ProxyServer = class {
228
273
  break;
229
274
  }
230
275
  case Modes.record: {
276
+ if (!id) {
277
+ throw new Error("Record ID is required");
278
+ }
231
279
  this.switchToRecordMode(id);
232
280
  break;
233
281
  }
234
282
  case Modes.replay: {
235
- this.switchToReplayMode(id);
283
+ if (!id) {
284
+ throw new Error("Replay ID is required");
285
+ }
286
+ await this.switchToReplayMode(id);
236
287
  break;
237
288
  }
238
289
  default: {
@@ -249,36 +300,33 @@ var ProxyServer = class {
249
300
  console.log("Switched to transparent mode");
250
301
  }
251
302
  switchToRecordMode(id) {
252
- if (!id) {
253
- throw new Error("Record ID is required");
254
- }
255
303
  this.mode = Modes.record;
256
304
  this.recordingId = id;
257
305
  this.replayId = null;
258
306
  this.currentSession = { id, recordings: [], websocketRecordings: [] };
259
- this.requestSequenceMap.clear();
260
307
  console.log(`Switched to record mode with ID: ${id}`);
261
308
  }
262
- switchToReplayMode(id) {
263
- if (!id) {
264
- throw new Error("Replay ID is required");
265
- }
309
+ async switchToReplayMode(id) {
266
310
  this.mode = Modes.replay;
267
311
  this.replayId = id;
268
312
  this.recordingId = null;
269
313
  this.currentSession = null;
270
- this.replaySequenceMap.clear();
314
+ const session = this.replaySessions.get(id);
315
+ if (session) {
316
+ session.servedRecordingIdsByKey.clear();
317
+ console.log(`Reset served recordings tracker for session: ${id}`);
318
+ } else {
319
+ this.getOrCreateReplaySession(id);
320
+ }
271
321
  console.log(`Switched to replay mode with ID: ${id}`);
272
322
  }
273
323
  setupModeTimeout(timeout) {
274
- if (timeout && timeout > 0) {
275
- this.modeTimeout = setTimeout(async () => {
276
- console.log("Timeout reached, switching back to transparent mode");
277
- await this.saveCurrentSession(true);
278
- this.switchToTransparentMode();
279
- this.modeTimeout = null;
280
- }, timeout);
281
- }
324
+ this.modeTimeout = setTimeout(async () => {
325
+ console.log("Timeout reached, switching back to transparent mode");
326
+ await this.saveCurrentSession(true);
327
+ this.switchToTransparentMode();
328
+ this.modeTimeout = null;
329
+ }, timeout);
282
330
  }
283
331
  async saveCurrentSession(filterIncomplete = false) {
284
332
  if (!this.currentSession) {
@@ -304,6 +352,8 @@ var ProxyServer = class {
304
352
  return;
305
353
  }
306
354
  const key = getReqID(req);
355
+ const recordingId = this.recordingIdCounter++;
356
+ req.__recordingId = recordingId;
307
357
  const record = {
308
358
  request: {
309
359
  method: req.method,
@@ -313,44 +363,57 @@ var ProxyServer = class {
313
363
  },
314
364
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
315
365
  key,
316
- sequence: -1
317
- // Temporary, will be set when response arrives
366
+ recordingId
318
367
  };
319
368
  this.currentSession.recordings.push(record);
320
369
  console.log(
321
370
  // eslint-disable-next-line sonarjs/no-nested-template-literals
322
- `saveRequestRecordSync: Saved ${req.method} ${req.url} (key: ${key}, body: ${body ? `${body.length} chars` : "null"}, total: ${this.currentSession.recordings.length}, sessionId: ${this.currentSession.id})`
371
+ `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})`
323
372
  );
324
373
  }
325
374
  updateRequestBodySync(req, body) {
326
375
  if (!this.currentSession) {
327
376
  return;
328
377
  }
329
- const key = getReqID(req);
330
- const record = this.currentSession.recordings.findLast(
331
- (r) => r.key === key && !r.response
378
+ const recordingId = req.__recordingId;
379
+ if (recordingId === void 0) {
380
+ console.error(
381
+ `updateRequestBodySync: No recording ID found on request ${req.method} ${req.url}`
382
+ );
383
+ return;
384
+ }
385
+ const record = this.currentSession.recordings.find(
386
+ (r) => r.recordingId === recordingId
332
387
  );
333
388
  if (!record) {
334
389
  console.error(
335
- `updateRequestBodySync: Could not find request record for ${req.method} ${req.url}`
390
+ `updateRequestBodySync: Could not find recording with ID ${recordingId} for ${req.method} ${req.url}`
336
391
  );
337
392
  return;
338
393
  }
339
394
  record.request.body = body || null;
340
395
  console.log(
341
- `updateRequestBodySync: Updated body for ${req.method} ${req.url} (${body.length} chars)`
396
+ `updateRequestBodySync: Updated body for ${req.method} ${req.url} (${body.length} chars, recordingId: ${recordingId})`
342
397
  );
343
398
  }
344
399
  async recordResponse(req, proxyRes) {
345
400
  if (!this.currentSession) {
346
401
  return;
347
402
  }
348
- const key = getReqID(req);
349
- const record = this.currentSession.recordings.findLast(
350
- (r) => r.key === key && !r.response
403
+ const recordingId = req.__recordingId;
404
+ if (recordingId === void 0) {
405
+ console.error(
406
+ `recordResponse: No recording ID found on request ${req.method} ${req.url}`
407
+ );
408
+ return;
409
+ }
410
+ const record = this.currentSession.recordings.find(
411
+ (r) => r.recordingId === recordingId
351
412
  );
352
413
  if (!record) {
353
- console.error("Request record not found for response:", key);
414
+ console.error(
415
+ `recordResponse: Could not find recording with ID ${recordingId} for ${req.method} ${req.url}`
416
+ );
354
417
  return;
355
418
  }
356
419
  const chunks = [];
@@ -364,34 +427,28 @@ var ProxyServer = class {
364
427
  headers: proxyRes.headers,
365
428
  body: body || null
366
429
  };
367
- console.log(`Recorded: ${req.method} ${req.url}`);
430
+ console.log(
431
+ `Recorded: ${req.method} ${req.url} (recordingId: ${recordingId})`
432
+ );
368
433
  });
369
434
  }
370
435
  async recordResponseData(req, proxyRes, body) {
371
436
  if (!this.currentSession) {
372
437
  return false;
373
438
  }
374
- const key = getReqID(req);
375
- const record = this.currentSession.recordings.findLast(
376
- (r) => r.key === key && !r.response
377
- );
378
- if (!record) {
379
- const host = req.headers.host || "unknown";
380
- const recordsWithKey = this.currentSession.recordings.filter(
381
- (r) => r.key === key
382
- );
439
+ const recordingId = req.__recordingId;
440
+ if (recordingId === void 0) {
383
441
  console.error(
384
- `Request record not found for response: ${key} at ${req.method} ${host}${req.url}`
385
- );
386
- console.error(
387
- ` Total recordings: ${this.currentSession.recordings.length}, with this key: ${recordsWithKey.length}`
442
+ `recordResponseData: No recording ID found on request ${req.method} ${req.url}`
388
443
  );
444
+ return false;
445
+ }
446
+ const record = this.currentSession.recordings.find(
447
+ (r) => r.recordingId === recordingId
448
+ );
449
+ if (!record) {
389
450
  console.error(
390
- ` Records with key:`,
391
- recordsWithKey.map((r) => ({
392
- seq: r.sequence,
393
- hasResponse: !!r.response
394
- }))
451
+ `recordResponseData: Could not find recording with ID ${recordingId} for ${req.method} ${req.url}`
395
452
  );
396
453
  return false;
397
454
  }
@@ -400,34 +457,78 @@ var ProxyServer = class {
400
457
  headers: proxyRes.headers,
401
458
  body: body || null
402
459
  };
403
- record.timestamp = (/* @__PURE__ */ new Date()).toISOString();
404
- const currentSequence = this.requestSequenceMap.get(key) || 0;
405
- record.sequence = currentSequence;
406
- this.requestSequenceMap.set(key, currentSequence + 1);
407
460
  console.log(
408
- `recordResponseData: Recorded response for ${req.method} ${req.url} (seq: ${record.sequence})`
461
+ `recordResponseData: Recorded response for ${req.method} ${req.url} (recordingId: ${recordingId})`
409
462
  );
410
463
  return true;
411
464
  }
412
465
  async handleReplayRequest(req, res) {
466
+ const recordingId = this.getRecordingIdFromCookie(req) || this.replayId;
467
+ if (!recordingId) {
468
+ const corsHeaders = this.getCorsHeaders(req);
469
+ res.writeHead(HTTP_STATUS_BAD_REQUEST, {
470
+ "Content-Type": "application/json",
471
+ ...corsHeaders
472
+ });
473
+ res.end(JSON.stringify({ error: "No replay session active" }));
474
+ return;
475
+ }
413
476
  const key = getReqID(req);
414
- const filePath = getRecordingPath(this.recordingsDir, this.replayId);
477
+ const filePath = getRecordingPath(this.recordingsDir, recordingId);
415
478
  try {
416
- const session = await loadRecordingSession(filePath);
479
+ const sessionState = this.getOrCreateReplaySession(recordingId);
480
+ if (!sessionState.loadedSession) {
481
+ sessionState.loadedSession = await loadRecordingSession(filePath);
482
+ console.log(`[REPLAY] Loaded recording session: ${recordingId}`);
483
+ }
484
+ const session = sessionState.loadedSession;
485
+ if (!sessionState.servedRecordingIdsByKey.has(key)) {
486
+ sessionState.servedRecordingIdsByKey.set(key, /* @__PURE__ */ new Set());
487
+ }
488
+ const servedForThisKey = sessionState.servedRecordingIdsByKey.get(key);
417
489
  const host = req.headers.host || "unknown";
418
- const recordsWithKey = session.recordings.filter((r) => r.key === key && r.response).toSorted((a, b) => a.sequence - b.sequence);
490
+ const recordsWithKey = session.recordings.filter((r) => r.key === key && r.response).toSorted((a, b) => a.recordingId - b.recordingId);
419
491
  if (recordsWithKey.length === 0) {
420
- throw new Error(
421
- `No recording found for ${key} at ${req.method} ${host}${req.url}`
492
+ const errorMsg = `No recording found for ${key} at ${req.method} ${host}${req.url}`;
493
+ console.error(`[REPLAY ERROR] ${errorMsg} (session: ${recordingId})`);
494
+ console.error(
495
+ `[REPLAY ERROR] This request was not made during recording - possible test non-determinism`
496
+ );
497
+ const errorResponse = {
498
+ error: "No recording found",
499
+ message: errorMsg,
500
+ key,
501
+ sessionId: recordingId
502
+ };
503
+ const corsHeaders = this.getCorsHeaders(req);
504
+ res.writeHead(HTTP_STATUS_NOT_FOUND, {
505
+ "Content-Type": "application/json",
506
+ ...corsHeaders
507
+ });
508
+ res.end(JSON.stringify(errorResponse));
509
+ return;
510
+ }
511
+ const requestCount = servedForThisKey.size + 1;
512
+ console.log(
513
+ `[REPLAY REQUEST #${requestCount}] ${req.method} ${req.url} (session: ${recordingId}, total: ${recordsWithKey.length}, served: ${servedForThisKey.size})`
514
+ );
515
+ let record;
516
+ for (const rec of recordsWithKey) {
517
+ if (!servedForThisKey.has(rec.recordingId)) {
518
+ record = rec;
519
+ break;
520
+ }
521
+ }
522
+ if (!record) {
523
+ console.log(
524
+ `[REPLAY WARNING] All ${recordsWithKey.length} recordings already served for ${key} (session: ${recordingId}), reusing last one`
422
525
  );
526
+ record = recordsWithKey[recordsWithKey.length - 1];
423
527
  }
424
- const usageCount = this.replaySequenceMap.get(key) || 0;
425
- const recordIndex = usageCount % recordsWithKey.length;
426
- const record = recordsWithKey[recordIndex];
528
+ servedForThisKey.add(record.recordingId);
427
529
  console.log(
428
- `Replaying ${req.method} ${req.url} (usage: ${usageCount}, sequence: ${record.sequence}, body_len: ${record.response?.body?.length || 0})`
530
+ `[REPLAY SERVING] recordingId: ${record.recordingId}, session: ${recordingId}, body_len: ${record.response?.body?.length || 0}`
429
531
  );
430
- this.replaySequenceMap.set(key, usageCount + 1);
431
532
  if (!record.response) {
432
533
  throw new Error(
433
534
  `No response recorded for this request: ${req.method} ${host}${req.url}`
@@ -493,6 +594,7 @@ var ProxyServer = class {
493
594
  this.proxy.web(req, res, { target });
494
595
  }
495
596
  }
597
+ // TODO: check if can handle streaming requests
496
598
  async bufferAndProxyRequest(req, res, target) {
497
599
  const chunks = [];
498
600
  req.on("data", (chunk) => {
@@ -1,3 +1,3 @@
1
1
  import '@playwright/test';
2
- export { P as PlaywrightTestInfo, g as generateSessionId, p as playwrightProxy, s as setProxyMode, b as startRecording, c as startReplay, d as stopProxy } from '../index-CBjvm5rb.cjs';
2
+ export { P as PlaywrightTestInfo, g as generateSessionId, p as playwrightProxy, s as setProxyMode, b as startRecording, c as startReplay, d as stopProxy } from '../index-CjM3evKb.cjs';
3
3
  import 'node:http';
@@ -1,3 +1,3 @@
1
1
  import '@playwright/test';
2
- export { P as PlaywrightTestInfo, g as generateSessionId, p as playwrightProxy, s as setProxyMode, b as startRecording, c as startReplay, d as stopProxy } from '../index-CBjvm5rb.js';
2
+ export { P as PlaywrightTestInfo, g as generateSessionId, p as playwrightProxy, s as setProxyMode, b as startRecording, c as startReplay, d as stopProxy } from '../index-CjM3evKb.js';
3
3
  import 'node:http';