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.
@@ -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.js';
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.js';
3
3
  import 'node:http';
@@ -1,4 +1,6 @@
1
- // src/constants.ts
1
+ import path from 'path';
2
+
3
+ // src/playwright/index.ts
2
4
  var RECORDING_ID_HEADER = "x-test-rcrd-id";
3
5
 
4
6
  // src/types.ts
@@ -38,12 +40,34 @@ async function setProxyMode(mode, sessionId, timeout) {
38
40
  throw new Error(`Failed to set proxy mode: ${text}`);
39
41
  }
40
42
  await response.json();
41
- console.log(`Proxy mode set to: ${mode} (session: ${sessionId})`);
42
43
  } catch (error) {
43
44
  console.error(`Error setting proxy mode:`, error);
44
45
  throw error;
45
46
  }
46
47
  }
48
+ async function cleanupSession(sessionId) {
49
+ const proxyPort = getProxyPort();
50
+ try {
51
+ const body = {
52
+ cleanup: true,
53
+ id: sessionId
54
+ };
55
+ const response = await fetch(`http://127.0.0.1:${proxyPort}/__control`, {
56
+ method: "POST",
57
+ headers: { "Content-Type": "application/json" },
58
+ body: JSON.stringify(body)
59
+ });
60
+ if (!response.ok) {
61
+ const text = await response.text();
62
+ console.error(`Failed to cleanup session ${sessionId}:`, text);
63
+ throw new Error(`Failed to cleanup session: ${text}`);
64
+ }
65
+ await response.json();
66
+ } catch (error) {
67
+ console.error(`Error cleaning up session: ${sessionId}`, error);
68
+ throw error;
69
+ }
70
+ }
47
71
  function parseSpecFilePath(specPath) {
48
72
  const folderMatch = specPath.match(/^(.+?)\/([^/]+)\.(spec|test)\.ts$/);
49
73
  if (folderMatch) {
@@ -85,6 +109,50 @@ async function stopProxy(testInfo) {
85
109
  const sessionId = generateSessionId(testInfo);
86
110
  await setProxyMode(Modes.transparent, sessionId);
87
111
  }
112
+ var cachedRecordingsDir = null;
113
+ async function getRecordingsDir() {
114
+ if (cachedRecordingsDir) {
115
+ return cachedRecordingsDir;
116
+ }
117
+ const proxyPort = getProxyPort();
118
+ try {
119
+ const response = await fetch(`http://127.0.0.1:${proxyPort}/__control`);
120
+ if (response.ok) {
121
+ const data = await response.json();
122
+ if (data.recordingsDir) {
123
+ cachedRecordingsDir = data.recordingsDir;
124
+ return cachedRecordingsDir;
125
+ }
126
+ }
127
+ } catch (error) {
128
+ console.warn(
129
+ "Failed to get recordings directory from proxy, using default:",
130
+ error
131
+ );
132
+ }
133
+ cachedRecordingsDir = path.join(process.cwd(), "e2e", "recordings");
134
+ return cachedRecordingsDir;
135
+ }
136
+ async function setupClientSideRecording(page, sessionId, mode, url) {
137
+ const harFileName = sessionId.replaceAll("/", "__");
138
+ const recordingsDir = await getRecordingsDir();
139
+ const harPath = path.join(recordingsDir, `${harFileName}.har`);
140
+ try {
141
+ await page.routeFromHAR(harPath, {
142
+ url,
143
+ update: mode === Modes.record,
144
+ updateContent: "embed"
145
+ });
146
+ } catch (error) {
147
+ if (mode === Modes.replay) {
148
+ console.error(
149
+ `[Client-Side Replay] Failed to load HAR file. Run tests in record mode first.`,
150
+ error
151
+ );
152
+ throw error;
153
+ }
154
+ }
155
+ }
88
156
  var playwrightProxy = {
89
157
  /**
90
158
  * Setup before test - sets the proxy mode and configures page with custom header
@@ -92,24 +160,66 @@ var playwrightProxy = {
92
160
  * @param page - Playwright page object
93
161
  * @param testInfo - Playwright test info object
94
162
  * @param mode - The proxy mode to use for this test
95
- * @param timeout - Optional timeout in milliseconds
163
+ * @param options - Optional configuration including timeout and client-side recording patterns
96
164
  */
97
- async before(page, testInfo, mode, timeout) {
165
+ async before(page, testInfo, mode, options) {
166
+ const timeout = typeof options === "number" ? options : options?.timeout;
167
+ const clientSideOptions = typeof options === "object" && options !== null ? options : void 0;
98
168
  const sessionId = generateSessionId(testInfo);
99
169
  await page.setExtraHTTPHeaders({
100
170
  [RECORDING_ID_HEADER]: sessionId
101
171
  });
102
172
  await setProxyMode(mode, sessionId, timeout);
103
- page.on("close", async () => {
104
- try {
105
- await setProxyMode(Modes.replay, sessionId);
106
- console.log(
107
- `[Cleanup] Switched to replay mode for session: ${sessionId}`
108
- );
109
- } catch (error) {
110
- console.error("[Cleanup] Error during page close cleanup:", error);
111
- }
112
- });
173
+ if (clientSideOptions?.url) {
174
+ await setupClientSideRecording(
175
+ page,
176
+ sessionId,
177
+ mode,
178
+ clientSideOptions.url
179
+ );
180
+ }
181
+ const proxyPort = process.env.TEST_PROXY_RECORDER_PORT || "8100";
182
+ const proxyUrl = `localhost:${proxyPort}`;
183
+ await page.route(
184
+ (url) => {
185
+ const urlStr = url.toString();
186
+ const matches = urlStr.includes(proxyUrl);
187
+ return matches;
188
+ },
189
+ async (route) => {
190
+ try {
191
+ const headers = route.request().headers();
192
+ headers[RECORDING_ID_HEADER] = sessionId;
193
+ await route.continue({ headers });
194
+ } catch (error) {
195
+ console.error(
196
+ `[Route Handler Error] Failed to add ${RECORDING_ID_HEADER} header:`,
197
+ error
198
+ );
199
+ await route.fallback();
200
+ }
201
+ },
202
+ { times: Infinity }
203
+ // Ensure the handler applies to all matching requests
204
+ );
205
+ const context = page.context();
206
+ const contextId = context._guid || "default";
207
+ const handlerKey = `cleanup_${contextId}`;
208
+ if (!globalThis[handlerKey]) {
209
+ globalThis[handlerKey] = true;
210
+ context.on("close", async () => {
211
+ try {
212
+ await cleanupSession(sessionId);
213
+ } catch (error) {
214
+ console.warn(
215
+ `[Cleanup] Failed to cleanup session ${sessionId}:`,
216
+ error
217
+ );
218
+ } finally {
219
+ delete globalThis[handlerKey];
220
+ }
221
+ });
222
+ }
113
223
  },
114
224
  /**
115
225
  * Global teardown - switches proxy to transparent mode
@@ -120,6 +230,6 @@ var playwrightProxy = {
120
230
  }
121
231
  };
122
232
 
123
- export { generateSessionId, playwrightProxy, setProxyMode, startRecording, startReplay, stopProxy };
233
+ export { cleanupSession, generateSessionId, playwrightProxy, setProxyMode, startRecording, startReplay, stopProxy };
124
234
  //# sourceMappingURL=index.mjs.map
125
235
  //# sourceMappingURL=index.mjs.map
package/dist/proxy.js CHANGED
@@ -118,16 +118,19 @@ async function saveRecordingSession(recordingsDir2, session) {
118
118
  `Saved ${processedRecordings.length} HTTP recordings and ${session.websocketRecordings?.length || 0} WebSocket recordings to ${filePath}`
119
119
  );
120
120
  }
121
- function getReqID(req) {
122
- const urlParts = req.url.split("?");
123
- const pathname = urlParts[0];
124
- const query = urlParts[1] || "";
121
+ function generateRecordingKey(pathname, query, method) {
125
122
  const pathPart = pathname === "/" ? "root" : pathname.slice(1);
126
123
  const normalizedPath = filenamify2(pathPart, { replacement: "_" });
127
124
  const queryHash = generateQueryHash(query);
128
- const filename = `${req.method}_${normalizedPath}${queryHash}.json`;
125
+ const filename = `${method}_${normalizedPath}${queryHash}.json`;
129
126
  return filenamify2(filename, { replacement: "_" });
130
127
  }
128
+ function getReqID(req) {
129
+ const urlParts = req.url.split("?");
130
+ const pathname = urlParts[0];
131
+ const query = urlParts[1] || "";
132
+ return generateRecordingKey(pathname, query, req.method);
133
+ }
131
134
  function generateQueryHash(query) {
132
135
  if (!query) {
133
136
  return "";
@@ -169,6 +172,8 @@ var ProxyServer = class {
169
172
  // Track multiple concurrent replay sessions by recording ID
170
173
  recordingPromises;
171
174
  // Stack of promises that resolve to completed recordings
175
+ flushPromise;
176
+ // Promise for in-progress flush operation
172
177
  constructor(targets2, recordingsDir2) {
173
178
  this.targets = targets2;
174
179
  this.currentTargetIndex = 0;
@@ -182,6 +187,7 @@ var ProxyServer = class {
182
187
  this.recordingsDir = recordingsDir2;
183
188
  this.replaySessions = /* @__PURE__ */ new Map();
184
189
  this.recordingPromises = [];
190
+ this.flushPromise = null;
185
191
  this.proxy = httpProxy.createProxyServer({
186
192
  secure: false,
187
193
  changeOrigin: true,
@@ -280,7 +286,15 @@ var ProxyServer = class {
280
286
  * @returns The recording ID, or null if not found
281
287
  */
282
288
  getRecordingIdFromRequest(req) {
283
- return this.getRecordingIdFromHeader(req) || this.getRecordingIdFromCookie(req);
289
+ const fromHeader = this.getRecordingIdFromHeader(req);
290
+ const fromCookie = this.getRecordingIdFromCookie(req);
291
+ if (fromHeader) {
292
+ return fromHeader;
293
+ }
294
+ if (fromCookie) {
295
+ return fromCookie;
296
+ }
297
+ return null;
284
298
  }
285
299
  /**
286
300
  * Get or create a replay session state for a given recording ID
@@ -296,7 +310,8 @@ var ProxyServer = class {
296
310
  recordingId,
297
311
  servedRecordingIdsByKey: /* @__PURE__ */ new Map(),
298
312
  loadedSession: null,
299
- lastAccessTime: Date.now()
313
+ lastAccessTime: Date.now(),
314
+ sortedRecordingsByKey: /* @__PURE__ */ new Map()
300
315
  };
301
316
  this.replaySessions.set(recordingId, session);
302
317
  console.log(
@@ -305,6 +320,24 @@ var ProxyServer = class {
305
320
  }
306
321
  return session;
307
322
  }
323
+ /**
324
+ * Clean up a session - removes it from memory and resets counters
325
+ * @param sessionId The session ID to clean up
326
+ */
327
+ async cleanupSession(sessionId) {
328
+ if (this.replaySessions.has(sessionId)) {
329
+ this.replaySessions.delete(sessionId);
330
+ }
331
+ if (this.recordingId === sessionId) {
332
+ await this.saveCurrentSession();
333
+ this.currentSession = null;
334
+ this.recordingId = null;
335
+ }
336
+ if (this.replayId === sessionId) {
337
+ this.replayId = null;
338
+ }
339
+ console.log(`[CLEANUP] Session ${sessionId} cleaned up successfully`);
340
+ }
308
341
  parseGetParams(req) {
309
342
  const url = new URL(req.url || "", `http://${req.headers.host}`);
310
343
  const mode = url.searchParams.get("mode");
@@ -328,9 +361,31 @@ var ProxyServer = class {
328
361
  throw new Error("Unsupported control method");
329
362
  }
330
363
  async handleControlRequest(req, res) {
364
+ if (req.method === "GET") {
365
+ sendJsonResponse(res, HTTP_STATUS_OK, {
366
+ recordingsDir: this.recordingsDir,
367
+ mode: this.mode,
368
+ id: this.recordingId || this.replayId
369
+ });
370
+ return;
371
+ }
331
372
  try {
332
373
  const data = await this.parseControlRequest(req);
333
- const { mode, id, timeout: requestTimeout } = data;
374
+ const { mode, id, timeout: requestTimeout, cleanup } = data;
375
+ if (cleanup && id) {
376
+ await this.cleanupSession(id);
377
+ sendJsonResponse(res, HTTP_STATUS_OK, {
378
+ success: true,
379
+ message: `Session ${id} cleaned up`,
380
+ mode: this.mode
381
+ });
382
+ return;
383
+ }
384
+ if (!mode) {
385
+ throw new Error(
386
+ "Mode parameter is required when cleanup is not specified"
387
+ );
388
+ }
334
389
  const timeout = requestTimeout ?? DEFAULT_TIMEOUT_MS;
335
390
  this.clearModeTimeout();
336
391
  await this.switchMode(mode, id);
@@ -346,7 +401,8 @@ var ProxyServer = class {
346
401
  success: true,
347
402
  mode: this.mode,
348
403
  id: this.recordingId || this.replayId,
349
- timeout
404
+ timeout,
405
+ recordingsDir: this.recordingsDir
350
406
  });
351
407
  } catch (error) {
352
408
  console.error("Control request error:", error);
@@ -411,16 +467,21 @@ var ProxyServer = class {
411
467
  this.replayId = id;
412
468
  this.recordingId = null;
413
469
  this.currentSession = null;
414
- const session = this.replaySessions.get(id);
415
- if (session) {
416
- session.servedRecordingIdsByKey.clear();
417
- console.log(`Reset served recordings tracker for session: ${id}`);
418
- } else {
419
- this.getOrCreateReplaySession(id);
470
+ const sessionState = this.getOrCreateReplaySession(id);
471
+ sessionState.servedRecordingIdsByKey.clear();
472
+ sessionState.sortedRecordingsByKey.clear();
473
+ const filePath = getRecordingPath(this.recordingsDir, id);
474
+ try {
475
+ sessionState.loadedSession = await loadRecordingSession(filePath);
476
+ console.log(`[REPLAY] Loaded recording session: ${id}`);
477
+ } catch (error) {
478
+ console.error(`[REPLAY ERROR] Failed to load session ${id}:`, error);
479
+ sessionState.loadedSession = null;
420
480
  }
421
481
  console.log(`Switched to replay mode with ID: ${id}`);
422
482
  }
423
483
  setupModeTimeout(timeout) {
484
+ clearTimeout(this.modeTimeout || 0);
424
485
  this.modeTimeout = setTimeout(async () => {
425
486
  console.log("Timeout reached, switching back to transparent mode");
426
487
  await this.saveCurrentSession();
@@ -429,21 +490,32 @@ var ProxyServer = class {
429
490
  }, timeout);
430
491
  }
431
492
  async flushPendingRecordings() {
493
+ if (this.flushPromise) {
494
+ await this.flushPromise;
495
+ return;
496
+ }
432
497
  if (this.recordingPromises.length === 0) {
433
498
  return;
434
499
  }
435
- const results = await Promise.allSettled(this.recordingPromises);
436
- if (this.currentSession) {
437
- for (const result of results) {
438
- if (result.status === "fulfilled" && result.value) {
439
- this.currentSession.recordings.push(result.value);
500
+ this.flushPromise = (async () => {
501
+ try {
502
+ const results = await Promise.allSettled(this.recordingPromises);
503
+ if (this.currentSession) {
504
+ for (const result of results) {
505
+ if (result.status === "fulfilled" && result.value) {
506
+ this.currentSession.recordings.push(result.value);
507
+ }
508
+ }
509
+ console.log(
510
+ `Flushed ${results.length} recordings to session (total: ${this.currentSession.recordings.length})`
511
+ );
440
512
  }
513
+ this.recordingPromises = [];
514
+ } finally {
515
+ this.flushPromise = null;
441
516
  }
442
- console.log(
443
- `Flushed ${results.length} recordings to session (total: ${this.currentSession.recordings.length})`
444
- );
445
- }
446
- this.recordingPromises = [];
517
+ })();
518
+ await this.flushPromise;
447
519
  }
448
520
  async saveCurrentSession() {
449
521
  if (!this.currentSession) {
@@ -456,7 +528,29 @@ var ProxyServer = class {
456
528
  await saveRecordingSession(this.recordingsDir, this.currentSession);
457
529
  }
458
530
  getRecordingIdOrError(req, res) {
459
- const recordingId = this.getRecordingIdFromRequest(req) || this.replayId;
531
+ const recordingIdFromRequest = this.getRecordingIdFromRequest(req);
532
+ if (recordingIdFromRequest) {
533
+ return recordingIdFromRequest;
534
+ }
535
+ if (this.replaySessions.size > 1) {
536
+ console.warn(
537
+ `[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)`
538
+ );
539
+ const corsHeaders = this.getCorsHeaders(req);
540
+ res.writeHead(HTTP_STATUS_BAD_REQUEST, {
541
+ "Content-Type": "application/json",
542
+ ...corsHeaders
543
+ });
544
+ res.end(
545
+ JSON.stringify({
546
+ error: "Missing recording ID in concurrent replay mode. Ensure x-test-rcrd-id header is set.",
547
+ activeSessions: [...this.replaySessions.keys()],
548
+ hint: "This usually means page.setExtraHTTPHeaders() did not apply to this request type"
549
+ })
550
+ );
551
+ return null;
552
+ }
553
+ const recordingId = this.replayId;
460
554
  if (!recordingId) {
461
555
  const corsHeaders = this.getCorsHeaders(req);
462
556
  res.writeHead(HTTP_STATUS_BAD_REQUEST, {
@@ -466,22 +560,30 @@ var ProxyServer = class {
466
560
  res.end(JSON.stringify({ error: "No replay session active" }));
467
561
  return null;
468
562
  }
563
+ console.log(
564
+ `[FALLBACK] Using replayId fallback for ${req.method} ${req.url} -> session: ${recordingId} (single session mode)`
565
+ );
469
566
  return recordingId;
470
567
  }
471
- async ensureSessionLoaded(recordingId, filePath) {
472
- const sessionState = this.getOrCreateReplaySession(recordingId);
473
- if (!sessionState.loadedSession) {
474
- sessionState.loadedSession = await loadRecordingSession(filePath);
475
- console.log(`[REPLAY] Loaded recording session: ${recordingId}`);
476
- }
477
- return sessionState;
478
- }
479
568
  getServedTracker(sessionState, key) {
480
569
  if (!sessionState.servedRecordingIdsByKey.has(key)) {
481
570
  sessionState.servedRecordingIdsByKey.set(key, /* @__PURE__ */ new Set());
482
571
  }
483
572
  return sessionState.servedRecordingIdsByKey.get(key);
484
573
  }
574
+ getSortedRecordings(sessionState, key) {
575
+ if (sessionState.sortedRecordingsByKey.has(key)) {
576
+ return sessionState.sortedRecordingsByKey.get(key);
577
+ }
578
+ const session = sessionState.loadedSession;
579
+ const sortedRecords = session.recordings.filter((r) => r.key === key && r.response).toSorted((a, b) => {
580
+ const aSeq = a.sequence !== void 0 ? a.sequence : a.recordingId;
581
+ const bSeq = b.sequence !== void 0 ? b.sequence : b.recordingId;
582
+ return aSeq - bSeq;
583
+ });
584
+ sessionState.sortedRecordingsByKey.set(key, sortedRecords);
585
+ return sortedRecords;
586
+ }
485
587
  selectReplayRecord(recordsWithKey, servedForThisKey, key, recordingId) {
486
588
  for (const rec of recordsWithKey) {
487
589
  if (!servedForThisKey.has(rec.recordingId)) {
@@ -502,18 +604,17 @@ var ProxyServer = class {
502
604
  const key = getReqID(req);
503
605
  const filePath = getRecordingPath(this.recordingsDir, recordingId);
504
606
  try {
505
- const sessionState = await this.ensureSessionLoaded(
506
- recordingId,
507
- filePath
508
- );
509
- const session = sessionState.loadedSession;
607
+ const sessionState = this.getOrCreateReplaySession(recordingId);
608
+ if (!sessionState.loadedSession) {
609
+ const error = new Error(
610
+ `Recording session file not found: ${filePath}`
611
+ );
612
+ error.code = "ENOENT";
613
+ throw error;
614
+ }
510
615
  const servedForThisKey = this.getServedTracker(sessionState, key);
511
616
  const host = req.headers.host || "unknown";
512
- const recordsWithKey = session.recordings.filter((r) => r.key === key && r.response).toSorted((a, b) => {
513
- const aSeq = a.sequence !== void 0 ? a.sequence : a.recordingId;
514
- const bSeq = b.sequence !== void 0 ? b.sequence : b.recordingId;
515
- return aSeq - bSeq;
516
- });
617
+ const recordsWithKey = this.getSortedRecordings(sessionState, key);
517
618
  if (recordsWithKey.length === 0) {
518
619
  const errorMsg = `No recording found for ${key} at ${req.method} ${host}${req.url}`;
519
620
  console.error(`[REPLAY ERROR] ${errorMsg} (session: ${recordingId})`);
@@ -724,7 +825,6 @@ var ProxyServer = class {
724
825
  })();
725
826
  });
726
827
  this.recordingPromises.push(recordingPromise);
727
- await recordingPromise;
728
828
  }
729
829
  handleUpgrade(req, socket, head) {
730
830
  if (this.mode === Modes.replay) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "test-proxy-recorder",
3
- "version": "0.3.1",
3
+ "version": "0.3.3",
4
4
  "description": "HTTP proxy server for recording and replaying network requests in testing. Works seamlessly with Playwright testing framework.",
5
5
  "type": "module",
6
6
  "main": "dist/index.mjs",