test-proxy-recorder 0.3.1 → 0.3.3

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,7 +41,7 @@ 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");
@@ -82,16 +82,19 @@ async function saveRecordingSession(recordingsDir, session) {
82
82
  `Saved ${processedRecordings.length} HTTP recordings and ${session.websocketRecordings?.length || 0} WebSocket recordings to ${filePath}`
83
83
  );
84
84
  }
85
- function getReqID(req) {
86
- const urlParts = req.url.split("?");
87
- const pathname = urlParts[0];
88
- const query = urlParts[1] || "";
85
+ function generateRecordingKey(pathname, query, method) {
89
86
  const pathPart = pathname === "/" ? "root" : pathname.slice(1);
90
87
  const normalizedPath = filenamify2(pathPart, { replacement: "_" });
91
88
  const queryHash = generateQueryHash(query);
92
- const filename = `${req.method}_${normalizedPath}${queryHash}.json`;
89
+ const filename = `${method}_${normalizedPath}${queryHash}.json`;
93
90
  return filenamify2(filename, { replacement: "_" });
94
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
+ }
95
98
  function generateQueryHash(query) {
96
99
  if (!query) {
97
100
  return "";
@@ -133,6 +136,8 @@ var ProxyServer = class {
133
136
  // Track multiple concurrent replay sessions by recording ID
134
137
  recordingPromises;
135
138
  // Stack of promises that resolve to completed recordings
139
+ flushPromise;
140
+ // Promise for in-progress flush operation
136
141
  constructor(targets, recordingsDir) {
137
142
  this.targets = targets;
138
143
  this.currentTargetIndex = 0;
@@ -146,6 +151,7 @@ var ProxyServer = class {
146
151
  this.recordingsDir = recordingsDir;
147
152
  this.replaySessions = /* @__PURE__ */ new Map();
148
153
  this.recordingPromises = [];
154
+ this.flushPromise = null;
149
155
  this.proxy = httpProxy.createProxyServer({
150
156
  secure: false,
151
157
  changeOrigin: true,
@@ -244,7 +250,15 @@ var ProxyServer = class {
244
250
  * @returns The recording ID, or null if not found
245
251
  */
246
252
  getRecordingIdFromRequest(req) {
247
- return this.getRecordingIdFromHeader(req) || this.getRecordingIdFromCookie(req);
253
+ const fromHeader = this.getRecordingIdFromHeader(req);
254
+ const fromCookie = this.getRecordingIdFromCookie(req);
255
+ if (fromHeader) {
256
+ return fromHeader;
257
+ }
258
+ if (fromCookie) {
259
+ return fromCookie;
260
+ }
261
+ return null;
248
262
  }
249
263
  /**
250
264
  * Get or create a replay session state for a given recording ID
@@ -260,7 +274,8 @@ var ProxyServer = class {
260
274
  recordingId,
261
275
  servedRecordingIdsByKey: /* @__PURE__ */ new Map(),
262
276
  loadedSession: null,
263
- lastAccessTime: Date.now()
277
+ lastAccessTime: Date.now(),
278
+ sortedRecordingsByKey: /* @__PURE__ */ new Map()
264
279
  };
265
280
  this.replaySessions.set(recordingId, session);
266
281
  console.log(
@@ -269,6 +284,24 @@ var ProxyServer = class {
269
284
  }
270
285
  return session;
271
286
  }
287
+ /**
288
+ * Clean up a session - removes it from memory and resets counters
289
+ * @param sessionId The session ID to clean up
290
+ */
291
+ async cleanupSession(sessionId) {
292
+ if (this.replaySessions.has(sessionId)) {
293
+ this.replaySessions.delete(sessionId);
294
+ }
295
+ if (this.recordingId === sessionId) {
296
+ await this.saveCurrentSession();
297
+ this.currentSession = null;
298
+ this.recordingId = null;
299
+ }
300
+ if (this.replayId === sessionId) {
301
+ this.replayId = null;
302
+ }
303
+ console.log(`[CLEANUP] Session ${sessionId} cleaned up successfully`);
304
+ }
272
305
  parseGetParams(req) {
273
306
  const url = new URL(req.url || "", `http://${req.headers.host}`);
274
307
  const mode = url.searchParams.get("mode");
@@ -292,9 +325,31 @@ var ProxyServer = class {
292
325
  throw new Error("Unsupported control method");
293
326
  }
294
327
  async handleControlRequest(req, res) {
328
+ if (req.method === "GET") {
329
+ sendJsonResponse(res, HTTP_STATUS_OK, {
330
+ recordingsDir: this.recordingsDir,
331
+ mode: this.mode,
332
+ id: this.recordingId || this.replayId
333
+ });
334
+ return;
335
+ }
295
336
  try {
296
337
  const data = await this.parseControlRequest(req);
297
- const { mode, id, timeout: requestTimeout } = data;
338
+ const { mode, id, timeout: requestTimeout, cleanup } = data;
339
+ if (cleanup && id) {
340
+ await this.cleanupSession(id);
341
+ sendJsonResponse(res, HTTP_STATUS_OK, {
342
+ success: true,
343
+ message: `Session ${id} cleaned up`,
344
+ mode: this.mode
345
+ });
346
+ return;
347
+ }
348
+ if (!mode) {
349
+ throw new Error(
350
+ "Mode parameter is required when cleanup is not specified"
351
+ );
352
+ }
298
353
  const timeout = requestTimeout ?? DEFAULT_TIMEOUT_MS;
299
354
  this.clearModeTimeout();
300
355
  await this.switchMode(mode, id);
@@ -310,7 +365,8 @@ var ProxyServer = class {
310
365
  success: true,
311
366
  mode: this.mode,
312
367
  id: this.recordingId || this.replayId,
313
- timeout
368
+ timeout,
369
+ recordingsDir: this.recordingsDir
314
370
  });
315
371
  } catch (error) {
316
372
  console.error("Control request error:", error);
@@ -375,16 +431,21 @@ var ProxyServer = class {
375
431
  this.replayId = id;
376
432
  this.recordingId = null;
377
433
  this.currentSession = null;
378
- const session = this.replaySessions.get(id);
379
- if (session) {
380
- session.servedRecordingIdsByKey.clear();
381
- console.log(`Reset served recordings tracker for session: ${id}`);
382
- } else {
383
- this.getOrCreateReplaySession(id);
434
+ const sessionState = this.getOrCreateReplaySession(id);
435
+ sessionState.servedRecordingIdsByKey.clear();
436
+ sessionState.sortedRecordingsByKey.clear();
437
+ const filePath = getRecordingPath(this.recordingsDir, id);
438
+ try {
439
+ sessionState.loadedSession = await loadRecordingSession(filePath);
440
+ console.log(`[REPLAY] Loaded recording session: ${id}`);
441
+ } catch (error) {
442
+ console.error(`[REPLAY ERROR] Failed to load session ${id}:`, error);
443
+ sessionState.loadedSession = null;
384
444
  }
385
445
  console.log(`Switched to replay mode with ID: ${id}`);
386
446
  }
387
447
  setupModeTimeout(timeout) {
448
+ clearTimeout(this.modeTimeout || 0);
388
449
  this.modeTimeout = setTimeout(async () => {
389
450
  console.log("Timeout reached, switching back to transparent mode");
390
451
  await this.saveCurrentSession();
@@ -393,21 +454,32 @@ var ProxyServer = class {
393
454
  }, timeout);
394
455
  }
395
456
  async flushPendingRecordings() {
457
+ if (this.flushPromise) {
458
+ await this.flushPromise;
459
+ return;
460
+ }
396
461
  if (this.recordingPromises.length === 0) {
397
462
  return;
398
463
  }
399
- const results = await Promise.allSettled(this.recordingPromises);
400
- if (this.currentSession) {
401
- for (const result of results) {
402
- if (result.status === "fulfilled" && result.value) {
403
- this.currentSession.recordings.push(result.value);
464
+ this.flushPromise = (async () => {
465
+ try {
466
+ const results = await Promise.allSettled(this.recordingPromises);
467
+ if (this.currentSession) {
468
+ for (const result of results) {
469
+ if (result.status === "fulfilled" && result.value) {
470
+ this.currentSession.recordings.push(result.value);
471
+ }
472
+ }
473
+ console.log(
474
+ `Flushed ${results.length} recordings to session (total: ${this.currentSession.recordings.length})`
475
+ );
404
476
  }
477
+ this.recordingPromises = [];
478
+ } finally {
479
+ this.flushPromise = null;
405
480
  }
406
- console.log(
407
- `Flushed ${results.length} recordings to session (total: ${this.currentSession.recordings.length})`
408
- );
409
- }
410
- this.recordingPromises = [];
481
+ })();
482
+ await this.flushPromise;
411
483
  }
412
484
  async saveCurrentSession() {
413
485
  if (!this.currentSession) {
@@ -420,7 +492,29 @@ var ProxyServer = class {
420
492
  await saveRecordingSession(this.recordingsDir, this.currentSession);
421
493
  }
422
494
  getRecordingIdOrError(req, res) {
423
- const recordingId = this.getRecordingIdFromRequest(req) || this.replayId;
495
+ const recordingIdFromRequest = this.getRecordingIdFromRequest(req);
496
+ if (recordingIdFromRequest) {
497
+ return recordingIdFromRequest;
498
+ }
499
+ if (this.replaySessions.size > 1) {
500
+ console.warn(
501
+ `[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)`
502
+ );
503
+ const corsHeaders = this.getCorsHeaders(req);
504
+ res.writeHead(HTTP_STATUS_BAD_REQUEST, {
505
+ "Content-Type": "application/json",
506
+ ...corsHeaders
507
+ });
508
+ res.end(
509
+ JSON.stringify({
510
+ error: "Missing recording ID in concurrent replay mode. Ensure x-test-rcrd-id header is set.",
511
+ activeSessions: [...this.replaySessions.keys()],
512
+ hint: "This usually means page.setExtraHTTPHeaders() did not apply to this request type"
513
+ })
514
+ );
515
+ return null;
516
+ }
517
+ const recordingId = this.replayId;
424
518
  if (!recordingId) {
425
519
  const corsHeaders = this.getCorsHeaders(req);
426
520
  res.writeHead(HTTP_STATUS_BAD_REQUEST, {
@@ -430,22 +524,30 @@ var ProxyServer = class {
430
524
  res.end(JSON.stringify({ error: "No replay session active" }));
431
525
  return null;
432
526
  }
527
+ console.log(
528
+ `[FALLBACK] Using replayId fallback for ${req.method} ${req.url} -> session: ${recordingId} (single session mode)`
529
+ );
433
530
  return recordingId;
434
531
  }
435
- async ensureSessionLoaded(recordingId, filePath) {
436
- const sessionState = this.getOrCreateReplaySession(recordingId);
437
- if (!sessionState.loadedSession) {
438
- sessionState.loadedSession = await loadRecordingSession(filePath);
439
- console.log(`[REPLAY] Loaded recording session: ${recordingId}`);
440
- }
441
- return sessionState;
442
- }
443
532
  getServedTracker(sessionState, key) {
444
533
  if (!sessionState.servedRecordingIdsByKey.has(key)) {
445
534
  sessionState.servedRecordingIdsByKey.set(key, /* @__PURE__ */ new Set());
446
535
  }
447
536
  return sessionState.servedRecordingIdsByKey.get(key);
448
537
  }
538
+ getSortedRecordings(sessionState, key) {
539
+ if (sessionState.sortedRecordingsByKey.has(key)) {
540
+ return sessionState.sortedRecordingsByKey.get(key);
541
+ }
542
+ const session = sessionState.loadedSession;
543
+ const sortedRecords = session.recordings.filter((r) => r.key === key && r.response).toSorted((a, b) => {
544
+ const aSeq = a.sequence !== void 0 ? a.sequence : a.recordingId;
545
+ const bSeq = b.sequence !== void 0 ? b.sequence : b.recordingId;
546
+ return aSeq - bSeq;
547
+ });
548
+ sessionState.sortedRecordingsByKey.set(key, sortedRecords);
549
+ return sortedRecords;
550
+ }
449
551
  selectReplayRecord(recordsWithKey, servedForThisKey, key, recordingId) {
450
552
  for (const rec of recordsWithKey) {
451
553
  if (!servedForThisKey.has(rec.recordingId)) {
@@ -466,18 +568,17 @@ var ProxyServer = class {
466
568
  const key = getReqID(req);
467
569
  const filePath = getRecordingPath(this.recordingsDir, recordingId);
468
570
  try {
469
- const sessionState = await this.ensureSessionLoaded(
470
- recordingId,
471
- filePath
472
- );
473
- const session = sessionState.loadedSession;
571
+ const sessionState = this.getOrCreateReplaySession(recordingId);
572
+ if (!sessionState.loadedSession) {
573
+ const error = new Error(
574
+ `Recording session file not found: ${filePath}`
575
+ );
576
+ error.code = "ENOENT";
577
+ throw error;
578
+ }
474
579
  const servedForThisKey = this.getServedTracker(sessionState, key);
475
580
  const host = req.headers.host || "unknown";
476
- const recordsWithKey = session.recordings.filter((r) => r.key === key && r.response).toSorted((a, b) => {
477
- const aSeq = a.sequence !== void 0 ? a.sequence : a.recordingId;
478
- const bSeq = b.sequence !== void 0 ? b.sequence : b.recordingId;
479
- return aSeq - bSeq;
480
- });
581
+ const recordsWithKey = this.getSortedRecordings(sessionState, key);
481
582
  if (recordsWithKey.length === 0) {
482
583
  const errorMsg = `No recording found for ${key} at ${req.method} ${host}${req.url}`;
483
584
  console.error(`[REPLAY ERROR] ${errorMsg} (session: ${recordingId})`);
@@ -688,7 +789,6 @@ var ProxyServer = class {
688
789
  })();
689
790
  });
690
791
  this.recordingPromises.push(recordingPromise);
691
- await recordingPromise;
692
792
  }
693
793
  handleUpgrade(req, socket, head) {
694
794
  if (this.mode === Modes.replay) {
@@ -853,8 +953,6 @@ var ProxyServer = class {
853
953
  );
854
954
  }
855
955
  };
856
-
857
- // src/playwright/index.ts
858
956
  function getProxyPort() {
859
957
  const envPort = process.env.TEST_PROXY_RECORDER_PORT;
860
958
  if (envPort) {
@@ -884,12 +982,34 @@ async function setProxyMode(mode, sessionId, timeout) {
884
982
  throw new Error(`Failed to set proxy mode: ${text}`);
885
983
  }
886
984
  await response.json();
887
- console.log(`Proxy mode set to: ${mode} (session: ${sessionId})`);
888
985
  } catch (error) {
889
986
  console.error(`Error setting proxy mode:`, error);
890
987
  throw error;
891
988
  }
892
989
  }
990
+ async function cleanupSession(sessionId) {
991
+ const proxyPort = getProxyPort();
992
+ try {
993
+ const body = {
994
+ cleanup: true,
995
+ id: sessionId
996
+ };
997
+ const response = await fetch(`http://127.0.0.1:${proxyPort}/__control`, {
998
+ method: "POST",
999
+ headers: { "Content-Type": "application/json" },
1000
+ body: JSON.stringify(body)
1001
+ });
1002
+ if (!response.ok) {
1003
+ const text = await response.text();
1004
+ console.error(`Failed to cleanup session ${sessionId}:`, text);
1005
+ throw new Error(`Failed to cleanup session: ${text}`);
1006
+ }
1007
+ await response.json();
1008
+ } catch (error) {
1009
+ console.error(`Error cleaning up session: ${sessionId}`, error);
1010
+ throw error;
1011
+ }
1012
+ }
893
1013
  function parseSpecFilePath(specPath) {
894
1014
  const folderMatch = specPath.match(/^(.+?)\/([^/]+)\.(spec|test)\.ts$/);
895
1015
  if (folderMatch) {
@@ -931,6 +1051,50 @@ async function stopProxy(testInfo) {
931
1051
  const sessionId = generateSessionId(testInfo);
932
1052
  await setProxyMode(Modes.transparent, sessionId);
933
1053
  }
1054
+ var cachedRecordingsDir = null;
1055
+ async function getRecordingsDir() {
1056
+ if (cachedRecordingsDir) {
1057
+ return cachedRecordingsDir;
1058
+ }
1059
+ const proxyPort = getProxyPort();
1060
+ try {
1061
+ const response = await fetch(`http://127.0.0.1:${proxyPort}/__control`);
1062
+ if (response.ok) {
1063
+ const data = await response.json();
1064
+ if (data.recordingsDir) {
1065
+ cachedRecordingsDir = data.recordingsDir;
1066
+ return cachedRecordingsDir;
1067
+ }
1068
+ }
1069
+ } catch (error) {
1070
+ console.warn(
1071
+ "Failed to get recordings directory from proxy, using default:",
1072
+ error
1073
+ );
1074
+ }
1075
+ cachedRecordingsDir = path2.join(process.cwd(), "e2e", "recordings");
1076
+ return cachedRecordingsDir;
1077
+ }
1078
+ async function setupClientSideRecording(page, sessionId, mode, url) {
1079
+ const harFileName = sessionId.replaceAll("/", "__");
1080
+ const recordingsDir = await getRecordingsDir();
1081
+ const harPath = path2.join(recordingsDir, `${harFileName}.har`);
1082
+ try {
1083
+ await page.routeFromHAR(harPath, {
1084
+ url,
1085
+ update: mode === Modes.record,
1086
+ updateContent: "embed"
1087
+ });
1088
+ } catch (error) {
1089
+ if (mode === Modes.replay) {
1090
+ console.error(
1091
+ `[Client-Side Replay] Failed to load HAR file. Run tests in record mode first.`,
1092
+ error
1093
+ );
1094
+ throw error;
1095
+ }
1096
+ }
1097
+ }
934
1098
  var playwrightProxy = {
935
1099
  /**
936
1100
  * Setup before test - sets the proxy mode and configures page with custom header
@@ -938,24 +1102,66 @@ var playwrightProxy = {
938
1102
  * @param page - Playwright page object
939
1103
  * @param testInfo - Playwright test info object
940
1104
  * @param mode - The proxy mode to use for this test
941
- * @param timeout - Optional timeout in milliseconds
1105
+ * @param options - Optional configuration including timeout and client-side recording patterns
942
1106
  */
943
- async before(page, testInfo, mode, timeout) {
1107
+ async before(page, testInfo, mode, options) {
1108
+ const timeout = typeof options === "number" ? options : options?.timeout;
1109
+ const clientSideOptions = typeof options === "object" && options !== null ? options : void 0;
944
1110
  const sessionId = generateSessionId(testInfo);
945
1111
  await page.setExtraHTTPHeaders({
946
1112
  [RECORDING_ID_HEADER]: sessionId
947
1113
  });
948
1114
  await setProxyMode(mode, sessionId, timeout);
949
- page.on("close", async () => {
950
- try {
951
- await setProxyMode(Modes.replay, sessionId);
952
- console.log(
953
- `[Cleanup] Switched to replay mode for session: ${sessionId}`
954
- );
955
- } catch (error) {
956
- console.error("[Cleanup] Error during page close cleanup:", error);
957
- }
958
- });
1115
+ if (clientSideOptions?.url) {
1116
+ await setupClientSideRecording(
1117
+ page,
1118
+ sessionId,
1119
+ mode,
1120
+ clientSideOptions.url
1121
+ );
1122
+ }
1123
+ const proxyPort = process.env.TEST_PROXY_RECORDER_PORT || "8100";
1124
+ const proxyUrl = `localhost:${proxyPort}`;
1125
+ await page.route(
1126
+ (url) => {
1127
+ const urlStr = url.toString();
1128
+ const matches = urlStr.includes(proxyUrl);
1129
+ return matches;
1130
+ },
1131
+ async (route) => {
1132
+ try {
1133
+ const headers = route.request().headers();
1134
+ headers[RECORDING_ID_HEADER] = sessionId;
1135
+ await route.continue({ headers });
1136
+ } catch (error) {
1137
+ console.error(
1138
+ `[Route Handler Error] Failed to add ${RECORDING_ID_HEADER} header:`,
1139
+ error
1140
+ );
1141
+ await route.fallback();
1142
+ }
1143
+ },
1144
+ { times: Infinity }
1145
+ // Ensure the handler applies to all matching requests
1146
+ );
1147
+ const context = page.context();
1148
+ const contextId = context._guid || "default";
1149
+ const handlerKey = `cleanup_${contextId}`;
1150
+ if (!globalThis[handlerKey]) {
1151
+ globalThis[handlerKey] = true;
1152
+ context.on("close", async () => {
1153
+ try {
1154
+ await cleanupSession(sessionId);
1155
+ } catch (error) {
1156
+ console.warn(
1157
+ `[Cleanup] Failed to cleanup session ${sessionId}:`,
1158
+ error
1159
+ );
1160
+ } finally {
1161
+ delete globalThis[handlerKey];
1162
+ }
1163
+ });
1164
+ }
959
1165
  },
960
1166
  /**
961
1167
  * Global teardown - switches proxy to transparent mode
@@ -1,6 +1,12 @@
1
1
  'use strict';
2
2
 
3
- // src/constants.ts
3
+ var path = require('path');
4
+
5
+ function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
6
+
7
+ var path__default = /*#__PURE__*/_interopDefault(path);
8
+
9
+ // src/playwright/index.ts
4
10
  var RECORDING_ID_HEADER = "x-test-rcrd-id";
5
11
 
6
12
  // src/types.ts
@@ -40,12 +46,34 @@ async function setProxyMode(mode, sessionId, timeout) {
40
46
  throw new Error(`Failed to set proxy mode: ${text}`);
41
47
  }
42
48
  await response.json();
43
- console.log(`Proxy mode set to: ${mode} (session: ${sessionId})`);
44
49
  } catch (error) {
45
50
  console.error(`Error setting proxy mode:`, error);
46
51
  throw error;
47
52
  }
48
53
  }
54
+ async function cleanupSession(sessionId) {
55
+ const proxyPort = getProxyPort();
56
+ try {
57
+ const body = {
58
+ cleanup: true,
59
+ id: sessionId
60
+ };
61
+ const response = await fetch(`http://127.0.0.1:${proxyPort}/__control`, {
62
+ method: "POST",
63
+ headers: { "Content-Type": "application/json" },
64
+ body: JSON.stringify(body)
65
+ });
66
+ if (!response.ok) {
67
+ const text = await response.text();
68
+ console.error(`Failed to cleanup session ${sessionId}:`, text);
69
+ throw new Error(`Failed to cleanup session: ${text}`);
70
+ }
71
+ await response.json();
72
+ } catch (error) {
73
+ console.error(`Error cleaning up session: ${sessionId}`, error);
74
+ throw error;
75
+ }
76
+ }
49
77
  function parseSpecFilePath(specPath) {
50
78
  const folderMatch = specPath.match(/^(.+?)\/([^/]+)\.(spec|test)\.ts$/);
51
79
  if (folderMatch) {
@@ -87,6 +115,50 @@ async function stopProxy(testInfo) {
87
115
  const sessionId = generateSessionId(testInfo);
88
116
  await setProxyMode(Modes.transparent, sessionId);
89
117
  }
118
+ var cachedRecordingsDir = null;
119
+ async function getRecordingsDir() {
120
+ if (cachedRecordingsDir) {
121
+ return cachedRecordingsDir;
122
+ }
123
+ const proxyPort = getProxyPort();
124
+ try {
125
+ const response = await fetch(`http://127.0.0.1:${proxyPort}/__control`);
126
+ if (response.ok) {
127
+ const data = await response.json();
128
+ if (data.recordingsDir) {
129
+ cachedRecordingsDir = data.recordingsDir;
130
+ return cachedRecordingsDir;
131
+ }
132
+ }
133
+ } catch (error) {
134
+ console.warn(
135
+ "Failed to get recordings directory from proxy, using default:",
136
+ error
137
+ );
138
+ }
139
+ cachedRecordingsDir = path__default.default.join(process.cwd(), "e2e", "recordings");
140
+ return cachedRecordingsDir;
141
+ }
142
+ async function setupClientSideRecording(page, sessionId, mode, url) {
143
+ const harFileName = sessionId.replaceAll("/", "__");
144
+ const recordingsDir = await getRecordingsDir();
145
+ const harPath = path__default.default.join(recordingsDir, `${harFileName}.har`);
146
+ try {
147
+ await page.routeFromHAR(harPath, {
148
+ url,
149
+ update: mode === Modes.record,
150
+ updateContent: "embed"
151
+ });
152
+ } catch (error) {
153
+ if (mode === Modes.replay) {
154
+ console.error(
155
+ `[Client-Side Replay] Failed to load HAR file. Run tests in record mode first.`,
156
+ error
157
+ );
158
+ throw error;
159
+ }
160
+ }
161
+ }
90
162
  var playwrightProxy = {
91
163
  /**
92
164
  * Setup before test - sets the proxy mode and configures page with custom header
@@ -94,24 +166,66 @@ var playwrightProxy = {
94
166
  * @param page - Playwright page object
95
167
  * @param testInfo - Playwright test info object
96
168
  * @param mode - The proxy mode to use for this test
97
- * @param timeout - Optional timeout in milliseconds
169
+ * @param options - Optional configuration including timeout and client-side recording patterns
98
170
  */
99
- async before(page, testInfo, mode, timeout) {
171
+ async before(page, testInfo, mode, options) {
172
+ const timeout = typeof options === "number" ? options : options?.timeout;
173
+ const clientSideOptions = typeof options === "object" && options !== null ? options : void 0;
100
174
  const sessionId = generateSessionId(testInfo);
101
175
  await page.setExtraHTTPHeaders({
102
176
  [RECORDING_ID_HEADER]: sessionId
103
177
  });
104
178
  await setProxyMode(mode, sessionId, timeout);
105
- page.on("close", async () => {
106
- try {
107
- await setProxyMode(Modes.replay, sessionId);
108
- console.log(
109
- `[Cleanup] Switched to replay mode for session: ${sessionId}`
110
- );
111
- } catch (error) {
112
- console.error("[Cleanup] Error during page close cleanup:", error);
113
- }
114
- });
179
+ if (clientSideOptions?.url) {
180
+ await setupClientSideRecording(
181
+ page,
182
+ sessionId,
183
+ mode,
184
+ clientSideOptions.url
185
+ );
186
+ }
187
+ const proxyPort = process.env.TEST_PROXY_RECORDER_PORT || "8100";
188
+ const proxyUrl = `localhost:${proxyPort}`;
189
+ await page.route(
190
+ (url) => {
191
+ const urlStr = url.toString();
192
+ const matches = urlStr.includes(proxyUrl);
193
+ return matches;
194
+ },
195
+ async (route) => {
196
+ try {
197
+ const headers = route.request().headers();
198
+ headers[RECORDING_ID_HEADER] = sessionId;
199
+ await route.continue({ headers });
200
+ } catch (error) {
201
+ console.error(
202
+ `[Route Handler Error] Failed to add ${RECORDING_ID_HEADER} header:`,
203
+ error
204
+ );
205
+ await route.fallback();
206
+ }
207
+ },
208
+ { times: Infinity }
209
+ // Ensure the handler applies to all matching requests
210
+ );
211
+ const context = page.context();
212
+ const contextId = context._guid || "default";
213
+ const handlerKey = `cleanup_${contextId}`;
214
+ if (!globalThis[handlerKey]) {
215
+ globalThis[handlerKey] = true;
216
+ context.on("close", async () => {
217
+ try {
218
+ await cleanupSession(sessionId);
219
+ } catch (error) {
220
+ console.warn(
221
+ `[Cleanup] Failed to cleanup session ${sessionId}:`,
222
+ error
223
+ );
224
+ } finally {
225
+ delete globalThis[handlerKey];
226
+ }
227
+ });
228
+ }
115
229
  },
116
230
  /**
117
231
  * Global teardown - switches proxy to transparent mode
@@ -122,6 +236,7 @@ var playwrightProxy = {
122
236
  }
123
237
  };
124
238
 
239
+ exports.cleanupSession = cleanupSession;
125
240
  exports.generateSessionId = generateSessionId;
126
241
  exports.playwrightProxy = playwrightProxy;
127
242
  exports.setProxyMode = setProxyMode;
@@ -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-CVuiglPk.cjs';
2
+ export { f as ClientSideRecordingOptions, P as PlaywrightTestInfo, e as cleanupSession, g as generateSessionId, p as playwrightProxy, s as setProxyMode, b as startRecording, c as startReplay, d as stopProxy } from '../index-BlBWqSE4.cjs';
3
3
  import 'node:http';