test-proxy-recorder 0.1.4 → 0.1.6

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
@@ -24,7 +24,7 @@ var Modes = {
24
24
  };
25
25
  var JSON_INDENT_SPACES = 2;
26
26
  function getRecordingPath(recordingsDir, id) {
27
- return path.join(recordingsDir, `${id}.json`);
27
+ return path.join(recordingsDir, `${id}.mock.json`);
28
28
  }
29
29
  async function loadRecordingSession(filePath) {
30
30
  const fileContent = await fs.readFile(filePath, "utf8");
@@ -32,6 +32,8 @@ async function loadRecordingSession(filePath) {
32
32
  }
33
33
  async function saveRecordingSession(recordingsDir, session) {
34
34
  const filePath = getRecordingPath(recordingsDir, session.id);
35
+ const dirPath = path.dirname(filePath);
36
+ await fs.mkdir(dirPath, { recursive: true });
35
37
  await fs.writeFile(
36
38
  filePath,
37
39
  JSON.stringify(session, null, JSON_INDENT_SPACES)
@@ -116,6 +118,7 @@ var ProxyServer = class {
116
118
  this.handleUpgrade(req, socket, head);
117
119
  });
118
120
  server.listen(port, () => {
121
+ process.env.TEST_PROXY_RECORDER_PORT = String(port);
119
122
  this.logServerStartup(port);
120
123
  });
121
124
  return server;
@@ -124,14 +127,17 @@ var ProxyServer = class {
124
127
  this.proxy.on("error", this.handleProxyError.bind(this));
125
128
  this.proxy.on("proxyRes", this.handleProxyResponse.bind(this));
126
129
  }
127
- handleProxyError(err, _req, res) {
130
+ handleProxyError(err, req, res) {
128
131
  console.error("Proxy error:", err);
129
132
  if (!(res instanceof http.ServerResponse)) {
130
133
  return;
131
134
  }
132
135
  if (!res.headersSent) {
136
+ const origin = req.headers.origin;
133
137
  res.writeHead(HTTP_STATUS_BAD_GATEWAY, {
134
- "Content-Type": "application/json"
138
+ "Content-Type": "application/json",
139
+ "Access-Control-Allow-Origin": origin || "*",
140
+ "Access-Control-Allow-Credentials": "true"
135
141
  });
136
142
  }
137
143
  res.end(JSON.stringify({ error: "Proxy error", message: err.message }));
@@ -187,7 +193,7 @@ var ProxyServer = class {
187
193
  async switchMode(mode, id) {
188
194
  if (this.currentSession) {
189
195
  console.log("Switching mode, saving current session first");
190
- await this.saveCurrentSession();
196
+ await this.saveCurrentSession(true);
191
197
  console.log("Session saved, continuing with mode switch");
192
198
  }
193
199
  switch (mode) {
@@ -242,13 +248,13 @@ var ProxyServer = class {
242
248
  if (timeout && timeout > 0) {
243
249
  this.modeTimeout = setTimeout(async () => {
244
250
  console.log("Timeout reached, switching back to transparent mode");
245
- await this.saveCurrentSession();
251
+ await this.saveCurrentSession(true);
246
252
  this.switchToTransparentMode();
247
253
  this.modeTimeout = null;
248
254
  }, timeout);
249
255
  }
250
256
  }
251
- async saveCurrentSession() {
257
+ async saveCurrentSession(filterIncomplete = false) {
252
258
  if (!this.currentSession) {
253
259
  console.log("No current session to save");
254
260
  return;
@@ -257,13 +263,27 @@ var ProxyServer = class {
257
263
  console.log("Session has no recordings, skipping save");
258
264
  return;
259
265
  }
266
+ if (filterIncomplete) {
267
+ const incompleteCount = this.currentSession.recordings.filter(
268
+ (r) => !r.response
269
+ ).length;
270
+ if (incompleteCount > 0) {
271
+ console.log(
272
+ `Removing ${incompleteCount} incomplete recording(s) without responses`
273
+ );
274
+ this.currentSession.recordings = this.currentSession.recordings.filter(
275
+ (r) => r.response
276
+ );
277
+ }
278
+ }
260
279
  console.log(
261
280
  `Saving session with ${this.currentSession.recordings.length} HTTP and ${this.currentSession.websocketRecordings.length} WebSocket recordings`
262
281
  );
263
282
  await saveRecordingSession(this.recordingsDir, this.currentSession);
264
283
  }
265
- async saveRequestRecord(req, body) {
284
+ saveRequestRecordSync(req, body) {
266
285
  if (!this.currentSession) {
286
+ console.log("saveRequestRecordSync: No current session");
267
287
  return;
268
288
  }
269
289
  const key = getReqID(req);
@@ -281,6 +301,30 @@ var ProxyServer = class {
281
301
  sequence: currentSequence
282
302
  };
283
303
  this.currentSession.recordings.push(record);
304
+ console.log(
305
+ // eslint-disable-next-line sonarjs/no-nested-template-literals
306
+ `saveRequestRecordSync: Saved ${req.method} ${req.url} (key: ${key}, seq: ${currentSequence}, body: ${body ? `${body.length} chars` : "null"}, total: ${this.currentSession.recordings.length}, sessionId: ${this.currentSession.id})`
307
+ );
308
+ }
309
+ updateRequestBodySync(req, body) {
310
+ if (!this.currentSession) {
311
+ console.log("updateRequestBodySync: No current session");
312
+ return;
313
+ }
314
+ const key = getReqID(req);
315
+ const record = this.currentSession.recordings.findLast(
316
+ (r) => r.key === key && !r.response
317
+ );
318
+ if (!record) {
319
+ console.error(
320
+ `updateRequestBodySync: Could not find request record for ${req.method} ${req.url}`
321
+ );
322
+ return;
323
+ }
324
+ record.request.body = body || null;
325
+ console.log(
326
+ `updateRequestBodySync: Updated body for ${req.method} ${req.url} (${body.length} chars)`
327
+ );
284
328
  }
285
329
  async recordResponse(req, proxyRes) {
286
330
  if (!this.currentSession) {
@@ -309,49 +353,105 @@ var ProxyServer = class {
309
353
  console.log(`Recorded: ${req.method} ${req.url}`);
310
354
  });
311
355
  }
356
+ async recordResponseData(req, proxyRes, body) {
357
+ if (!this.currentSession) {
358
+ console.log("recordResponseData: No current session");
359
+ return false;
360
+ }
361
+ const key = getReqID(req);
362
+ const record = this.currentSession.recordings.findLast(
363
+ (r) => r.key === key && !r.response
364
+ );
365
+ if (!record) {
366
+ const host = req.headers.host || "unknown";
367
+ const recordsWithKey = this.currentSession.recordings.filter(
368
+ (r) => r.key === key
369
+ );
370
+ console.error(
371
+ `Request record not found for response: ${key} at ${req.method} ${host}${req.url}`
372
+ );
373
+ console.error(
374
+ ` Total recordings: ${this.currentSession.recordings.length}, with this key: ${recordsWithKey.length}`
375
+ );
376
+ console.error(
377
+ ` Records with key:`,
378
+ recordsWithKey.map((r) => ({
379
+ seq: r.sequence,
380
+ hasResponse: !!r.response
381
+ }))
382
+ );
383
+ return false;
384
+ }
385
+ record.response = {
386
+ statusCode: proxyRes.statusCode,
387
+ headers: proxyRes.headers,
388
+ body: body || null
389
+ };
390
+ await this.saveCurrentSession();
391
+ console.log(
392
+ `recordResponseData: Recorded response for ${req.method} ${req.url}`
393
+ );
394
+ return true;
395
+ }
312
396
  async handleReplayRequest(req, res) {
313
397
  const key = getReqID(req);
314
398
  const filePath = getRecordingPath(this.recordingsDir, this.replayId);
315
399
  try {
316
400
  const session = await loadRecordingSession(filePath);
317
- const currentSequence = this.replaySequenceMap.get(key) || 0;
318
- const record = session.recordings.find(
319
- (r) => r.key === key && r.sequence === currentSequence
401
+ const host = req.headers.host || "unknown";
402
+ const recordsWithKey = session.recordings.filter(
403
+ (r) => r.key === key && r.response
320
404
  );
321
- if (!record) {
405
+ if (recordsWithKey.length === 0) {
322
406
  throw new Error(
323
- `No recording found for ${key} with sequence ${currentSequence}`
407
+ `No recording found for ${key} at ${req.method} ${host}${req.url}`
324
408
  );
325
409
  }
410
+ const usageCount = this.replaySequenceMap.get(key) || 0;
411
+ const recordIndex = usageCount % recordsWithKey.length;
412
+ const record = recordsWithKey[recordIndex];
413
+ console.log(
414
+ `Replaying ${req.method} ${req.url} (usage: ${usageCount}, using recording ${recordIndex}/${recordsWithKey.length})`
415
+ );
416
+ this.replaySequenceMap.set(key, usageCount + 1);
326
417
  if (!record.response) {
327
- throw new Error("No response recorded for this request");
418
+ throw new Error(
419
+ `No response recorded for this request: ${req.method} ${host}${req.url}`
420
+ );
328
421
  }
329
- this.replaySequenceMap.set(key, currentSequence + 1);
330
422
  const { statusCode, headers, body } = record.response;
331
423
  const origin = req.headers.origin;
332
424
  const responseHeaders = {
333
425
  ...headers,
334
426
  "access-control-allow-origin": origin || "*",
335
- "access-control-allow-credentials": "true"
427
+ "access-control-allow-credentials": "true",
428
+ "access-control-allow-headers": req.headers["access-control-request-headers"] || "Origin, X-Requested-With, Content-Type, Accept, Authorization",
429
+ "access-control-allow-methods": "GET, POST, PUT, DELETE, PATCH, OPTIONS",
430
+ "access-control-expose-headers": "*"
336
431
  };
337
432
  res.writeHead(statusCode, responseHeaders);
338
433
  res.end(body);
339
- console.log(
340
- `Replayed: ${req.method} ${req.url} (sequence: ${currentSequence})`
341
- );
342
434
  } catch (error) {
343
- this.handleReplayError(res, error, key, filePath);
435
+ this.handleReplayError(req, res, error, key, filePath);
344
436
  }
345
437
  }
346
- handleReplayError(res, err, key, filePath) {
438
+ handleReplayError(req, res, err, key, filePath) {
347
439
  const isFileNotFound = err instanceof Error && "code" in err && err.code === "ENOENT";
348
440
  console.error("Replay error:", err);
349
- sendJsonResponse(res, HTTP_STATUS_NOT_FOUND, {
350
- error: isFileNotFound ? "Recording file not found" : "Recording not found",
351
- message: err instanceof Error ? err.message : "Unknown error",
352
- key,
353
- filePath
441
+ const origin = req.headers.origin;
442
+ res.writeHead(HTTP_STATUS_NOT_FOUND, {
443
+ "Content-Type": "application/json",
444
+ "Access-Control-Allow-Origin": origin || "*",
445
+ "Access-Control-Allow-Credentials": "true"
354
446
  });
447
+ res.end(
448
+ JSON.stringify({
449
+ error: isFileNotFound ? "Recording file not found" : "Recording not found",
450
+ message: err instanceof Error ? err.message : "Unknown error",
451
+ key,
452
+ filePath
453
+ })
454
+ );
355
455
  }
356
456
  async handleRequest(req, res) {
357
457
  if (req.method === "OPTIONS") {
@@ -381,6 +481,7 @@ var ProxyServer = class {
381
481
  const target = this.getTarget();
382
482
  console.log(`[${this.mode}] ${req.method} ${req.url} -> ${target}`);
383
483
  if (this.mode === Modes.record) {
484
+ this.saveRequestRecordSync(req, null);
384
485
  await this.bufferAndProxyRequest(req, res, target);
385
486
  } else {
386
487
  this.proxy.web(req, res, { target });
@@ -391,11 +492,20 @@ var ProxyServer = class {
391
492
  req.on("data", (chunk) => {
392
493
  chunks.push(chunk);
393
494
  });
394
- await new Promise((resolve) => {
395
- req.on("end", () => resolve());
396
- });
495
+ try {
496
+ await new Promise((resolve, reject) => {
497
+ req.on("end", () => resolve());
498
+ req.on("error", (err) => reject(err));
499
+ setTimeout(
500
+ () => reject(new Error("Request buffering timeout")),
501
+ 3e4
502
+ );
503
+ });
504
+ } catch (error) {
505
+ console.error("Error buffering request:", error);
506
+ }
397
507
  const body = Buffer.concat(chunks).toString("utf8");
398
- await this.saveRequestRecord(req, body);
508
+ this.updateRequestBodySync(req, body);
399
509
  const targetUrl = new URL(target);
400
510
  const isHttps = targetUrl.protocol === "https:";
401
511
  const requestModule = isHttps ? https : http;
@@ -410,9 +520,38 @@ var ProxyServer = class {
410
520
  },
411
521
  (proxyRes) => {
412
522
  this.addCorsHeaders(proxyRes, req);
413
- this.recordResponse(req, proxyRes);
414
- res.writeHead(proxyRes.statusCode || 200, proxyRes.headers);
415
- proxyRes.pipe(res);
523
+ const responseChunks = [];
524
+ proxyRes.on("data", (chunk) => {
525
+ responseChunks.push(chunk);
526
+ });
527
+ proxyRes.on("end", async () => {
528
+ const responseBody = Buffer.concat(responseChunks);
529
+ const recorded = await this.recordResponseData(
530
+ req,
531
+ proxyRes,
532
+ responseBody.toString("utf8")
533
+ );
534
+ const origin = req.headers.origin;
535
+ const responseHeaders = {
536
+ ...proxyRes.headers,
537
+ "access-control-allow-origin": origin || "*",
538
+ "access-control-allow-credentials": "true",
539
+ "access-control-allow-headers": req.headers["access-control-request-headers"] || "Origin, X-Requested-With, Content-Type, Accept, Authorization",
540
+ "access-control-allow-methods": "GET, POST, PUT, DELETE, PATCH, OPTIONS",
541
+ "access-control-expose-headers": "*"
542
+ };
543
+ res.writeHead(proxyRes.statusCode || 200, responseHeaders);
544
+ res.end(responseBody);
545
+ if (recorded) {
546
+ console.log(`Recorded: ${req.method} ${req.url}`);
547
+ }
548
+ });
549
+ proxyRes.on("error", (err) => {
550
+ console.error("Proxy response error:", err);
551
+ if (!res.headersSent) {
552
+ this.handleProxyError(err, req, res);
553
+ }
554
+ });
416
555
  }
417
556
  );
418
557
  proxyReq.on("error", (err) => {
@@ -594,15 +733,25 @@ var ProxyServer = class {
594
733
  };
595
734
 
596
735
  // src/playwright/index.ts
597
- var INTERNAL_API_URL = process.env.INTERNAL_API_URL || "http://localhost:8100";
736
+ function getProxyPort() {
737
+ const envPort = process.env.TEST_PROXY_RECORDER_PORT;
738
+ if (envPort) {
739
+ const parsed = Number.parseInt(envPort, 10);
740
+ if (!Number.isNaN(parsed)) {
741
+ return parsed;
742
+ }
743
+ }
744
+ return 8100;
745
+ }
598
746
  async function setProxyMode(mode, sessionId, timeout) {
747
+ const proxyPort = getProxyPort();
599
748
  try {
600
749
  const body = {
601
750
  mode,
602
751
  id: sessionId,
603
752
  ...timeout && { timeout }
604
753
  };
605
- const response = await fetch(`${INTERNAL_API_URL}/__control`, {
754
+ const response = await fetch(`http://127.0.0.1:${proxyPort}/__control`, {
606
755
  method: "POST",
607
756
  headers: { "Content-Type": "application/json" },
608
757
  body: JSON.stringify(body)
@@ -619,8 +768,34 @@ async function setProxyMode(mode, sessionId, timeout) {
619
768
  throw error;
620
769
  }
621
770
  }
771
+ function parseSpecFilePath(specPath) {
772
+ const folderMatch = specPath.match(/^(.+?)\/([^/]+)\.(spec|test)\.ts$/);
773
+ if (folderMatch) {
774
+ return { folder: folderMatch[1], fileName: folderMatch[2] };
775
+ }
776
+ const fileMatch = specPath.match(/^([^/]+)\.(spec|test)\.ts$/);
777
+ if (fileMatch) {
778
+ return { folder: null, fileName: fileMatch[1] };
779
+ }
780
+ return { folder: null, fileName: null };
781
+ }
782
+ function buildSessionPath(folder, fileName, testName) {
783
+ if (folder && fileName) {
784
+ return `${folder}/${fileName}__${testName}`;
785
+ }
786
+ if (fileName) {
787
+ return `${fileName}__${testName}`;
788
+ }
789
+ return testName;
790
+ }
622
791
  function generateSessionId(testInfo) {
623
- return testInfo.title.toLowerCase().replaceAll(/\s+/g, "-");
792
+ const { titlePath } = testInfo;
793
+ if (!titlePath || titlePath.length === 0) {
794
+ return testInfo.title.toLowerCase().replaceAll(/\s+/g, "-");
795
+ }
796
+ const { folder, fileName } = parseSpecFilePath(titlePath[0]);
797
+ const testName = titlePath.at(-1).toLowerCase().replaceAll(/\s+/g, "-");
798
+ return buildSessionPath(folder, fileName, testName);
624
799
  }
625
800
  async function startRecording(testInfo) {
626
801
  const sessionId = generateSessionId(testInfo);
@@ -8,15 +8,25 @@ var Modes = {
8
8
  };
9
9
 
10
10
  // src/playwright/index.ts
11
- var INTERNAL_API_URL = process.env.INTERNAL_API_URL || "http://localhost:8100";
11
+ function getProxyPort() {
12
+ const envPort = process.env.TEST_PROXY_RECORDER_PORT;
13
+ if (envPort) {
14
+ const parsed = Number.parseInt(envPort, 10);
15
+ if (!Number.isNaN(parsed)) {
16
+ return parsed;
17
+ }
18
+ }
19
+ return 8100;
20
+ }
12
21
  async function setProxyMode(mode, sessionId, timeout) {
22
+ const proxyPort = getProxyPort();
13
23
  try {
14
24
  const body = {
15
25
  mode,
16
26
  id: sessionId,
17
27
  ...timeout && { timeout }
18
28
  };
19
- const response = await fetch(`${INTERNAL_API_URL}/__control`, {
29
+ const response = await fetch(`http://127.0.0.1:${proxyPort}/__control`, {
20
30
  method: "POST",
21
31
  headers: { "Content-Type": "application/json" },
22
32
  body: JSON.stringify(body)
@@ -33,8 +43,34 @@ async function setProxyMode(mode, sessionId, timeout) {
33
43
  throw error;
34
44
  }
35
45
  }
46
+ function parseSpecFilePath(specPath) {
47
+ const folderMatch = specPath.match(/^(.+?)\/([^/]+)\.(spec|test)\.ts$/);
48
+ if (folderMatch) {
49
+ return { folder: folderMatch[1], fileName: folderMatch[2] };
50
+ }
51
+ const fileMatch = specPath.match(/^([^/]+)\.(spec|test)\.ts$/);
52
+ if (fileMatch) {
53
+ return { folder: null, fileName: fileMatch[1] };
54
+ }
55
+ return { folder: null, fileName: null };
56
+ }
57
+ function buildSessionPath(folder, fileName, testName) {
58
+ if (folder && fileName) {
59
+ return `${folder}/${fileName}__${testName}`;
60
+ }
61
+ if (fileName) {
62
+ return `${fileName}__${testName}`;
63
+ }
64
+ return testName;
65
+ }
36
66
  function generateSessionId(testInfo) {
37
- return testInfo.title.toLowerCase().replaceAll(/\s+/g, "-");
67
+ const { titlePath } = testInfo;
68
+ if (!titlePath || titlePath.length === 0) {
69
+ return testInfo.title.toLowerCase().replaceAll(/\s+/g, "-");
70
+ }
71
+ const { folder, fileName } = parseSpecFilePath(titlePath[0]);
72
+ const testName = titlePath.at(-1).toLowerCase().replaceAll(/\s+/g, "-");
73
+ return buildSessionPath(folder, fileName, testName);
38
74
  }
39
75
  async function startRecording(testInfo) {
40
76
  const sessionId = generateSessionId(testInfo);
@@ -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-De4mgziH.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-CBjvm5rb.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-De4mgziH.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-CBjvm5rb.js';
3
3
  import 'node:http';
@@ -6,15 +6,25 @@ var Modes = {
6
6
  };
7
7
 
8
8
  // src/playwright/index.ts
9
- var INTERNAL_API_URL = process.env.INTERNAL_API_URL || "http://localhost:8100";
9
+ function getProxyPort() {
10
+ const envPort = process.env.TEST_PROXY_RECORDER_PORT;
11
+ if (envPort) {
12
+ const parsed = Number.parseInt(envPort, 10);
13
+ if (!Number.isNaN(parsed)) {
14
+ return parsed;
15
+ }
16
+ }
17
+ return 8100;
18
+ }
10
19
  async function setProxyMode(mode, sessionId, timeout) {
20
+ const proxyPort = getProxyPort();
11
21
  try {
12
22
  const body = {
13
23
  mode,
14
24
  id: sessionId,
15
25
  ...timeout && { timeout }
16
26
  };
17
- const response = await fetch(`${INTERNAL_API_URL}/__control`, {
27
+ const response = await fetch(`http://127.0.0.1:${proxyPort}/__control`, {
18
28
  method: "POST",
19
29
  headers: { "Content-Type": "application/json" },
20
30
  body: JSON.stringify(body)
@@ -31,8 +41,34 @@ async function setProxyMode(mode, sessionId, timeout) {
31
41
  throw error;
32
42
  }
33
43
  }
44
+ function parseSpecFilePath(specPath) {
45
+ const folderMatch = specPath.match(/^(.+?)\/([^/]+)\.(spec|test)\.ts$/);
46
+ if (folderMatch) {
47
+ return { folder: folderMatch[1], fileName: folderMatch[2] };
48
+ }
49
+ const fileMatch = specPath.match(/^([^/]+)\.(spec|test)\.ts$/);
50
+ if (fileMatch) {
51
+ return { folder: null, fileName: fileMatch[1] };
52
+ }
53
+ return { folder: null, fileName: null };
54
+ }
55
+ function buildSessionPath(folder, fileName, testName) {
56
+ if (folder && fileName) {
57
+ return `${folder}/${fileName}__${testName}`;
58
+ }
59
+ if (fileName) {
60
+ return `${fileName}__${testName}`;
61
+ }
62
+ return testName;
63
+ }
34
64
  function generateSessionId(testInfo) {
35
- return testInfo.title.toLowerCase().replaceAll(/\s+/g, "-");
65
+ const { titlePath } = testInfo;
66
+ if (!titlePath || titlePath.length === 0) {
67
+ return testInfo.title.toLowerCase().replaceAll(/\s+/g, "-");
68
+ }
69
+ const { folder, fileName } = parseSpecFilePath(titlePath[0]);
70
+ const testName = titlePath.at(-1).toLowerCase().replaceAll(/\s+/g, "-");
71
+ return buildSessionPath(folder, fileName, testName);
36
72
  }
37
73
  async function startRecording(testInfo) {
38
74
  const sessionId = generateSessionId(testInfo);