test-proxy-recorder 0.1.9 → 0.1.11

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
@@ -3,9 +3,9 @@ import http from 'http';
3
3
  import https from 'https';
4
4
  import httpProxy from 'http-proxy';
5
5
  import { WebSocket, WebSocketServer } from 'ws';
6
- import path from 'path';
7
6
  import crypto from 'crypto';
8
- import filenamify from 'filenamify';
7
+ import path from 'path';
8
+ import filenamify2 from 'filenamify';
9
9
 
10
10
  // src/ProxyServer.ts
11
11
 
@@ -24,23 +24,53 @@ var Modes = {
24
24
  replay: "replay"
25
25
  };
26
26
  var JSON_INDENT_SPACES = 2;
27
+ var EXTENSION = ".mock.json";
28
+ var MAX_FILENAME_LENGTH = 255 - EXTENSION.length;
29
+ var HASH_LENGTH = 8;
30
+ function generateHash(str) {
31
+ return crypto.createHash("shake256", { outputLength: HASH_LENGTH / 2 }).update(str).digest("hex");
32
+ }
27
33
  function getRecordingPath(recordingsDir, id) {
28
- return path.join(recordingsDir, `${id}.mock.json`);
34
+ let processedId = id.replaceAll("/", "__");
35
+ if (processedId.length > MAX_FILENAME_LENGTH) {
36
+ const hash = generateHash(id);
37
+ const maxBaseLength = MAX_FILENAME_LENGTH - HASH_LENGTH - 1;
38
+ processedId = `${processedId.slice(0, maxBaseLength)}_${hash}`;
39
+ }
40
+ const sanitizedId = filenamify2(processedId, {
41
+ replacement: "_",
42
+ maxLength: 255
43
+ // Set explicit max to prevent filenamify's default truncation
44
+ });
45
+ return path.join(recordingsDir, `${sanitizedId}${EXTENSION}`);
29
46
  }
30
47
  async function loadRecordingSession(filePath) {
31
48
  const fileContent = await fs.readFile(filePath, "utf8");
32
49
  return JSON.parse(fileContent);
33
50
  }
51
+ function processRecordings(recordings) {
52
+ const keySequenceMap = /* @__PURE__ */ new Map();
53
+ return recordings.map((recording) => {
54
+ const key = recording.key;
55
+ const currentSeq = keySequenceMap.get(key) || 0;
56
+ keySequenceMap.set(key, currentSeq + 1);
57
+ return { ...recording, sequence: currentSeq };
58
+ });
59
+ }
34
60
  async function saveRecordingSession(recordingsDir, session) {
35
61
  const filePath = getRecordingPath(recordingsDir, session.id);
36
- const dirPath = path.dirname(filePath);
37
- await fs.mkdir(dirPath, { recursive: true });
62
+ await fs.mkdir(recordingsDir, { recursive: true });
63
+ const processedRecordings = processRecordings(session.recordings);
64
+ const processedSession = {
65
+ ...session,
66
+ recordings: processedRecordings
67
+ };
38
68
  await fs.writeFile(
39
69
  filePath,
40
- JSON.stringify(session, null, JSON_INDENT_SPACES)
70
+ JSON.stringify(processedSession, null, JSON_INDENT_SPACES)
41
71
  );
42
72
  console.log(
43
- `Saved ${session.recordings.length} HTTP recordings and ${session.websocketRecordings?.length || 0} WebSocket recordings to ${filePath}`
73
+ `Saved ${processedRecordings.length} HTTP recordings and ${session.websocketRecordings?.length || 0} WebSocket recordings to ${filePath}`
44
74
  );
45
75
  }
