test-proxy-recorder 0.1.9 → 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/README.md CHANGED
@@ -102,7 +102,7 @@ Create `e2e/global-teardown.ts`:
102
102
  import { setProxyMode } from 'test-proxy-recorder';
103
103
 
104
104
  async function globalTeardown() {
105
- await setProxyMode('transparent');
105
+ await setProxyMode('transparent').catch(err => { console.error(err) });
106
106
  }
107
107
 
108
108
  export default globalTeardown;
@@ -247,7 +247,7 @@ Create `e2e/global-teardown.ts`:
247
247
  import { setProxyMode } from 'test-proxy-recorder';
248
248
 
249
249
  async function globalTeardown() {
250
- await setProxyMode('transparent');
250
+ await setProxyMode('transparent').catch(err => { console.error(err) });
251
251
  }
252
252
 
253
253
  export default globalTeardown;
@@ -28,7 +28,6 @@ interface Recording {
28
28
  response?: RecordedResponse;
29
29
  timestamp: string;
30
30
  key: string;
31
- sequence: number;
32
31
  recordingId: number;
33
32
  }
34
33
  interface WebSocketMessage {
@@ -28,7 +28,6 @@ interface Recording {
28
28
  response?: RecordedResponse;
29
29
  timestamp: string;
30
30
  key: string;
31
- sequence: number;
32
31
  recordingId: number;
33
32
  }
34
33
  interface WebSocketMessage {
package/dist/index.cjs CHANGED
@@ -98,12 +98,10 @@ var ProxyServer = class {
98
98
  proxy;
99
99
  currentSession;
100
100
  recordingsDir;
101
- requestSequenceMap;
102
- // Track sequence per request key
103
- replaySequenceMap;
104
- // Track replay position per request key
105
101
  recordingIdCounter;
106
102
  // Unique ID for each recording entry
103
+ replaySessions;
104
+ // Track multiple concurrent replay sessions by recording ID
107
105
  constructor(targets, recordingsDir) {
108
106
  this.targets = targets;
109
107
  this.currentTargetIndex = 0;
@@ -114,11 +112,11 @@ var ProxyServer = class {
114
112
  this.modeTimeout = null;
115
113
  this.currentSession = null;
116
114
  this.recordingsDir = recordingsDir;
117
- this.requestSequenceMap = /* @__PURE__ */ new Map();
118
- this.replaySequenceMap = /* @__PURE__ */ new Map();
115
+ this.replaySessions = /* @__PURE__ */ new Map();
119
116
  this.proxy = httpProxy__default.default.createProxyServer({
120
117
  secure: false,
121
- changeOrigin: true
118
+ changeOrigin: true,
119
+ ws: true
122
120
  });
123
121
  this.setupProxyEventHandlers();
124
122
  }
@@ -186,6 +184,43 @@ var ProxyServer = class {
186
184
  this.currentTargetIndex = (this.currentTargetIndex + 1) % this.targets.length;
187
185
  return target;
188
186
  }
187
+ /**
188
+ * Extract recording ID from request cookie
189
+ * Used for concurrent replay session routing
190
+ * @param req The incoming HTTP request
191
+ * @returns The recording ID from cookie, or null if not found
192
+ */
193
+ getRecordingIdFromCookie(req) {
194
+ const cookies = req.headers.cookie;
195
+ if (!cookies) {
196
+ return null;
197
+ }
198
+ const match = cookies.match(/proxy-recording-id=([^;]+)/);
199
+ return match ? decodeURIComponent(match[1]) : null;
200
+ }
201
+ /**
202
+ * Get or create a replay session state for a given recording ID
203
+ * @param recordingId The recording ID to get/create session for
204
+ * @returns The replay session state
205
+ */
206
+ getOrCreateReplaySession(recordingId) {
207
+ let session = this.replaySessions.get(recordingId);
208
+ if (session) {
209
+ session.lastAccessTime = Date.now();
210
+ } else {
211
+ session = {
212
+ recordingId,
213
+ servedRecordingIdsByKey: /* @__PURE__ */ new Map(),
214
+ loadedSession: null,
215
+ lastAccessTime: Date.now()
216
+ };
217
+ this.replaySessions.set(recordingId, session);
218
+ console.log(
219
+ `[CONCURRENT REPLAY] Created new session for recording: ${recordingId}`
220
+ );
221
+ }
222
+ return session;
223
+ }
189
224
  parseGetParams(req) {
190
225
  const url = new URL(req.url || "", `http://${req.headers.host}`);
191
226
  const mode = url.searchParams.get("mode");
@@ -202,16 +237,25 @@ var ProxyServer = class {
202
237
  let data;
203
238
  if (req.method === "GET") {
204
239
  data = this.parseGetParams(req);
205
- } else {
240
+ } else if (req.method === "POST") {
206
241
  const body = await readRequestBody(req);
207
- console.log("MODE CHANGE (POST)", body);
242
+ console.log(`MODE CHANGE (${req.method})`, body);
208
243
  data = JSON.parse(body);
244
+ } else {
245
+ return;
209
246
  }
210
247
  const { mode, id, timeout: requestTimeout } = data;
211
248
  const timeout = requestTimeout ?? DEFAULT_TIMEOUT_MS;
212
249
  this.clearModeTimeout();
213
250
  await this.switchMode(mode, id);
214
251
  this.setupModeTimeout(timeout);
252
+ if (mode === Modes.replay && id) {
253
+ res.setHeader(
254
+ "Set-Cookie",
255
+ `proxy-recording-id=${encodeURIComponent(id)}; HttpOnly; Path=/; SameSite=Lax`
256
+ );
257
+ console.log(`[CONCURRENT REPLAY] Set cookie for recording: ${id}`);
258
+ }
215
259
  sendJsonResponse(res, HTTP_STATUS_OK, {
216
260
  success: true,
217
261
  mode: this.mode,
@@ -226,14 +270,12 @@ var ProxyServer = class {
226
270
  }
227
271
  }
228
272
  clearModeTimeout() {
229
- if (this.modeTimeout) {
230
- clearTimeout(this.modeTimeout);
231
- this.modeTimeout = null;
232
- }
273
+ clearTimeout(this.modeTimeout || 0);
274
+ this.modeTimeout = null;
233
275
  }
234
276
  async switchMode(mode, id) {
235
- if (this.currentSession) {
236
- console.log("Switching mode, saving current session first");
277
+ console.log(`Switching to ${mode.toUpperCase()} mode`);
278
+ if (this.currentSession && this.mode === Modes.record) {
237
279
  await this.saveCurrentSession(true);
238
280
  console.log("Session saved, continuing with mode switch");
239
281
  }
@@ -243,11 +285,17 @@ var ProxyServer = class {
243
285
  break;
244
286
  }
245
287
  case Modes.record: {
288
+ if (!id) {
289
+ throw new Error("Record ID is required");
290
+ }
246
291
  this.switchToRecordMode(id);
247
292
  break;
248
293
  }
249
294
  case Modes.replay: {
250
- this.switchToReplayMode(id);
295
+ if (!id) {
296
+ throw new Error("Replay ID is required");
297
+ }
298
+ await this.switchToReplayMode(id);
251
299
  break;
252
300
  }
253
301
  default: {
@@ -264,36 +312,33 @@ var ProxyServer = class {
264
312
  console.log("Switched to transparent mode");
265
313
  }
266
314
  switchToRecordMode(id) {
267
- if (!id) {
268
- throw new Error("Record ID is required");
269
- }
270
315
  this.mode = Modes.record;
271
316
  this.recordingId = id;
272
317
  this.replayId = null;
273
318
  this.currentSession = { id, recordings: [], websocketRecordings: [] };
274
- this.requestSequenceMap.clear();
275
319
  console.log(`Switched to record mode with ID: ${id}`);
276
320
  }
277
- switchToReplayMode(id) {
278
- if (!id) {
279
- throw new Error("Replay ID is required");
280
- }
321
+ async switchToReplayMode(id) {
281
322
  this.mode = Modes.replay;
282
323
  this.replayId = id;
283
324
  this.recordingId = null;
284
325
  this.currentSession = null;
285
- this.replaySequenceMap.clear();
326
+ const session = this.replaySessions.get(id);
327
+ if (session) {
328
+ session.servedRecordingIdsByKey.clear();
329
+ console.log(`Reset served recordings tracker for session: ${id}`);
330
+ } else {
331
+ this.getOrCreateReplaySession(id);
332
+ }
286
333
  console.log(`Switched to replay mode with ID: ${id}`);
287
334
  }
288
335
  setupModeTimeout(timeout) {
289
- if (timeout && timeout > 0) {
290
- this.modeTimeout = setTimeout(async () => {
291
- console.log("Timeout reached, switching back to transparent mode");
292
- await this.saveCurrentSession(true);
293
- this.switchToTransparentMode();
294
- this.modeTimeout = null;
295
- }, timeout);
296
- }
336
+ this.modeTimeout = setTimeout(async () => {
337
+ console.log("Timeout reached, switching back to transparent mode");
338
+ await this.saveCurrentSession(true);
339
+ this.switchToTransparentMode();
340
+ this.modeTimeout = null;
341
+ }, timeout);
297
342
  }
298
343
  async saveCurrentSession(filterIncomplete = false) {
299
344
  if (!this.currentSession) {
@@ -319,9 +364,6 @@ var ProxyServer = class {
319
364
  return;
320
365
  }
321
366
  const key = getReqID(req);
322
- const currentSequence = this.requestSequenceMap.get(key) || 0;
323
- const sequence = currentSequence;
324
- this.requestSequenceMap.set(key, currentSequence + 1);
325
367
  const recordingId = this.recordingIdCounter++;
326
368
  req.__recordingId = recordingId;
327
369
  const record = {
@@ -333,13 +375,12 @@ var ProxyServer = class {
333
375
  },
334
376
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
335
377
  key,
336
- sequence,
337
378
  recordingId
338
379
  };
339
380
  this.currentSession.recordings.push(record);
340
381
  console.log(
341
382
  // eslint-disable-next-line sonarjs/no-nested-template-literals
342
- `saveRequestRecordSync: Saved ${req.method} ${req.url} (key: ${key}, seq: ${sequence}, recordingId: ${recordingId}, body: ${body ? `${body.length} chars` : "null"}, total: ${this.currentSession.recordings.length}, sessionId: ${this.currentSession.id})`
383
+ `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})`
343
384
  );
344
385
  }
345
386
  updateRequestBodySync(req, body) {
@@ -399,7 +440,7 @@ var ProxyServer = class {
399
440
  body: body || null
400
441
  };
401
442
  console.log(
402
- `Recorded: ${req.method} ${req.url} (seq: ${record.sequence}, recordingId: ${recordingId})`
443
+ `Recorded: ${req.method} ${req.url} (recordingId: ${recordingId})`
403
444
  );
404
445
  });
405
446
  }
@@ -429,46 +470,77 @@ var ProxyServer = class {
429
470
  body: body || null
430
471
  };
431
472
  console.log(
432
- `recordResponseData: Recorded response for ${req.method} ${req.url} (seq: ${record.sequence}, recordingId: ${recordingId})`
473
+ `recordResponseData: Recorded response for ${req.method} ${req.url} (recordingId: ${recordingId})`
433
474
  );
434
475
  return true;
435
476
  }
436
477
  async handleReplayRequest(req, res) {
478
+ const recordingId = this.getRecordingIdFromCookie(req) || this.replayId;
479
+ if (!recordingId) {
480
+ const corsHeaders = this.getCorsHeaders(req);
481
+ res.writeHead(HTTP_STATUS_BAD_REQUEST, {
482
+ "Content-Type": "application/json",
483
+ ...corsHeaders
484
+ });
485
+ res.end(JSON.stringify({ error: "No replay session active" }));
486
+ return;
487
+ }
437
488
  const key = getReqID(req);
438
- const filePath = getRecordingPath(this.recordingsDir, this.replayId);
489
+ const filePath = getRecordingPath(this.recordingsDir, recordingId);
439
490
  try {
440
- const session = await loadRecordingSession(filePath);
491
+ const sessionState = this.getOrCreateReplaySession(recordingId);
492
+ if (!sessionState.loadedSession) {
493
+ sessionState.loadedSession = await loadRecordingSession(filePath);
494
+ console.log(`[REPLAY] Loaded recording session: ${recordingId}`);
495
+ }
496
+ const session = sessionState.loadedSession;
497
+ if (!sessionState.servedRecordingIdsByKey.has(key)) {
498
+ sessionState.servedRecordingIdsByKey.set(key, /* @__PURE__ */ new Set());
499
+ }
500
+ const servedForThisKey = sessionState.servedRecordingIdsByKey.get(key);
441
501
  const host = req.headers.host || "unknown";
442
- const recordsWithKey = session.recordings.filter((r) => r.key === key && r.response).toSorted((a, b) => a.sequence - b.sequence);
502
+ const recordsWithKey = session.recordings.filter((r) => r.key === key && r.response).toSorted((a, b) => a.recordingId - b.recordingId);
443
503
  if (recordsWithKey.length === 0) {
444
- console.warn(
445
- `No recording found for ${key} at ${req.method} ${host}${req.url}, returning default response`
504
+ const errorMsg = `No recording found for ${key} at ${req.method} ${host}${req.url}`;
505
+ console.error(`[REPLAY ERROR] ${errorMsg} (session: ${recordingId})`);
506
+ console.error(
507
+ `[REPLAY ERROR] This request was not made during recording - possible test non-determinism`
446
508
  );
447
- const defaultResponse = req.method === "GET" ? {
448
- data: [],
449
- items: [],
450
- results: [],
451
- updated_at: "0001-01-01T00:00:00Z"
452
- } : { success: true };
509
+ const errorResponse = {
510
+ error: "No recording found",
511
+ message: errorMsg,
512
+ key,
513
+ sessionId: recordingId
514
+ };
453
515
  const corsHeaders = this.getCorsHeaders(req);
454
- res.writeHead(HTTP_STATUS_OK, {
516
+ res.writeHead(HTTP_STATUS_NOT_FOUND, {
455
517
  "Content-Type": "application/json",
456
518
  ...corsHeaders
457
519
  });
458
- res.end(JSON.stringify(defaultResponse));
520
+ res.end(JSON.stringify(errorResponse));
459
521
  return;
460
522
  }
461
- const usageCount = this.replaySequenceMap.get(key) || 0;
523
+ const requestCount = servedForThisKey.size + 1;
524
+ console.log(
525
+ `[REPLAY REQUEST #${requestCount}] ${req.method} ${req.url} (session: ${recordingId}, total: ${recordsWithKey.length}, served: ${servedForThisKey.size})`
526
+ );
462
527
  let record;
463
- if (usageCount < recordsWithKey.length) {
464
- record = recordsWithKey[usageCount];
465
- } else {
528
+ for (const rec of recordsWithKey) {
529
+ if (!servedForThisKey.has(rec.recordingId)) {
530
+ record = rec;
531
+ break;
532
+ }
533
+ }
534
+ if (!record) {
535
+ console.log(
536
+ `[REPLAY WARNING] All ${recordsWithKey.length} recordings already served for ${key} (session: ${recordingId}), reusing last one`
537
+ );
466
538
  record = recordsWithKey[recordsWithKey.length - 1];
467
539
  }
540
+ servedForThisKey.add(record.recordingId);
468
541
  console.log(
469
- `Replaying ${req.method} ${req.url} (usage: ${usageCount}, sequence: ${record.sequence}, body_len: ${record.response?.body?.length || 0})`
542
+ `[REPLAY SERVING] recordingId: ${record.recordingId}, session: ${recordingId}, body_len: ${record.response?.body?.length || 0}`
470
543
  );
471
- this.replaySequenceMap.set(key, usageCount + 1);
472
544
  if (!record.response) {
473
545
  throw new Error(
474
546
  `No response recorded for this request: ${req.method} ${host}${req.url}`
@@ -534,6 +606,7 @@ var ProxyServer = class {
534
606
  this.proxy.web(req, res, { target });
535
607
  }
536
608
  }
609
+ // TODO: check if can handle streaming requests
537
610
  async bufferAndProxyRequest(req, res, target) {
538
611
  const chunks = [];
539
612
  req.on("data", (chunk) => {
package/dist/index.d.cts CHANGED
@@ -1,5 +1,5 @@
1
1
  import http from 'node:http';
2
- export { C as ControlRequest, M as Mode, P as PlaywrightTestInfo, R as Recording, a as RecordingSession, W as WebSocketRecording, g as generateSessionId, p as playwrightProxy, s as setProxyMode, b as startRecording, c as startReplay, d as stopProxy } from './index-CG-XcFDa.cjs';
2
+ export { C as ControlRequest, M as Mode, P as PlaywrightTestInfo, R as Recording, a as RecordingSession, W as WebSocketRecording, 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 '@playwright/test';
4
4
 
5
5
  declare class ProxyServer {
@@ -12,9 +12,8 @@ declare class ProxyServer {
12
12
  private proxy;
13
13
  private currentSession;
14
14
  private recordingsDir;
15
- private requestSequenceMap;
16
- private replaySequenceMap;
17
15
  private recordingIdCounter;
16
+ private replaySessions;
18
17
  constructor(targets: string[], recordingsDir: string);
19
18
  init(): Promise<void>;
20
19
  listen(port: number): http.Server;
@@ -29,6 +28,19 @@ declare class ProxyServer {
29
28
  private getCorsHeaders;
30
29
  private addCorsHeaders;
31
30
  private getTarget;
31
+ /**
32
+ * Extract recording ID from request cookie
33
+ * Used for concurrent replay session routing
34
+ * @param req The incoming HTTP request
35
+ * @returns The recording ID from cookie, or null if not found
36
+ */
37
+ private getRecordingIdFromCookie;
38
+ /**
39
+ * Get or create a replay session state for a given recording ID
40
+ * @param recordingId The recording ID to get/create session for
41
+ * @returns The replay session state
42
+ */
43
+ private getOrCreateReplaySession;
32
44
  private parseGetParams;
33
45
  private handleControlRequest;
34
46
  private clearModeTimeout;
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import http from 'node:http';
2
- export { C as ControlRequest, M as Mode, P as PlaywrightTestInfo, R as Recording, a as RecordingSession, W as WebSocketRecording, g as generateSessionId, p as playwrightProxy, s as setProxyMode, b as startRecording, c as startReplay, d as stopProxy } from './index-CG-XcFDa.js';
2
+ export { C as ControlRequest, M as Mode, P as PlaywrightTestInfo, R as Recording, a as RecordingSession, W as WebSocketRecording, 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 '@playwright/test';
4
4
 
5
5
  declare class ProxyServer {
@@ -12,9 +12,8 @@ declare class ProxyServer {
12
12
  private proxy;
13
13
  private currentSession;
14
14
  private recordingsDir;
15
- private requestSequenceMap;
16
- private replaySequenceMap;
17
15
  private recordingIdCounter;
16
+ private replaySessions;
18
17
  constructor(targets: string[], recordingsDir: string);
19
18
  init(): Promise<void>;
20
19
  listen(port: number): http.Server;
@@ -29,6 +28,19 @@ declare class ProxyServer {
29
28
  private getCorsHeaders;
30
29
  private addCorsHeaders;
31
30
  private getTarget;
31
+ /**
32
+ * Extract recording ID from request cookie
33
+ * Used for concurrent replay session routing
34
+ * @param req The incoming HTTP request
35
+ * @returns The recording ID from cookie, or null if not found
36
+ */
37
+ private getRecordingIdFromCookie;
38
+ /**
39
+ * Get or create a replay session state for a given recording ID
40
+ * @param recordingId The recording ID to get/create session for
41
+ * @returns The replay session state
42
+ */
43
+ private getOrCreateReplaySession;
32
44
  private parseGetParams;
33
45
  private handleControlRequest;
34
46
  private clearModeTimeout;
package/dist/index.mjs CHANGED
@@ -86,12 +86,10 @@ 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
93
89
  recordingIdCounter;
94
90
  // Unique ID for each recording entry
91
+ replaySessions;
92
+ // Track multiple concurrent replay sessions by recording ID
95
93
  constructor(targets, recordingsDir) {
96
94
  this.targets = targets;
97
95
  this.currentTargetIndex = 0;
@@ -102,11 +100,11 @@ var ProxyServer = class {
102
100
  this.modeTimeout = null;
103
101
  this.currentSession = null;
104
102
  this.recordingsDir = recordingsDir;
105
- this.requestSequenceMap = /* @__PURE__ */ new Map();
106
- this.replaySequenceMap = /* @__PURE__ */ new Map();
103
+ this.replaySessions = /* @__PURE__ */ new Map();
107
104
  this.proxy = httpProxy.createProxyServer({
108
105
  secure: false,
109
- changeOrigin: true
106
+ changeOrigin: true,
107
+ ws: true
110
108
  });
111
109
  this.setupProxyEventHandlers();
112
110
  }
@@ -174,6 +172,43 @@ var ProxyServer = class {
174
172
  this.currentTargetIndex = (this.currentTargetIndex + 1) % this.targets.length;
175
173
  return target;
176
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
+ }
177
212
  parseGetParams(req) {
178
213
  const url = new URL(req.url || "", `http://${req.headers.host}`);
179
214
  const mode = url.searchParams.get("mode");
@@ -190,16 +225,25 @@ var ProxyServer = class {
190
225
  let data;
191
226
  if (req.method === "GET") {
192
227
  data = this.parseGetParams(req);
193
- } else {
228
+ } else if (req.method === "POST") {
194
229
  const body = await readRequestBody(req);
195
- console.log("MODE CHANGE (POST)", body);
230
+ console.log(`MODE CHANGE (${req.method})`, body);
196
231
  data = JSON.parse(body);
232
+ } else {
233
+ return;
197
234
  }
198
235
  const { mode, id, timeout: requestTimeout } = data;
199
236
  const timeout = requestTimeout ?? DEFAULT_TIMEOUT_MS;
200
237
  this.clearModeTimeout();
201
238
  await this.switchMode(mode, id);
202
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
+ }
203
247
  sendJsonResponse(res, HTTP_STATUS_OK, {
204
248
  success: true,
205
249
  mode: this.mode,
@@ -214,14 +258,12 @@ var ProxyServer = class {
214
258
  }
215
259
  }
216
260
  clearModeTimeout() {
217
- if (this.modeTimeout) {
218
- clearTimeout(this.modeTimeout);
219
- this.modeTimeout = null;
220
- }
261
+ clearTimeout(this.modeTimeout || 0);
262
+ this.modeTimeout = null;
221
263
  }
222
264
  async switchMode(mode, id) {
223
- if (this.currentSession) {
224
- console.log("Switching mode, saving current session first");
265
+ console.log(`Switching to ${mode.toUpperCase()} mode`);
266
+ if (this.currentSession && this.mode === Modes.record) {
225
267
  await this.saveCurrentSession(true);
226
268
  console.log("Session saved, continuing with mode switch");
227
269
  }
@@ -231,11 +273,17 @@ var ProxyServer = class {
231
273
  break;
232
274
  }
233
275
  case Modes.record: {
276
+ if (!id) {
277
+ throw new Error("Record ID is required");
278
+ }
234
279
  this.switchToRecordMode(id);
235
280
  break;
236
281
  }
237
282
  case Modes.replay: {
238
- this.switchToReplayMode(id);
283
+ if (!id) {
284
+ throw new Error("Replay ID is required");
285
+ }
286
+ await this.switchToReplayMode(id);
239
287
  break;
240
288
  }
241
289
  default: {
@@ -252,36 +300,33 @@ var ProxyServer = class {
252
300
  console.log("Switched to transparent mode");
253
301
  }
254
302
  switchToRecordMode(id) {
255
- if (!id) {
256
- throw new Error("Record ID is required");
257
- }
258
303
  this.mode = Modes.record;
259
304
  this.recordingId = id;
260
305
  this.replayId = null;
261
306
  this.currentSession = { id, recordings: [], websocketRecordings: [] };
262
- this.requestSequenceMap.clear();
263
307
  console.log(`Switched to record mode with ID: ${id}`);
264
308
  }
265
- switchToReplayMode(id) {
266
- if (!id) {
267
- throw new Error("Replay ID is required");
268
- }
309
+ async switchToReplayMode(id) {
269
310
  this.mode = Modes.replay;
270
311
  this.replayId = id;
271
312
  this.recordingId = null;
272
313
  this.currentSession = null;
273
- 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
+ }
274
321
  console.log(`Switched to replay mode with ID: ${id}`);
275
322
  }
276
323
  setupModeTimeout(timeout) {
277
- if (timeout && timeout > 0) {
278
- this.modeTimeout = setTimeout(async () => {
279
- console.log("Timeout reached, switching back to transparent mode");
280
- await this.saveCurrentSession(true);
281
- this.switchToTransparentMode();
282
- this.modeTimeout = null;
283
- }, timeout);
284
- }
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);
285
330
  }
286
331
  async saveCurrentSession(filterIncomplete = false) {
287
332
  if (!this.currentSession) {
@@ -307,9 +352,6 @@ var ProxyServer = class {
307
352
  return;
308
353
  }
309
354
  const key = getReqID(req);
310
- const currentSequence = this.requestSequenceMap.get(key) || 0;
311
- const sequence = currentSequence;
312
- this.requestSequenceMap.set(key, currentSequence + 1);
313
355
  const recordingId = this.recordingIdCounter++;
314
356
  req.__recordingId = recordingId;
315
357
  const record = {
@@ -321,13 +363,12 @@ var ProxyServer = class {
321
363
  },
322
364
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
323
365
  key,
324
- sequence,
325
366
  recordingId
326
367
  };
327
368
  this.currentSession.recordings.push(record);
328
369
  console.log(
329
370
  // eslint-disable-next-line sonarjs/no-nested-template-literals
330
- `saveRequestRecordSync: Saved ${req.method} ${req.url} (key: ${key}, seq: ${sequence}, recordingId: ${recordingId}, 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})`
331
372
  );
332
373
  }
333
374
  updateRequestBodySync(req, body) {
@@ -387,7 +428,7 @@ var ProxyServer = class {
387
428
  body: body || null
388
429
  };
389
430
  console.log(
390
- `Recorded: ${req.method} ${req.url} (seq: ${record.sequence}, recordingId: ${recordingId})`
431
+ `Recorded: ${req.method} ${req.url} (recordingId: ${recordingId})`
391
432
  );
392
433
  });
393
434
  }
@@ -417,46 +458,77 @@ var ProxyServer = class {
417
458
  body: body || null
418
459
  };
419
460
  console.log(
420
- `recordResponseData: Recorded response for ${req.method} ${req.url} (seq: ${record.sequence}, recordingId: ${recordingId})`
461
+ `recordResponseData: Recorded response for ${req.method} ${req.url} (recordingId: ${recordingId})`
421
462
  );
422
463
  return true;
423
464
  }
424
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
+ }
425
476
  const key = getReqID(req);
426
- const filePath = getRecordingPath(this.recordingsDir, this.replayId);
477
+ const filePath = getRecordingPath(this.recordingsDir, recordingId);
427
478
  try {
428
- 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);
429
489
  const host = req.headers.host || "unknown";
430
- 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);
431
491
  if (recordsWithKey.length === 0) {
432
- console.warn(
433
- `No recording found for ${key} at ${req.method} ${host}${req.url}, returning default response`
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`
434
496
  );
435
- const defaultResponse = req.method === "GET" ? {
436
- data: [],
437
- items: [],
438
- results: [],
439
- updated_at: "0001-01-01T00:00:00Z"
440
- } : { success: true };
497
+ const errorResponse = {
498
+ error: "No recording found",
499
+ message: errorMsg,
500
+ key,
501
+ sessionId: recordingId
502
+ };
441
503
  const corsHeaders = this.getCorsHeaders(req);
442
- res.writeHead(HTTP_STATUS_OK, {
504
+ res.writeHead(HTTP_STATUS_NOT_FOUND, {
443
505
  "Content-Type": "application/json",
444
506
  ...corsHeaders
445
507
  });
446
- res.end(JSON.stringify(defaultResponse));
508
+ res.end(JSON.stringify(errorResponse));
447
509
  return;
448
510
  }
449
- const usageCount = this.replaySequenceMap.get(key) || 0;
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
+ );
450
515
  let record;
451
- if (usageCount < recordsWithKey.length) {
452
- record = recordsWithKey[usageCount];
453
- } else {
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`
525
+ );
454
526
  record = recordsWithKey[recordsWithKey.length - 1];
455
527
  }
528
+ servedForThisKey.add(record.recordingId);
456
529
  console.log(
457
- `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}`
458
531
  );
459
- this.replaySequenceMap.set(key, usageCount + 1);
460
532
  if (!record.response) {
461
533
  throw new Error(
462
534
  `No response recorded for this request: ${req.method} ${host}${req.url}`
@@ -522,6 +594,7 @@ var ProxyServer = class {
522
594
  this.proxy.web(req, res, { target });
523
595
  }
524
596
  }
597
+ // TODO: check if can handle streaming requests
525
598
  async bufferAndProxyRequest(req, res, target) {
526
599
  const chunks = [];
527
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-CG-XcFDa.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-CG-XcFDa.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';
package/dist/proxy.js CHANGED
@@ -120,12 +120,10 @@ 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
127
123
  recordingIdCounter;
128
124
  // Unique ID for each recording entry
125
+ replaySessions;
126
+ // Track multiple concurrent replay sessions by recording ID
129
127
  constructor(targets2, recordingsDir2) {
130
128
  this.targets = targets2;
131
129
  this.currentTargetIndex = 0;
@@ -136,11 +134,11 @@ var ProxyServer = class {
136
134
  this.modeTimeout = null;
137
135
  this.currentSession = null;
138
136
  this.recordingsDir = recordingsDir2;
139
- this.requestSequenceMap = /* @__PURE__ */ new Map();
140
- this.replaySequenceMap = /* @__PURE__ */ new Map();
137
+ this.replaySessions = /* @__PURE__ */ new Map();
141
138
  this.proxy = httpProxy.createProxyServer({
142
139
  secure: false,
143
- changeOrigin: true
140
+ changeOrigin: true,
141
+ ws: true
144
142
  });
145
143
  this.setupProxyEventHandlers();
146
144
  }
@@ -208,6 +206,43 @@ var ProxyServer = class {
208
206
  this.currentTargetIndex = (this.currentTargetIndex + 1) % this.targets.length;
209
207
  return target;
210
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
+ }
211
246
  parseGetParams(req) {
212
247
  const url = new URL(req.url || "", `http://${req.headers.host}`);
213
248
  const mode = url.searchParams.get("mode");
@@ -224,16 +259,25 @@ var ProxyServer = class {
224
259
  let data;
225
260
  if (req.method === "GET") {
226
261
  data = this.parseGetParams(req);
227
- } else {
262
+ } else if (req.method === "POST") {
228
263
  const body = await readRequestBody(req);
229
- console.log("MODE CHANGE (POST)", body);
264
+ console.log(`MODE CHANGE (${req.method})`, body);
230
265
  data = JSON.parse(body);
266
+ } else {
267
+ return;
231
268
  }
232
269
  const { mode, id, timeout: requestTimeout } = data;
233
270
  const timeout = requestTimeout ?? DEFAULT_TIMEOUT_MS;
234
271
  this.clearModeTimeout();
235
272
  await this.switchMode(mode, id);
236
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
+ }
237
281
  sendJsonResponse(res, HTTP_STATUS_OK, {
238
282
  success: true,
239
283
  mode: this.mode,
@@ -248,14 +292,12 @@ var ProxyServer = class {
248
292
  }
249
293
  }
250
294
  clearModeTimeout() {
251
- if (this.modeTimeout) {
252
- clearTimeout(this.modeTimeout);
253
- this.modeTimeout = null;
254
- }
295
+ clearTimeout(this.modeTimeout || 0);
296
+ this.modeTimeout = null;
255
297
  }
256
298
  async switchMode(mode, id) {
257
- if (this.currentSession) {
258
- console.log("Switching mode, saving current session first");
299
+ console.log(`Switching to ${mode.toUpperCase()} mode`);
300
+ if (this.currentSession && this.mode === Modes.record) {
259
301
  await this.saveCurrentSession(true);
260
302
  console.log("Session saved, continuing with mode switch");
261
303
  }
@@ -265,11 +307,17 @@ var ProxyServer = class {
265
307
  break;
266
308
  }
267
309
  case Modes.record: {
310
+ if (!id) {
311
+ throw new Error("Record ID is required");
312
+ }
268
313
  this.switchToRecordMode(id);
269
314
  break;
270
315
  }
271
316
  case Modes.replay: {
272
- this.switchToReplayMode(id);
317
+ if (!id) {
318
+ throw new Error("Replay ID is required");
319
+ }
320
+ await this.switchToReplayMode(id);
273
321
  break;
274
322
  }
275
323
  default: {
@@ -286,36 +334,33 @@ var ProxyServer = class {
286
334
  console.log("Switched to transparent mode");
287
335
  }
288
336
  switchToRecordMode(id) {
289
- if (!id) {
290
- throw new Error("Record ID is required");
291
- }
292
337
  this.mode = Modes.record;
293
338
  this.recordingId = id;
294
339
  this.replayId = null;
295
340
  this.currentSession = { id, recordings: [], websocketRecordings: [] };
296
- this.requestSequenceMap.clear();
297
341
  console.log(`Switched to record mode with ID: ${id}`);
298
342
  }
299
- switchToReplayMode(id) {
300
- if (!id) {
301
- throw new Error("Replay ID is required");
302
- }
343
+ async switchToReplayMode(id) {
303
344
  this.mode = Modes.replay;
304
345
  this.replayId = id;
305
346
  this.recordingId = null;
306
347
  this.currentSession = null;
307
- 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
+ }
308
355
  console.log(`Switched to replay mode with ID: ${id}`);
309
356
  }
310
357
  setupModeTimeout(timeout) {
311
- if (timeout && timeout > 0) {
312
- this.modeTimeout = setTimeout(async () => {
313
- console.log("Timeout reached, switching back to transparent mode");
314
- await this.saveCurrentSession(true);
315
- this.switchToTransparentMode();
316
- this.modeTimeout = null;
317
- }, timeout);
318
- }
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);
319
364
  }
320
365
  async saveCurrentSession(filterIncomplete = false) {
321
366
  if (!this.currentSession) {
@@ -341,9 +386,6 @@ var ProxyServer = class {
341
386
  return;
342
387
  }
343
388
  const key = getReqID(req);
344
- const currentSequence = this.requestSequenceMap.get(key) || 0;
345
- const sequence = currentSequence;
346
- this.requestSequenceMap.set(key, currentSequence + 1);
347
389
  const recordingId = this.recordingIdCounter++;
348
390
  req.__recordingId = recordingId;
349
391
  const record = {
@@ -355,13 +397,12 @@ var ProxyServer = class {
355
397
  },
356
398
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
357
399
  key,
358
- sequence,
359
400
  recordingId
360
401
  };
361
402
  this.currentSession.recordings.push(record);
362
403
  console.log(
363
404
  // eslint-disable-next-line sonarjs/no-nested-template-literals
364
- `saveRequestRecordSync: Saved ${req.method} ${req.url} (key: ${key}, seq: ${sequence}, recordingId: ${recordingId}, 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})`
365
406
  );
366
407
  }
367
408
  updateRequestBodySync(req, body) {
@@ -421,7 +462,7 @@ var ProxyServer = class {
421
462
  body: body || null
422
463
  };
423
464
  console.log(
424
- `Recorded: ${req.method} ${req.url} (seq: ${record.sequence}, recordingId: ${recordingId})`
465
+ `Recorded: ${req.method} ${req.url} (recordingId: ${recordingId})`
425
466
  );
426
467
  });
427
468
  }
@@ -451,46 +492,77 @@ var ProxyServer = class {
451
492
  body: body || null
452
493
  };
453
494
  console.log(
454
- `recordResponseData: Recorded response for ${req.method} ${req.url} (seq: ${record.sequence}, recordingId: ${recordingId})`
495
+ `recordResponseData: Recorded response for ${req.method} ${req.url} (recordingId: ${recordingId})`
455
496
  );
456
497
  return true;
457
498
  }
458
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
+ }
459
510
  const key = getReqID(req);
460
- const filePath = getRecordingPath(this.recordingsDir, this.replayId);
511
+ const filePath = getRecordingPath(this.recordingsDir, recordingId);
461
512
  try {
462
- 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);
463
523
  const host = req.headers.host || "unknown";
464
- 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);
465
525
  if (recordsWithKey.length === 0) {
466
- console.warn(
467
- `No recording found for ${key} at ${req.method} ${host}${req.url}, returning default response`
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`
468
530
  );
469
- const defaultResponse = req.method === "GET" ? {
470
- data: [],
471
- items: [],
472
- results: [],
473
- updated_at: "0001-01-01T00:00:00Z"
474
- } : { success: true };
531
+ const errorResponse = {
532
+ error: "No recording found",
533
+ message: errorMsg,
534
+ key,
535
+ sessionId: recordingId
536
+ };
475
537
  const corsHeaders = this.getCorsHeaders(req);
476
- res.writeHead(HTTP_STATUS_OK, {
538
+ res.writeHead(HTTP_STATUS_NOT_FOUND, {
477
539
  "Content-Type": "application/json",
478
540
  ...corsHeaders
479
541
  });
480
- res.end(JSON.stringify(defaultResponse));
542
+ res.end(JSON.stringify(errorResponse));
481
543
  return;
482
544
  }
483
- const usageCount = this.replaySequenceMap.get(key) || 0;
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
+ );
484
549
  let record;
485
- if (usageCount < recordsWithKey.length) {
486
- record = recordsWithKey[usageCount];
487
- } else {
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`
559
+ );
488
560
  record = recordsWithKey[recordsWithKey.length - 1];
489
561
  }
562
+ servedForThisKey.add(record.recordingId);
490
563
  console.log(
491
- `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}`
492
565
  );
493
- this.replaySequenceMap.set(key, usageCount + 1);
494
566
  if (!record.response) {
495
567
  throw new Error(
496
568
  `No response recorded for this request: ${req.method} ${host}${req.url}`
@@ -556,6 +628,7 @@ var ProxyServer = class {
556
628
  this.proxy.web(req, res, { target });
557
629
  }
558
630
  }
631
+ // TODO: check if can handle streaming requests
559
632
  async bufferAndProxyRequest(req, res, target) {
560
633
  const chunks = [];
561
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.9",
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",