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