test-proxy-recorder 0.3.0 → 0.3.2

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.cjs CHANGED
@@ -6,7 +6,7 @@ var https = require('https');
6
6
  var httpProxy = require('http-proxy');
7
7
  var ws = require('ws');
8
8
  var crypto = require('crypto');
9
- var path = require('path');
9
+ var path2 = require('path');
10
10
  var filenamify2 = require('filenamify');
11
11
 
12
12
  function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
@@ -16,7 +16,7 @@ var http__default = /*#__PURE__*/_interopDefault(http);
16
16
  var https__default = /*#__PURE__*/_interopDefault(https);
17
17
  var httpProxy__default = /*#__PURE__*/_interopDefault(httpProxy);
18
18
  var crypto__default = /*#__PURE__*/_interopDefault(crypto);
19
- var path__default = /*#__PURE__*/_interopDefault(path);
19
+ var path2__default = /*#__PURE__*/_interopDefault(path2);
20
20
  var filenamify2__default = /*#__PURE__*/_interopDefault(filenamify2);
21
21
 
22
22
  // src/constants.ts
@@ -53,20 +53,30 @@ function getRecordingPath(recordingsDir, id) {
53
53
  maxLength: 255
54
54
  // Set explicit max to prevent filenamify's default truncation
55
55
  });
56
- return path__default.default.join(recordingsDir, `${sanitizedId}${EXTENSION}`);
56
+ return path2__default.default.join(recordingsDir, `${sanitizedId}${EXTENSION}`);
57
57
  }
58
58
  async function loadRecordingSession(filePath) {
59
59
  const fileContent = await fs__default.default.readFile(filePath, "utf8");
60
60
  return JSON.parse(fileContent);
61
61
  }
62
62
  function processRecordings(recordings) {
63
- const keySequenceMap = /* @__PURE__ */ new Map();
64
- return recordings.map((recording) => {
63
+ const recordingsByKey = /* @__PURE__ */ new Map();
64
+ for (const recording of recordings) {
65
65
  const key = recording.key;
66
- const currentSeq = keySequenceMap.get(key) || 0;
67
- keySequenceMap.set(key, currentSeq + 1);
68
- return { ...recording, sequence: currentSeq };
69
- });
66
+ if (!recordingsByKey.has(key)) {
67
+ recordingsByKey.set(key, []);
68
+ }
69
+ recordingsByKey.get(key).push(recording);
70
+ }
71
+ const processedRecordings = [];
72
+ for (const [_key, keyRecordings] of recordingsByKey) {
73
+ keyRecordings.sort((a, b) => a.recordingId - b.recordingId);
74
+ keyRecordings.forEach((recording, index) => {
75
+ processedRecordings.push({ ...recording, sequence: index });
76
+ });
77
+ }
78
+ processedRecordings.sort((a, b) => a.recordingId - b.recordingId);
79
+ return processedRecordings;
70
80
  }
71
81
  async function saveRecordingSession(recordingsDir, session) {
72
82
  const filePath = getRecordingPath(recordingsDir, session.id);
@@ -84,16 +94,19 @@ async function saveRecordingSession(recordingsDir, session) {
84
94
  `Saved ${processedRecordings.length} HTTP recordings and ${session.websocketRecordings?.length || 0} WebSocket recordings to ${filePath}`
85
95
  );
86
96
  }
