test-proxy-recorder 0.1.3 → 0.1.5

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/README.md CHANGED
@@ -191,7 +191,7 @@ interface ControlRequest {
191
191
  ### Playwright Integration API
192
192
 
193
193
  ```typescript
194
- import { playwrightProxy } from 'test-proxy-recorder';
194
+ import { playwrightProxy, setProxyMode } from 'test-proxy-recorder';
195
195
 
196
196
  // Main helper object for use with Playwright tests
197
197
  const playwrightProxy = {
@@ -203,14 +203,84 @@ const playwrightProxy = {
203
203
  };
204
204
  ```
205
205
 
206
- ## Recording Format
206
+ ### Global Teardown and Hooks Setup (Recommended)
207
+
208
+ For robust test setups, it's recommended to configure global teardown and afterEach hooks to ensure the proxy is properly reset even when tests fail. This prevents the proxy from staying in record/replay mode, which could affect subsequent test runs.
209
+
210
+ #### 1. Create Global Teardown File
211
+
212
+ Create `e2e/global-teardown.ts` to reset the proxy mode after all tests complete:
213
+
214
+ ```typescript
215
+ import { setProxyMode } from 'test-proxy-recorder';
216
+
217
+ async function globalTeardown() {
218
+ await setProxyMode('transparent');
219
+ }
220
+
221
+ export default globalTeardown;
222
+ ```
223
+
224
+ #### 2. Create Global Hooks File
225
+
226
+ Create `e2e/global-hooks.ts` to ensure proxy cleanup happens after each test, even on failure:
207
227
 
208
- Recordings are stored as JSON files in the recordings directory:
228
+ ```typescript
229
+ import { test } from '@playwright/test';
230
+ import { playwrightProxy } from 'test-proxy-recorder';
209
231
 
232
+ /**
233
+ * Global afterEach hook to ensure proxy cleanup happens even when tests fail.
234
+ * This will run after every test across all test files.
235
+ */
236
+ test.afterEach(async ({}, testInfo) => {
237
+ try {
238
+ await playwrightProxy.after(testInfo);
239
+ } catch (error) {
240
+ console.error('Error during proxy cleanup:', error);
241
+ // Don't throw - we want cleanup to continue even if this fails
242
+ }
243
+ });
210
244
  ```
245
+
246
+ #### 3. Configure Playwright
247
+
248
+ Update your `playwright.config.ts` to include the global teardown:
249
+
250
+ ```typescript
251
+ import { defineConfig } from '@playwright/test';
252
+
253
+ export default defineConfig({
254
+ testDir: './e2e',
255
+ globalTeardown: './e2e/global-teardown.ts',
256
+ // ... rest of your config
257
+ });
258
+ ```
259
+
260
+ #### 4. Import Global Hooks in Your Base Page or Test Setup
261
+
262
+ Import the global hooks file in your base test file or base page to register the afterEach hook:
263
+
264
+ ```typescript
265
+ // In your e2e/basePage.ts or similar base test file
266
+ import { test as base } from '@playwright/test';
267
+
268
+ // Import global hooks to register afterEach for proxy cleanup
269
+ import './global-hooks';
270
+
271
+ export const test = base.extend({
272
+ // your fixtures
273
+ });
274
+ ```
275
+
276
+ ## Recording Format
277
+
278
+ Recordings are stored as JSON files with `.mock.json` extension in the recordings directory:
279
+
280
+ ```text
211
281
  recordings/
212
- ├── test-session-1.json
213
- ├── test-session-2.json
282
+ ├── test-session-1.mock.json
283
+ ├── test-session-2.mock.json
214
284
  └── ...
215
285
  ```
216
286
 
@@ -228,11 +298,7 @@ Each recording contains:
228
298
  test-proxy-recorder http://localhost:8000 --port 8100
229
299
  ```
230
300
 
231
- 2. **Configure your app** to use the proxy:
232
-
233
- ```bash
234
- export EXTERNAL_API_URL=http://localhost:8100 yarn dev
235
- ```
301
+ 2. **Configure your app** to use the proxy (point your app to the proxy port, e.g., 8100)
236
302
 
237
303
  3. **Record responses** (first run):
238
304
 
@@ -28,6 +28,7 @@ interface Recording {
28
28
  response?: RecordedResponse;
29
29
  timestamp: string;
30
30
  key: string;
31
+ sequence: number;
31
32
  }
32
33
  interface WebSocketMessage {
33
34
  direction: 'client-to-server' | 'server-to-client';
@@ -46,16 +47,20 @@ interface RecordingSession {
46
47
  websocketRecordings: WebSocketRecording[];
47
48
  }
48
49
 
49
- type PlaywrightTestInfo = Pick<TestInfo, 'title'>;
50
+ type PlaywrightTestInfo = Pick<TestInfo, 'title' | 'titlePath'>;
50
51
  /**
51
52
  * Set the proxy mode for a given session
52
53
  * @param mode - The proxy mode to set (recording, replay, transparent)
53
54
  * @param sessionId - Unique identifier for the session
54
55
  * @param timeout - Optional timeout in milliseconds
55
56
  */
56
- declare function setProxyMode(mode: Mode, sessionId: string, timeout?: number): Promise<void>;
57
+ declare function setProxyMode(mode: Mode, sessionId?: string, timeout?: number): Promise<void>;
57
58
  /**
58
59
  * Generate a session ID from test info
60
+ * Uses titlePath to create folder structure with test file name
61
+ * Supports both .spec.ts and .test.ts extensions
62
+ * Example: ['jobs/Create.spec.ts', 'create a job'] becomes 'jobs/Create__create-a-job'
63
+ * Example: ['users/Auth.test.ts', 'login test'] becomes 'users/Auth__login-test'
59
64
  * @param testInfo - Playwright test info object
60
65
  */
61
66
  declare function generateSessionId(testInfo: PlaywrightTestInfo): string;
@@ -28,6 +28,7 @@ interface Recording {
28
28
  response?: RecordedResponse;
29
29
  timestamp: string;
30
30
  key: string;
31
+ sequence: number;
31
32
  }
32
33
  interface WebSocketMessage {
33
34
  direction: 'client-to-server' | 'server-to-client';
@@ -46,16 +47,20 @@ interface RecordingSession {
46
47
  websocketRecordings: WebSocketRecording[];
47
48
  }
48
49
 
49
- type PlaywrightTestInfo = Pick<TestInfo, 'title'>;
50
+ type PlaywrightTestInfo = Pick<TestInfo, 'title' | 'titlePath'>;
50
51
  /**
51
52
  * Set the proxy mode for a given session
52
53
  * @param mode - The proxy mode to set (recording, replay, transparent)
53
54
  * @param sessionId - Unique identifier for the session
54
55
  * @param timeout - Optional timeout in milliseconds
55
56
  */
56
- declare function setProxyMode(mode: Mode, sessionId: string, timeout?: number): Promise<void>;
57
+ declare function setProxyMode(mode: Mode, sessionId?: string, timeout?: number): Promise<void>;
57
58
  /**
58
59
  * Generate a session ID from test info
60
+ * Uses titlePath to create folder structure with test file name
61
+ * Supports both .spec.ts and .test.ts extensions
62
+ * Example: ['jobs/Create.spec.ts', 'create a job'] becomes 'jobs/Create__create-a-job'
63
+ * Example: ['users/Auth.test.ts', 'login test'] becomes 'users/Auth__login-test'
59
64
  * @param testInfo - Playwright test info object
60
65
  */
61
66
  declare function generateSessionId(testInfo: PlaywrightTestInfo): string;
package/dist/index.cjs CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  var fs = require('fs/promises');
4
4
  var http = require('http');
5
+ var https = require('https');
5
6
  var httpProxy = require('http-proxy');
6
7
  var ws = require('ws');
7
8
  var path = require('path');
@@ -11,6 +12,7 @@ function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
11
12
 
12
13
  var fs__default = /*#__PURE__*/_interopDefault(fs);
13
14
  var http__default = /*#__PURE__*/_interopDefault(http);
15
+ var https__default = /*#__PURE__*/_interopDefault(https);
14
16
  var httpProxy__default = /*#__PURE__*/_interopDefault(httpProxy);
15
17
  var path__default = /*#__PURE__*/_interopDefault(path);
16
18
  var filenamify__default = /*#__PURE__*/_interopDefault(filenamify);
@@ -33,7 +35,7 @@ var Modes = {
33
35
  };
34
36
  var JSON_INDENT_SPACES = 2;
35
37
  function getRecordingPath(recordingsDir, id) {
36
- return path__default.default.join(recordingsDir, `${id}.json`);
38
+ return path__default.default.join(recordingsDir, `${id}.mock.json`);
37
39
  }
38
40
  async function loadRecordingSession(filePath) {
39
41
  const fileContent = await fs__default.default.readFile(filePath, "utf8");
@@ -41,6 +43,8 @@ async function loadRecordingSession(filePath) {
41
43
  }
42
44
  async function saveRecordingSession(recordingsDir, session) {
43
45
  const filePath = getRecordingPath(recordingsDir, session.id);
46
+ const dirPath = path__default.default.dirname(filePath);
47
+ await fs__default.default.mkdir(dirPath, { recursive: true });
44
48
  await fs__default.default.writeFile(
45
49
  filePath,
46
50
  JSON.stringify(session, null, JSON_INDENT_SPACES)
@@ -93,6 +97,10 @@ var ProxyServer = class {
93
97
  proxy;
94
98
  currentSession;
95
99
  recordingsDir;
100
+ requestSequenceMap;
101
+ // Track sequence per request key
102
+ replaySequenceMap;
103
+ // Track replay position per request key
96
104
  constructor(targets, recordingsDir) {
97
105
  this.targets = targets;
98
106
  this.currentTargetIndex = 0;
@@ -102,6 +110,8 @@ var ProxyServer = class {
102
110
  this.modeTimeout = null;
103
111
  this.currentSession = null;
104
112
  this.recordingsDir = recordingsDir;
113
+ this.requestSequenceMap = /* @__PURE__ */ new Map();
114
+ this.replaySequenceMap = /* @__PURE__ */ new Map();
105
115
  this.proxy = httpProxy__default.default.createProxyServer({
106
116
  secure: false,
107
117
  changeOrigin: true
@@ -119,6 +129,7 @@ var ProxyServer = class {
119
129
  this.handleUpgrade(req, socket, head);
120
130
  });
121
131
  server.listen(port, () => {
132
+ process.env.TEST_PROXY_RECORDER_PORT = String(port);
122
133
  this.logServerStartup(port);
123
134
  });
124
135
  return server;
@@ -140,10 +151,19 @@ var ProxyServer = class {
140
151
  res.end(JSON.stringify({ error: "Proxy error", message: err.message }));
141
152
  }
142
153
  handleProxyResponse(proxyRes, req) {
154
+ this.addCorsHeaders(proxyRes, req);
143
155
  if (this.mode === Modes.record && this.recordingId) {
144
156
  this.recordResponse(req, proxyRes);
145
157
  }
146
158
  }
159
+ addCorsHeaders(proxyRes, req) {
160
+ const origin = req.headers.origin;
161
+ proxyRes.headers["access-control-allow-origin"] = origin || "*";
162
+ proxyRes.headers["access-control-allow-credentials"] = "true";
163
+ proxyRes.headers["access-control-allow-headers"] = req.headers["access-control-request-headers"] || "Origin, X-Requested-With, Content-Type, Accept, Authorization";
164
+ proxyRes.headers["access-control-allow-methods"] = "GET, POST, PUT, DELETE, PATCH, OPTIONS";
165
+ proxyRes.headers["access-control-expose-headers"] = "*";
166
+ }
147
167
  getTarget() {
148
168
  const target = this.targets[this.currentTargetIndex];
149
169
  this.currentTargetIndex = (this.currentTargetIndex + 1) % this.targets.length;
@@ -218,6 +238,7 @@ var ProxyServer = class {
218
238
  this.recordingId = id;
219
239
  this.replayId = null;
220
240
  this.currentSession = { id, recordings: [], websocketRecordings: [] };
241
+ this.requestSequenceMap.clear();
221
242
  console.log(`Switched to record mode with ID: ${id}`);
222
243
  }
223
244
  switchToReplayMode(id) {
@@ -228,6 +249,7 @@ var ProxyServer = class {
228
249
  this.replayId = id;
229
250
  this.recordingId = null;
230
251
  this.currentSession = null;
252
+ this.replaySequenceMap.clear();
231
253
  console.log(`Switched to replay mode with ID: ${id}`);
232
254
  }
233
255
  setupModeTimeout(timeout) {
@@ -259,6 +281,8 @@ var ProxyServer = class {
259
281
  return;
260
282
  }
261
283
  const key = getReqID(req);
284
+ const currentSequence = this.requestSequenceMap.get(key) || 0;
285
+ this.requestSequenceMap.set(key, currentSequence + 1);
262
286
  const record = {
263
287
  request: {
264
288
  method: req.method,
@@ -267,7 +291,8 @@ var ProxyServer = class {
267
291
  body: body || null
268
292
  },
269
293
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
270
- key
294
+ key,
295
+ sequence: currentSequence
271
296
  };
272
297
  this.currentSession.recordings.push(record);
273
298
  }
@@ -276,7 +301,9 @@ var ProxyServer = class {
276
301
  return;
277
302
  }
278
303
  const key = getReqID(req);
279
- const record = this.currentSession.recordings.find((r) => r.key === key);
304
+ const record = this.currentSession.recordings.findLast(
305
+ (r) => r.key === key && !r.response
306
+ );
280
307
  if (!record) {
281
308
  console.error("Request record not found for response:", key);
282
309
  return;
@@ -301,17 +328,31 @@ var ProxyServer = class {
301
328
  const filePath = getRecordingPath(this.recordingsDir, this.replayId);
302
329
  try {
303
330
  const session = await loadRecordingSession(filePath);
304
- const record = session.recordings.find((r) => r.key === key);
331
+ const currentSequence = this.replaySequenceMap.get(key) || 0;
332
+ const record = session.recordings.find(
333
+ (r) => r.key === key && r.sequence === currentSequence
334
+ );
305
335
  if (!record) {
306
- throw new Error(`No recording found for ${key}`);
336
+ throw new Error(
337
+ `No recording found for ${key} with sequence ${currentSequence}`
338
+ );
307
339
  }
308
340
  if (!record.response) {
309
341
  throw new Error("No response recorded for this request");
310
342
  }
343
+ this.replaySequenceMap.set(key, currentSequence + 1);
311
344
  const { statusCode, headers, body } = record.response;
312
- res.writeHead(statusCode, headers);
345
+ const origin = req.headers.origin;
346
+ const responseHeaders = {
347
+ ...headers,
348
+ "access-control-allow-origin": origin || "*",
349
+ "access-control-allow-credentials": "true"
350
+ };
351
+ res.writeHead(statusCode, responseHeaders);
313
352
  res.end(body);
314
- console.log(`Replayed: ${req.method} ${req.url}`);
353
+ console.log(
354
+ `Replayed: ${req.method} ${req.url} (sequence: ${currentSequence})`
355
+ );
315
356
  } catch (error) {
316
357
  this.handleReplayError(res, error, key, filePath);
317
358
  }
@@ -327,6 +368,9 @@ var ProxyServer = class {
327
368
  });
328
369
  }
329
370
  async handleRequest(req, res) {
371
+ if (req.method === "OPTIONS") {
372
+ return this.handleCorsPreflightRequest(req, res);
373
+ }
330
374
  if (req.url === CONTROL_ENDPOINT) {
331
375
  return this.handleControlRequest(req, res);
332
376
  }
@@ -335,23 +379,63 @@ var ProxyServer = class {
335
379
  }
336
380
  await this.handleProxyRequest(req, res);
337
381
  }
382
+ handleCorsPreflightRequest(req, res) {
383
+ const origin = req.headers.origin;
384
+ res.writeHead(HTTP_STATUS_OK, {
385
+ "Access-Control-Allow-Origin": origin || "*",
386
+ "Access-Control-Allow-Credentials": "true",
387
+ "Access-Control-Allow-Headers": req.headers["access-control-request-headers"] || "Origin, X-Requested-With, Content-Type, Accept, Authorization",
388
+ "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, PATCH, OPTIONS",
389
+ "Access-Control-Max-Age": "86400"
390
+ // 24 hours
391
+ });
392
+ res.end();
393
+ }
338
394
  async handleProxyRequest(req, res) {
339
395
  const target = this.getTarget();
340
396
  console.log(`[${this.mode}] ${req.method} ${req.url} -> ${target}`);
341
397
  if (this.mode === Modes.record) {
342
- await this.bufferRequestForRecord(req);
398
+ await this.bufferAndProxyRequest(req, res, target);
399
+ } else {
400
+ this.proxy.web(req, res, { target });
343
401
  }
344
- this.proxy.web(req, res, { target });
345
402
  }
346
- async bufferRequestForRecord(req) {
403
+ async bufferAndProxyRequest(req, res, target) {
347
404
  const chunks = [];
348
405
  req.on("data", (chunk) => {
349
406
  chunks.push(chunk);
350
407
  });
351
- req.on("end", async () => {
352
- const body = Buffer.concat(chunks).toString("utf8");
353
- await this.saveRequestRecord(req, body);
408
+ await new Promise((resolve) => {
409
+ req.on("end", () => resolve());
354
410
  });
411
+ const body = Buffer.concat(chunks).toString("utf8");
412
+ await this.saveRequestRecord(req, body);
413
+ const targetUrl = new URL(target);
414
+ const isHttps = targetUrl.protocol === "https:";
415
+ const requestModule = isHttps ? https__default.default : http__default.default;
416
+ const defaultPort = isHttps ? 443 : 80;
417
+ const proxyReq = requestModule.request(
418
+ {
419
+ hostname: targetUrl.hostname,
420
+ port: targetUrl.port || defaultPort,
421
+ path: req.url,
422
+ method: req.method,
423
+ headers: req.headers
424
+ },
425
+ (proxyRes) => {
426
+ this.addCorsHeaders(proxyRes, req);
427
+ this.recordResponse(req, proxyRes);
428
+ res.writeHead(proxyRes.statusCode || 200, proxyRes.headers);
429
+ proxyRes.pipe(res);
430
+ }
431
+ );
432
+ proxyReq.on("error", (err) => {
433
+ this.handleProxyError(err, req, res);
434
+ });
435
+ if (chunks.length > 0) {
436
+ proxyReq.write(Buffer.concat(chunks));
437
+ }
438
+ proxyReq.end();
355
439
  }
356
440
  handleUpgrade(req, socket, head) {
357
441
  if (this.mode === Modes.replay) {
@@ -524,15 +608,25 @@ var ProxyServer = class {
524
608
  };
525
609
 
526
610
  // src/playwright/index.ts
527
- var INTERNAL_API_URL = process.env.INTERNAL_API_URL || "http://localhost:8100";
611
+ function getProxyPort() {
612
+ const envPort = process.env.TEST_PROXY_RECORDER_PORT;
613
+ if (envPort) {
614
+ const parsed = Number.parseInt(envPort, 10);
615
+ if (!Number.isNaN(parsed)) {
616
+ return parsed;
617
+ }
618
+ }
619
+ return 8100;
620
+ }
528
621
  async function setProxyMode(mode, sessionId, timeout) {
622
+ const proxyPort = getProxyPort();
529
623
  try {
530
624
  const body = {
531
625
  mode,
532
626
  id: sessionId,
533
627
  ...timeout && { timeout }
534
628
  };
535
- const response = await fetch(`${INTERNAL_API_URL}/__control`, {
629
+ const response = await fetch(`http://127.0.0.1:${proxyPort}/__control`, {
536
630
  method: "POST",
537
631
  headers: { "Content-Type": "application/json" },
538
632
  body: JSON.stringify(body)
@@ -549,8 +643,34 @@ async function setProxyMode(mode, sessionId, timeout) {
549
643
  throw error;
550
644
  }
551
645
  }
