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/proxy.js CHANGED
@@ -1,4 +1,4 @@
1
- import path2 from 'path';
1
+ import path from 'path';
2
2
  import { Command } from 'commander';
3
3
  import fs from 'fs/promises';
4
4
  import http from 'http';
@@ -6,7 +6,7 @@ import https from 'https';
6
6
  import httpProxy from 'http-proxy';
7
7
  import { WebSocket, WebSocketServer } from 'ws';
8
8
  import crypto from 'crypto';
9
- import filenamify from 'filenamify';
9
+ import filenamify2 from 'filenamify';
10
10
 
11
11
  // src/cli.ts
12
12
  var DEFAULT_PORT = 8e3;
@@ -39,7 +39,7 @@ function parseCliArgs() {
39
39
  if (targets2.length === 0) {
40
40
  program.help();
41
41
  }
42
- const recordingsDir2 = path2.resolve(process.cwd(), options.recordingsDir);
42
+ const recordingsDir2 = path.resolve(process.cwd(), options.recordingsDir);
43
43
  return { targets: targets2, port: port2, recordingsDir: recordingsDir2 };
44
44
  }
45
45
 
@@ -58,23 +58,53 @@ var Modes = {
58
58
  replay: "replay"
59
59
  };
60
60
  var JSON_INDENT_SPACES = 2;
61
+ var EXTENSION = ".mock.json";
62
+ var MAX_FILENAME_LENGTH = 255 - EXTENSION.length;
63
+ var HASH_LENGTH = 8;
64
+ function generateHash(str) {
65
+ return crypto.createHash("shake256", { outputLength: HASH_LENGTH / 2 }).update(str).digest("hex");
66
+ }
61
67
  function getRecordingPath(recordingsDir2, id) {
62
- return path2.join(recordingsDir2, `${id}.mock.json`);
68
+ let processedId = id.replaceAll("/", "__");
69
+ if (processedId.length > MAX_FILENAME_LENGTH) {
70
+ const hash = generateHash(id);
71
+ const maxBaseLength = MAX_FILENAME_LENGTH - HASH_LENGTH - 1;
72
+ processedId = `${processedId.slice(0, maxBaseLength)}_${hash}`;
73
+ }
74
+ const sanitizedId = filenamify2(processedId, {
75
+ replacement: "_",
76
+ maxLength: 255
77
+ // Set explicit max to prevent filenamify's default truncation
78
+ });
79
+ return path.join(recordingsDir2, `${sanitizedId}${EXTENSION}`);
63
80
  }
64
81
  async function loadRecordingSession(filePath) {
65
82
  const fileContent = await fs.readFile(filePath, "utf8");
66
83
  return JSON.parse(fileContent);
67
84
  }
85
+ function processRecordings(recordings) {
86
+ const keySequenceMap = /* @__PURE__ */ new Map();
87
+ return recordings.map((recording) => {
88
+ const key = recording.key;
89
+ const currentSeq = keySequenceMap.get(key) || 0;
90
+ keySequenceMap.set(key, currentSeq + 1);
91
+ return { ...recording, sequence: currentSeq };
92
+ });
93
+ }
68
94
  async function saveRecordingSession(recordingsDir2, session) {
69
95
  const filePath = getRecordingPath(recordingsDir2, session.id);
70
- const dirPath = path2.dirname(filePath);
71
- await fs.mkdir(dirPath, { recursive: true });
96
+ await fs.mkdir(recordingsDir2, { recursive: true });
97
+ const processedRecordings = processRecordings(session.recordings);
98
+ const processedSession = {
99
+ ...session,
100
+ recordings: processedRecordings
101
+ };
72
102
  await fs.writeFile(
73
103
  filePath,
74
- JSON.stringify(session, null, JSON_INDENT_SPACES)
104
+ JSON.stringify(processedSession, null, JSON_INDENT_SPACES)
75
105
  );
76
106
  console.log(
77
- `Saved ${session.recordings.length} HTTP recordings and ${session.websocketRecordings?.length || 0} WebSocket recordings to ${filePath}`
107
+ `Saved ${processedRecordings.length} HTTP recordings and ${session.websocketRecordings?.length || 0} WebSocket recordings to ${filePath}`
78
108
  );
79
109
  }
