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.
@@ -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
@@ -44,6 +46,30 @@ async function setProxyMode(mode, sessionId, timeout) {
44
46
  throw error;
45
47
  }
46
48
  }
49
+ async function cleanupSession(sessionId) {
50
+ const proxyPort = getProxyPort();
51
+ try {
52
+ const body = {
53
+ cleanup: true,
54
+ id: sessionId
55
+ };
56
+ const response = await fetch(`http://127.0.0.1:${proxyPort}/__control`, {
57
+ method: "POST",
58
+ headers: { "Content-Type": "application/json" },
59
+ body: JSON.stringify(body)
60
+ });
61
+ if (!response.ok) {
62
+ const text = await response.text();
63
+ console.error(`Failed to cleanup session ${sessionId}:`, text);
64
+ throw new Error(`Failed to cleanup session: ${text}`);
65
+ }
66
+ await response.json();
67
+ console.log(`Session cleaned up: ${sessionId}`);
68
+ } catch (error) {
69
+ console.error(`Error cleaning up session:`, error);
70
+ throw error;
71
+ }
72
+ }
47
73
  function parseSpecFilePath(specPath) {
48
74
  const folderMatch = specPath.match(/^(.+?)\/([^/]+)\.(spec|test)\.ts$/);
49
75
  if (folderMatch) {
@@ -85,6 +111,53 @@ async function stopProxy(testInfo) {
85
111
  const sessionId = generateSessionId(testInfo);
86
112
  await setProxyMode(Modes.transparent, sessionId);
87
113
  }
114
+ var cachedRecordingsDir = null;
115
+ async function getRecordingsDir() {
116
+ if (cachedRecordingsDir) {
117
+ return cachedRecordingsDir;
118
+ }
119
+ const proxyPort = getProxyPort();
120
+ try {
121
+ const response = await fetch(`http://127.0.0.1:${proxyPort}/__control`);
122
+ if (response.ok) {
123
+ const data = await response.json();
124
+ if (data.recordingsDir) {
125
+ cachedRecordingsDir = data.recordingsDir;
126
+ return cachedRecordingsDir;
127
+ }
128
+ }
129
+ } catch (error) {
130
+ console.warn(
131
+ "Failed to get recordings directory from proxy, using default:",
132
+ error
133
+ );
134
+ }
135
+ cachedRecordingsDir = path.join(process.cwd(), "e2e", "recordings");
136
+ return cachedRecordingsDir;
137
+ }
138
+ async function setupClientSideRecording(page, sessionId, mode, url) {
139
+ const harFileName = sessionId.replaceAll("/", "__");
140
+ const recordingsDir = await getRecordingsDir();
141
+ const harPath = path.join(recordingsDir, `${harFileName}.har`);
142
+ console.log(
143
+ `[Client-Side Recording] Setting up HAR for session: ${sessionId}, mode: ${mode}, path: ${harPath}`
144
+ );
145
+ try {
146
+ await page.routeFromHAR(harPath, {
147
+ url,
148
+ update: mode === Modes.record,
149
+ updateContent: "embed"
150
+ });
151
+ } catch (error) {
152
+ if (mode === Modes.replay) {
153
+ console.error(
154
+ `[Client-Side Replay] Failed to load HAR file. Run tests in record mode first.`,
155
+ error
156
+ );
157
+ throw error;
158
+ }
159
+ }
160
+ }
88
161
  var playwrightProxy = {
89
162
  /**
90
163
  * Setup before test - sets the proxy mode and configures page with custom header
@@ -92,24 +165,84 @@ var playwrightProxy = {
92
165
  * @param page - Playwright page object
93
166
  * @param testInfo - Playwright test info object
94
167
  * @param mode - The proxy mode to use for this test
95
- * @param timeout - Optional timeout in milliseconds
168
+ * @param options - Optional configuration including timeout and client-side recording patterns
96
169
  */
97
- async before(page, testInfo, mode, timeout) {
170
+ async before(page, testInfo, mode, options) {
171
+ const timeout = typeof options === "number" ? options : options?.timeout;
172
+ const clientSideOptions = typeof options === "object" && options !== null ? options : void 0;
98
173
  const sessionId = generateSessionId(testInfo);
99
174
  await page.setExtraHTTPHeaders({
100
175
  [RECORDING_ID_HEADER]: sessionId
101
176
  });
177
+ console.log(`[Setup] Setting proxy mode: ${mode}, session: ${sessionId}`);
102
178
  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
- });
179
+ console.log(`[Setup] Proxy mode set successfully`);
180
+ if (clientSideOptions?.url) {
181
+ console.log(`[Setup] Setting up client-side recording with pattern: ${clientSideOptions.url}`);
182
+ await setupClientSideRecording(
183
+ page,
184
+ sessionId,
185
+ mode,
186
+ clientSideOptions.url
187
+ );
188
+ console.log(`[Setup] Client-side recording setup complete`);
189
+ }
190
+ const proxyPort = process.env.TEST_PROXY_RECORDER_PORT || "8100";
191
+ const proxyUrl = `localhost:${proxyPort}`;
192
+ console.log(`[Setup] Registering proxy route handler for: ${proxyUrl}`);
193
+ await page.route(
194
+ (url) => {
195
+ const urlStr = url.toString();
196
+ const matches = urlStr.includes(proxyUrl);
197
+ if (matches) {
198
+ console.log(`[Route Matcher] Matched proxy request: ${urlStr}`);
199
+ }
200
+ return matches;
201
+ },
202
+ async (route) => {
203
+ try {
204
+ const url = route.request().url();
205
+ const method = route.request().method();
206
+ const headers = route.request().headers();
207
+ const hadHeader = !!headers[RECORDING_ID_HEADER];
208
+ headers[RECORDING_ID_HEADER] = sessionId;
209
+ console.log(
210
+ `[Route Intercept] ${method} ${url} (had header: ${hadHeader}, adding session: ${sessionId})`
211
+ );
212
+ await route.continue({ headers });
213
+ } catch (error) {
214
+ console.error(
215
+ `[Route Handler Error] Failed to add ${RECORDING_ID_HEADER} header:`,
216
+ error
217
+ );
218
+ await route.fallback();
219
+ }
220
+ },
221
+ { times: Infinity }
222
+ // Ensure the handler applies to all matching requests
223
+ );
224
+ console.log(`[Setup] Proxy route handler registered`);
225
+ const context = page.context();
226
+ const contextId = context._guid || "default";
227
+ const handlerKey = `cleanup_${contextId}`;
228
+ if (!globalThis[handlerKey]) {
229
+ globalThis[handlerKey] = true;
230
+ context.on("close", async () => {
231
+ try {
232
+ console.log(
233
+ `[Cleanup] Browser context closed, cleaning up session: ${sessionId}`
234
+ );
235
+ await cleanupSession(sessionId);
236
+ } catch (error) {
237
+ console.warn(
238
+ `[Cleanup] Failed to cleanup session ${sessionId}:`,
239
+ error
240
+ );
241
+ } finally {
242
+ delete globalThis[handlerKey];
243
+ }
244
+ });
245
+ }
113
246
  },
114
247
  /**
115
248
  * Global teardown - switches proxy to transparent mode
@@ -120,6 +253,6 @@ var playwrightProxy = {
120
253
  }
121
254
  };
122
255
 
123
- export { generateSessionId, playwrightProxy, setProxyMode, startRecording, startReplay, stopProxy };
256
+ export { cleanupSession, generateSessionId, playwrightProxy, setProxyMode, startRecording, startReplay, stopProxy };
124
257
  //# sourceMappingURL=index.mjs.map
125
258
  //# 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 "";
@@ -280,7 +283,15 @@ var ProxyServer = class {
280
283
  * @returns The recording ID, or null if not found
281
284
  */
282
285
  getRecordingIdFromRequest(req) {
283
- return this.getRecordingIdFromHeader(req) || this.getRecordingIdFromCookie(req);
286
+ const fromHeader = this.getRecordingIdFromHeader(req);
287
+ const fromCookie = this.getRecordingIdFromCookie(req);
288
+ if (fromHeader) {
289
+ return fromHeader;
290
+ }
291
+ if (fromCookie) {
292
+ return fromCookie;
293
+ }
294
+ return null;
284
295
  }
285
296
  /**
286
297
  * Get or create a replay session state for a given recording ID
@@ -305,6 +316,27 @@ var ProxyServer = class {
305
316
  }
306
317
  return session;
307
318
  }
319
+ /**
320
+ * Clean up a session - removes it from memory and resets counters
321
+ * @param sessionId The session ID to clean up
322
+ */
323
+ async cleanupSession(sessionId) {
324
+ if (this.replaySessions.has(sessionId)) {
325
+ console.log(`[CLEANUP] Removing replay session: ${sessionId}`);
326
+ this.replaySessions.delete(sessionId);
327
+ }
328
+ if (this.recordingId === sessionId) {
329
+ console.log(`[CLEANUP] Saving and clearing active recording session: ${sessionId}`);
330
+ await this.saveCurrentSession();
331
+ this.currentSession = null;
332
+ this.recordingId = null;
333
+ }
334
+ if (this.replayId === sessionId) {
335
+ console.log(`[CLEANUP] Clearing active replay session: ${sessionId}`);
336
+ this.replayId = null;
337
+ }
338
+ console.log(`[CLEANUP] Session ${sessionId} cleaned up successfully`);
339
+ }
308
340
  parseGetParams(req) {
309
341
  const url = new URL(req.url || "", `http://${req.headers.host}`);
310
342
  const mode = url.searchParams.get("mode");
@@ -328,9 +360,29 @@ var ProxyServer = class {
328
360
  throw new Error("Unsupported control method");
329
361
  }
330
362
  async handleControlRequest(req, res) {
363
+ if (req.method === "GET") {
364
+ sendJsonResponse(res, HTTP_STATUS_OK, {
365
+ recordingsDir: this.recordingsDir,
366
+ mode: this.mode,
367
+ id: this.recordingId || this.replayId
368
+ });
369
+ return;
370
+ }
331
371
  try {
332
372
  const data = await this.parseControlRequest(req);
333
- const { mode, id, timeout: requestTimeout } = data;
373
+ const { mode, id, timeout: requestTimeout, cleanup } = data;
374
+ if (cleanup && id) {
375
+ await this.cleanupSession(id);
376
+ sendJsonResponse(res, HTTP_STATUS_OK, {
377
+ success: true,
378
+ message: `Session ${id} cleaned up`,
379
+ mode: this.mode
380
+ });
381
+ return;
382
+ }
383
+ if (!mode) {
384
+ throw new Error("Mode parameter is required when cleanup is not specified");
385
+ }
334
386
  const timeout = requestTimeout ?? DEFAULT_TIMEOUT_MS;
335
387
  this.clearModeTimeout();
336
388
  await this.switchMode(mode, id);
@@ -346,7 +398,8 @@ var ProxyServer = class {
346
398
  success: true,
347
399
  mode: this.mode,
348
400
  id: this.recordingId || this.replayId,
349
- timeout
401
+ timeout,
402
+ recordingsDir: this.recordingsDir
350
403
  });
351
404
  } catch (error) {
352
405
  console.error("Control request error:", error);
@@ -421,6 +474,7 @@ var ProxyServer = class {
421
474
  console.log(`Switched to replay mode with ID: ${id}`);
422
475
  }
423
476
  setupModeTimeout(timeout) {
477
+ clearTimeout(this.modeTimeout || 0);
424
478
  this.modeTimeout = setTimeout(async () => {
425
479
  console.log("Timeout reached, switching back to transparent mode");
426
480
  await this.saveCurrentSession();
@@ -456,7 +510,29 @@ var ProxyServer = class {
456
510
  await saveRecordingSession(this.recordingsDir, this.currentSession);
457
511
  }
458
512
  getRecordingIdOrError(req, res) {
459
- const recordingId = this.getRecordingIdFromRequest(req) || this.replayId;
513
+ const recordingIdFromRequest = this.getRecordingIdFromRequest(req);
514
+ if (recordingIdFromRequest) {
515
+ return recordingIdFromRequest;
516
+ }
517
+ if (this.replaySessions.size > 1) {
518
+ console.warn(
519
+ `[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)`
520
+ );
521
+ const corsHeaders = this.getCorsHeaders(req);
522
+ res.writeHead(HTTP_STATUS_BAD_REQUEST, {
523
+ "Content-Type": "application/json",
524
+ ...corsHeaders
525
+ });
526
+ res.end(
527
+ JSON.stringify({
528
+ error: "Missing recording ID in concurrent replay mode. Ensure x-test-rcrd-id header is set.",
529
+ activeSessions: [...this.replaySessions.keys()],
530
+ hint: "This usually means page.setExtraHTTPHeaders() did not apply to this request type"
531
+ })
532
+ );
533
+ return null;
534
+ }
535
+ const recordingId = this.replayId;
460
536
  if (!recordingId) {
461
537
  const corsHeaders = this.getCorsHeaders(req);
462
538
  res.writeHead(HTTP_STATUS_BAD_REQUEST, {
@@ -466,6 +542,9 @@ var ProxyServer = class {
466
542
  res.end(JSON.stringify({ error: "No replay session active" }));
467
543
  return null;
468
544
  }
545
+ console.log(
546
+ `[FALLBACK] Using replayId fallback for ${req.method} ${req.url} -> session: ${recordingId} (single session mode)`
547
+ );
469
548
  return recordingId;
470
549
  }
471
550
  async ensureSessionLoaded(recordingId, filePath) {
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.2",
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",