646
+ function parseSpecFilePath(specPath) {
647
+ const folderMatch = specPath.match(/^(.+?)\/([^/]+)\.(spec|test)\.ts$/);
648
+ if (folderMatch) {
649
+ return { folder: folderMatch[1], fileName: folderMatch[2] };
650
+ }
651
+ const fileMatch = specPath.match(/^([^/]+)\.(spec|test)\.ts$/);
652
+ if (fileMatch) {
653
+ return { folder: null, fileName: fileMatch[1] };
654
+ }
655
+ return { folder: null, fileName: null };
656
+ }
657
+ function buildSessionPath(folder, fileName, testName) {
658
+ if (folder && fileName) {
659
+ return `${folder}/${fileName}__${testName}`;
660
+ }
661
+ if (fileName) {
662
+ return `${fileName}__${testName}`;
663
+ }
664
+ return testName;
665
+ }
552
666
  function generateSessionId(testInfo) {
553
- return testInfo.title.toLowerCase().replaceAll(/\s+/g, "-");
667
+ const { titlePath } = testInfo;
668
+ if (!titlePath || titlePath.length === 0) {
669
+ return testInfo.title.toLowerCase().replaceAll(/\s+/g, "-");
670
+ }
671
+ const { folder, fileName } = parseSpecFilePath(titlePath[0]);
672
+ const testName = titlePath.at(-1).toLowerCase().replaceAll(/\s+/g, "-");
673
+ return buildSessionPath(folder, fileName, testName);
554
674
  }