80
110
  function getReqID(req) {
@@ -82,10 +112,10 @@ function getReqID(req) {
82
112
  const pathname = urlParts[0];
83
113
  const query = urlParts[1] || "";
84
114
  const pathPart = pathname === "/" ? "root" : pathname.slice(1);
85
- const normalizedPath = filenamify(pathPart, { replacement: "_" });
115
+ const normalizedPath = filenamify2(pathPart, { replacement: "_" });
86
116
  const queryHash = generateQueryHash(query);
87
117
  const filename = `${req.method}_${normalizedPath}${queryHash}.json`;
88
- return filenamify(filename, { replacement: "_" });
118
+ return filenamify2(filename, { replacement: "_" });
89
119
  }
90
120
  function generateQueryHash(query) {
91
121
  if (!query) {
@@ -120,12 +150,10 @@ var ProxyServer = class {
120
150
  proxy;
121
151
  currentSession;
122
152
  recordingsDir;
123
- requestSequenceMap;
124
- // Track sequence per request key
125
- replaySequenceMap;
126
- // Track replay position per request key
127
153
  recordingIdCounter;
128
154
  // Unique ID for each recording entry
155
+ replaySessions;
156
+ // Track multiple concurrent replay sessions by recording ID
129
157
  constructor(targets2, recordingsDir2) {
130
158
  this.targets = targets2;
131
159
  this.currentTargetIndex = 0;
@@ -136,11 +164,11 @@ var ProxyServer = class {
136
164
  this.modeTimeout = null;
137
165
  this.currentSession = null;
138
166
  this.recordingsDir = recordingsDir2;
139
- this.requestSequenceMap = /* @__PURE__ */ new Map();
140
- this.replaySequenceMap = /* @__PURE__ */ new Map();
167
+ this.replaySessions = /* @__PURE__ */ new Map();
141
168
  this.proxy = httpProxy.createProxyServer({
142
169
  secure: false,
143
- changeOrigin: true
170
+ changeOrigin: true,
171
+ ws: true
144
172
  });
145
173
  this.setupProxyEventHandlers();
146
174
  }
@@ -208,6 +236,43 @@ var ProxyServer = class {
208
236
  this.currentTargetIndex = (this.currentTargetIndex + 1) % this.targets.length;
209
237
  return target;
210
238
  }
239
+ /**
240
+ * Extract recording ID from request cookie
241
+ * Used for concurrent replay session routing
242
+ * @param req The incoming HTTP request
243
+ * @returns The recording ID from cookie, or null if not found
244
+ */
245
+ getRecordingIdFromCookie(req) {
246
+ const cookies = req.headers.cookie;
247
+ if (!cookies) {
248
+ return null;
249
+ }
250
+ const match = cookies.match(/proxy-recording-id=([^;]+)/);
251
+ return match ? decodeURIComponent(match[1]) : null;
252
+ }
253
+ /**
254
+ * Get or create a replay session state for a given recording ID
255
+ * @param recordingId The recording ID to get/create session for
256
+ * @returns The replay session state
257
+ */
258
+ getOrCreateReplaySession(recordingId) {
259
+ let session = this.replaySessions.get(recordingId);
260
+ if (session) {
261
+ session.lastAccessTime = Date.now();
262
+ } else {
263
+ session = {
264
+ recordingId,
265
+ servedRecordingIdsByKey: /* @__PURE__ */ new Map(),
266
+ loadedSession: null,
267
+ lastAccessTime: Date.now()
268
+ };
269
+ this.replaySessions.set(recordingId, session);
270
+ console.log(
271
+ `[CONCURRENT REPLAY] Created new session for recording: ${recordingId}`
272
+ );
273
+ }
274
+ return session;
275
+ }
211
276
  parseGetParams(req) {
212
277
  const url = new URL(req.url || "", `http://${req.headers.host}`);
213
278
  const mode = url.searchParams.get("mode");
@@ -224,16 +289,25 @@ var ProxyServer = class {
224
289
  let data;
225
290
  if (req.method === "GET") {
226
291
  data = this.parseGetParams(req);
227
- } else {
292
+ } else if (req.method === "POST") {
228
293
  const body = await readRequestBody(req);
229
- console.log("MODE CHANGE (POST)", body);
294
+ console.log(`MODE CHANGE (${req.method})`, body);
230
295
  data = JSON.parse(body);
296
+ } else {
297
+ return;
231
298
  }
232
299
  const { mode, id, timeout: requestTimeout } = data;
233
300
  const timeout = requestTimeout ?? DEFAULT_TIMEOUT_MS;
234
301
  this.clearModeTimeout();
235
302
  await this.switchMode(mode, id);
236
303
  this.setupModeTimeout(timeout);
304
+ if (mode === Modes.replay && id) {
305
+ res.setHeader(
306
+ "Set-Cookie",
307
+ `proxy-recording-id=${encodeURIComponent(id)}; HttpOnly; Path=/; SameSite=Lax`
308
+ );
309
+ console.log(`[CONCURRENT REPLAY] Set cookie for recording: ${id}`);
310
+ }
237
311
  sendJsonResponse(res, HTTP_STATUS_OK, {
238
312
  success: true,
239
313
  mode: this.mode,
@@ -248,14 +322,12 @@ var ProxyServer = class {
248
322
  }
249
323
  }
250
324
  clearModeTimeout() {
251
- if (this.modeTimeout) {
252
- clearTimeout(this.modeTimeout);
253
- this.modeTimeout = null;
254
- }
325
+ clearTimeout(this.modeTimeout || 0);
326
+ this.modeTimeout = null;
255
327
  }
256
328
  async switchMode(mode, id) {
257
- if (this.currentSession) {
258
- console.log("Switching mode, saving current session first");
329
+ console.log(`Switching to ${mode.toUpperCase()} mode`);
330
+ if (this.currentSession && this.mode === Modes.record) {
259
331
  await this.saveCurrentSession(true);
260
332
  console.log("Session saved, continuing with mode switch");
261
333
  }
@@ -265,11 +337,17 @@ var ProxyServer = class {
265
337
  break;
266
338
  }
267
339
  case Modes.record: {
340
+ if (!id) {
341
+ throw new Error("Record ID is required");
342
+ }
268
343
  this.switchToRecordMode(id);
269
344
  break;
270
345
  }
271
346
  case Modes.replay: {
272
- this.switchToReplayMode(id);
347
+ if (!id) {
348
+ throw new Error("Replay ID is required");
349
+ }
350
+ await this.switchToReplayMode(id);
273
351
  break;
274
352
  }
275
353
  default: {
@@ -286,36 +364,33 @@ var ProxyServer = class {
286
364
  console.log("Switched to transparent mode");
287
365
  }
288
366
  switchToRecordMode(id) {
289
- if (!id) {
290
- throw new Error("Record ID is required");
291
- }
292
367
  this.mode = Modes.record;
293
368
  this.recordingId = id;
294
369
  this.replayId = null;
295
370
  this.currentSession = { id, recordings: [], websocketRecordings: [] };
296
- this.requestSequenceMap.clear();
297
371
  console.log(`Switched to record mode with ID: ${id}`);
298
372
  }
299
- switchToReplayMode(id) {
300
- if (!id) {
301
- throw new Error("Replay ID is required");
302
- }
373
+ async switchToReplayMode(id) {
303
374
  this.mode = Modes.replay;
304
375
  this.replayId = id;
305
376
  this.recordingId = null;
306
377
  this.currentSession = null;
307
- this.replaySequenceMap.clear();
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);
384
+ }
308
385
  console.log(`Switched to replay mode with ID: ${id}`);
309
386
  }
310
387
  setupModeTimeout(timeout) {
311
- if (timeout && timeout > 0) {
312
- this.modeTimeout = setTimeout(async () => {
313
- console.log("Timeout reached, switching back to transparent mode");
314
- await this.saveCurrentSession(true);
315
- this.switchToTransparentMode();
316
- this.modeTimeout = null;
317
- }, timeout);
318
- }
388
+ this.modeTimeout = setTimeout(async () => {
389
+ console.log("Timeout reached, switching back to transparent mode");
390
+ await this.saveCurrentSession(true);
391
+ this.switchToTransparentMode();
392
+ this.modeTimeout = null;
393
+ }, timeout);
319
394
  }
320
395
  async saveCurrentSession(filterIncomplete = false) {
321
396
  if (!this.currentSession) {
@@ -341,9 +416,6 @@ var ProxyServer = class {
341
416
  return;
342
417
  }
343
418
  const key = getReqID(req);
344
- const currentSequence = this.requestSequenceMap.get(key) || 0;
345
- const sequence = currentSequence;
346
- this.requestSequenceMap.set(key, currentSequence + 1);
347
419
  const recordingId = this.recordingIdCounter++;
348
420
  req.__recordingId = recordingId;
349
421
  const record = {
@@ -355,13 +427,12 @@ var ProxyServer = class {
355
427
  },
356
428
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
357
429
  key,
358
- sequence,
359
430
  recordingId
360
431
  };
361
432
  this.currentSession.recordings.push(record);
362
433
  console.log(
363
434
  // eslint-disable-next-line sonarjs/no-nested-template-literals
364
- `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})`
435
+ `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})`
365
436
  );
366
437
  }
367
438
  updateRequestBodySync(req, body) {
@@ -421,7 +492,7 @@ var ProxyServer = class {
421
492
  body: body || null
422
493
  };
423
494
  console.log(
424
- `Recorded: ${req.method} ${req.url} (seq: ${record.sequence}, recordingId: ${recordingId})`
495
+ `Recorded: ${req.method} ${req.url} (recordingId: ${recordingId})`
425
496
  );
426
497
  });
427
498
  }
@@ -451,46 +522,77 @@ var ProxyServer = class {
451
522
  body: body || null
452
523
  };
453
524
  console.log(
454
- `recordResponseData: Recorded response for ${req.method} ${req.url} (seq: ${record.sequence}, recordingId: ${recordingId})`
525
+ `recordResponseData: Recorded response for ${req.method} ${req.url} (recordingId: ${recordingId})`
455
526
  );
456
527
  return true;
457
528
  }
458
529
  async handleReplayRequest(req, res) {
530
+ const recordingId = this.getRecordingIdFromCookie(req) || this.replayId;
531
+ if (!recordingId) {
532
+ const corsHeaders = this.getCorsHeaders(req);
533
+ res.writeHead(HTTP_STATUS_BAD_REQUEST, {
534
+ "Content-Type": "application/json",
535
+ ...corsHeaders
536
+ });
537
+ res.end(JSON.stringify({ error: "No replay session active" }));
538
+ return;
539
+ }
459
540
  const key = getReqID(req);
460
- const filePath = getRecordingPath(this.recordingsDir, this.replayId);
541
+ const filePath = getRecordingPath(this.recordingsDir, recordingId);
461
542
  try {
462
- const session = await loadRecordingSession(filePath);
543
+ const sessionState = this.getOrCreateReplaySession(recordingId);
544
+ if (!sessionState.loadedSession) {
545
+ sessionState.loadedSession = await loadRecordingSession(filePath);
546
+ console.log(`[REPLAY] Loaded recording session: ${recordingId}`);
547
+ }
548
+ const session = sessionState.loadedSession;
549
+ if (!sessionState.servedRecordingIdsByKey.has(key)) {
550
+ sessionState.servedRecordingIdsByKey.set(key, /* @__PURE__ */ new Set());
551
+ }
552
+ const servedForThisKey = sessionState.servedRecordingIdsByKey.get(key);
463
553
  const host = req.headers.host || "unknown";
464
- const recordsWithKey = session.recordings.filter((r) => r.key === key && r.response).toSorted((a, b) => a.sequence - b.sequence);
554
+ const recordsWithKey = session.recordings.filter((r) => r.key === key && r.response).toSorted((a, b) => a.recordingId - b.recordingId);
465
555
  if (recordsWithKey.length === 0) {
466
- console.warn(
467
- `No recording found for ${key} at ${req.method} ${host}${req.url}, returning default response`
556
+ const errorMsg = `No recording found for ${key} at ${req.method} ${host}${req.url}`;
557
+ console.error(`[REPLAY ERROR] ${errorMsg} (session: ${recordingId})`);
558
+ console.error(
559
+ `[REPLAY ERROR] This request was not made during recording - possible test non-determinism`
468
560
  );
469
- const defaultResponse = req.method === "GET" ? {
470
- data: [],
471
- items: [],
472
- results: [],
473
- updated_at: "0001-01-01T00:00:00Z"
474
- } : { success: true };
561
+ const errorResponse = {
562
+ error: "No recording found",
563
+ message: errorMsg,
564
+ key,
565
+ sessionId: recordingId
566
+ };
475
567
  const corsHeaders = this.getCorsHeaders(req);
476
- res.writeHead(HTTP_STATUS_OK, {
568
+ res.writeHead(HTTP_STATUS_NOT_FOUND, {
477
569
  "Content-Type": "application/json",
478
570
  ...corsHeaders
479
571
  });
480
- res.end(JSON.stringify(defaultResponse));
572
+ res.end(JSON.stringify(errorResponse));
481
573
  return;
482
574
  }
483
- const usageCount = this.replaySequenceMap.get(key) || 0;
575
+ const requestCount = servedForThisKey.size + 1;
576
+ console.log(
577
+ `[REPLAY REQUEST #${requestCount}] ${req.method} ${req.url} (session: ${recordingId}, total: ${recordsWithKey.length}, served: ${servedForThisKey.size})`
578
+ );
484
579
  let record;
485
- if (usageCount < recordsWithKey.length) {
486
- record = recordsWithKey[usageCount];
487
- } else {
580
+ for (const rec of recordsWithKey) {
581
+ if (!servedForThisKey.has(rec.recordingId)) {
582
+ record = rec;
583
+ break;
584
+ }
585
+ }
586
+ if (!record) {
587
+ console.log(
588
+ `[REPLAY WARNING] All ${recordsWithKey.length} recordings already served for ${key} (session: ${recordingId}), reusing last one`
589
+ );
488
590
  record = recordsWithKey[recordsWithKey.length - 1];
489
591
  }
592
+ servedForThisKey.add(record.recordingId);
490
593
  console.log(
491
- `Replaying ${req.method} ${req.url} (usage: ${usageCount}, sequence: ${record.sequence}, body_len: ${record.response?.body?.length || 0})`
594
+ `[REPLAY SERVING] recordingId: ${record.recordingId}, session: ${recordingId}, body_len: ${record.response?.body?.length || 0}`
492
595
  );
493
- this.replaySequenceMap.set(key, usageCount + 1);
494
596
  if (!record.response) {
495
597
  throw new Error(
496
598
  `No response recorded for this request: ${req.method} ${host}${req.url}`
@@ -556,6 +658,7 @@ var ProxyServer = class {
556
658
  this.proxy.web(req, res, { target });
557
659
  }
558
660
  }
661
+ // TODO: check if can handle streaming requests
559
662
  async bufferAndProxyRequest(req, res, target) {
560
663
  const chunks = [];
561
664
  req.on("data", (chunk) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "test-proxy-recorder",
3
- "version": "0.1.9",
3
+ "version": "0.1.11",
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",