test-proxy-recorder 0.1.9 → 0.1.10
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 +2 -2
- package/dist/{index-CG-XcFDa.d.cts → index-CjM3evKb.d.cts} +0 -1
- package/dist/{index-CG-XcFDa.d.ts → index-CjM3evKb.d.ts} +0 -1
- package/dist/index.cjs +132 -59
- package/dist/index.d.cts +15 -3
- package/dist/index.d.ts +15 -3
- package/dist/index.mjs +132 -59
- package/dist/playwright/index.d.cts +1 -1
- package/dist/playwright/index.d.ts +1 -1
- package/dist/proxy.js +132 -59
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -102,7 +102,7 @@ Create `e2e/global-teardown.ts`:
|
|
|
102
102
|
import { setProxyMode } from 'test-proxy-recorder';
|
|
103
103
|
|
|
104
104
|
async function globalTeardown() {
|
|
105
|
-
await setProxyMode('transparent');
|
|
105
|
+
await setProxyMode('transparent').catch(err => { console.error(err) });
|
|
106
106
|
}
|
|
107
107
|
|
|
108
108
|
export default globalTeardown;
|
|
@@ -247,7 +247,7 @@ Create `e2e/global-teardown.ts`:
|
|
|
247
247
|
import { setProxyMode } from 'test-proxy-recorder';
|
|
248
248
|
|
|
249
249
|
async function globalTeardown() {
|
|
250
|
-
await setProxyMode('transparent');
|
|
250
|
+
await setProxyMode('transparent').catch(err => { console.error(err) });
|
|
251
251
|
}
|
|
252
252
|
|
|
253
253
|
export default globalTeardown;
|
package/dist/index.cjs
CHANGED
|
@@ -98,12 +98,10 @@ var ProxyServer = class {
|
|
|
98
98
|
proxy;
|
|
99
99
|
currentSession;
|
|
100
100
|
recordingsDir;
|
|
101
|
-
requestSequenceMap;
|
|
102
|
-
// Track sequence per request key
|
|
103
|
-
replaySequenceMap;
|
|
104
|
-
// Track replay position per request key
|
|
105
101
|
recordingIdCounter;
|
|
106
102
|
// Unique ID for each recording entry
|
|
103
|
+
replaySessions;
|
|
104
|
+
// Track multiple concurrent replay sessions by recording ID
|
|
107
105
|
constructor(targets, recordingsDir) {
|
|
108
106
|
this.targets = targets;
|
|
109
107
|
this.currentTargetIndex = 0;
|
|
@@ -114,11 +112,11 @@ var ProxyServer = class {
|
|
|
114
112
|
this.modeTimeout = null;
|
|
115
113
|
this.currentSession = null;
|
|
116
114
|
this.recordingsDir = recordingsDir;
|
|
117
|
-
this.
|
|
118
|
-
this.replaySequenceMap = /* @__PURE__ */ new Map();
|
|
115
|
+
this.replaySessions = /* @__PURE__ */ new Map();
|
|
119
116
|
this.proxy = httpProxy__default.default.createProxyServer({
|
|
120
117
|
secure: false,
|
|
121
|
-
changeOrigin: true
|
|
118
|
+
changeOrigin: true,
|
|
119
|
+
ws: true
|
|
122
120
|
});
|
|
123
121
|
this.setupProxyEventHandlers();
|
|
124
122
|
}
|
|
@@ -186,6 +184,43 @@ var ProxyServer = class {
|
|
|
186
184
|
this.currentTargetIndex = (this.currentTargetIndex + 1) % this.targets.length;
|
|
187
185
|
return target;
|
|
188
186
|
}
|
|
187
|
+
/**
|
|
188
|
+
* Extract recording ID from request cookie
|
|
189
|
+
* Used for concurrent replay session routing
|
|
190
|
+
* @param req The incoming HTTP request
|
|
191
|
+
* @returns The recording ID from cookie, or null if not found
|
|
192
|
+
*/
|
|
193
|
+
getRecordingIdFromCookie(req) {
|
|
194
|
+
const cookies = req.headers.cookie;
|
|
195
|
+
if (!cookies) {
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
const match = cookies.match(/proxy-recording-id=([^;]+)/);
|
|
199
|
+
return match ? decodeURIComponent(match[1]) : null;
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Get or create a replay session state for a given recording ID
|
|
203
|
+
* @param recordingId The recording ID to get/create session for
|
|
204
|
+
* @returns The replay session state
|
|
205
|
+
*/
|
|
206
|
+
getOrCreateReplaySession(recordingId) {
|
|
207
|
+
let session = this.replaySessions.get(recordingId);
|
|
208
|
+
if (session) {
|
|
209
|
+
session.lastAccessTime = Date.now();
|
|
210
|
+
} else {
|
|
211
|
+
session = {
|
|
212
|
+
recordingId,
|
|
213
|
+
servedRecordingIdsByKey: /* @__PURE__ */ new Map(),
|
|
214
|
+
loadedSession: null,
|
|
215
|
+
lastAccessTime: Date.now()
|
|
216
|
+
};
|
|
217
|
+
this.replaySessions.set(recordingId, session);
|
|
218
|
+
console.log(
|
|
219
|
+
`[CONCURRENT REPLAY] Created new session for recording: ${recordingId}`
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
return session;
|
|
223
|
+
}
|
|
189
224
|
parseGetParams(req) {
|
|
190
225
|
const url = new URL(req.url || "", `http://${req.headers.host}`);
|
|
191
226
|
const mode = url.searchParams.get("mode");
|
|
@@ -202,16 +237,25 @@ var ProxyServer = class {
|
|
|
202
237
|
let data;
|
|
203
238
|
if (req.method === "GET") {
|
|
204
239
|
data = this.parseGetParams(req);
|
|
205
|
-
} else {
|
|
240
|
+
} else if (req.method === "POST") {
|
|
206
241
|
const body = await readRequestBody(req);
|
|
207
|
-
console.log(
|
|
242
|
+
console.log(`MODE CHANGE (${req.method})`, body);
|
|
208
243
|
data = JSON.parse(body);
|
|
244
|
+
} else {
|
|
245
|
+
return;
|
|
209
246
|
}
|
|
210
247
|
const { mode, id, timeout: requestTimeout } = data;
|
|
211
248
|
const timeout = requestTimeout ?? DEFAULT_TIMEOUT_MS;
|
|
212
249
|
this.clearModeTimeout();
|
|
213
250
|
await this.switchMode(mode, id);
|
|
214
251
|
this.setupModeTimeout(timeout);
|
|
252
|
+
if (mode === Modes.replay && id) {
|
|
253
|
+
res.setHeader(
|
|
254
|
+
"Set-Cookie",
|
|
255
|
+
`proxy-recording-id=${encodeURIComponent(id)}; HttpOnly; Path=/; SameSite=Lax`
|
|
256
|
+
);
|
|
257
|
+
console.log(`[CONCURRENT REPLAY] Set cookie for recording: ${id}`);
|
|
258
|
+
}
|
|
215
259
|
sendJsonResponse(res, HTTP_STATUS_OK, {
|
|
216
260
|
success: true,
|
|
217
261
|
mode: this.mode,
|
|
@@ -226,14 +270,12 @@ var ProxyServer = class {
|
|
|
226
270
|
}
|
|
227
271
|
}
|
|
228
272
|
clearModeTimeout() {
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
this.modeTimeout = null;
|
|
232
|
-
}
|
|
273
|
+
clearTimeout(this.modeTimeout || 0);
|
|
274
|
+
this.modeTimeout = null;
|
|
233
275
|
}
|
|
234
276
|
async switchMode(mode, id) {
|
|
235
|
-
|
|
236
|
-
|
|
277
|
+
console.log(`Switching to ${mode.toUpperCase()} mode`);
|
|
278
|
+
if (this.currentSession && this.mode === Modes.record) {
|
|
237
279
|
await this.saveCurrentSession(true);
|
|
238
280
|
console.log("Session saved, continuing with mode switch");
|
|
239
281
|
}
|
|
@@ -243,11 +285,17 @@ var ProxyServer = class {
|
|
|
243
285
|
break;
|
|
244
286
|
}
|
|
245
287
|
case Modes.record: {
|
|
288
|
+
if (!id) {
|
|
289
|
+
throw new Error("Record ID is required");
|
|
290
|
+
}
|
|
246
291
|
this.switchToRecordMode(id);
|
|
247
292
|
break;
|
|
248
293
|
}
|
|
249
294
|
case Modes.replay: {
|
|
250
|
-
|
|
295
|
+
if (!id) {
|
|
296
|
+
throw new Error("Replay ID is required");
|
|
297
|
+
}
|
|
298
|
+
await this.switchToReplayMode(id);
|
|
251
299
|
break;
|
|
252
300
|
}
|
|
253
301
|
default: {
|
|
@@ -264,36 +312,33 @@ var ProxyServer = class {
|
|
|
264
312
|
console.log("Switched to transparent mode");
|
|
265
313
|
}
|
|
266
314
|
switchToRecordMode(id) {
|
|
267
|
-
if (!id) {
|
|
268
|
-
throw new Error("Record ID is required");
|
|
269
|
-
}
|
|
270
315
|
this.mode = Modes.record;
|
|
271
316
|
this.recordingId = id;
|
|
272
317
|
this.replayId = null;
|
|
273
318
|
this.currentSession = { id, recordings: [], websocketRecordings: [] };
|
|
274
|
-
this.requestSequenceMap.clear();
|
|
275
319
|
console.log(`Switched to record mode with ID: ${id}`);
|
|
276
320
|
}
|
|
277
|
-
switchToReplayMode(id) {
|
|
278
|
-
if (!id) {
|
|
279
|
-
throw new Error("Replay ID is required");
|
|
280
|
-
}
|
|
321
|
+
async switchToReplayMode(id) {
|
|
281
322
|
this.mode = Modes.replay;
|
|
282
323
|
this.replayId = id;
|
|
283
324
|
this.recordingId = null;
|
|
284
325
|
this.currentSession = null;
|
|
285
|
-
this.
|
|
326
|
+
const session = this.replaySessions.get(id);
|
|
327
|
+
if (session) {
|
|
328
|
+
session.servedRecordingIdsByKey.clear();
|
|
329
|
+
console.log(`Reset served recordings tracker for session: ${id}`);
|
|
330
|
+
} else {
|
|
331
|
+
this.getOrCreateReplaySession(id);
|
|
332
|
+
}
|
|
286
333
|
console.log(`Switched to replay mode with ID: ${id}`);
|
|
287
334
|
}
|
|
288
335
|
setupModeTimeout(timeout) {
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
}, timeout);
|
|
296
|
-
}
|
|
336
|
+
this.modeTimeout = setTimeout(async () => {
|
|
337
|
+
console.log("Timeout reached, switching back to transparent mode");
|
|
338
|
+
await this.saveCurrentSession(true);
|
|
339
|
+
this.switchToTransparentMode();
|
|
340
|
+
this.modeTimeout = null;
|
|
341
|
+
}, timeout);
|
|
297
342
|
}
|
|
298
343
|
async saveCurrentSession(filterIncomplete = false) {
|
|
299
344
|
if (!this.currentSession) {
|
|
@@ -319,9 +364,6 @@ var ProxyServer = class {
|
|
|
319
364
|
return;
|
|
320
365
|
}
|
|
321
366
|
const key = getReqID(req);
|
|
322
|
-
const currentSequence = this.requestSequenceMap.get(key) || 0;
|
|
323
|
-
const sequence = currentSequence;
|
|
324
|
-
this.requestSequenceMap.set(key, currentSequence + 1);
|
|
325
367
|
const recordingId = this.recordingIdCounter++;
|
|
326
368
|
req.__recordingId = recordingId;
|
|
327
369
|
const record = {
|
|
@@ -333,13 +375,12 @@ var ProxyServer = class {
|
|
|
333
375
|
},
|
|
334
376
|
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
335
377
|
key,
|
|
336
|
-
sequence,
|
|
337
378
|
recordingId
|
|
338
379
|
};
|
|
339
380
|
this.currentSession.recordings.push(record);
|
|
340
381
|
console.log(
|
|
341
382
|
// eslint-disable-next-line sonarjs/no-nested-template-literals
|
|
342
|
-
`saveRequestRecordSync: Saved ${req.method} ${req.url} (key: ${key},
|
|
383
|
+
`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})`
|
|
343
384
|
);
|
|
344
385
|
}
|
|
345
386
|
updateRequestBodySync(req, body) {
|
|
@@ -399,7 +440,7 @@ var ProxyServer = class {
|
|
|
399
440
|
body: body || null
|
|
400
441
|
};
|
|
401
442
|
console.log(
|
|
402
|
-
`Recorded: ${req.method} ${req.url} (
|
|
443
|
+
`Recorded: ${req.method} ${req.url} (recordingId: ${recordingId})`
|
|
403
444
|
);
|
|
404
445
|
});
|
|
405
446
|
}
|
|
@@ -429,46 +470,77 @@ var ProxyServer = class {
|
|
|
429
470
|
body: body || null
|
|
430
471
|
};
|
|
431
472
|
console.log(
|
|
432
|
-
`recordResponseData: Recorded response for ${req.method} ${req.url} (
|
|
473
|
+
`recordResponseData: Recorded response for ${req.method} ${req.url} (recordingId: ${recordingId})`
|
|
433
474
|
);
|
|
434
475
|
return true;
|
|
435
476
|
}
|
|
436
477
|
async handleReplayRequest(req, res) {
|
|
478
|
+
const recordingId = this.getRecordingIdFromCookie(req) || this.replayId;
|
|
479
|
+
if (!recordingId) {
|
|
480
|
+
const corsHeaders = this.getCorsHeaders(req);
|
|
481
|
+
res.writeHead(HTTP_STATUS_BAD_REQUEST, {
|
|
482
|
+
"Content-Type": "application/json",
|
|
483
|
+
...corsHeaders
|
|
484
|
+
});
|
|
485
|
+
res.end(JSON.stringify({ error: "No replay session active" }));
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
437
488
|
const key = getReqID(req);
|
|
438
|
-
const filePath = getRecordingPath(this.recordingsDir,
|
|
489
|
+
const filePath = getRecordingPath(this.recordingsDir, recordingId);
|
|
439
490
|
try {
|
|
440
|
-
const
|
|
491
|
+
const sessionState = this.getOrCreateReplaySession(recordingId);
|
|
492
|
+
if (!sessionState.loadedSession) {
|
|
493
|
+
sessionState.loadedSession = await loadRecordingSession(filePath);
|
|
494
|
+
console.log(`[REPLAY] Loaded recording session: ${recordingId}`);
|
|
495
|
+
}
|
|
496
|
+
const session = sessionState.loadedSession;
|
|
497
|
+
if (!sessionState.servedRecordingIdsByKey.has(key)) {
|
|
498
|
+
sessionState.servedRecordingIdsByKey.set(key, /* @__PURE__ */ new Set());
|
|
499
|
+
}
|
|
500
|
+
const servedForThisKey = sessionState.servedRecordingIdsByKey.get(key);
|
|
441
501
|
const host = req.headers.host || "unknown";
|
|
442
|
-
const recordsWithKey = session.recordings.filter((r) => r.key === key && r.response).toSorted((a, b) => a.
|
|
502
|
+
const recordsWithKey = session.recordings.filter((r) => r.key === key && r.response).toSorted((a, b) => a.recordingId - b.recordingId);
|
|
443
503
|
if (recordsWithKey.length === 0) {
|
|
444
|
-
|
|
445
|
-
|
|
504
|
+
const errorMsg = `No recording found for ${key} at ${req.method} ${host}${req.url}`;
|
|
505
|
+
console.error(`[REPLAY ERROR] ${errorMsg} (session: ${recordingId})`);
|
|
506
|
+
console.error(
|
|
507
|
+
`[REPLAY ERROR] This request was not made during recording - possible test non-determinism`
|
|
446
508
|
);
|
|
447
|
-
const
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
}
|
|
509
|
+
const errorResponse = {
|
|
510
|
+
error: "No recording found",
|
|
511
|
+
message: errorMsg,
|
|
512
|
+
key,
|
|
513
|
+
sessionId: recordingId
|
|
514
|
+
};
|
|
453
515
|
const corsHeaders = this.getCorsHeaders(req);
|
|
454
|
-
res.writeHead(
|
|
516
|
+
res.writeHead(HTTP_STATUS_NOT_FOUND, {
|
|
455
517
|
"Content-Type": "application/json",
|
|
456
518
|
...corsHeaders
|
|
457
519
|
});
|
|
458
|
-
res.end(JSON.stringify(
|
|
520
|
+
res.end(JSON.stringify(errorResponse));
|
|
459
521
|
return;
|
|
460
522
|
}
|
|
461
|
-
const
|
|
523
|
+
const requestCount = servedForThisKey.size + 1;
|
|
524
|
+
console.log(
|
|
525
|
+
`[REPLAY REQUEST #${requestCount}] ${req.method} ${req.url} (session: ${recordingId}, total: ${recordsWithKey.length}, served: ${servedForThisKey.size})`
|
|
526
|
+
);
|
|
462
527
|
let record;
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
528
|
+
for (const rec of recordsWithKey) {
|
|
529
|
+
if (!servedForThisKey.has(rec.recordingId)) {
|
|
530
|
+
record = rec;
|
|
531
|
+
break;
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
if (!record) {
|
|
535
|
+
console.log(
|
|
536
|
+
`[REPLAY WARNING] All ${recordsWithKey.length} recordings already served for ${key} (session: ${recordingId}), reusing last one`
|
|
537
|
+
);
|
|
466
538
|
record = recordsWithKey[recordsWithKey.length - 1];
|
|
467
539
|
}
|
|
540
|
+
servedForThisKey.add(record.recordingId);
|
|
468
541
|
console.log(
|
|
469
|
-
`
|
|
542
|
+
`[REPLAY SERVING] recordingId: ${record.recordingId}, session: ${recordingId}, body_len: ${record.response?.body?.length || 0}`
|
|
470
543
|
);
|
|
471
|
-
this.replaySequenceMap.set(key, usageCount + 1);
|
|
472
544
|
if (!record.response) {
|
|
473
545
|
throw new Error(
|
|
474
546
|
`No response recorded for this request: ${req.method} ${host}${req.url}`
|
|
@@ -534,6 +606,7 @@ var ProxyServer = class {
|
|
|
534
606
|
this.proxy.web(req, res, { target });
|
|
535
607
|
}
|
|
536
608
|
}
|
|
609
|
+
// TODO: check if can handle streaming requests
|
|
537
610
|
async bufferAndProxyRequest(req, res, target) {
|
|
538
611
|
const chunks = [];
|
|
539
612
|
req.on("data", (chunk) => {
|
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-
|
|
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-CjM3evKb.cjs';
|
|
3
3
|
import '@playwright/test';
|
|
4
4
|
|
|
5
5
|
declare class ProxyServer {
|
|
@@ -12,9 +12,8 @@ declare class ProxyServer {
|
|
|
12
12
|
private proxy;
|
|
13
13
|
private currentSession;
|
|
14
14
|
private recordingsDir;
|
|
15
|
-
private requestSequenceMap;
|
|
16
|
-
private replaySequenceMap;
|
|
17
15
|
private recordingIdCounter;
|
|
16
|
+
private replaySessions;
|
|
18
17
|
constructor(targets: string[], recordingsDir: string);
|
|
19
18
|
init(): Promise<void>;
|
|
20
19
|
listen(port: number): http.Server;
|
|
@@ -29,6 +28,19 @@ declare class ProxyServer {
|
|
|
29
28
|
private getCorsHeaders;
|
|
30
29
|
private addCorsHeaders;
|
|
31
30
|
private getTarget;
|
|
31
|
+
/**
|
|
32
|
+
* Extract recording ID from request cookie
|
|
33
|
+
* Used for concurrent replay session routing
|
|
34
|
+
* @param req The incoming HTTP request
|
|
35
|
+
* @returns The recording ID from cookie, or null if not found
|
|
36
|
+
*/
|
|
37
|
+
private getRecordingIdFromCookie;
|
|
38
|
+
/**
|
|
39
|
+
* Get or create a replay session state for a given recording ID
|
|
40
|
+
* @param recordingId The recording ID to get/create session for
|
|
41
|
+
* @returns The replay session state
|
|
42
|
+
*/
|
|
43
|
+
private getOrCreateReplaySession;
|
|
32
44
|
private parseGetParams;
|
|
33
45
|
private handleControlRequest;
|
|
34
46
|
private clearModeTimeout;
|
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-
|
|
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-CjM3evKb.js';
|
|
3
3
|
import '@playwright/test';
|
|
4
4
|
|
|
5
5
|
declare class ProxyServer {
|
|
@@ -12,9 +12,8 @@ declare class ProxyServer {
|
|
|
12
12
|
private proxy;
|
|
13
13
|
private currentSession;
|
|
14
14
|
private recordingsDir;
|
|
15
|
-
private requestSequenceMap;
|
|
16
|
-
private replaySequenceMap;
|
|
17
15
|
private recordingIdCounter;
|
|
16
|
+
private replaySessions;
|
|
18
17
|
constructor(targets: string[], recordingsDir: string);
|
|
19
18
|
init(): Promise<void>;
|
|
20
19
|
listen(port: number): http.Server;
|
|
@@ -29,6 +28,19 @@ declare class ProxyServer {
|
|
|
29
28
|
private getCorsHeaders;
|
|
30
29
|
private addCorsHeaders;
|
|
31
30
|
private getTarget;
|
|
31
|
+
/**
|
|
32
|
+
* Extract recording ID from request cookie
|
|
33
|
+
* Used for concurrent replay session routing
|
|
34
|
+
* @param req The incoming HTTP request
|
|
35
|
+
* @returns The recording ID from cookie, or null if not found
|
|
36
|
+
*/
|
|
37
|
+
private getRecordingIdFromCookie;
|
|
38
|
+
/**
|
|
39
|
+
* Get or create a replay session state for a given recording ID
|
|
40
|
+
* @param recordingId The recording ID to get/create session for
|
|
41
|
+
* @returns The replay session state
|
|
42
|
+
*/
|
|
43
|
+
private getOrCreateReplaySession;
|
|
32
44
|
private parseGetParams;
|
|
33
45
|
private handleControlRequest;
|
|
34
46
|
private clearModeTimeout;
|
package/dist/index.mjs
CHANGED
|
@@ -86,12 +86,10 @@ var ProxyServer = class {
|
|
|
86
86
|
proxy;
|
|
87
87
|
currentSession;
|
|
88
88
|
recordingsDir;
|
|
89
|
-
requestSequenceMap;
|
|
90
|
-
// Track sequence per request key
|
|
91
|
-
replaySequenceMap;
|
|
92
|
-
// Track replay position per request key
|
|
93
89
|
recordingIdCounter;
|
|
94
90
|
// Unique ID for each recording entry
|
|
91
|
+
replaySessions;
|
|
92
|
+
// Track multiple concurrent replay sessions by recording ID
|
|
95
93
|
constructor(targets, recordingsDir) {
|
|
96
94
|
this.targets = targets;
|
|
97
95
|
this.currentTargetIndex = 0;
|
|
@@ -102,11 +100,11 @@ var ProxyServer = class {
|
|
|
102
100
|
this.modeTimeout = null;
|
|
103
101
|
this.currentSession = null;
|
|
104
102
|
this.recordingsDir = recordingsDir;
|
|
105
|
-
this.
|
|
106
|
-
this.replaySequenceMap = /* @__PURE__ */ new Map();
|
|
103
|
+
this.replaySessions = /* @__PURE__ */ new Map();
|
|
107
104
|
this.proxy = httpProxy.createProxyServer({
|
|
108
105
|
secure: false,
|
|
109
|
-
changeOrigin: true
|
|
106
|
+
changeOrigin: true,
|
|
107
|
+
ws: true
|
|
110
108
|
});
|
|
111
109
|
this.setupProxyEventHandlers();
|
|
112
110
|
}
|
|
@@ -174,6 +172,43 @@ var ProxyServer = class {
|
|
|
174
172
|
this.currentTargetIndex = (this.currentTargetIndex + 1) % this.targets.length;
|
|
175
173
|
return target;
|
|
176
174
|
}
|
|
175
|
+
/**
|
|
176
|
+
* Extract recording ID from request cookie
|
|
177
|
+
* Used for concurrent replay session routing
|
|
178
|
+
* @param req The incoming HTTP request
|
|
179
|
+
* @returns The recording ID from cookie, or null if not found
|
|
180
|
+
*/
|
|
181
|
+
getRecordingIdFromCookie(req) {
|
|
182
|
+
const cookies = req.headers.cookie;
|
|
183
|
+
if (!cookies) {
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
const match = cookies.match(/proxy-recording-id=([^;]+)/);
|
|
187
|
+
return match ? decodeURIComponent(match[1]) : null;
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Get or create a replay session state for a given recording ID
|
|
191
|
+
* @param recordingId The recording ID to get/create session for
|
|
192
|
+
* @returns The replay session state
|
|
193
|
+
*/
|
|
194
|
+
getOrCreateReplaySession(recordingId) {
|
|
195
|
+
let session = this.replaySessions.get(recordingId);
|
|
196
|
+
if (session) {
|
|
197
|
+
session.lastAccessTime = Date.now();
|
|
198
|
+
} else {
|
|
199
|
+
session = {
|
|
200
|
+
recordingId,
|
|
201
|
+
servedRecordingIdsByKey: /* @__PURE__ */ new Map(),
|
|
202
|
+
loadedSession: null,
|
|
203
|
+
lastAccessTime: Date.now()
|
|
204
|
+
};
|
|
205
|
+
this.replaySessions.set(recordingId, session);
|
|
206
|
+
console.log(
|
|
207
|
+
`[CONCURRENT REPLAY] Created new session for recording: ${recordingId}`
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
return session;
|
|
211
|
+
}
|
|
177
212
|
parseGetParams(req) {
|
|
178
213
|
const url = new URL(req.url || "", `http://${req.headers.host}`);
|
|
179
214
|
const mode = url.searchParams.get("mode");
|
|
@@ -190,16 +225,25 @@ var ProxyServer = class {
|
|
|
190
225
|
let data;
|
|
191
226
|
if (req.method === "GET") {
|
|
192
227
|
data = this.parseGetParams(req);
|
|
193
|
-
} else {
|
|
228
|
+
} else if (req.method === "POST") {
|
|
194
229
|
const body = await readRequestBody(req);
|
|
195
|
-
console.log(
|
|
230
|
+
console.log(`MODE CHANGE (${req.method})`, body);
|
|
196
231
|
data = JSON.parse(body);
|
|
232
|
+
} else {
|
|
233
|
+
return;
|
|
197
234
|
}
|
|
198
235
|
const { mode, id, timeout: requestTimeout } = data;
|
|
199
236
|
const timeout = requestTimeout ?? DEFAULT_TIMEOUT_MS;
|
|
200
237
|
this.clearModeTimeout();
|
|
201
238
|
await this.switchMode(mode, id);
|
|
202
239
|
this.setupModeTimeout(timeout);
|
|
240
|
+
if (mode === Modes.replay && id) {
|
|
241
|
+
res.setHeader(
|
|
242
|
+
"Set-Cookie",
|
|
243
|
+
`proxy-recording-id=${encodeURIComponent(id)}; HttpOnly; Path=/; SameSite=Lax`
|
|
244
|
+
);
|
|
245
|
+
console.log(`[CONCURRENT REPLAY] Set cookie for recording: ${id}`);
|
|
246
|
+
}
|
|
203
247
|
sendJsonResponse(res, HTTP_STATUS_OK, {
|
|
204
248
|
success: true,
|
|
205
249
|
mode: this.mode,
|
|
@@ -214,14 +258,12 @@ var ProxyServer = class {
|
|
|
214
258
|
}
|
|
215
259
|
}
|
|
216
260
|
clearModeTimeout() {
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
this.modeTimeout = null;
|
|
220
|
-
}
|
|
261
|
+
clearTimeout(this.modeTimeout || 0);
|
|
262
|
+
this.modeTimeout = null;
|
|
221
263
|
}
|
|
222
264
|
async switchMode(mode, id) {
|
|
223
|
-
|
|
224
|
-
|
|
265
|
+
console.log(`Switching to ${mode.toUpperCase()} mode`);
|
|
266
|
+
if (this.currentSession && this.mode === Modes.record) {
|
|
225
267
|
await this.saveCurrentSession(true);
|
|
226
268
|
console.log("Session saved, continuing with mode switch");
|
|
227
269
|
}
|
|
@@ -231,11 +273,17 @@ var ProxyServer = class {
|
|
|
231
273
|
break;
|
|
232
274
|
}
|
|
233
275
|
case Modes.record: {
|
|
276
|
+
if (!id) {
|
|
277
|
+
throw new Error("Record ID is required");
|
|
278
|
+
}
|
|
234
279
|
this.switchToRecordMode(id);
|
|
235
280
|
break;
|
|
236
281
|
}
|
|
237
282
|
case Modes.replay: {
|
|
238
|
-
|
|
283
|
+
if (!id) {
|
|
284
|
+
throw new Error("Replay ID is required");
|
|
285
|
+
}
|
|
286
|
+
await this.switchToReplayMode(id);
|
|
239
287
|
break;
|
|
240
288
|
}
|
|
241
289
|
default: {
|
|
@@ -252,36 +300,33 @@ var ProxyServer = class {
|
|
|
252
300
|
console.log("Switched to transparent mode");
|
|
253
301
|
}
|
|
254
302
|
switchToRecordMode(id) {
|
|
255
|
-
if (!id) {
|
|
256
|
-
throw new Error("Record ID is required");
|
|
257
|
-
}
|
|
258
303
|
this.mode = Modes.record;
|
|
259
304
|
this.recordingId = id;
|
|
260
305
|
this.replayId = null;
|
|
261
306
|
this.currentSession = { id, recordings: [], websocketRecordings: [] };
|
|
262
|
-
this.requestSequenceMap.clear();
|
|
263
307
|
console.log(`Switched to record mode with ID: ${id}`);
|
|
264
308
|
}
|
|
265
|
-
switchToReplayMode(id) {
|
|
266
|
-
if (!id) {
|
|
267
|
-
throw new Error("Replay ID is required");
|
|
268
|
-
}
|
|
309
|
+
async switchToReplayMode(id) {
|
|
269
310
|
this.mode = Modes.replay;
|
|
270
311
|
this.replayId = id;
|
|
271
312
|
this.recordingId = null;
|
|
272
313
|
this.currentSession = null;
|
|
273
|
-
this.
|
|
314
|
+
const session = this.replaySessions.get(id);
|
|
315
|
+
if (session) {
|
|
316
|
+
session.servedRecordingIdsByKey.clear();
|
|
317
|
+
console.log(`Reset served recordings tracker for session: ${id}`);
|
|
318
|
+
} else {
|
|
319
|
+
this.getOrCreateReplaySession(id);
|
|
320
|
+
}
|
|
274
321
|
console.log(`Switched to replay mode with ID: ${id}`);
|
|
275
322
|
}
|
|
276
323
|
setupModeTimeout(timeout) {
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
}, timeout);
|
|
284
|
-
}
|
|
324
|
+
this.modeTimeout = setTimeout(async () => {
|
|
325
|
+
console.log("Timeout reached, switching back to transparent mode");
|
|
326
|
+
await this.saveCurrentSession(true);
|
|
327
|
+
this.switchToTransparentMode();
|
|
328
|
+
this.modeTimeout = null;
|
|
329
|
+
}, timeout);
|
|
285
330
|
}
|
|
286
331
|
async saveCurrentSession(filterIncomplete = false) {
|
|
287
332
|
if (!this.currentSession) {
|
|
@@ -307,9 +352,6 @@ var ProxyServer = class {
|
|
|
307
352
|
return;
|
|
308
353
|
}
|
|
309
354
|
const key = getReqID(req);
|
|
310
|
-
const currentSequence = this.requestSequenceMap.get(key) || 0;
|
|
311
|
-
const sequence = currentSequence;
|
|
312
|
-
this.requestSequenceMap.set(key, currentSequence + 1);
|
|
313
355
|
const recordingId = this.recordingIdCounter++;
|
|
314
356
|
req.__recordingId = recordingId;
|
|
315
357
|
const record = {
|
|
@@ -321,13 +363,12 @@ var ProxyServer = class {
|
|
|
321
363
|
},
|
|
322
364
|
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
323
365
|
key,
|
|
324
|
-
sequence,
|
|
325
366
|
recordingId
|
|
326
367
|
};
|
|
327
368
|
this.currentSession.recordings.push(record);
|
|
328
369
|
console.log(
|
|
329
370
|
// eslint-disable-next-line sonarjs/no-nested-template-literals
|
|
330
|
-
`saveRequestRecordSync: Saved ${req.method} ${req.url} (key: ${key},
|
|
371
|
+
`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
372
|
);
|
|
332
373
|
}
|
|
333
374
|
updateRequestBodySync(req, body) {
|
|
@@ -387,7 +428,7 @@ var ProxyServer = class {
|
|
|
387
428
|
body: body || null
|
|
388
429
|
};
|
|
389
430
|
console.log(
|
|
390
|
-
`Recorded: ${req.method} ${req.url} (
|
|
431
|
+
`Recorded: ${req.method} ${req.url} (recordingId: ${recordingId})`
|
|
391
432
|
);
|
|
392
433
|
});
|
|
393
434
|
}
|
|
@@ -417,46 +458,77 @@ var ProxyServer = class {
|
|
|
417
458
|
body: body || null
|
|
418
459
|
};
|
|
419
460
|
console.log(
|
|
420
|
-
`recordResponseData: Recorded response for ${req.method} ${req.url} (
|
|
461
|
+
`recordResponseData: Recorded response for ${req.method} ${req.url} (recordingId: ${recordingId})`
|
|
421
462
|
);
|
|
422
463
|
return true;
|
|
423
464
|
}
|
|
424
465
|
async handleReplayRequest(req, res) {
|
|
466
|
+
const recordingId = this.getRecordingIdFromCookie(req) || this.replayId;
|
|
467
|
+
if (!recordingId) {
|
|
468
|
+
const corsHeaders = this.getCorsHeaders(req);
|
|
469
|
+
res.writeHead(HTTP_STATUS_BAD_REQUEST, {
|
|
470
|
+
"Content-Type": "application/json",
|
|
471
|
+
...corsHeaders
|
|
472
|
+
});
|
|
473
|
+
res.end(JSON.stringify({ error: "No replay session active" }));
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
425
476
|
const key = getReqID(req);
|
|
426
|
-
const filePath = getRecordingPath(this.recordingsDir,
|
|
477
|
+
const filePath = getRecordingPath(this.recordingsDir, recordingId);
|
|
427
478
|
try {
|
|
428
|
-
const
|
|
479
|
+
const sessionState = this.getOrCreateReplaySession(recordingId);
|
|
480
|
+
if (!sessionState.loadedSession) {
|
|
481
|
+
sessionState.loadedSession = await loadRecordingSession(filePath);
|
|
482
|
+
console.log(`[REPLAY] Loaded recording session: ${recordingId}`);
|
|
483
|
+
}
|
|
484
|
+
const session = sessionState.loadedSession;
|
|
485
|
+
if (!sessionState.servedRecordingIdsByKey.has(key)) {
|
|
486
|
+
sessionState.servedRecordingIdsByKey.set(key, /* @__PURE__ */ new Set());
|
|
487
|
+
}
|
|
488
|
+
const servedForThisKey = sessionState.servedRecordingIdsByKey.get(key);
|
|
429
489
|
const host = req.headers.host || "unknown";
|
|
430
|
-
const recordsWithKey = session.recordings.filter((r) => r.key === key && r.response).toSorted((a, b) => a.
|
|
490
|
+
const recordsWithKey = session.recordings.filter((r) => r.key === key && r.response).toSorted((a, b) => a.recordingId - b.recordingId);
|
|
431
491
|
if (recordsWithKey.length === 0) {
|
|
432
|
-
|
|
433
|
-
|
|
492
|
+
const errorMsg = `No recording found for ${key} at ${req.method} ${host}${req.url}`;
|
|
493
|
+
console.error(`[REPLAY ERROR] ${errorMsg} (session: ${recordingId})`);
|
|
494
|
+
console.error(
|
|
495
|
+
`[REPLAY ERROR] This request was not made during recording - possible test non-determinism`
|
|
434
496
|
);
|
|
435
|
-
const
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
}
|
|
497
|
+
const errorResponse = {
|
|
498
|
+
error: "No recording found",
|
|
499
|
+
message: errorMsg,
|
|
500
|
+
key,
|
|
501
|
+
sessionId: recordingId
|
|
502
|
+
};
|
|
441
503
|
const corsHeaders = this.getCorsHeaders(req);
|
|
442
|
-
res.writeHead(
|
|
504
|
+
res.writeHead(HTTP_STATUS_NOT_FOUND, {
|
|
443
505
|
"Content-Type": "application/json",
|
|
444
506
|
...corsHeaders
|
|
445
507
|
});
|
|
446
|
-
res.end(JSON.stringify(
|
|
508
|
+
res.end(JSON.stringify(errorResponse));
|
|
447
509
|
return;
|
|
448
510
|
}
|
|
449
|
-
const
|
|
511
|
+
const requestCount = servedForThisKey.size + 1;
|
|
512
|
+
console.log(
|
|
513
|
+
`[REPLAY REQUEST #${requestCount}] ${req.method} ${req.url} (session: ${recordingId}, total: ${recordsWithKey.length}, served: ${servedForThisKey.size})`
|
|
514
|
+
);
|
|
450
515
|
let record;
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
516
|
+
for (const rec of recordsWithKey) {
|
|
517
|
+
if (!servedForThisKey.has(rec.recordingId)) {
|
|
518
|
+
record = rec;
|
|
519
|
+
break;
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
if (!record) {
|
|
523
|
+
console.log(
|
|
524
|
+
`[REPLAY WARNING] All ${recordsWithKey.length} recordings already served for ${key} (session: ${recordingId}), reusing last one`
|
|
525
|
+
);
|
|
454
526
|
record = recordsWithKey[recordsWithKey.length - 1];
|
|
455
527
|
}
|
|
528
|
+
servedForThisKey.add(record.recordingId);
|
|
456
529
|
console.log(
|
|
457
|
-
`
|
|
530
|
+
`[REPLAY SERVING] recordingId: ${record.recordingId}, session: ${recordingId}, body_len: ${record.response?.body?.length || 0}`
|
|
458
531
|
);
|
|
459
|
-
this.replaySequenceMap.set(key, usageCount + 1);
|
|
460
532
|
if (!record.response) {
|
|
461
533
|
throw new Error(
|
|
462
534
|
`No response recorded for this request: ${req.method} ${host}${req.url}`
|
|
@@ -522,6 +594,7 @@ var ProxyServer = class {
|
|
|
522
594
|
this.proxy.web(req, res, { target });
|
|
523
595
|
}
|
|
524
596
|
}
|
|
597
|
+
// TODO: check if can handle streaming requests
|
|
525
598
|
async bufferAndProxyRequest(req, res, target) {
|
|
526
599
|
const chunks = [];
|
|
527
600
|
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-
|
|
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-CjM3evKb.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-
|
|
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-CjM3evKb.js';
|
|
3
3
|
import 'node:http';
|
package/dist/proxy.js
CHANGED
|
@@ -120,12 +120,10 @@ var ProxyServer = class {
|
|
|
120
120
|
proxy;
|
|
121
121
|
currentSession;
|
|
122
122
|
recordingsDir;
|
|
123
|
-
requestSequenceMap;
|
|
124
|
-
// Track sequence per request key
|
|
125
|
-
replaySequenceMap;
|
|
126
|
-
// Track replay position per request key
|
|
127
123
|
recordingIdCounter;
|
|
128
124
|
// Unique ID for each recording entry
|
|
125
|
+
replaySessions;
|
|
126
|
+
// Track multiple concurrent replay sessions by recording ID
|
|
129
127
|
constructor(targets2, recordingsDir2) {
|
|
130
128
|
this.targets = targets2;
|
|
131
129
|
this.currentTargetIndex = 0;
|
|
@@ -136,11 +134,11 @@ var ProxyServer = class {
|
|
|
136
134
|
this.modeTimeout = null;
|
|
137
135
|
this.currentSession = null;
|
|
138
136
|
this.recordingsDir = recordingsDir2;
|
|
139
|
-
this.
|
|
140
|
-
this.replaySequenceMap = /* @__PURE__ */ new Map();
|
|
137
|
+
this.replaySessions = /* @__PURE__ */ new Map();
|
|
141
138
|
this.proxy = httpProxy.createProxyServer({
|
|
142
139
|
secure: false,
|
|
143
|
-
changeOrigin: true
|
|
140
|
+
changeOrigin: true,
|
|
141
|
+
ws: true
|
|
144
142
|
});
|
|
145
143
|
this.setupProxyEventHandlers();
|
|
146
144
|
}
|
|
@@ -208,6 +206,43 @@ var ProxyServer = class {
|
|
|
208
206
|
this.currentTargetIndex = (this.currentTargetIndex + 1) % this.targets.length;
|
|
209
207
|
return target;
|
|
210
208
|
}
|
|
209
|
+
/**
|
|
210
|
+
* Extract recording ID from request cookie
|
|
211
|
+
* Used for concurrent replay session routing
|
|
212
|
+
* @param req The incoming HTTP request
|
|
213
|
+
* @returns The recording ID from cookie, or null if not found
|
|
214
|
+
*/
|
|
215
|
+
getRecordingIdFromCookie(req) {
|
|
216
|
+
const cookies = req.headers.cookie;
|
|
217
|
+
if (!cookies) {
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
const match = cookies.match(/proxy-recording-id=([^;]+)/);
|
|
221
|
+
return match ? decodeURIComponent(match[1]) : null;
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* Get or create a replay session state for a given recording ID
|
|
225
|
+
* @param recordingId The recording ID to get/create session for
|
|
226
|
+
* @returns The replay session state
|
|
227
|
+
*/
|
|
228
|
+
getOrCreateReplaySession(recordingId) {
|
|
229
|
+
let session = this.replaySessions.get(recordingId);
|
|
230
|
+
if (session) {
|
|
231
|
+
session.lastAccessTime = Date.now();
|
|
232
|
+
} else {
|
|
233
|
+
session = {
|
|
234
|
+
recordingId,
|
|
235
|
+
servedRecordingIdsByKey: /* @__PURE__ */ new Map(),
|
|
236
|
+
loadedSession: null,
|
|
237
|
+
lastAccessTime: Date.now()
|
|
238
|
+
};
|
|
239
|
+
this.replaySessions.set(recordingId, session);
|
|
240
|
+
console.log(
|
|
241
|
+
`[CONCURRENT REPLAY] Created new session for recording: ${recordingId}`
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
return session;
|
|
245
|
+
}
|
|
211
246
|
parseGetParams(req) {
|
|
212
247
|
const url = new URL(req.url || "", `http://${req.headers.host}`);
|
|
213
248
|
const mode = url.searchParams.get("mode");
|
|
@@ -224,16 +259,25 @@ var ProxyServer = class {
|
|
|
224
259
|
let data;
|
|
225
260
|
if (req.method === "GET") {
|
|
226
261
|
data = this.parseGetParams(req);
|
|
227
|
-
} else {
|
|
262
|
+
} else if (req.method === "POST") {
|
|
228
263
|
const body = await readRequestBody(req);
|
|
229
|
-
console.log(
|
|
264
|
+
console.log(`MODE CHANGE (${req.method})`, body);
|
|
230
265
|
data = JSON.parse(body);
|
|
266
|
+
} else {
|
|
267
|
+
return;
|
|
231
268
|
}
|
|
232
269
|
const { mode, id, timeout: requestTimeout } = data;
|
|
233
270
|
const timeout = requestTimeout ?? DEFAULT_TIMEOUT_MS;
|
|
234
271
|
this.clearModeTimeout();
|
|
235
272
|
await this.switchMode(mode, id);
|
|
236
273
|
this.setupModeTimeout(timeout);
|
|
274
|
+
if (mode === Modes.replay && id) {
|
|
275
|
+
res.setHeader(
|
|
276
|
+
"Set-Cookie",
|
|
277
|
+
`proxy-recording-id=${encodeURIComponent(id)}; HttpOnly; Path=/; SameSite=Lax`
|
|
278
|
+
);
|
|
279
|
+
console.log(`[CONCURRENT REPLAY] Set cookie for recording: ${id}`);
|
|
280
|
+
}
|
|
237
281
|
sendJsonResponse(res, HTTP_STATUS_OK, {
|
|
238
282
|
success: true,
|
|
239
283
|
mode: this.mode,
|
|
@@ -248,14 +292,12 @@ var ProxyServer = class {
|
|
|
248
292
|
}
|
|
249
293
|
}
|
|
250
294
|
clearModeTimeout() {
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
this.modeTimeout = null;
|
|
254
|
-
}
|
|
295
|
+
clearTimeout(this.modeTimeout || 0);
|
|
296
|
+
this.modeTimeout = null;
|
|
255
297
|
}
|
|
256
298
|
async switchMode(mode, id) {
|
|
257
|
-
|
|
258
|
-
|
|
299
|
+
console.log(`Switching to ${mode.toUpperCase()} mode`);
|
|
300
|
+
if (this.currentSession && this.mode === Modes.record) {
|
|
259
301
|
await this.saveCurrentSession(true);
|
|
260
302
|
console.log("Session saved, continuing with mode switch");
|
|
261
303
|
}
|
|
@@ -265,11 +307,17 @@ var ProxyServer = class {
|
|
|
265
307
|
break;
|
|
266
308
|
}
|
|
267
309
|
case Modes.record: {
|
|
310
|
+
if (!id) {
|
|
311
|
+
throw new Error("Record ID is required");
|
|
312
|
+
}
|
|
268
313
|
this.switchToRecordMode(id);
|
|
269
314
|
break;
|
|
270
315
|
}
|
|
271
316
|
case Modes.replay: {
|
|
272
|
-
|
|
317
|
+
if (!id) {
|
|
318
|
+
throw new Error("Replay ID is required");
|
|
319
|
+
}
|
|
320
|
+
await this.switchToReplayMode(id);
|
|
273
321
|
break;
|
|
274
322
|
}
|
|
275
323
|
default: {
|
|
@@ -286,36 +334,33 @@ var ProxyServer = class {
|
|
|
286
334
|
console.log("Switched to transparent mode");
|
|
287
335
|
}
|
|
288
336
|
switchToRecordMode(id) {
|
|
289
|
-
if (!id) {
|
|
290
|
-
throw new Error("Record ID is required");
|
|
291
|
-
}
|
|
292
337
|
this.mode = Modes.record;
|
|
293
338
|
this.recordingId = id;
|
|
294
339
|
this.replayId = null;
|
|
295
340
|
this.currentSession = { id, recordings: [], websocketRecordings: [] };
|
|
296
|
-
this.requestSequenceMap.clear();
|
|
297
341
|
console.log(`Switched to record mode with ID: ${id}`);
|
|
298
342
|
}
|
|
299
|
-
switchToReplayMode(id) {
|
|
300
|
-
if (!id) {
|
|
301
|
-
throw new Error("Replay ID is required");
|
|
302
|
-
}
|
|
343
|
+
async switchToReplayMode(id) {
|
|
303
344
|
this.mode = Modes.replay;
|
|
304
345
|
this.replayId = id;
|
|
305
346
|
this.recordingId = null;
|
|
306
347
|
this.currentSession = null;
|
|
307
|
-
this.
|
|
348
|
+
const session = this.replaySessions.get(id);
|
|
349
|
+
if (session) {
|
|
350
|
+
session.servedRecordingIdsByKey.clear();
|
|
351
|
+
console.log(`Reset served recordings tracker for session: ${id}`);
|
|
352
|
+
} else {
|
|
353
|
+
this.getOrCreateReplaySession(id);
|
|
354
|
+
}
|
|
308
355
|
console.log(`Switched to replay mode with ID: ${id}`);
|
|
309
356
|
}
|
|
310
357
|
setupModeTimeout(timeout) {
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
}, timeout);
|
|
318
|
-
}
|
|
358
|
+
this.modeTimeout = setTimeout(async () => {
|
|
359
|
+
console.log("Timeout reached, switching back to transparent mode");
|
|
360
|
+
await this.saveCurrentSession(true);
|
|
361
|
+
this.switchToTransparentMode();
|
|
362
|
+
this.modeTimeout = null;
|
|
363
|
+
}, timeout);
|
|
319
364
|
}
|
|
320
365
|
async saveCurrentSession(filterIncomplete = false) {
|
|
321
366
|
if (!this.currentSession) {
|
|
@@ -341,9 +386,6 @@ var ProxyServer = class {
|
|
|
341
386
|
return;
|
|
342
387
|
}
|
|
343
388
|
const key = getReqID(req);
|
|
344
|
-
const currentSequence = this.requestSequenceMap.get(key) || 0;
|
|
345
|
-
const sequence = currentSequence;
|
|
346
|
-
this.requestSequenceMap.set(key, currentSequence + 1);
|
|
347
389
|
const recordingId = this.recordingIdCounter++;
|
|
348
390
|
req.__recordingId = recordingId;
|
|
349
391
|
const record = {
|
|
@@ -355,13 +397,12 @@ var ProxyServer = class {
|
|
|
355
397
|
},
|
|
356
398
|
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
357
399
|
key,
|
|
358
|
-
sequence,
|
|
359
400
|
recordingId
|
|
360
401
|
};
|
|
361
402
|
this.currentSession.recordings.push(record);
|
|
362
403
|
console.log(
|
|
363
404
|
// eslint-disable-next-line sonarjs/no-nested-template-literals
|
|
364
|
-
`saveRequestRecordSync: Saved ${req.method} ${req.url} (key: ${key},
|
|
405
|
+
`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
406
|
);
|
|
366
407
|
}
|
|
367
408
|
updateRequestBodySync(req, body) {
|
|
@@ -421,7 +462,7 @@ var ProxyServer = class {
|
|
|
421
462
|
body: body || null
|
|
422
463
|
};
|
|
423
464
|
console.log(
|
|
424
|
-
`Recorded: ${req.method} ${req.url} (
|
|
465
|
+
`Recorded: ${req.method} ${req.url} (recordingId: ${recordingId})`
|
|
425
466
|
);
|
|
426
467
|
});
|
|
427
468
|
}
|
|
@@ -451,46 +492,77 @@ var ProxyServer = class {
|
|
|
451
492
|
body: body || null
|
|
452
493
|
};
|
|
453
494
|
console.log(
|
|
454
|
-
`recordResponseData: Recorded response for ${req.method} ${req.url} (
|
|
495
|
+
`recordResponseData: Recorded response for ${req.method} ${req.url} (recordingId: ${recordingId})`
|
|
455
496
|
);
|
|
456
497
|
return true;
|
|
457
498
|
}
|
|
458
499
|
async handleReplayRequest(req, res) {
|
|
500
|
+
const recordingId = this.getRecordingIdFromCookie(req) || this.replayId;
|
|
501
|
+
if (!recordingId) {
|
|
502
|
+
const corsHeaders = this.getCorsHeaders(req);
|
|
503
|
+
res.writeHead(HTTP_STATUS_BAD_REQUEST, {
|
|
504
|
+
"Content-Type": "application/json",
|
|
505
|
+
...corsHeaders
|
|
506
|
+
});
|
|
507
|
+
res.end(JSON.stringify({ error: "No replay session active" }));
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
459
510
|
const key = getReqID(req);
|
|
460
|
-
const filePath = getRecordingPath(this.recordingsDir,
|
|
511
|
+
const filePath = getRecordingPath(this.recordingsDir, recordingId);
|
|
461
512
|
try {
|
|
462
|
-
const
|
|
513
|
+
const sessionState = this.getOrCreateReplaySession(recordingId);
|
|
514
|
+
if (!sessionState.loadedSession) {
|
|
515
|
+
sessionState.loadedSession = await loadRecordingSession(filePath);
|
|
516
|
+
console.log(`[REPLAY] Loaded recording session: ${recordingId}`);
|
|
517
|
+
}
|
|
518
|
+
const session = sessionState.loadedSession;
|
|
519
|
+
if (!sessionState.servedRecordingIdsByKey.has(key)) {
|
|
520
|
+
sessionState.servedRecordingIdsByKey.set(key, /* @__PURE__ */ new Set());
|
|
521
|
+
}
|
|
522
|
+
const servedForThisKey = sessionState.servedRecordingIdsByKey.get(key);
|
|
463
523
|
const host = req.headers.host || "unknown";
|
|
464
|
-
const recordsWithKey = session.recordings.filter((r) => r.key === key && r.response).toSorted((a, b) => a.
|
|
524
|
+
const recordsWithKey = session.recordings.filter((r) => r.key === key && r.response).toSorted((a, b) => a.recordingId - b.recordingId);
|
|
465
525
|
if (recordsWithKey.length === 0) {
|
|
466
|
-
|
|
467
|
-
|
|
526
|
+
const errorMsg = `No recording found for ${key} at ${req.method} ${host}${req.url}`;
|
|
527
|
+
console.error(`[REPLAY ERROR] ${errorMsg} (session: ${recordingId})`);
|
|
528
|
+
console.error(
|
|
529
|
+
`[REPLAY ERROR] This request was not made during recording - possible test non-determinism`
|
|
468
530
|
);
|
|
469
|
-
const
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
}
|
|
531
|
+
const errorResponse = {
|
|
532
|
+
error: "No recording found",
|
|
533
|
+
message: errorMsg,
|
|
534
|
+
key,
|
|
535
|
+
sessionId: recordingId
|
|
536
|
+
};
|
|
475
537
|
const corsHeaders = this.getCorsHeaders(req);
|
|
476
|
-
res.writeHead(
|
|
538
|
+
res.writeHead(HTTP_STATUS_NOT_FOUND, {
|
|
477
539
|
"Content-Type": "application/json",
|
|
478
540
|
...corsHeaders
|
|
479
541
|
});
|
|
480
|
-
res.end(JSON.stringify(
|
|
542
|
+
res.end(JSON.stringify(errorResponse));
|
|
481
543
|
return;
|
|
482
544
|
}
|
|
483
|
-
const
|
|
545
|
+
const requestCount = servedForThisKey.size + 1;
|
|
546
|
+
console.log(
|
|
547
|
+
`[REPLAY REQUEST #${requestCount}] ${req.method} ${req.url} (session: ${recordingId}, total: ${recordsWithKey.length}, served: ${servedForThisKey.size})`
|
|
548
|
+
);
|
|
484
549
|
let record;
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
550
|
+
for (const rec of recordsWithKey) {
|
|
551
|
+
if (!servedForThisKey.has(rec.recordingId)) {
|
|
552
|
+
record = rec;
|
|
553
|
+
break;
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
if (!record) {
|
|
557
|
+
console.log(
|
|
558
|
+
`[REPLAY WARNING] All ${recordsWithKey.length} recordings already served for ${key} (session: ${recordingId}), reusing last one`
|
|
559
|
+
);
|
|
488
560
|
record = recordsWithKey[recordsWithKey.length - 1];
|
|
489
561
|
}
|
|
562
|
+
servedForThisKey.add(record.recordingId);
|
|
490
563
|
console.log(
|
|
491
|
-
`
|
|
564
|
+
`[REPLAY SERVING] recordingId: ${record.recordingId}, session: ${recordingId}, body_len: ${record.response?.body?.length || 0}`
|
|
492
565
|
);
|
|
493
|
-
this.replaySequenceMap.set(key, usageCount + 1);
|
|
494
566
|
if (!record.response) {
|
|
495
567
|
throw new Error(
|
|
496
568
|
`No response recorded for this request: ${req.method} ${host}${req.url}`
|
|
@@ -556,6 +628,7 @@ var ProxyServer = class {
|
|
|
556
628
|
this.proxy.web(req, res, { target });
|
|
557
629
|
}
|
|
558
630
|
}
|
|
631
|
+
// TODO: check if can handle streaming requests
|
|
559
632
|
async bufferAndProxyRequest(req, res, target) {
|
|
560
633
|
const chunks = [];
|
|
561
634
|
req.on("data", (chunk) => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "test-proxy-recorder",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.10",
|
|
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",
|