555
675
  async function startRecording(testInfo) {
556
676
  const sessionId = generateSessionId(testInfo);
package/dist/index.d.cts CHANGED
@@ -1,5 +1,5 @@
1
1
  import http from 'node:http';
2
- export { C as ControlRequest, M as Mode, P as PlaywrightTestInfo, R as Recording, a as RecordingSession, W as WebSocketRecording, g as generateSessionId, p as playwrightProxy, s as setProxyMode, b as startRecording, c as startReplay, d as stopProxy } from './index-DfFpm8mB.cjs';
2
+ export { C as ControlRequest, M as Mode, P as PlaywrightTestInfo, R as Recording, a as RecordingSession, W as WebSocketRecording, 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 '@playwright/test';
4
4
 
5
5
  declare class ProxyServer {
@@ -12,12 +12,15 @@ declare class ProxyServer {
12
12
  private proxy;
13
13
  private currentSession;
14
14
  private recordingsDir;
15
+ private requestSequenceMap;
16
+ private replaySequenceMap;
15
17
  constructor(targets: string[], recordingsDir: string);
16
18
  init(): Promise<void>;
17
19
  listen(port: number): http.Server;
18
20
  private setupProxyEventHandlers;
19
21
  private handleProxyError;
20
22
  private handleProxyResponse;
23
+ private addCorsHeaders;
21
24
  private getTarget;
22
25
  private handleControlRequest;
23
26
  private clearModeTimeout;
@@ -32,8 +35,9 @@ declare class ProxyServer {
32
35
  private handleReplayRequest;
33
36
  private handleReplayError;
34
37
  private handleRequest;
38
+ private handleCorsPreflightRequest;
35
39
  private handleProxyRequest;
36
- private bufferRequestForRecord;
40
+ private bufferAndProxyRequest;
37
41
  private handleUpgrade;
38
42
  private handleRecordWebSocket;
39
43
  private handleReplayWebSocket;
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import http from 'node:http';
2
- export { C as ControlRequest, M as Mode, P as PlaywrightTestInfo, R as Recording, a as RecordingSession, W as WebSocketRecording, g as generateSessionId, p as playwrightProxy, s as setProxyMode, b as startRecording, c as startReplay, d as stopProxy } from './index-DfFpm8mB.js';
2
+ export { C as ControlRequest, M as Mode, P as PlaywrightTestInfo, R as Recording, a as RecordingSession, W as WebSocketRecording, 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 '@playwright/test';
4
4
 
5
5
  declare class ProxyServer {
@@ -12,12 +12,15 @@ declare class ProxyServer {
12
12
  private proxy;
13
13
  private currentSession;
14
14
  private recordingsDir;
15
+ private requestSequenceMap;
16
+ private replaySequenceMap;
15
17
  constructor(targets: string[], recordingsDir: string);
16
18
  init(): Promise<void>;
17
19
  listen(port: number): http.Server;
18
20
  private setupProxyEventHandlers;
19
21
  private handleProxyError;
20
22
  private handleProxyResponse;
23
+ private addCorsHeaders;
21
24
  private getTarget;
22
25
  private handleControlRequest;
23
26
  private clearModeTimeout;
@@ -32,8 +35,9 @@ declare class ProxyServer {
32
35
  private handleReplayRequest;
33
36
  private handleReplayError;
34
37
  private handleRequest;
38
+ private handleCorsPreflightRequest;
35
39
  private handleProxyRequest;
36
- private bufferRequestForRecord;
40
+ private bufferAndProxyRequest;
37
41
  private handleUpgrade;
38
42
  private handleRecordWebSocket;
39
43
  private handleReplayWebSocket;
package/dist/index.mjs CHANGED
@@ -1,5 +1,6 @@
1
1
  import fs from 'fs/promises';
2
2
  import http from 'http';
3
+ import https from 'https';
3
4
  import httpProxy from 'http-proxy';
4
5
  import { WebSocket, WebSocketServer } from 'ws';
5
6
  import path from 'path';
@@ -23,7 +24,7 @@ var Modes = {
23
24
  };
24
25
  var JSON_INDENT_SPACES = 2;
25
26
  function getRecordingPath(recordingsDir, id) {
26
- return path.join(recordingsDir, `${id}.json`);
27
+ return path.join(recordingsDir, `${id}.mock.json`);
27
28
  }
28
29
  async function loadRecordingSession(filePath) {
29
30
  const fileContent = await fs.readFile(filePath, "utf8");
@@ -31,6 +32,8 @@ async function loadRecordingSession(filePath) {
31
32
  }
32
33
  async function saveRecordingSession(recordingsDir, session) {
33
34
  const filePath = getRecordingPath(recordingsDir, session.id);
35
+ const dirPath = path.dirname(filePath);
36
+ await fs.mkdir(dirPath, { recursive: true });
34
37
  await fs.writeFile(
35
38
  filePath,
36
39
  JSON.stringify(session, null, JSON_INDENT_SPACES)
@@ -83,6 +86,10 @@ var ProxyServer = class {
83
86
  proxy;
84
87
  currentSession;
85
88
  recordingsDir;
89
+ requestSequenceMap;
90
+ // Track sequence per request key
91
+ replaySequenceMap;
92
+ // Track replay position per request key
86
93
  constructor(targets, recordingsDir) {
87
94
  this.targets = targets;
88
95
  this.currentTargetIndex = 0;
@@ -92,6 +99,8 @@ var ProxyServer = class {
92
99
  this.modeTimeout = null;
93
100
  this.currentSession = null;
94
101
  this.recordingsDir = recordingsDir;
102
+ this.requestSequenceMap = /* @__PURE__ */ new Map();
103
+ this.replaySequenceMap = /* @__PURE__ */ new Map();
95
104
  this.proxy = httpProxy.createProxyServer({
96
105
  secure: false,
97
106
  changeOrigin: true
@@ -109,6 +118,7 @@ var ProxyServer = class {
109
118
  this.handleUpgrade(req, socket, head);
110
119
  });
111
120
  server.listen(port, () => {
121
+ process.env.TEST_PROXY_RECORDER_PORT = String(port);
112
122
  this.logServerStartup(port);
113
123
  });
114
124
  return server;
@@ -130,10 +140,19 @@ var ProxyServer = class {
130
140
  res.end(JSON.stringify({ error: "Proxy error", message: err.message }));
131
141
  }
132
142
  handleProxyResponse(proxyRes, req) {
143
+ this.addCorsHeaders(proxyRes, req);
133
144
  if (this.mode === Modes.record && this.recordingId) {
134
145
  this.recordResponse(req, proxyRes);
135
146
  }
136
147
  }
148
+ addCorsHeaders(proxyRes, req) {
149
+ const origin = req.headers.origin;
150
+ proxyRes.headers["access-control-allow-origin"] = origin || "*";
151
+ proxyRes.headers["access-control-allow-credentials"] = "true";
152
+ proxyRes.headers["access-control-allow-headers"] = req.headers["access-control-request-headers"] || "Origin, X-Requested-With, Content-Type, Accept, Authorization";
153
+ proxyRes.headers["access-control-allow-methods"] = "GET, POST, PUT, DELETE, PATCH, OPTIONS";
154
+ proxyRes.headers["access-control-expose-headers"] = "*";
155
+ }
137
156
  getTarget() {
138
157
  const target = this.targets[this.currentTargetIndex];
139
158
  this.currentTargetIndex = (this.currentTargetIndex + 1) % this.targets.length;
@@ -208,6 +227,7 @@ var ProxyServer = class {
208
227
  this.recordingId = id;
209
228
  this.replayId = null;
210
229
  this.currentSession = { id, recordings: [], websocketRecordings: [] };
230
+ this.requestSequenceMap.clear();
211
231
  console.log(`Switched to record mode with ID: ${id}`);
212
232
  }
213
233
  switchToReplayMode(id) {
@@ -218,6 +238,7 @@ var ProxyServer = class {
218
238
  this.replayId = id;
219
239
  this.recordingId = null;
220
240
  this.currentSession = null;
241
+ this.replaySequenceMap.clear();
221
242
  console.log(`Switched to replay mode with ID: ${id}`);
222
243
  }
223
244
  setupModeTimeout(timeout) {
@@ -249,6 +270,8 @@ var ProxyServer = class {
249
270
  return;
250
271
  }
251
272
  const key = getReqID(req);
273
+ const currentSequence = this.requestSequenceMap.get(key) || 0;
274
+ this.requestSequenceMap.set(key, currentSequence + 1);
252
275
  const record = {
253
276
  request: {
254
277
  method: req.method,
@@ -257,7 +280,8 @@ var ProxyServer = class {
257
280
  body: body || null
258
281
  },
259
282
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
260
- key
283
+ key,
284
+ sequence: currentSequence
261
285
  };
262
286
  this.currentSession.recordings.push(record);
263
287
  }
@@ -266,7 +290,9 @@ var ProxyServer = class {
266
290
  return;
267
291
  }
268
292
  const key = getReqID(req);
269
- const record = this.currentSession.recordings.find((r) => r.key === key);
293
+ const record = this.currentSession.recordings.findLast(
294
+ (r) => r.key === key && !r.response
295
+ );
270
296
  if (!record) {
271
297
  console.error("Request record not found for response:", key);
272
298
  return;
@@ -291,17 +317,31 @@ var ProxyServer = class {
291
317
  const filePath = getRecordingPath(this.recordingsDir, this.replayId);
292
318
  try {
293
319
  const session = await loadRecordingSession(filePath);
294
- const record = session.recordings.find((r) => r.key === key);
320
+ const currentSequence = this.replaySequenceMap.get(key) || 0;
321
+ const record = session.recordings.find(
322
+ (r) => r.key === key && r.sequence === currentSequence
323
+ );
295
324
  if (!record) {
296
- throw new Error(`No recording found for ${key}`);
325
+ throw new Error(
326
+ `No recording found for ${key} with sequence ${currentSequence}`
327
+ );
297
328
  }
298
329
  if (!record.response) {
299
330
  throw new Error("No response recorded for this request");
300
331
  }
332
+ this.replaySequenceMap.set(key, currentSequence + 1);
301
333
  const { statusCode, headers, body } = record.response;
302
- res.writeHead(statusCode, headers);
334
+ const origin = req.headers.origin;
335
+ const responseHeaders = {
336
+ ...headers,
337
+ "access-control-allow-origin": origin || "*",
338
+ "access-control-allow-credentials": "true"
339
+ };
340
+ res.writeHead(statusCode, responseHeaders);
303
341
  res.end(body);
304
- console.log(`Replayed: ${req.method} ${req.url}`);
342
+ console.log(
343
+ `Replayed: ${req.method} ${req.url} (sequence: ${currentSequence})`
344
+ );
305
345
  } catch (error) {
306
346
  this.handleReplayError(res, error, key, filePath);
307
347
  }
@@ -317,6 +357,9 @@ var ProxyServer = class {
317
357
  });
318
358
  }
319
359
  async handleRequest(req, res) {
360
+ if (req.method === "OPTIONS") {
361
+ return this.handleCorsPreflightRequest(req, res);
362
+ }
320
363
  if (req.url === CONTROL_ENDPOINT) {
321
364
  return this.handleControlRequest(req, res);
322
365
  }
@@ -325,23 +368,63 @@ var ProxyServer = class {
325
368
  }
326
369
  await this.handleProxyRequest(req, res);
327
370
  }
371
+ handleCorsPreflightRequest(req, res) {
372
+ const origin = req.headers.origin;
373
+ res.writeHead(HTTP_STATUS_OK, {
374
+ "Access-Control-Allow-Origin": origin || "*",
375
+ "Access-Control-Allow-Credentials": "true",
376
+ "Access-Control-Allow-Headers": req.headers["access-control-request-headers"] || "Origin, X-Requested-With, Content-Type, Accept, Authorization",
377
+ "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, PATCH, OPTIONS",
378
+ "Access-Control-Max-Age": "86400"
379
+ // 24 hours
380
+ });
381
+ res.end();
382
+ }
328
383
  async handleProxyRequest(req, res) {
329
384
  const target = this.getTarget();
330
385
  console.log(`[${this.mode}] ${req.method} ${req.url} -> ${target}`);
331
386
  if (this.mode === Modes.record) {
332
- await this.bufferRequestForRecord(req);
387
+ await this.bufferAndProxyRequest(req, res, target);
388
+ } else {
389
+ this.proxy.web(req, res, { target });
333
390
  }
334
- this.proxy.web(req, res, { target });
335
391
  }
336
- async bufferRequestForRecord(req) {
392
+ async bufferAndProxyRequest(req, res, target) {
337
393
  const chunks = [];
338
394
  req.on("data", (chunk) => {
339
395
  chunks.push(chunk);
340
396
  });
341
- req.on("end", async () => {
342
- const body = Buffer.concat(chunks).toString("utf8");
343
- await this.saveRequestRecord(req, body);
397
+ await new Promise((resolve) => {
398
+ req.on("end", () => resolve());
344
399
  });
400
+ const body = Buffer.concat(chunks).toString("utf8");
401
+ await this.saveRequestRecord(req, body);
402
+ const targetUrl = new URL(target);
403
+ const isHttps = targetUrl.protocol === "https:";
404
+ const requestModule = isHttps ? https : http;
405
+ const defaultPort = isHttps ? 443 : 80;
406
+ const proxyReq = requestModule.request(
407
+ {
408
+ hostname: targetUrl.hostname,
409
+ port: targetUrl.port || defaultPort,
410
+ path: req.url,
411
+ method: req.method,
412
+ headers: req.headers
413
+ },
414
+ (proxyRes) => {
415
+ this.addCorsHeaders(proxyRes, req);
416
+ this.recordResponse(req, proxyRes);
417
+ res.writeHead(proxyRes.statusCode || 200, proxyRes.headers);
418
+ proxyRes.pipe(res);
419
+ }
420
+ );
421
+ proxyReq.on("error", (err) => {
422
+ this.handleProxyError(err, req, res);
423
+ });
424
+ if (chunks.length > 0) {
425
+ proxyReq.write(Buffer.concat(chunks));
426
+ }
427
+ proxyReq.end();
345
428
  }
346
429
  handleUpgrade(req, socket, head) {
347
430
  if (this.mode === Modes.replay) {
@@ -514,15 +597,25 @@ var ProxyServer = class {
514
597
  };
515
598
 
516
599
  // src/playwright/index.ts
517
- var INTERNAL_API_URL = process.env.INTERNAL_API_URL || "http://localhost:8100";
600
+ function getProxyPort() {
601
+ const envPort = process.env.TEST_PROXY_RECORDER_PORT;
602
+ if (envPort) {
603
+ const parsed = Number.parseInt(envPort, 10);
604
+ if (!Number.isNaN(parsed)) {
605
+ return parsed;
606
+ }
607
+ }
608
+ return 8100;
609
+ }
518
610
  async function setProxyMode(mode, sessionId, timeout) {
611
+ const proxyPort = getProxyPort();
519
612
  try {
520
613
  const body = {
521
614
  mode,
522
615
  id: sessionId,
523
616
  ...timeout && { timeout }
524
617
  };
525
- const response = await fetch(`${INTERNAL_API_URL}/__control`, {
618
+ const response = await fetch(`http://127.0.0.1:${proxyPort}/__control`, {
526
619
  method: "POST",
527
620
  headers: { "Content-Type": "application/json" },
528
621
  body: JSON.stringify(body)
@@ -539,8 +632,34 @@ async function setProxyMode(mode, sessionId, timeout) {
539
632
  throw error;
540
633
  }
541
634
  }
635
+ function parseSpecFilePath(specPath) {
636
+ const folderMatch = specPath.match(/^(.+?)\/([^/]+)\.(spec|test)\.ts$/);
637
+ if (folderMatch) {
638
+ return { folder: folderMatch[1], fileName: folderMatch[2] };
639
+ }
640
+ const fileMatch = specPath.match(/^([^/]+)\.(spec|test)\.ts$/);
641
+ if (fileMatch) {
642
+ return { folder: null, fileName: fileMatch[1] };
643
+ }
644
+ return { folder: null, fileName: null };
645
+ }
646
+ function buildSessionPath(folder, fileName, testName) {
647
+ if (folder && fileName) {
648
+ return `${folder}/${fileName}__${testName}`;
649
+ }
650
+ if (fileName) {
651
+ return `${fileName}__${testName}`;
652
+ }
653
+ return testName;
654
+ }
542
655
  function generateSessionId(testInfo) {
543
- return testInfo.title.toLowerCase().replaceAll(/\s+/g, "-");
656
+ const { titlePath } = testInfo;
657
+ if (!titlePath || titlePath.length === 0) {
658
+ return testInfo.title.toLowerCase().replaceAll(/\s+/g, "-");
659
+ }
660
+ const { folder, fileName } = parseSpecFilePath(titlePath[0]);
661
+ const testName = titlePath.at(-1).toLowerCase().replaceAll(/\s+/g, "-");
662
+ return buildSessionPath(folder, fileName, testName);
544
663
  }
545
664
  async function startRecording(testInfo) {
546
665
  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-DfFpm8mB.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-DfFpm8mB.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);
package/dist/proxy.js CHANGED
@@ -1,7 +1,8 @@
1
- import path from 'path';
1
+ import path2 from 'path';
2
2
  import { Command } from 'commander';
3
3
  import fs from 'fs/promises';
4
4
  import http from 'http';
5
+ import https from 'https';
5
6
  import httpProxy from 'http-proxy';
6
7
  import { WebSocket, WebSocketServer } from 'ws';
7
8
  import filenamify from 'filenamify';
@@ -37,7 +38,7 @@ function parseCliArgs() {
37
38
  if (targets2.length === 0) {
38
39
  program.help();
39
40
  }
40
- const recordingsDir2 = path.resolve(process.cwd(), options.recordingsDir);
41
+ const recordingsDir2 = path2.resolve(process.cwd(), options.recordingsDir);
41
42
  return { targets: targets2, port: port2, recordingsDir: recordingsDir2 };
42
43
  }
43
44
 
@@ -57,7 +58,7 @@ var Modes = {
57
58
  };
58
59
  var JSON_INDENT_SPACES = 2;
59
60
  function getRecordingPath(recordingsDir2, id) {
60
- return path.join(recordingsDir2, `${id}.json`);
61
+ return path2.join(recordingsDir2, `${id}.mock.json`);
61
62
  }
62
63
  async function loadRecordingSession(filePath) {
63
64
  const fileContent = await fs.readFile(filePath, "utf8");
@@ -65,6 +66,8 @@ async function loadRecordingSession(filePath) {
65
66
  }
66
67
  async function saveRecordingSession(recordingsDir2, session) {
67
68
  const filePath = getRecordingPath(recordingsDir2, session.id);
69
+ const dirPath = path2.dirname(filePath);
70
+ await fs.mkdir(dirPath, { recursive: true });
68
71
  await fs.writeFile(
69
72
  filePath,
70
73
  JSON.stringify(session, null, JSON_INDENT_SPACES)
@@ -117,6 +120,10 @@ var ProxyServer = class {
117
120
  proxy;
118
121
  currentSession;
119
122
  recordingsDir;
123
+ requestSequenceMap;
124
+ // Track sequence per request key
125
+ replaySequenceMap;
126
+ // Track replay position per request key
120
127
  constructor(targets2, recordingsDir2) {
121
128
  this.targets = targets2;
122
129
  this.currentTargetIndex = 0;
@@ -126,6 +133,8 @@ var ProxyServer = class {
126
133
  this.modeTimeout = null;
127
134
  this.currentSession = null;
128
135
  this.recordingsDir = recordingsDir2;
136
+ this.requestSequenceMap = /* @__PURE__ */ new Map();
137
+ this.replaySequenceMap = /* @__PURE__ */ new Map();
129
138
  this.proxy = httpProxy.createProxyServer({
130
139
  secure: false,
131
140
  changeOrigin: true
@@ -143,6 +152,7 @@ var ProxyServer = class {
143
152
  this.handleUpgrade(req, socket, head);
144
153
  });
145
154
  server.listen(port2, () => {
155
+ process.env.TEST_PROXY_RECORDER_PORT = String(port2);
146
156
  this.logServerStartup(port2);
147
157
  });
148
158
  return server;
@@ -164,10 +174,19 @@ var ProxyServer = class {
164
174
  res.end(JSON.stringify({ error: "Proxy error", message: err.message }));
165
175
  }
166
176
  handleProxyResponse(proxyRes, req) {
177
+ this.addCorsHeaders(proxyRes, req);
167
178
  if (this.mode === Modes.record && this.recordingId) {
168
179
  this.recordResponse(req, proxyRes);
169
180
  }
170
181
  }
182
+ addCorsHeaders(proxyRes, req) {
183
+ const origin = req.headers.origin;
184
+ proxyRes.headers["access-control-allow-origin"] = origin || "*";
185
+ proxyRes.headers["access-control-allow-credentials"] = "true";
186
+ proxyRes.headers["access-control-allow-headers"] = req.headers["access-control-request-headers"] || "Origin, X-Requested-With, Content-Type, Accept, Authorization";
187
+ proxyRes.headers["access-control-allow-methods"] = "GET, POST, PUT, DELETE, PATCH, OPTIONS";
188
+ proxyRes.headers["access-control-expose-headers"] = "*";
189
+ }
171
190
  getTarget() {
172
191
  const target = this.targets[this.currentTargetIndex];
173
192
  this.currentTargetIndex = (this.currentTargetIndex + 1) % this.targets.length;
@@ -242,6 +261,7 @@ var ProxyServer = class {
242
261
  this.recordingId = id;
243
262
  this.replayId = null;
244
263
  this.currentSession = { id, recordings: [], websocketRecordings: [] };
264
+ this.requestSequenceMap.clear();
245
265
  console.log(`Switched to record mode with ID: ${id}`);
246
266
  }
247
267
  switchToReplayMode(id) {
@@ -252,6 +272,7 @@ var ProxyServer = class {
252
272
  this.replayId = id;
253
273
  this.recordingId = null;
254
274
  this.currentSession = null;
275
+ this.replaySequenceMap.clear();
255
276
  console.log(`Switched to replay mode with ID: ${id}`);
256
277
  }
257
278
  setupModeTimeout(timeout) {
@@ -283,6 +304,8 @@ var ProxyServer = class {
283
304
  return;
284
305
  }
285
306
  const key = getReqID(req);
307
+ const currentSequence = this.requestSequenceMap.get(key) || 0;
308
+ this.requestSequenceMap.set(key, currentSequence + 1);
286
309
  const record = {
287
310
  request: {
288
311
  method: req.method,
@@ -291,7 +314,8 @@ var ProxyServer = class {
291
314
  body: body || null
292
315
  },
293
316
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
294
- key
317
+ key,
318
+ sequence: currentSequence
295
319
  };
296
320
  this.currentSession.recordings.push(record);
297
321
  }
@@ -300,7 +324,9 @@ var ProxyServer = class {
300
324
  return;
301
325
  }
302
326
  const key = getReqID(req);
303
- const record = this.currentSession.recordings.find((r) => r.key === key);
327
+ const record = this.currentSession.recordings.findLast(
328
+ (r) => r.key === key && !r.response
329
+ );
304
330
  if (!record) {
305
331
  console.error("Request record not found for response:", key);
306
332
  return;
@@ -325,17 +351,31 @@ var ProxyServer = class {
325
351
  const filePath = getRecordingPath(this.recordingsDir, this.replayId);
326
352
  try {
327
353
  const session = await loadRecordingSession(filePath);
328
- const record = session.recordings.find((r) => r.key === key);
354
+ const currentSequence = this.replaySequenceMap.get(key) || 0;
355
+ const record = session.recordings.find(
356
+ (r) => r.key === key && r.sequence === currentSequence
357
+ );
329
358
  if (!record) {
330
- throw new Error(`No recording found for ${key}`);
359
+ throw new Error(
360
+ `No recording found for ${key} with sequence ${currentSequence}`
361
+ );
331
362
  }
332
363
  if (!record.response) {
333
364
  throw new Error("No response recorded for this request");
334
365
  }
366
+ this.replaySequenceMap.set(key, currentSequence + 1);
335
367
  const { statusCode, headers, body } = record.response;
336
- res.writeHead(statusCode, headers);
368
+ const origin = req.headers.origin;
369
+ const responseHeaders = {
370
+ ...headers,
371
+ "access-control-allow-origin": origin || "*",
372
+ "access-control-allow-credentials": "true"
373
+ };
374
+ res.writeHead(statusCode, responseHeaders);
337
375
  res.end(body);
338
- console.log(`Replayed: ${req.method} ${req.url}`);
376
+ console.log(
377
+ `Replayed: ${req.method} ${req.url} (sequence: ${currentSequence})`
378
+ );
339
379
  } catch (error) {
340
380
  this.handleReplayError(res, error, key, filePath);
341
381
  }
@@ -351,6 +391,9 @@ var ProxyServer = class {
351
391
  });
352
392
  }
353
393
  async handleRequest(req, res) {
394
+ if (req.method === "OPTIONS") {
395
+ return this.handleCorsPreflightRequest(req, res);
396
+ }
354
397
  if (req.url === CONTROL_ENDPOINT) {
355
398
  return this.handleControlRequest(req, res);
356
399
  }
@@ -359,23 +402,63 @@ var ProxyServer = class {
359
402
  }
360
403
  await this.handleProxyRequest(req, res);
361
404
  }
405
+ handleCorsPreflightRequest(req, res) {
406
+ const origin = req.headers.origin;
407
+ res.writeHead(HTTP_STATUS_OK, {
408
+ "Access-Control-Allow-Origin": origin || "*",
409
+ "Access-Control-Allow-Credentials": "true",
410
+ "Access-Control-Allow-Headers": req.headers["access-control-request-headers"] || "Origin, X-Requested-With, Content-Type, Accept, Authorization",
411
+ "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, PATCH, OPTIONS",
412
+ "Access-Control-Max-Age": "86400"
413
+ // 24 hours
414
+ });
415
+ res.end();
416
+ }
362
417
  async handleProxyRequest(req, res) {
363
418
  const target = this.getTarget();
364
419
  console.log(`[${this.mode}] ${req.method} ${req.url} -> ${target}`);
365
420
  if (this.mode === Modes.record) {
366
- await this.bufferRequestForRecord(req);
421
+ await this.bufferAndProxyRequest(req, res, target);
422
+ } else {
423
+ this.proxy.web(req, res, { target });
367
424
  }
368
- this.proxy.web(req, res, { target });
369
425
  }
370
- async bufferRequestForRecord(req) {
426
+ async bufferAndProxyRequest(req, res, target) {
371
427
  const chunks = [];
372
428
  req.on("data", (chunk) => {
373
429
  chunks.push(chunk);
374
430
  });
375
- req.on("end", async () => {
376
- const body = Buffer.concat(chunks).toString("utf8");
377
- await this.saveRequestRecord(req, body);
431
+ await new Promise((resolve) => {
432
+ req.on("end", () => resolve());
378
433
  });
434
+ const body = Buffer.concat(chunks).toString("utf8");
435
+ await this.saveRequestRecord(req, body);
436
+ const targetUrl = new URL(target);
437
+ const isHttps = targetUrl.protocol === "https:";
438
+ const requestModule = isHttps ? https : http;
439
+ const defaultPort = isHttps ? 443 : 80;
440
+ const proxyReq = requestModule.request(
441
+ {
442
+ hostname: targetUrl.hostname,
443
+ port: targetUrl.port || defaultPort,
444
+ path: req.url,
445
+ method: req.method,
446
+ headers: req.headers
447
+ },
448
+ (proxyRes) => {
449
+ this.addCorsHeaders(proxyRes, req);
450
+ this.recordResponse(req, proxyRes);
451
+ res.writeHead(proxyRes.statusCode || 200, proxyRes.headers);
452
+ proxyRes.pipe(res);
453
+ }
454
+ );
455
+ proxyReq.on("error", (err) => {
456
+ this.handleProxyError(err, req, res);
457
+ });
458
+ if (chunks.length > 0) {
459
+ proxyReq.write(Buffer.concat(chunks));
460
+ }
461
+ proxyReq.end();
379
462
  }
380
463
  handleUpgrade(req, socket, head) {
381
464
  if (this.mode === Modes.replay) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "test-proxy-recorder",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
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",