test-proxy-recorder 0.3.1 → 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,7 +53,7 @@ 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");
@@ -94,16 +94,19 @@ async function saveRecordingSession(recordingsDir, session) {
94
94
  `Saved ${processedRecordings.length} HTTP recordings and ${session.websocketRecordings?.length || 0} WebSocket recordings to ${filePath}`
95
95
  );
96
96
  }
97
- function getReqID(req) {
98
- const urlParts = req.url.split("?");
99
- const pathname = urlParts[0];
100
- const query = urlParts[1] || "";
97
+ function generateRecordingKey(pathname, query, method) {
101
98
  const pathPart = pathname === "/" ? "root" : pathname.slice(1);
102
99
  const normalizedPath = filenamify2__default.default(pathPart, { replacement: "_" });
103
100
  const queryHash = generateQueryHash(query);
104
- const filename = `${req.method}_${normalizedPath}${queryHash}.json`;
101
+ const filename = `${method}_${normalizedPath}${queryHash}.json`;
105
102
  return filenamify2__default.default(filename, { replacement: "_" });
106
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
+ }
107
110
  function generateQueryHash(query) {
108
111
  if (!query) {
109
112
  return "";
@@ -256,7 +259,15 @@ var ProxyServer = class {
256
259
  * @returns The recording ID, or null if not found
257
260
  */
258
261
  getRecordingIdFromRequest(req) {
259
- 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;
260
271
  }
261
272
  /**
262
273
  * Get or create a replay session state for a given recording ID
@@ -281,6 +292,27 @@ var ProxyServer = class {
281
292
  }
282
293
  return session;
283
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
+ }
284
316
  parseGetParams(req) {
285
317
  const url = new URL(req.url || "", `http://${req.headers.host}`);
286
318
  const mode = url.searchParams.get("mode");
@@ -304,9 +336,29 @@ var ProxyServer = class {
304
336
  throw new Error("Unsupported control method");
305
337
  }
306
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
+ }
307
347
  try {
308
348
  const data = await this.parseControlRequest(req);
309
- const { mode, id, timeout: requestTimeout } = data;
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
+ });
357
+ return;
358
+ }
359
+ if (!mode) {
360
+ throw new Error("Mode parameter is required when cleanup is not specified");
361
+ }
310
362
  const timeout = requestTimeout ?? DEFAULT_TIMEOUT_MS;
311
363
  this.clearModeTimeout();
312
364
  await this.switchMode(mode, id);
@@ -322,7 +374,8 @@ var ProxyServer = class {
322
374
  success: true,
323
375
  mode: this.mode,
324
376
  id: this.recordingId || this.replayId,
325
- timeout
377
+ timeout,
378
+ recordingsDir: this.recordingsDir
326
379
  });
327
380
  } catch (error) {
328
381
  console.error("Control request error:", error);
@@ -397,6 +450,7 @@ var ProxyServer = class {
397
450
  console.log(`Switched to replay mode with ID: ${id}`);
398
451
  }
399
452
  setupModeTimeout(timeout) {
453
+ clearTimeout(this.modeTimeout || 0);
400
454
  this.modeTimeout = setTimeout(async () => {
401
455
  console.log("Timeout reached, switching back to transparent mode");
402
456
  await this.saveCurrentSession();
@@ -432,7 +486,29 @@ var ProxyServer = class {
432
486
  await saveRecordingSession(this.recordingsDir, this.currentSession);
433
487
  }
434
488
  getRecordingIdOrError(req, res) {
435
- const recordingId = this.getRecordingIdFromRequest(req) || this.replayId;
489
+ const recordingIdFromRequest = this.getRecordingIdFromRequest(req);
490
+ if (recordingIdFromRequest) {
491
+ return recordingIdFromRequest;
492
+ }
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)`
496
+ );
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
+ })
508
+ );
509
+ return null;
510
+ }
511
+ const recordingId = this.replayId;
436
512
  if (!recordingId) {
437
513
  const corsHeaders = this.getCorsHeaders(req);
438
514
  res.writeHead(HTTP_STATUS_BAD_REQUEST, {
@@ -442,6 +518,9 @@ var ProxyServer = class {
442
518
  res.end(JSON.stringify({ error: "No replay session active" }));
443
519
  return null;
444
520
  }
521
+ console.log(
522
+ `[FALLBACK] Using replayId fallback for ${req.method} ${req.url} -> session: ${recordingId} (single session mode)`
523
+ );
445
524
  return recordingId;
446
525
  }
447
526
  async ensureSessionLoaded(recordingId, filePath) {
@@ -865,8 +944,6 @@ var ProxyServer = class {
865
944
  );
866
945
  }
867
946
  };
868
-
869
- // src/playwright/index.ts
870
947
  function getProxyPort() {
871
948
  const envPort = process.env.TEST_PROXY_RECORDER_PORT;
872
949
  if (envPort) {
@@ -902,6 +979,30 @@ async function setProxyMode(mode, sessionId, timeout) {
902
979
  throw error;
903
980
  }
904
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
+ }
905
1006
  function parseSpecFilePath(specPath) {
906
1007
  const folderMatch = specPath.match(/^(.+?)\/([^/]+)\.(spec|test)\.ts$/);
907
1008
  if (folderMatch) {
@@ -943,6 +1044,53 @@ async function stopProxy(testInfo) {
943
1044
  const sessionId = generateSessionId(testInfo);
944
1045
  await setProxyMode(Modes.transparent, sessionId);
945
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
+ }
946
1094
  var playwrightProxy = {
947
1095
  /**
948
1096
  * Setup before test - sets the proxy mode and configures page with custom header
@@ -950,24 +1098,84 @@ var playwrightProxy = {
950
1098
  * @param page - Playwright page object
951
1099
  * @param testInfo - Playwright test info object
952
1100
  * @param mode - The proxy mode to use for this test
953
- * @param timeout - Optional timeout in milliseconds
1101
+ * @param options - Optional configuration including timeout and client-side recording patterns
954
1102
  */
955
- 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;
956
1106
  const sessionId = generateSessionId(testInfo);
957
1107
  await page.setExtraHTTPHeaders({
958
1108
  [RECORDING_ID_HEADER]: sessionId
959
1109
  });
1110
+ console.log(`[Setup] Setting proxy mode: ${mode}, session: ${sessionId}`);
960
1111
  await setProxyMode(mode, sessionId, timeout);
961
- page.on("close", async () => {
962
- try {
963
- await setProxyMode(Modes.replay, sessionId);
964
- console.log(
965
- `[Cleanup] Switched to replay mode for session: ${sessionId}`
966
- );
967
- } catch (error) {
968
- console.error("[Cleanup] Error during page close cleanup:", error);
969
- }
970
- });
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
+ }
971
1179
  },
972
1180
  /**
973
1181
  * Global teardown - switches proxy to transparent mode
package/dist/index.d.cts CHANGED
@@ -1,6 +1,6 @@
1
1
  export { RECORDING_ID_HEADER, createHeadersWithRecordingId, getRecordingId, setNextProxyHeaders } from './nextjs/index.cjs';
2
2
  import http from 'node:http';
3
- export { C as ControlRequest, M as Mode, P as PlaywrightTestInfo, R as Recording, a as RecordingSession, W as WebSocketRecording, g as generateSessionId, p as playwrightProxy, s as setProxyMode, b as startRecording, c as startReplay, d as stopProxy } from './index-CVuiglPk.cjs';
3
+ export { C as ControlRequest, M as Mode, P as PlaywrightTestInfo, R as Recording, a as RecordingSession, W as WebSocketRecording, g as generateSessionId, p as playwrightProxy, s as setProxyMode, b as startRecording, c as startReplay, d as stopProxy } from './index-BlBWqSE4.cjs';
4
4
  import '@playwright/test';
5
5
 
6
6
  declare class ProxyServer {
@@ -56,6 +56,11 @@ declare class ProxyServer {
56
56
  * @returns The replay session state
57
57
  */
58
58
  private getOrCreateReplaySession;
59
+ /**
60
+ * Clean up a session - removes it from memory and resets counters
61
+ * @param sessionId The session ID to clean up
62
+ */
63
+ private cleanupSession;
59
64
  private parseGetParams;
60
65
  private parseControlRequest;
61
66
  private handleControlRequest;
package/dist/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  export { RECORDING_ID_HEADER, createHeadersWithRecordingId, getRecordingId, setNextProxyHeaders } from './nextjs/index.js';
2
2
  import http from 'node:http';
3
- export { C as ControlRequest, M as Mode, P as PlaywrightTestInfo, R as Recording, a as RecordingSession, W as WebSocketRecording, g as generateSessionId, p as playwrightProxy, s as setProxyMode, b as startRecording, c as startReplay, d as stopProxy } from './index-CVuiglPk.js';
3
+ export { C as ControlRequest, M as Mode, P as PlaywrightTestInfo, R as Recording, a as RecordingSession, W as WebSocketRecording, g as generateSessionId, p as playwrightProxy, s as setProxyMode, b as startRecording, c as startReplay, d as stopProxy } from './index-BlBWqSE4.js';
4
4
  import '@playwright/test';
5
5
 
6
6
  declare class ProxyServer {
@@ -56,6 +56,11 @@ declare class ProxyServer {
56
56
  * @returns The replay session state
57
57
  */
58
58
  private getOrCreateReplaySession;
59
+ /**
60
+ * Clean up a session - removes it from memory and resets counters
61
+ * @param sessionId The session ID to clean up
62
+ */
63
+ private cleanupSession;
59
64
  private parseGetParams;
60
65
  private parseControlRequest;
61
66
  private handleControlRequest;