87
- function getReqID(req) {
88
- const urlParts = req.url.split("?");
89
- const pathname = urlParts[0];
90
- const query = urlParts[1] || "";
97
+ function generateRecordingKey(pathname, query, method) {
91
98
  const pathPart = pathname === "/" ? "root" : pathname.slice(1);
92
99
  const normalizedPath = filenamify2__default.default(pathPart, { replacement: "_" });
93
100
  const queryHash = generateQueryHash(query);
94
- const filename = `${req.method}_${normalizedPath}${queryHash}.json`;
101
+ const filename = `${method}_${normalizedPath}${queryHash}.json`;
95
102
  return filenamify2__default.default(filename, { replacement: "_" });
96
103
  }
104
+ function getReqID(req) {
105
+ const urlParts = req.url.split("?");
106
+ const pathname = urlParts[0];
107
+ const query = urlParts[1] || "";
108
+ return generateRecordingKey(pathname, query, req.method);
109
+ }
97
110
  function generateQueryHash(query) {
98
111
  if (!query) {
99
112
  return "";
@@ -129,19 +142,25 @@ var ProxyServer = class {
129
142
  recordingsDir;
130
143
  recordingIdCounter;
131
144
  // Unique ID for each recording entry
145
+ sequenceCounterByKey;
146
+ // Sequence counter per key (endpoint)
132
147
  replaySessions;
133
148
  // Track multiple concurrent replay sessions by recording ID
149
+ recordingPromises;
150
+ // Stack of promises that resolve to completed recordings
134
151
  constructor(targets, recordingsDir) {
135
152
  this.targets = targets;
136
153
  this.currentTargetIndex = 0;
137
154
  this.mode = Modes.transparent;
138
155
  this.recordingId = null;
139
156
  this.recordingIdCounter = 0;
157
+ this.sequenceCounterByKey = /* @__PURE__ */ new Map();
140
158
  this.replayId = null;
141
159
  this.modeTimeout = null;
142
160
  this.currentSession = null;
143
161
  this.recordingsDir = recordingsDir;
144
162
  this.replaySessions = /* @__PURE__ */ new Map();
163
+ this.recordingPromises = [];
145
164
  this.proxy = httpProxy__default.default.createProxyServer({
146
165
  secure: false,
147
166
  changeOrigin: true,
@@ -167,7 +186,7 @@ var ProxyServer = class {
167
186
  }
168
187
  setupProxyEventHandlers() {
169
188
  this.proxy.on("error", this.handleProxyError.bind(this));
170
- this.proxy.on("proxyRes", this.handleProxyResponse.bind(this));
189
+ this.proxy.on("proxyRes", this.addCorsHeaders.bind(this));
171
190
  }
172
191
  handleProxyError(err, req, res) {
173
192
  console.error("Proxy error:", err);
@@ -183,12 +202,6 @@ var ProxyServer = class {
183
202
  }
184
203
  res.end(JSON.stringify({ error: "Proxy error", message: err.message }));
185
204
  }
186
- handleProxyResponse(proxyRes, req) {
187
- this.addCorsHeaders(proxyRes, req);
188
- if (this.mode === Modes.record && this.recordingId) {
189
- this.recordResponse(req, proxyRes);
190
- }
191
- }
192
205
  /**
193
206
  * Get CORS headers for a given request
194
207
  * @param req The incoming HTTP request
@@ -246,7 +259,15 @@ var ProxyServer = class {
246
259
  * @returns The recording ID, or null if not found
247
260
  */
248
261
  getRecordingIdFromRequest(req) {
249
- return this.getRecordingIdFromHeader(req) || this.getRecordingIdFromCookie(req);
262
+ const fromHeader = this.getRecordingIdFromHeader(req);
263
+ const fromCookie = this.getRecordingIdFromCookie(req);
264
+ if (fromHeader) {
265
+ return fromHeader;
266
+ }
267
+ if (fromCookie) {
268
+ return fromCookie;
269
+ }
270
+ return null;
250
271
  }
251
272
  /**
252
273
  * Get or create a replay session state for a given recording ID
@@ -271,6 +292,27 @@ var ProxyServer = class {
271
292
  }
272
293
  return session;
273
294
  }
295
+ /**
296
+ * Clean up a session - removes it from memory and resets counters
297
+ * @param sessionId The session ID to clean up
298
+ */
299
+ async cleanupSession(sessionId) {
300
+ if (this.replaySessions.has(sessionId)) {
301
+ console.log(`[CLEANUP] Removing replay session: ${sessionId}`);
302
+ this.replaySessions.delete(sessionId);
303
+ }
304
+ if (this.recordingId === sessionId) {
305
+ console.log(`[CLEANUP] Saving and clearing active recording session: ${sessionId}`);
306
+ await this.saveCurrentSession();
307
+ this.currentSession = null;
308
+ this.recordingId = null;
309
+ }
310
+ if (this.replayId === sessionId) {
311
+ console.log(`[CLEANUP] Clearing active replay session: ${sessionId}`);
312
+ this.replayId = null;
313
+ }
314
+ console.log(`[CLEANUP] Session ${sessionId} cleaned up successfully`);
315
+ }
274
316
  parseGetParams(req) {
275
317
  const url = new URL(req.url || "", `http://${req.headers.host}`);
276
318
  const mode = url.searchParams.get("mode");
@@ -282,19 +324,41 @@ var ProxyServer = class {
282
324
  }
283
325
  return { mode, id, timeout };
284
326
  }
327
+ async parseControlRequest(req) {
328
+ if (req.method === "GET") {
329
+ return this.parseGetParams(req);
330
+ }
331
+ if (req.method === "POST") {
332
+ const body = await readRequestBody(req);
333
+ console.log(`MODE CHANGE (${req.method})`, body);
334
+ return JSON.parse(body);
335
+ }
336
+ throw new Error("Unsupported control method");
337
+ }
285
338
  async handleControlRequest(req, res) {
339
+ if (req.method === "GET") {
340
+ sendJsonResponse(res, HTTP_STATUS_OK, {
341
+ recordingsDir: this.recordingsDir,
342
+ mode: this.mode,
343
+ id: this.recordingId || this.replayId
344
+ });
345
+ return;
346
+ }
286
347
  try {
287
- let data;
288
- if (req.method === "GET") {
289
- data = this.parseGetParams(req);
290
- } else if (req.method === "POST") {
291
- const body = await readRequestBody(req);
292
- console.log(`MODE CHANGE (${req.method})`, body);
293
- data = JSON.parse(body);
294
- } else {
348
+ const data = await this.parseControlRequest(req);
349
+ const { mode, id, timeout: requestTimeout, cleanup } = data;
350
+ if (cleanup && id) {
351
+ await this.cleanupSession(id);
352
+ sendJsonResponse(res, HTTP_STATUS_OK, {
353
+ success: true,
354
+ message: `Session ${id} cleaned up`,
355
+ mode: this.mode
356
+ });
295
357
  return;
296
358
  }
297
- const { mode, id, timeout: requestTimeout } = data;
359
+ if (!mode) {
360
+ throw new Error("Mode parameter is required when cleanup is not specified");
361
+ }
298
362
  const timeout = requestTimeout ?? DEFAULT_TIMEOUT_MS;
299
363
  this.clearModeTimeout();
300
364
  await this.switchMode(mode, id);
@@ -310,7 +374,8 @@ var ProxyServer = class {
310
374
  success: true,
311
375
  mode: this.mode,
312
376
  id: this.recordingId || this.replayId,
313
- timeout
377
+ timeout,
378
+ recordingsDir: this.recordingsDir
314
379
  });
315
380
  } catch (error) {
316
381
  console.error("Control request error:", error);
@@ -326,7 +391,7 @@ var ProxyServer = class {
326
391
  async switchMode(mode, id) {
327
392
  console.log(`Switching to ${mode.toUpperCase()} mode`);
328
393
  if (this.currentSession && this.mode === Modes.record) {
329
- await this.saveCurrentSession(true);
394
+ await this.saveCurrentSession();
330
395
  console.log("Session saved, continuing with mode switch");
331
396
  }
332
397
  switch (mode) {
@@ -366,6 +431,8 @@ var ProxyServer = class {
366
431
  this.recordingId = id;
367
432
  this.replayId = null;
368
433
  this.currentSession = { id, recordings: [], websocketRecordings: [] };
434
+ this.recordingIdCounter = 0;
435
+ this.sequenceCounterByKey.clear();
369
436
  console.log(`Switched to record mode with ID: ${id}`);
370
437
  }
371
438
  async switchToReplayMode(id) {
@@ -383,171 +450,119 @@ var ProxyServer = class {
383
450
  console.log(`Switched to replay mode with ID: ${id}`);
384
451
  }
385
452
  setupModeTimeout(timeout) {
453
+ clearTimeout(this.modeTimeout || 0);
386
454
  this.modeTimeout = setTimeout(async () => {
387
455
  console.log("Timeout reached, switching back to transparent mode");
388
- await this.saveCurrentSession(true);
456
+ await this.saveCurrentSession();
389
457
  this.switchToTransparentMode();
390
458
  this.modeTimeout = null;
391
459
  }, timeout);
392
460
  }
393
- async saveCurrentSession(filterIncomplete = false) {
394
- if (!this.currentSession) {
461
+ async flushPendingRecordings() {
462
+ if (this.recordingPromises.length === 0) {
395
463
  return;
396
464
  }
397
- if (filterIncomplete) {
398
- const incompleteCount = this.currentSession.recordings.filter(
399
- (r) => !r.response
400
- ).length;
401
- if (incompleteCount > 0) {
402
- this.currentSession.recordings = this.currentSession.recordings.filter(
403
- (r) => r.response
404
- );
465
+ const results = await Promise.allSettled(this.recordingPromises);
466
+ if (this.currentSession) {
467
+ for (const result of results) {
468
+ if (result.status === "fulfilled" && result.value) {
469
+ this.currentSession.recordings.push(result.value);
470
+ }
405
471
  }
472
+ console.log(
473
+ `Flushed ${results.length} recordings to session (total: ${this.currentSession.recordings.length})`
474
+ );
406
475
  }
407
- console.log(
408
- `Saving session with ${this.currentSession.recordings.length} HTTP and ${this.currentSession.websocketRecordings.length} WebSocket recordings`
409
- );
410
- await saveRecordingSession(this.recordingsDir, this.currentSession);
476
+ this.recordingPromises = [];
411
477
  }
412
- saveRequestRecordSync(req, body) {
478
+ async saveCurrentSession() {
413
479
  if (!this.currentSession) {
414
480
  return;
415
481
  }
416
- const key = getReqID(req);
417
- const recordingId = this.recordingIdCounter++;
418
- req.__recordingId = recordingId;
419
- const record = {
420
- request: {
421
- method: req.method,
422
- url: req.url,
423
- headers: req.headers,
424
- body: body || null
425
- },
426
- timestamp: (/* @__PURE__ */ new Date()).toISOString(),
427
- key,
428
- recordingId
429
- };
430
- this.currentSession.recordings.push(record);
482
+ await this.flushPendingRecordings();
431
483
  console.log(
432
- // eslint-disable-next-line sonarjs/no-nested-template-literals
433
- `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})`
484
+ `Saving session with ${this.currentSession.recordings.length} HTTP and ${this.currentSession.websocketRecordings.length} WebSocket recordings`
434
485
  );
486
+ await saveRecordingSession(this.recordingsDir, this.currentSession);
435
487
  }
436
- updateRequestBodySync(req, body) {
437
- if (!this.currentSession) {
438
- return;
488
+ getRecordingIdOrError(req, res) {
489
+ const recordingIdFromRequest = this.getRecordingIdFromRequest(req);
490
+ if (recordingIdFromRequest) {
491
+ return recordingIdFromRequest;
439
492
  }
440
- const recordingId = req.__recordingId;
441
- if (recordingId === void 0) {
442
- console.error(
443
- `updateRequestBodySync: No recording ID found on request ${req.method} ${req.url}`
493
+ if (this.replaySessions.size > 1) {
494
+ console.warn(
495
+ `[CONCURRENT REPLAY WARNING] Request to ${req.method} ${req.url} is missing ${RECORDING_ID_HEADER} header/cookie. Active sessions: ${[...this.replaySessions.keys()].join(", ")}. this.replayId fallback would be: ${this.replayId} (NOT USING - could be wrong session)`
444
496
  );
445
- return;
446
- }
447
- const record = this.currentSession.recordings.find(
448
- (r) => r.recordingId === recordingId
449
- );
450
- if (!record) {
451
- console.error(
452
- `updateRequestBodySync: Could not find recording with ID ${recordingId} for ${req.method} ${req.url}`
497
+ const corsHeaders = this.getCorsHeaders(req);
498
+ res.writeHead(HTTP_STATUS_BAD_REQUEST, {
499
+ "Content-Type": "application/json",
500
+ ...corsHeaders
501
+ });
502
+ res.end(
503
+ JSON.stringify({
504
+ error: "Missing recording ID in concurrent replay mode. Ensure x-test-rcrd-id header is set.",
505
+ activeSessions: [...this.replaySessions.keys()],
506
+ hint: "This usually means page.setExtraHTTPHeaders() did not apply to this request type"
507
+ })
453
508
  );
454
- return;
509
+ return null;
510
+ }
511
+ const recordingId = this.replayId;
512
+ if (!recordingId) {
513
+ const corsHeaders = this.getCorsHeaders(req);
514
+ res.writeHead(HTTP_STATUS_BAD_REQUEST, {
515
+ "Content-Type": "application/json",
516
+ ...corsHeaders
517
+ });
518
+ res.end(JSON.stringify({ error: "No replay session active" }));
519
+ return null;
455
520
  }
456
- record.request.body = body || null;
457
521
  console.log(
458
- `updateRequestBodySync: Updated body for ${req.method} ${req.url} (${body.length} chars, recordingId: ${recordingId})`
522
+ `[FALLBACK] Using replayId fallback for ${req.method} ${req.url} -> session: ${recordingId} (single session mode)`
459
523
  );
524
+ return recordingId;
460
525
  }
461
- async recordResponse(req, proxyRes) {
462
- if (!this.currentSession) {
463
- return;
526
+ async ensureSessionLoaded(recordingId, filePath) {
527
+ const sessionState = this.getOrCreateReplaySession(recordingId);
528
+ if (!sessionState.loadedSession) {
529
+ sessionState.loadedSession = await loadRecordingSession(filePath);
530
+ console.log(`[REPLAY] Loaded recording session: ${recordingId}`);
464
531
  }
465
- const recordingId = req.__recordingId;
466
- if (recordingId === void 0) {
467
- console.error(
468
- `recordResponse: No recording ID found on request ${req.method} ${req.url}`
469
- );
470
- return;
471
- }
472
- const record = this.currentSession.recordings.find(
473
- (r) => r.recordingId === recordingId
474
- );
475
- if (!record) {
476
- console.error(
477
- `recordResponse: Could not find recording with ID ${recordingId} for ${req.method} ${req.url}`
478
- );
479
- return;
480
- }
481
- const chunks = [];
482
- proxyRes.on("data", (chunk) => {
483
- chunks.push(chunk);
484
- });
485
- proxyRes.on("end", async () => {
486
- const body = Buffer.concat(chunks).toString("utf8");
487
- record.response = {
488
- statusCode: proxyRes.statusCode,
489
- headers: proxyRes.headers,
490
- body: body || null
491
- };
492
- console.log(
493
- `Recorded: ${req.method} ${req.url} (recordingId: ${recordingId})`
494
- );
495
- });
532
+ return sessionState;
496
533
  }
497
- async recordResponseData(req, proxyRes, body) {
498
- if (!this.currentSession) {
499
- return false;
534
+ getServedTracker(sessionState, key) {
535
+ if (!sessionState.servedRecordingIdsByKey.has(key)) {
536
+ sessionState.servedRecordingIdsByKey.set(key, /* @__PURE__ */ new Set());
500
537
  }
501
- const recordingId = req.__recordingId;
502
- if (recordingId === void 0) {
503
- console.error(
504
- `recordResponseData: No recording ID found on request ${req.method} ${req.url}`
505
- );
506
- return false;
538
+ return sessionState.servedRecordingIdsByKey.get(key);
539
+ }
540
+ selectReplayRecord(recordsWithKey, servedForThisKey, key, recordingId) {
541
+ for (const rec of recordsWithKey) {
542
+ if (!servedForThisKey.has(rec.recordingId)) {
543
+ return rec;
544
+ }
507
545
  }
508
- const record = this.currentSession.recordings.find(
509
- (r) => r.recordingId === recordingId
510
- );
511
- if (!record) {
512
- console.error(
513
- `recordResponseData: Could not find recording with ID ${recordingId} for ${req.method} ${req.url}`
546
+ if (recordsWithKey.length > 0) {
547
+ console.log(
548
+ `[REPLAY WARNING] All ${recordsWithKey.length} recordings already served for ${key} (session: ${recordingId}), reusing last one`
514
549
  );
515
- return false;
550
+ return recordsWithKey[recordsWithKey.length - 1];
516
551
  }
517
- record.response = {
518
- statusCode: proxyRes.statusCode,
519
- headers: proxyRes.headers,
520
- body: body || null
521
- };
522
- console.log(
523
- `recordResponseData: Recorded response for ${req.method} ${req.url} (recordingId: ${recordingId})`
524
- );
525
- return true;
552
+ return null;
526
553
  }
527
554
  async handleReplayRequest(req, res) {
528
- const recordingId = this.getRecordingIdFromRequest(req) || this.replayId;
529
- if (!recordingId) {
530
- const corsHeaders = this.getCorsHeaders(req);
531
- res.writeHead(HTTP_STATUS_BAD_REQUEST, {
532
- "Content-Type": "application/json",
533
- ...corsHeaders
534
- });
535
- res.end(JSON.stringify({ error: "No replay session active" }));
536
- return;
537
- }
555
+ const recordingId = this.getRecordingIdOrError(req, res);
556
+ if (!recordingId) return;
538
557
  const key = getReqID(req);
539
558
  const filePath = getRecordingPath(this.recordingsDir, recordingId);
540
559
  try {
541
- const sessionState = this.getOrCreateReplaySession(recordingId);
542
- if (!sessionState.loadedSession) {
543
- sessionState.loadedSession = await loadRecordingSession(filePath);
544
- console.log(`[REPLAY] Loaded recording session: ${recordingId}`);
545
- }
560
+ const sessionState = await this.ensureSessionLoaded(
561
+ recordingId,
562
+ filePath
563
+ );
546
564
  const session = sessionState.loadedSession;
547
- if (!sessionState.servedRecordingIdsByKey.has(key)) {
548
- sessionState.servedRecordingIdsByKey.set(key, /* @__PURE__ */ new Set());
549
- }
550
- const servedForThisKey = sessionState.servedRecordingIdsByKey.get(key);
565
+ const servedForThisKey = this.getServedTracker(sessionState, key);
551
566
  const host = req.headers.host || "unknown";
552
567
  const recordsWithKey = session.recordings.filter((r) => r.key === key && r.response).toSorted((a, b) => {
553
568
  const aSeq = a.sequence !== void 0 ? a.sequence : a.recordingId;
@@ -576,30 +591,23 @@ var ProxyServer = class {
576
591
  }
577
592
  const requestCount = servedForThisKey.size + 1;
578
593
  console.log(
579
- `[replay request #${requestCount}] ${req.method} ${req.url} (session: ${recordingId}, total: ${recordsWithKey.length}, served: ${servedForThisKey.size})`
594
+ `[replay request #${requestCount}] ${req.method} ${req.url} (key: ${key}, session: ${recordingId}, total: ${recordsWithKey.length}, served: ${servedForThisKey.size})`
580
595
  );
581
- let record;
582
- for (const rec of recordsWithKey) {
583
- if (!servedForThisKey.has(rec.recordingId)) {
584
- record = rec;
585
- break;
586
- }
587
- }
588
- if (!record) {
589
- console.log(
590
- `[REPLAY WARNING] All ${recordsWithKey.length} recordings already served for ${key} (session: ${recordingId}), reusing last one`
596
+ const record = this.selectReplayRecord(
597
+ recordsWithKey,
598
+ servedForThisKey,
599
+ key,
600
+ recordingId
601
+ );
602
+ if (!record || !record.response) {
603
+ throw new Error(
604
+ `No response recorded for this request: ${req.method} ${host}${req.url}`
591
605
  );
592
- record = recordsWithKey[recordsWithKey.length - 1];
593
606
  }
594
607
  servedForThisKey.add(record.recordingId);
595
608
  console.log(
596
609
  `[replay serving] recordingId: ${record.recordingId}, session: ${recordingId}, body_len: ${record.response?.body?.length || 0}`
597
610
  );
598
- if (!record.response) {
599
- throw new Error(
600
- `No response recorded for this request: ${req.method} ${host}${req.url}`
601
- );
602
- }
603
611
  const { statusCode, headers, body } = record.response;
604
612
  const responseHeaders = {
605
613
  ...headers,
@@ -654,82 +662,124 @@ var ProxyServer = class {
654
662
  const target = this.getTarget();
655
663
  console.log(`[${this.mode}] ${req.method} ${req.url} -> ${target}`);
656
664
  if (this.mode === Modes.record) {
657
- this.saveRequestRecordSync(req, null);
658
- await this.bufferAndProxyRequest(req, res, target);
665
+ await this.recordAndProxyRequest(req, res, target);
659
666
  } else {
660
667
  this.proxy.web(req, res, { target });
661
668
  }
662
669
  }
663
- // TODO: check if can handle streaming requests
664
- async bufferAndProxyRequest(req, res, target) {
665
- const chunks = [];
666
- req.on("data", (chunk) => {
667
- chunks.push(chunk);
668
- });
669
- try {
670
- await new Promise((resolve, reject) => {
671
- req.on("end", () => resolve());
672
- req.on("error", (err) => reject(err));
673
- setTimeout(
674
- () => reject(new Error("Request buffering timeout")),
675
- 3e4
676
- );
677
- });
678
- } catch (error) {
679
- console.error("Error buffering request:", error);
670
+ // Note: streaming requests are buffered before proxying; streaming passthrough is not yet implemented
671
+ async recordAndProxyRequest(req, res, target) {
672
+ if (!this.currentSession) {
673
+ return;
680
674
  }
681
- const body = Buffer.concat(chunks).toString("utf8");
682
- this.updateRequestBodySync(req, body);
683
- const targetUrl = new URL(target);
684
- const isHttps = targetUrl.protocol === "https:";
685
- const requestModule = isHttps ? https__default.default : http__default.default;
686
- const defaultPort = isHttps ? 443 : 80;
687
- const proxyReq = requestModule.request(
688
- {
689
- hostname: targetUrl.hostname,
690
- port: targetUrl.port || defaultPort,
691
- path: req.url,
692
- method: req.method,
693
- headers: req.headers
694
- },
695
- (proxyRes) => {
696
- this.addCorsHeaders(proxyRes, req);
697
- const responseChunks = [];
698
- proxyRes.on("data", (chunk) => {
699
- responseChunks.push(chunk);
700
- });
701
- proxyRes.on("end", async () => {
702
- const responseBody = Buffer.concat(responseChunks);
703
- const recorded = await this.recordResponseData(
704
- req,
705
- proxyRes,
706
- responseBody.toString("utf8")
707
- );
708
- const responseHeaders = {
709
- ...proxyRes.headers,
710
- ...this.getCorsHeaders(req)
711
- };
712
- res.writeHead(proxyRes.statusCode || 200, responseHeaders);
713
- res.end(responseBody);
714
- if (recorded) {
715
- console.log(`Recorded: ${req.method} ${req.url}`);
675
+ const key = getReqID(req);
676
+ const recordingId = this.recordingIdCounter++;
677
+ const sequence = this.sequenceCounterByKey.get(key) || 0;
678
+ this.sequenceCounterByKey.set(key, sequence + 1);
679
+ const recordingPromise = new Promise((resolve) => {
680
+ (async () => {
681
+ try {
682
+ const chunks = [];
683
+ req.on("data", (chunk) => {
684
+ chunks.push(chunk);
685
+ });
686
+ try {
687
+ await new Promise((resolveBuffer, rejectBuffer) => {
688
+ req.on("end", () => resolveBuffer());
689
+ req.on("error", (err) => rejectBuffer(err));
690
+ setTimeout(
691
+ () => rejectBuffer(new Error("Request buffering timeout")),
692
+ 3e4
693
+ );
694
+ });
695
+ } catch (error) {
696
+ console.error("Error buffering request:", error);
716
697
  }
717
- });
718
- proxyRes.on("error", (err) => {
719
- console.error("Proxy response error:", err);
720
- if (!res.headersSent) {
698
+ const requestBody = Buffer.concat(chunks).toString("utf8");
699
+ const targetUrl = new URL(target);
700
+ const isHttps = targetUrl.protocol === "https:";
701
+ const requestModule = isHttps ? https__default.default : http__default.default;
702
+ const defaultPort = isHttps ? 443 : 80;
703
+ const proxyReq = requestModule.request(
704
+ {
705
+ hostname: targetUrl.hostname,
706
+ port: targetUrl.port || defaultPort,
707
+ path: req.url,
708
+ method: req.method,
709
+ headers: req.headers
710
+ },
711
+ (proxyRes) => {
712
+ this.addCorsHeaders(proxyRes, req);
713
+ const responseChunks = [];
714
+ proxyRes.on("data", (chunk) => {
715
+ responseChunks.push(chunk);
716
+ });
717
+ proxyRes.on("end", async () => {
718
+ try {
719
+ const responseBody = Buffer.concat(responseChunks);
720
+ const responseBodyStr = responseBody.toString("utf8");
721
+ const recording = {
722
+ request: {
723
+ method: req.method,
724
+ url: req.url,
725
+ headers: req.headers,
726
+ body: requestBody || null
727
+ },
728
+ response: {
729
+ statusCode: proxyRes.statusCode,
730
+ headers: proxyRes.headers,
731
+ body: responseBodyStr || null
732
+ },
733
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
734
+ key,
735
+ recordingId,
736
+ sequence
737
+ };
738
+ const responseHeaders = {
739
+ ...proxyRes.headers,
740
+ ...this.getCorsHeaders(req)
741
+ };
742
+ res.writeHead(proxyRes.statusCode || 200, responseHeaders);
743
+ res.end(responseBody);
744
+ console.log(
745
+ `Recorded: ${req.method} ${req.url} (recordingId: ${recordingId}, sequence: ${sequence})`
746
+ );
747
+ resolve(recording);
748
+ } catch (error) {
749
+ console.error("Error completing recording:", error);
750
+ resolve(null);
751
+ }
752
+ });
753
+ proxyRes.on("error", (err) => {
754
+ console.error("Proxy response error:", err);
755
+ if (!res.headersSent) {
756
+ this.handleProxyError(err, req, res);
757
+ }
758
+ resolve(null);
759
+ });
760
+ }
761
+ );
762
+ proxyReq.on("error", (err) => {
721
763
  this.handleProxyError(err, req, res);
764
+ resolve(null);
765
+ });
766
+ if (chunks.length > 0) {
767
+ proxyReq.write(Buffer.concat(chunks));
722
768
  }
723
- });
724
- }
725
- );
726
- proxyReq.on("error", (err) => {
727
- this.handleProxyError(err, req, res);
769
+ proxyReq.end();
770
+ } catch (error) {
771
+ console.error("Error in recordAndProxyRequest:", error);
772
+ try {
773
+ this.handleProxyError(error, req, res);
774
+ } catch (error_) {
775
+ console.error("Failed to handle proxy error:", error_);
776
+ }
777
+ resolve(null);
778
+ }
779
+ })();
728
780
  });
729
- if (chunks.length > 0) {
730
- proxyReq.write(Buffer.concat(chunks));
731
- }
732
- proxyReq.end();
781
+ this.recordingPromises.push(recordingPromise);
782
+ await recordingPromise;
733
783
  }
734
784
  handleUpgrade(req, socket, head) {
735
785
  if (this.mode === Modes.replay) {
@@ -894,8 +944,6 @@ var ProxyServer = class {
894
944
  );
895
945
  }
896
946
  };
897
-
898
- // src/playwright/index.ts
899
947
  function getProxyPort() {
900
948
  const envPort = process.env.TEST_PROXY_RECORDER_PORT;
901
949
  if (envPort) {
@@ -931,6 +979,30 @@ async function setProxyMode(mode, sessionId, timeout) {
931
979
  throw error;
932
980
  }
933
981
  }
982
+ async function cleanupSession(sessionId) {
983
+ const proxyPort = getProxyPort();
984
+ try {
985
+ const body = {
986
+ cleanup: true,
987
+ id: sessionId
988
+ };
989
+ const response = await fetch(`http://127.0.0.1:${proxyPort}/__control`, {
990
+ method: "POST",
991
+ headers: { "Content-Type": "application/json" },
992
+ body: JSON.stringify(body)
993
+ });
994
+ if (!response.ok) {
995
+ const text = await response.text();
996
+ console.error(`Failed to cleanup session ${sessionId}:`, text);
997
+ throw new Error(`Failed to cleanup session: ${text}`);
998
+ }
999
+ await response.json();
1000
+ console.log(`Session cleaned up: ${sessionId}`);
1001
+ } catch (error) {
1002
+ console.error(`Error cleaning up session:`, error);
1003
+ throw error;
1004
+ }
1005
+ }
934
1006
  function parseSpecFilePath(specPath) {
935
1007
  const folderMatch = specPath.match(/^(.+?)\/([^/]+)\.(spec|test)\.ts$/);
936
1008
  if (folderMatch) {
@@ -972,6 +1044,53 @@ async function stopProxy(testInfo) {
972
1044
  const sessionId = generateSessionId(testInfo);
973
1045
  await setProxyMode(Modes.transparent, sessionId);
974
1046
  }
1047
+ var cachedRecordingsDir = null;
1048
+ async function getRecordingsDir() {
1049
+ if (cachedRecordingsDir) {
1050
+ return cachedRecordingsDir;
1051
+ }
1052
+ const proxyPort = getProxyPort();
1053
+ try {
1054
+ const response = await fetch(`http://127.0.0.1:${proxyPort}/__control`);
1055
+ if (response.ok) {
1056
+ const data = await response.json();
1057
+ if (data.recordingsDir) {
1058
+ cachedRecordingsDir = data.recordingsDir;
1059
+ return cachedRecordingsDir;
1060
+ }
1061
+ }
1062
+ } catch (error) {
1063
+ console.warn(
1064
+ "Failed to get recordings directory from proxy, using default:",
1065
+ error
1066
+ );
1067
+ }
1068
+ cachedRecordingsDir = path2__default.default.join(process.cwd(), "e2e", "recordings");
1069
+ return cachedRecordingsDir;
1070
+ }
1071
+ async function setupClientSideRecording(page, sessionId, mode, url) {
1072
+ const harFileName = sessionId.replaceAll("/", "__");
1073
+ const recordingsDir = await getRecordingsDir();
1074
+ const harPath = path2__default.default.join(recordingsDir, `${harFileName}.har`);
1075
+ console.log(
1076
+ `[Client-Side Recording] Setting up HAR for session: ${sessionId}, mode: ${mode}, path: ${harPath}`
1077
+ );
1078
+ try {
1079
+ await page.routeFromHAR(harPath, {
1080
+ url,
1081
+ update: mode === Modes.record,
1082
+ updateContent: "embed"
1083
+ });
1084
+ } catch (error) {
1085
+ if (mode === Modes.replay) {
1086
+ console.error(
1087
+ `[Client-Side Replay] Failed to load HAR file. Run tests in record mode first.`,
1088
+ error
1089
+ );
1090
+ throw error;
1091
+ }
1092
+ }
1093
+ }
975
1094
  var playwrightProxy = {
976
1095
  /**
977
1096
  * Setup before test - sets the proxy mode and configures page with custom header
@@ -979,24 +1098,84 @@ var playwrightProxy = {
979
1098
  * @param page - Playwright page object
980
1099
  * @param testInfo - Playwright test info object
981
1100
  * @param mode - The proxy mode to use for this test
982
- * @param timeout - Optional timeout in milliseconds
1101
+ * @param options - Optional configuration including timeout and client-side recording patterns
983
1102
  */
984
- async before(page, testInfo, mode, timeout) {
1103
+ async before(page, testInfo, mode, options) {
1104
+ const timeout = typeof options === "number" ? options : options?.timeout;
1105
+ const clientSideOptions = typeof options === "object" && options !== null ? options : void 0;
985
1106
  const sessionId = generateSessionId(testInfo);
986
1107
  await page.setExtraHTTPHeaders({
987
1108
  [RECORDING_ID_HEADER]: sessionId
988
1109
  });
1110
+ console.log(`[Setup] Setting proxy mode: ${mode}, session: ${sessionId}`);
989
1111
  await setProxyMode(mode, sessionId, timeout);
990
- page.on("close", async () => {
991
- try {
992
- await setProxyMode(Modes.replay, sessionId);
993
- console.log(
994
- `[Cleanup] Switched to transparent mode for session: ${sessionId}`
995
- );
996
- } catch (error) {
997
- console.error("[Cleanup] Error during page close cleanup:", error);
998
- }
999
- });
1112
+ console.log(`[Setup] Proxy mode set successfully`);
1113
+ if (clientSideOptions?.url) {
1114
+ console.log(`[Setup] Setting up client-side recording with pattern: ${clientSideOptions.url}`);
1115
+ await setupClientSideRecording(
1116
+ page,
1117
+ sessionId,
1118
+ mode,
1119
+ clientSideOptions.url
1120
+ );
1121
+ console.log(`[Setup] Client-side recording setup complete`);
1122
+ }
1123
+ const proxyPort = process.env.TEST_PROXY_RECORDER_PORT || "8100";
1124
+ const proxyUrl = `localhost:${proxyPort}`;
1125
+ console.log(`[Setup] Registering proxy route handler for: ${proxyUrl}`);
1126
+ await page.route(
1127
+ (url) => {
1128
+ const urlStr = url.toString();
1129
+ const matches = urlStr.includes(proxyUrl);
1130
+ if (matches) {
1131
+ console.log(`[Route Matcher] Matched proxy request: ${urlStr}`);
1132
+ }
1133
+ return matches;
1134
+ },
1135
+ async (route) => {
1136
+ try {
1137
+ const url = route.request().url();
1138
+ const method = route.request().method();
1139
+ const headers = route.request().headers();
1140
+ const hadHeader = !!headers[RECORDING_ID_HEADER];
1141
+ headers[RECORDING_ID_HEADER] = sessionId;
1142
+ console.log(
1143
+ `[Route Intercept] ${method} ${url} (had header: ${hadHeader}, adding session: ${sessionId})`
1144
+ );
1145
+ await route.continue({ headers });
1146
+ } catch (error) {
1147
+ console.error(
1148
+ `[Route Handler Error] Failed to add ${RECORDING_ID_HEADER} header:`,
1149
+ error
1150
+ );
1151
+ await route.fallback();
1152
+ }
1153
+ },
1154
+ { times: Infinity }
1155
+ // Ensure the handler applies to all matching requests
1156
+ );
1157
+ console.log(`[Setup] Proxy route handler registered`);
1158
+ const context = page.context();
1159
+ const contextId = context._guid || "default";
1160
+ const handlerKey = `cleanup_${contextId}`;
1161
+ if (!globalThis[handlerKey]) {
1162
+ globalThis[handlerKey] = true;
1163
+ context.on("close", async () => {
1164
+ try {
1165
+ console.log(
1166
+ `[Cleanup] Browser context closed, cleaning up session: ${sessionId}`
1167
+ );
1168
+ await cleanupSession(sessionId);
1169
+ } catch (error) {
1170
+ console.warn(
1171
+ `[Cleanup] Failed to cleanup session ${sessionId}:`,
1172
+ error
1173
+ );
1174
+ } finally {
1175
+ delete globalThis[handlerKey];
1176
+ }
1177
+ });
1178
+ }
1000
1179
  },
1001
1180
  /**
1002
1181
  * Global teardown - switches proxy to transparent mode