46
76
  function getReqID(req) {
@@ -48,10 +78,10 @@ function getReqID(req) {
48
78
  const pathname = urlParts[0];
49
79
  const query = urlParts[1] || "";
50
80
  const pathPart = pathname === "/" ? "root" : pathname.slice(1);
51
- const normalizedPath = filenamify(pathPart, { replacement: "_" });
81
+ const normalizedPath = filenamify2(pathPart, { replacement: "_" });
52
82
  const queryHash = generateQueryHash(query);
53
83
  const filename = `${req.method}_${normalizedPath}${queryHash}.json`;
54
- return filenamify(filename, { replacement: "_" });
84
+ return filenamify2(filename, { replacement: "_" });
55
85
  }
56
86
  function generateQueryHash(query) {
57
87
  if (!query) {
@@ -86,12 +116,10 @@ var ProxyServer = class {
86
116
  proxy;
87
117
  currentSession;
88
118
  recordingsDir;
89
- requestSequenceMap;
90
- // Track sequence per request key
91
- replaySequenceMap;
92
- // Track replay position per request key
93
119
  recordingIdCounter;
94
120
  // Unique ID for each recording entry
121
+ replaySessions;
122
+ // Track multiple concurrent replay sessions by recording ID
95
123
  constructor(targets, recordingsDir) {
96
124
  this.targets = targets;
97
125
  this.currentTargetIndex = 0;
@@ -102,11 +130,11 @@ var ProxyServer = class {
102
130
  this.modeTimeout = null;
103
131
  this.currentSession = null;
104
132
  this.recordingsDir = recordingsDir;
105
- this.requestSequenceMap = /* @__PURE__ */ new Map();
106
- this.replaySequenceMap = /* @__PURE__ */ new Map();
133
+ this.replaySessions = /* @__PURE__ */ new Map();
107
134
  this.proxy = httpProxy.createProxyServer({
108
135
  secure: false,
109
- changeOrigin: true
136
+ changeOrigin: true,
137
+ ws: true
110
138
  });
111
139
  this.setupProxyEventHandlers();
112
140
  }
@@ -174,6 +202,43 @@ var ProxyServer = class {
174
202
  this.currentTargetIndex = (this.currentTargetIndex + 1) % this.targets.length;
175
203
  return target;
176
204
  }
205
+ /**
206
+ * Extract recording ID from request cookie
207
+ * Used for concurrent replay session routing
208
+ * @param req The incoming HTTP request
209
+ * @returns The recording ID from cookie, or null if not found
210
+ */
211
+ getRecordingIdFromCookie(req) {
212
+ const cookies = req.headers.cookie;
213
+ if (!cookies) {
214
+ return null;
215
+ }
216
+ const match = cookies.match(/proxy-recording-id=([^;]+)/);
217
+ return match ? decodeURIComponent(match[1]) : null;
218
+ }
219
+ /**
220
+ * Get or create a replay session state for a given recording ID
221
+ * @param recordingId The recording ID to get/create session for
222
+ * @returns The replay session state
223
+ */
224
+ getOrCreateReplaySession(recordingId) {
225
+ let session = this.replaySessions.get(recordingId);
226
+ if (session) {
227
+ session.lastAccessTime = Date.now();
228
+ } else {
229
+ session = {
230
+ recordingId,
231
+ servedRecordingIdsByKey: /* @__PURE__ */ new Map(),
232
+ loadedSession: null,
233
+ lastAccessTime: Date.now()
234
+ };
235
+ this.replaySessions.set(recordingId, session);
236
+ console.log(
237
+ `[CONCURRENT REPLAY] Created new session for recording: ${recordingId}`
238
+ );
239
+ }
240
+ return session;
241
+ }
177
242
  parseGetParams(req) {
178
243
  const url = new URL(req.url || "", `http://${req.headers.host}`);
179
244
  const mode = url.searchParams.get("mode");
@@ -190,16 +255,25 @@ var ProxyServer = class {
190
255
  let data;
191
256
  if (req.method === "GET") {
192
257
  data = this.parseGetParams(req);
193
- } else {
258
+ } else if (req.method === "POST") {
194
259
  const body = await readRequestBody(req);
195
- console.log("MODE CHANGE (POST)", body);
260
+ console.log(`MODE CHANGE (${req.method})`, body);
196
261
  data = JSON.parse(body);
262
+ } else {
263
+ return;
197
264
  }
198
265
  const { mode, id, timeout: requestTimeout } = data;
199
266
  const timeout = requestTimeout ?? DEFAULT_TIMEOUT_MS;
200
267
  this.clearModeTimeout();
201
268
  await this.switchMode(mode, id);
202
269
  this.setupModeTimeout(timeout);
270
+ if (mode === Modes.replay && id) {
271
+ res.setHeader(
272
+ "Set-Cookie",
273
+ `proxy-recording-id=${encodeURIComponent(id)}; HttpOnly; Path=/; SameSite=Lax`
274
+ );
275
+ console.log(`[CONCURRENT REPLAY] Set cookie for recording: ${id}`);
276
+ }
203
277
  sendJsonResponse(res, HTTP_STATUS_OK, {
204
278
  success: true,
205
279
  mode: this.mode,
@@ -214,14 +288,12 @@ var ProxyServer = class {
214
288
  }
215
289
  }
216
290
  clearModeTimeout() {
217
- if (this.modeTimeout) {
218
- clearTimeout(this.modeTimeout);
219
- this.modeTimeout = null;
220
- }
291
+ clearTimeout(this.modeTimeout || 0);
292
+ this.modeTimeout = null;
221
293
  }
222
294
  async switchMode(mode, id) {
223
- if (this.currentSession) {
224
- console.log("Switching mode, saving current session first");
295
+ console.log(`Switching to ${mode.toUpperCase()} mode`);
296
+ if (this.currentSession && this.mode === Modes.record) {
225
297
  await this.saveCurrentSession(true);
226
298
  console.log("Session saved, continuing with mode switch");
227
299
  }
@@ -231,11 +303,17 @@ var ProxyServer = class {
231
303
  break;
232
304
  }
233
305
  case Modes.record: {
306
+ if (!id) {
307
+ throw new Error("Record ID is required");
308
+ }
234
309
  this.switchToRecordMode(id);
235
310
  break;
236
311
  }
237
312
  case Modes.replay: {
238
- this.switchToReplayMode(id);
313
+ if (!id) {
314
+ throw new Error("Replay ID is required");
315
+ }
316
+ await this.switchToReplayMode(id);
239
317
  break;
240
318
  }
241
319
  default: {
@@ -252,36 +330,33 @@ var ProxyServer = class {
252
330
  console.log("Switched to transparent mode");
253
331
  }
254
332
  switchToRecordMode(id) {
255
- if (!id) {
256
- throw new Error("Record ID is required");
257
- }
258
333
  this.mode = Modes.record;
259
334
  this.recordingId = id;
260
335
  this.replayId = null;
261
336
  this.currentSession = { id, recordings: [], websocketRecordings: [] };
262
- this.requestSequenceMap.clear();
263
337
  console.log(`Switched to record mode with ID: ${id}`);
264
338
  }
265
- switchToReplayMode(id) {
266
- if (!id) {
267
- throw new Error("Replay ID is required");
268
- }
339
+ async switchToReplayMode(id) {
269
340
  this.mode = Modes.replay;
270
341
  this.replayId = id;
271
342
  this.recordingId = null;
272
343
  this.currentSession = null;
273
- this.replaySequenceMap.clear();
344
+ const session = this.replaySessions.get(id);
345
+ if (session) {
346
+ session.servedRecordingIdsByKey.clear();
347
+ console.log(`Reset served recordings tracker for session: ${id}`);
348
+ } else {
349
+ this.getOrCreateReplaySession(id);
350
+ }
274
351
  console.log(`Switched to replay mode with ID: ${id}`);
275
352
  }
276
353
  setupModeTimeout(timeout) {
277
- if (timeout && timeout > 0) {
278
- this.modeTimeout = setTimeout(async () => {
279
- console.log("Timeout reached, switching back to transparent mode");
280
- await this.saveCurrentSession(true);
281
- this.switchToTransparentMode();
282
- this.modeTimeout = null;
283
- }, timeout);
284
- }
354
+ this.modeTimeout = setTimeout(async () => {
355
+ console.log("Timeout reached, switching back to transparent mode");
356
+ await this.saveCurrentSession(true);
357
+ this.switchToTransparentMode();
358
+ this.modeTimeout = null;
359
+ }, timeout);
285
360
  }
286
361
  async saveCurrentSession(filterIncomplete = false) {
287
362
  if (!this.currentSession) {
@@ -307,9 +382,6 @@ var ProxyServer = class {
307
382
  return;
308
383
  }
309
384
  const key = getReqID(req);
310
- const currentSequence = this.requestSequenceMap.get(key) || 0;
311
- const sequence = currentSequence;
312
- this.requestSequenceMap.set(key, currentSequence + 1);
313
385
  const recordingId = this.recordingIdCounter++;
314
386
  req.__recordingId = recordingId;
315
387
  const record = {
@@ -321,13 +393,12 @@ var ProxyServer = class {
321
393
  },
322
394
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
323
395
  key,
324
- sequence,
325
396
  recordingId
326
397
  };
327
398
  this.currentSession.recordings.push(record);
328
399
  console.log(
329
400
  // eslint-disable-next-line sonarjs/no-nested-template-literals
330
- `saveRequestRecordSync: Saved ${req.method} ${req.url} (key: ${key}, seq: ${sequence}, recordingId: ${recordingId}, body: ${body ? `${body.length} chars` : "null"}, total: ${this.currentSession.recordings.length}, sessionId: ${this.currentSession.id})`
401
+ `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})`
331
402
  );
332
403
  }
333
404
  updateRequestBodySync(req, body) {
@@ -387,7 +458,7 @@ var ProxyServer = class {
387
458
  body: body || null
388
459
  };
389
460
  console.log(
390
- `Recorded: ${req.method} ${req.url} (seq: ${record.sequence}, recordingId: ${recordingId})`
461
+ `Recorded: ${req.method} ${req.url} (recordingId: ${recordingId})`
391
462
  );
392
463
  });
393
464
  }
@@ -417,46 +488,77 @@ var ProxyServer = class {
417
488
  body: body || null
418
489
  };
419
490
  console.log(
420
- `recordResponseData: Recorded response for ${req.method} ${req.url} (seq: ${record.sequence}, recordingId: ${recordingId})`
491
+ `recordResponseData: Recorded response for ${req.method} ${req.url} (recordingId: ${recordingId})`
421
492
  );
422
493
  return true;
423
494
  }
424
495
  async handleReplayRequest(req, res) {
496
+ const recordingId = this.getRecordingIdFromCookie(req) || this.replayId;
497
+ if (!recordingId) {
498
+ const corsHeaders = this.getCorsHeaders(req);
499
+ res.writeHead(HTTP_STATUS_BAD_REQUEST, {
500
+ "Content-Type": "application/json",
501
+ ...corsHeaders
502
+ });
503
+ res.end(JSON.stringify({ error: "No replay session active" }));
504
+ return;
505
+ }
425
506
  const key = getReqID(req);
426
- const filePath = getRecordingPath(this.recordingsDir, this.replayId);
507
+ const filePath = getRecordingPath(this.recordingsDir, recordingId);
427
508
  try {
428
- const session = await loadRecordingSession(filePath);
509
+ const sessionState = this.getOrCreateReplaySession(recordingId);
510
+ if (!sessionState.loadedSession) {
511
+ sessionState.loadedSession = await loadRecordingSession(filePath);
512
+ console.log(`[REPLAY] Loaded recording session: ${recordingId}`);
513
+ }
514
+ const session = sessionState.loadedSession;
515
+ if (!sessionState.servedRecordingIdsByKey.has(key)) {
516
+ sessionState.servedRecordingIdsByKey.set(key, /* @__PURE__ */ new Set());
517
+ }
518
+ const servedForThisKey = sessionState.servedRecordingIdsByKey.get(key);
429
519
  const host = req.headers.host || "unknown";
430
- const recordsWithKey = session.recordings.filter((r) => r.key === key && r.response).toSorted((a, b) => a.sequence - b.sequence);
520
+ const recordsWithKey = session.recordings.filter((r) => r.key === key && r.response).toSorted((a, b) => a.recordingId - b.recordingId);
431
521
  if (recordsWithKey.length === 0) {
432
- console.warn(
433
- `No recording found for ${key} at ${req.method} ${host}${req.url}, returning default response`
522
+ const errorMsg = `No recording found for ${key} at ${req.method} ${host}${req.url}`;
523
+ console.error(`[REPLAY ERROR] ${errorMsg} (session: ${recordingId})`);
524
+ console.error(
525
+ `[REPLAY ERROR] This request was not made during recording - possible test non-determinism`
434
526
  );
435
- const defaultResponse = req.method === "GET" ? {
436
- data: [],
437
- items: [],
438
- results: [],
439
- updated_at: "0001-01-01T00:00:00Z"
440
- } : { success: true };
527
+ const errorResponse = {
528
+ error: "No recording found",
529
+ message: errorMsg,
530
+ key,
531
+ sessionId: recordingId
532
+ };
441
533
  const corsHeaders = this.getCorsHeaders(req);
442
- res.writeHead(HTTP_STATUS_OK, {
534
+ res.writeHead(HTTP_STATUS_NOT_FOUND, {
443
535
  "Content-Type": "application/json",
444
536
  ...corsHeaders
445
537
  });
446
- res.end(JSON.stringify(defaultResponse));
538
+ res.end(JSON.stringify(errorResponse));
447
539
  return;
448
540
  }
449
- const usageCount = this.replaySequenceMap.get(key) || 0;
541
+ const requestCount = servedForThisKey.size + 1;
542
+ console.log(
543
+ `[REPLAY REQUEST #${requestCount}] ${req.method} ${req.url} (session: ${recordingId}, total: ${recordsWithKey.length}, served: ${servedForThisKey.size})`
544
+ );
450
545
  let record;
451
- if (usageCount < recordsWithKey.length) {
452
- record = recordsWithKey[usageCount];
453
- } else {
546
+ for (const rec of recordsWithKey) {
547
+ if (!servedForThisKey.has(rec.recordingId)) {
548
+ record = rec;
549
+ break;
550
+ }
551
+ }
552
+ if (!record) {
553
+ console.log(
554
+ `[REPLAY WARNING] All ${recordsWithKey.length} recordings already served for ${key} (session: ${recordingId}), reusing last one`
555
+ );
454
556
  record = recordsWithKey[recordsWithKey.length - 1];
455
557
  }
558
+ servedForThisKey.add(record.recordingId);
456
559
  console.log(
457
- `Replaying ${req.method} ${req.url} (usage: ${usageCount}, sequence: ${record.sequence}, body_len: ${record.response?.body?.length || 0})`
560
+ `[REPLAY SERVING] recordingId: ${record.recordingId}, session: ${recordingId}, body_len: ${record.response?.body?.length || 0}`
458
561
  );
459
- this.replaySequenceMap.set(key, usageCount + 1);
460
562
  if (!record.response) {
461
563
  throw new Error(
462
564
  `No response recorded for this request: ${req.method} ${host}${req.url}`
@@ -522,6 +624,7 @@ var ProxyServer = class {
522
624
  this.proxy.web(req, res, { target });
523
625
  }
524
626
  }
627
+ // TODO: check if can handle streaming requests
525
628
  async bufferAndProxyRequest(req, res, target) {
526
629
  const chunks = [];
527
630
  req.on("data", (chunk) => {
@@ -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-CG-XcFDa.cjs';
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-Cx_Kflfl.cjs';
3
3
  import 'node:http';
@@ -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-CG-XcFDa.js';
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-Cx_Kflfl.js';
3
3
  import 'node:http';