test-proxy-recorder 0.3.0 → 0.3.2
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 +146 -21
- package/dist/{index-CVuiglPk.d.cts → index-BlBWqSE4.d.cts} +21 -4
- package/dist/{index-CVuiglPk.d.ts → index-BlBWqSE4.d.ts} +21 -4
- package/dist/index.cjs +439 -260
- package/dist/index.d.cts +15 -7
- package/dist/index.d.ts +15 -7
- package/dist/index.mjs +438 -259
- package/dist/playwright/index.cjs +151 -13
- package/dist/playwright/index.d.cts +1 -1
- package/dist/playwright/index.d.ts +1 -1
- package/dist/playwright/index.mjs +147 -14
- package/dist/proxy.js +296 -246
- package/package.json +1 -1
package/dist/index.cjs
CHANGED
|
@@ -6,7 +6,7 @@ var https = require('https');
|
|
|
6
6
|
var httpProxy = require('http-proxy');
|
|
7
7
|
var ws = require('ws');
|
|
8
8
|
var crypto = require('crypto');
|
|
9
|
-
var
|
|
9
|
+
var path2 = require('path');
|
|
10
10
|
var filenamify2 = require('filenamify');
|
|
11
11
|
|
|
12
12
|
function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
|
|
@@ -16,7 +16,7 @@ var http__default = /*#__PURE__*/_interopDefault(http);
|
|
|
16
16
|
var https__default = /*#__PURE__*/_interopDefault(https);
|
|
17
17
|
var httpProxy__default = /*#__PURE__*/_interopDefault(httpProxy);
|
|
18
18
|
var crypto__default = /*#__PURE__*/_interopDefault(crypto);
|
|
19
|
-
var
|
|
19
|
+
var path2__default = /*#__PURE__*/_interopDefault(path2);
|
|
20
20
|
var filenamify2__default = /*#__PURE__*/_interopDefault(filenamify2);
|
|
21
21
|
|
|
22
22
|
// src/constants.ts
|
|
@@ -53,20 +53,30 @@ function getRecordingPath(recordingsDir, id) {
|
|
|
53
53
|
maxLength: 255
|
|
54
54
|
// Set explicit max to prevent filenamify's default truncation
|
|
55
55
|
});
|
|
56
|
-
return
|
|
56
|
+
return path2__default.default.join(recordingsDir, `${sanitizedId}${EXTENSION}`);
|
|
57
57
|
}
|
|
58
58
|
async function loadRecordingSession(filePath) {
|
|
59
59
|
const fileContent = await fs__default.default.readFile(filePath, "utf8");
|
|
60
60
|
return JSON.parse(fileContent);
|
|
61
61
|
}
|
|
62
62
|
function processRecordings(recordings) {
|
|
63
|
-
const
|
|
64
|
-
|
|
63
|
+
const recordingsByKey = /* @__PURE__ */ new Map();
|
|
64
|
+
for (const recording of recordings) {
|
|
65
65
|
const key = recording.key;
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
66
|
+
if (!recordingsByKey.has(key)) {
|
|
67
|
+
recordingsByKey.set(key, []);
|
|
68
|
+
}
|
|
69
|
+
recordingsByKey.get(key).push(recording);
|
|
70
|
+
}
|
|
71
|
+
const processedRecordings = [];
|
|
72
|
+
for (const [_key, keyRecordings] of recordingsByKey) {
|
|
73
|
+
keyRecordings.sort((a, b) => a.recordingId - b.recordingId);
|
|
74
|
+
keyRecordings.forEach((recording, index) => {
|
|
75
|
+
processedRecordings.push({ ...recording, sequence: index });
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
processedRecordings.sort((a, b) => a.recordingId - b.recordingId);
|
|
79
|
+
return processedRecordings;
|
|
70
80
|
}
|
|
71
81
|
async function saveRecordingSession(recordingsDir, session) {
|
|
72
82
|
const filePath = getRecordingPath(recordingsDir, session.id);
|
|
@@ -84,16 +94,19 @@ async function saveRecordingSession(recordingsDir, session) {
|
|
|
84
94
|
`Saved ${processedRecordings.length} HTTP recordings and ${session.websocketRecordings?.length || 0} WebSocket recordings to ${filePath}`
|
|
85
95
|
);
|
|
86
96
|
}
|
|
87
|
-
function
|
|
88
|
-
const urlParts = req.url.split("?");
|
|
89
|
-
const pathname = urlParts[0];
|
|
90
|
-
const query = urlParts[1] || "";
|
|
97
|
+
function generateRecordingKey(pathname, query, method) {
|
|
91
98
|
const pathPart = pathname === "/" ? "root" : pathname.slice(1);
|
|
92
99
|
const normalizedPath = filenamify2__default.default(pathPart, { replacement: "_" });
|
|
93
100
|
const queryHash = generateQueryHash(query);
|
|
94
|
-
const filename = `${
|
|
101
|
+
const filename = `${method}_${normalizedPath}${queryHash}.json`;
|
|
95
102
|
return filenamify2__default.default(filename, { replacement: "_" });
|
|
96
103
|
}
|
|
104
|
+
function getReqID(req) {
|
|
105
|
+
const urlParts = req.url.split("?");
|
|
106
|
+
const pathname = urlParts[0];
|
|
107
|
+
const query = urlParts[1] || "";
|
|
108
|
+
return generateRecordingKey(pathname, query, req.method);
|
|
109
|
+
}
|
|
97
110
|
function generateQueryHash(query) {
|
|
98
111
|
if (!query) {
|
|
99
112
|
return "";
|
|
@@ -129,19 +142,25 @@ var ProxyServer = class {
|
|
|
129
142
|
recordingsDir;
|
|
130
143
|
recordingIdCounter;
|
|
131
144
|
// Unique ID for each recording entry
|
|
145
|
+
sequenceCounterByKey;
|
|
146
|
+
// Sequence counter per key (endpoint)
|
|
132
147
|
replaySessions;
|
|
133
148
|
// Track multiple concurrent replay sessions by recording ID
|
|
149
|
+
recordingPromises;
|
|
150
|
+
// Stack of promises that resolve to completed recordings
|
|
134
151
|
constructor(targets, recordingsDir) {
|
|
135
152
|
this.targets = targets;
|
|
136
153
|
this.currentTargetIndex = 0;
|
|
137
154
|
this.mode = Modes.transparent;
|
|
138
155
|
this.recordingId = null;
|
|
139
156
|
this.recordingIdCounter = 0;
|
|
157
|
+
this.sequenceCounterByKey = /* @__PURE__ */ new Map();
|
|
140
158
|
this.replayId = null;
|
|
141
159
|
this.modeTimeout = null;
|
|
142
160
|
this.currentSession = null;
|
|
143
161
|
this.recordingsDir = recordingsDir;
|
|
144
162
|
this.replaySessions = /* @__PURE__ */ new Map();
|
|
163
|
+
this.recordingPromises = [];
|
|
145
164
|
this.proxy = httpProxy__default.default.createProxyServer({
|
|
146
165
|
secure: false,
|
|
147
166
|
changeOrigin: true,
|
|
@@ -167,7 +186,7 @@ var ProxyServer = class {
|
|
|
167
186
|
}
|
|
168
187
|
setupProxyEventHandlers() {
|
|
169
188
|
this.proxy.on("error", this.handleProxyError.bind(this));
|
|
170
|
-
this.proxy.on("proxyRes", this.
|
|
189
|
+
this.proxy.on("proxyRes", this.addCorsHeaders.bind(this));
|
|
171
190
|
}
|
|
172
191
|
handleProxyError(err, req, res) {
|
|
173
192
|
console.error("Proxy error:", err);
|
|
@@ -183,12 +202,6 @@ var ProxyServer = class {
|
|
|
183
202
|
}
|
|
184
203
|
res.end(JSON.stringify({ error: "Proxy error", message: err.message }));
|
|
185
204
|
}
|
|
186
|
-
handleProxyResponse(proxyRes, req) {
|
|
187
|
-
this.addCorsHeaders(proxyRes, req);
|
|
188
|
-
if (this.mode === Modes.record && this.recordingId) {
|
|
189
|
-
this.recordResponse(req, proxyRes);
|
|
190
|
-
}
|
|
191
|
-
}
|
|
192
205
|
/**
|
|
193
206
|
* Get CORS headers for a given request
|
|
194
207
|
* @param req The incoming HTTP request
|
|
@@ -246,7 +259,15 @@ var ProxyServer = class {
|
|
|
246
259
|
* @returns The recording ID, or null if not found
|
|
247
260
|
*/
|
|
248
261
|
getRecordingIdFromRequest(req) {
|
|
249
|
-
|
|
262
|
+
const fromHeader = this.getRecordingIdFromHeader(req);
|
|
263
|
+
const fromCookie = this.getRecordingIdFromCookie(req);
|
|
264
|
+
if (fromHeader) {
|
|
265
|
+
return fromHeader;
|
|
266
|
+
}
|
|
267
|
+
if (fromCookie) {
|
|
268
|
+
return fromCookie;
|
|
269
|
+
}
|
|
270
|
+
return null;
|
|
250
271
|
}
|
|
251
272
|
/**
|
|
252
273
|
* Get or create a replay session state for a given recording ID
|
|
@@ -271,6 +292,27 @@ var ProxyServer = class {
|
|
|
271
292
|
}
|
|
272
293
|
return session;
|
|
273
294
|
}
|
|
295
|
+
/**
|
|
296
|
+
* Clean up a session - removes it from memory and resets counters
|
|
297
|
+
* @param sessionId The session ID to clean up
|
|
298
|
+
*/
|
|
299
|
+
async cleanupSession(sessionId) {
|
|
300
|
+
if (this.replaySessions.has(sessionId)) {
|
|
301
|
+
console.log(`[CLEANUP] Removing replay session: ${sessionId}`);
|
|
302
|
+
this.replaySessions.delete(sessionId);
|
|
303
|
+
}
|
|
304
|
+
if (this.recordingId === sessionId) {
|
|
305
|
+
console.log(`[CLEANUP] Saving and clearing active recording session: ${sessionId}`);
|
|
306
|
+
await this.saveCurrentSession();
|
|
307
|
+
this.currentSession = null;
|
|
308
|
+
this.recordingId = null;
|
|
309
|
+
}
|
|
310
|
+
if (this.replayId === sessionId) {
|
|
311
|
+
console.log(`[CLEANUP] Clearing active replay session: ${sessionId}`);
|
|
312
|
+
this.replayId = null;
|
|
313
|
+
}
|
|
314
|
+
console.log(`[CLEANUP] Session ${sessionId} cleaned up successfully`);
|
|
315
|
+
}
|
|
274
316
|
parseGetParams(req) {
|
|
275
317
|
const url = new URL(req.url || "", `http://${req.headers.host}`);
|
|
276
318
|
const mode = url.searchParams.get("mode");
|
|
@@ -282,19 +324,41 @@ var ProxyServer = class {
|
|
|
282
324
|
}
|
|
283
325
|
return { mode, id, timeout };
|
|
284
326
|
}
|
|
327
|
+
async parseControlRequest(req) {
|
|
328
|
+
if (req.method === "GET") {
|
|
329
|
+
return this.parseGetParams(req);
|
|
330
|
+
}
|
|
331
|
+
if (req.method === "POST") {
|
|
332
|
+
const body = await readRequestBody(req);
|
|
333
|
+
console.log(`MODE CHANGE (${req.method})`, body);
|
|
334
|
+
return JSON.parse(body);
|
|
335
|
+
}
|
|
336
|
+
throw new Error("Unsupported control method");
|
|
337
|
+
}
|
|
285
338
|
async handleControlRequest(req, res) {
|
|
339
|
+
if (req.method === "GET") {
|
|
340
|
+
sendJsonResponse(res, HTTP_STATUS_OK, {
|
|
341
|
+
recordingsDir: this.recordingsDir,
|
|
342
|
+
mode: this.mode,
|
|
343
|
+
id: this.recordingId || this.replayId
|
|
344
|
+
});
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
286
347
|
try {
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
348
|
+
const data = await this.parseControlRequest(req);
|
|
349
|
+
const { mode, id, timeout: requestTimeout, cleanup } = data;
|
|
350
|
+
if (cleanup && id) {
|
|
351
|
+
await this.cleanupSession(id);
|
|
352
|
+
sendJsonResponse(res, HTTP_STATUS_OK, {
|
|
353
|
+
success: true,
|
|
354
|
+
message: `Session ${id} cleaned up`,
|
|
355
|
+
mode: this.mode
|
|
356
|
+
});
|
|
295
357
|
return;
|
|
296
358
|
}
|
|
297
|
-
|
|
359
|
+
if (!mode) {
|
|
360
|
+
throw new Error("Mode parameter is required when cleanup is not specified");
|
|
361
|
+
}
|
|
298
362
|
const timeout = requestTimeout ?? DEFAULT_TIMEOUT_MS;
|
|
299
363
|
this.clearModeTimeout();
|
|
300
364
|
await this.switchMode(mode, id);
|
|
@@ -310,7 +374,8 @@ var ProxyServer = class {
|
|
|
310
374
|
success: true,
|
|
311
375
|
mode: this.mode,
|
|
312
376
|
id: this.recordingId || this.replayId,
|
|
313
|
-
timeout
|
|
377
|
+
timeout,
|
|
378
|
+
recordingsDir: this.recordingsDir
|
|
314
379
|
});
|
|
315
380
|
} catch (error) {
|
|
316
381
|
console.error("Control request error:", error);
|
|
@@ -326,7 +391,7 @@ var ProxyServer = class {
|
|
|
326
391
|
async switchMode(mode, id) {
|
|
327
392
|
console.log(`Switching to ${mode.toUpperCase()} mode`);
|
|
328
393
|
if (this.currentSession && this.mode === Modes.record) {
|
|
329
|
-
await this.saveCurrentSession(
|
|
394
|
+
await this.saveCurrentSession();
|
|
330
395
|
console.log("Session saved, continuing with mode switch");
|
|
331
396
|
}
|
|
332
397
|
switch (mode) {
|
|
@@ -366,6 +431,8 @@ var ProxyServer = class {
|
|
|
366
431
|
this.recordingId = id;
|
|
367
432
|
this.replayId = null;
|
|
368
433
|
this.currentSession = { id, recordings: [], websocketRecordings: [] };
|
|
434
|
+
this.recordingIdCounter = 0;
|
|
435
|
+
this.sequenceCounterByKey.clear();
|
|
369
436
|
console.log(`Switched to record mode with ID: ${id}`);
|
|
370
437
|
}
|
|
371
438
|
async switchToReplayMode(id) {
|
|
@@ -383,171 +450,119 @@ var ProxyServer = class {
|
|
|
383
450
|
console.log(`Switched to replay mode with ID: ${id}`);
|
|
384
451
|
}
|
|
385
452
|
setupModeTimeout(timeout) {
|
|
453
|
+
clearTimeout(this.modeTimeout || 0);
|
|
386
454
|
this.modeTimeout = setTimeout(async () => {
|
|
387
455
|
console.log("Timeout reached, switching back to transparent mode");
|
|
388
|
-
await this.saveCurrentSession(
|
|
456
|
+
await this.saveCurrentSession();
|
|
389
457
|
this.switchToTransparentMode();
|
|
390
458
|
this.modeTimeout = null;
|
|
391
459
|
}, timeout);
|
|
392
460
|
}
|
|
393
|
-
async
|
|
394
|
-
if (
|
|
461
|
+
async flushPendingRecordings() {
|
|
462
|
+
if (this.recordingPromises.length === 0) {
|
|
395
463
|
return;
|
|
396
464
|
}
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
(r) => r.response
|
|
404
|
-
);
|
|
465
|
+
const results = await Promise.allSettled(this.recordingPromises);
|
|
466
|
+
if (this.currentSession) {
|
|
467
|
+
for (const result of results) {
|
|
468
|
+
if (result.status === "fulfilled" && result.value) {
|
|
469
|
+
this.currentSession.recordings.push(result.value);
|
|
470
|
+
}
|
|
405
471
|
}
|
|
472
|
+
console.log(
|
|
473
|
+
`Flushed ${results.length} recordings to session (total: ${this.currentSession.recordings.length})`
|
|
474
|
+
);
|
|
406
475
|
}
|
|
407
|
-
|
|
408
|
-
`Saving session with ${this.currentSession.recordings.length} HTTP and ${this.currentSession.websocketRecordings.length} WebSocket recordings`
|
|
409
|
-
);
|
|
410
|
-
await saveRecordingSession(this.recordingsDir, this.currentSession);
|
|
476
|
+
this.recordingPromises = [];
|
|
411
477
|
}
|
|
412
|
-
|
|
478
|
+
async saveCurrentSession() {
|
|
413
479
|
if (!this.currentSession) {
|
|
414
480
|
return;
|
|
415
481
|
}
|
|
416
|
-
|
|
417
|
-
const recordingId = this.recordingIdCounter++;
|
|
418
|
-
req.__recordingId = recordingId;
|
|
419
|
-
const record = {
|
|
420
|
-
request: {
|
|
421
|
-
method: req.method,
|
|
422
|
-
url: req.url,
|
|
423
|
-
headers: req.headers,
|
|
424
|
-
body: body || null
|
|
425
|
-
},
|
|
426
|
-
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
427
|
-
key,
|
|
428
|
-
recordingId
|
|
429
|
-
};
|
|
430
|
-
this.currentSession.recordings.push(record);
|
|
482
|
+
await this.flushPendingRecordings();
|
|
431
483
|
console.log(
|
|
432
|
-
|
|
433
|
-
`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})`
|
|
484
|
+
`Saving session with ${this.currentSession.recordings.length} HTTP and ${this.currentSession.websocketRecordings.length} WebSocket recordings`
|
|
434
485
|
);
|
|
486
|
+
await saveRecordingSession(this.recordingsDir, this.currentSession);
|
|
435
487
|
}
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
488
|
+
getRecordingIdOrError(req, res) {
|
|
489
|
+
const recordingIdFromRequest = this.getRecordingIdFromRequest(req);
|
|
490
|
+
if (recordingIdFromRequest) {
|
|
491
|
+
return recordingIdFromRequest;
|
|
439
492
|
}
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
`updateRequestBodySync: No recording ID found on request ${req.method} ${req.url}`
|
|
493
|
+
if (this.replaySessions.size > 1) {
|
|
494
|
+
console.warn(
|
|
495
|
+
`[CONCURRENT REPLAY WARNING] Request to ${req.method} ${req.url} is missing ${RECORDING_ID_HEADER} header/cookie. Active sessions: ${[...this.replaySessions.keys()].join(", ")}. this.replayId fallback would be: ${this.replayId} (NOT USING - could be wrong session)`
|
|
444
496
|
);
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
497
|
+
const corsHeaders = this.getCorsHeaders(req);
|
|
498
|
+
res.writeHead(HTTP_STATUS_BAD_REQUEST, {
|
|
499
|
+
"Content-Type": "application/json",
|
|
500
|
+
...corsHeaders
|
|
501
|
+
});
|
|
502
|
+
res.end(
|
|
503
|
+
JSON.stringify({
|
|
504
|
+
error: "Missing recording ID in concurrent replay mode. Ensure x-test-rcrd-id header is set.",
|
|
505
|
+
activeSessions: [...this.replaySessions.keys()],
|
|
506
|
+
hint: "This usually means page.setExtraHTTPHeaders() did not apply to this request type"
|
|
507
|
+
})
|
|
453
508
|
);
|
|
454
|
-
return;
|
|
509
|
+
return null;
|
|
510
|
+
}
|
|
511
|
+
const recordingId = this.replayId;
|
|
512
|
+
if (!recordingId) {
|
|
513
|
+
const corsHeaders = this.getCorsHeaders(req);
|
|
514
|
+
res.writeHead(HTTP_STATUS_BAD_REQUEST, {
|
|
515
|
+
"Content-Type": "application/json",
|
|
516
|
+
...corsHeaders
|
|
517
|
+
});
|
|
518
|
+
res.end(JSON.stringify({ error: "No replay session active" }));
|
|
519
|
+
return null;
|
|
455
520
|
}
|
|
456
|
-
record.request.body = body || null;
|
|
457
521
|
console.log(
|
|
458
|
-
`
|
|
522
|
+
`[FALLBACK] Using replayId fallback for ${req.method} ${req.url} -> session: ${recordingId} (single session mode)`
|
|
459
523
|
);
|
|
524
|
+
return recordingId;
|
|
460
525
|
}
|
|
461
|
-
async
|
|
462
|
-
|
|
463
|
-
|
|
526
|
+
async ensureSessionLoaded(recordingId, filePath) {
|
|
527
|
+
const sessionState = this.getOrCreateReplaySession(recordingId);
|
|
528
|
+
if (!sessionState.loadedSession) {
|
|
529
|
+
sessionState.loadedSession = await loadRecordingSession(filePath);
|
|
530
|
+
console.log(`[REPLAY] Loaded recording session: ${recordingId}`);
|
|
464
531
|
}
|
|
465
|
-
|
|
466
|
-
if (recordingId === void 0) {
|
|
467
|
-
console.error(
|
|
468
|
-
`recordResponse: No recording ID found on request ${req.method} ${req.url}`
|
|
469
|
-
);
|
|
470
|
-
return;
|
|
471
|
-
}
|
|
472
|
-
const record = this.currentSession.recordings.find(
|
|
473
|
-
(r) => r.recordingId === recordingId
|
|
474
|
-
);
|
|
475
|
-
if (!record) {
|
|
476
|
-
console.error(
|
|
477
|
-
`recordResponse: Could not find recording with ID ${recordingId} for ${req.method} ${req.url}`
|
|
478
|
-
);
|
|
479
|
-
return;
|
|
480
|
-
}
|
|
481
|
-
const chunks = [];
|
|
482
|
-
proxyRes.on("data", (chunk) => {
|
|
483
|
-
chunks.push(chunk);
|
|
484
|
-
});
|
|
485
|
-
proxyRes.on("end", async () => {
|
|
486
|
-
const body = Buffer.concat(chunks).toString("utf8");
|
|
487
|
-
record.response = {
|
|
488
|
-
statusCode: proxyRes.statusCode,
|
|
489
|
-
headers: proxyRes.headers,
|
|
490
|
-
body: body || null
|
|
491
|
-
};
|
|
492
|
-
console.log(
|
|
493
|
-
`Recorded: ${req.method} ${req.url} (recordingId: ${recordingId})`
|
|
494
|
-
);
|
|
495
|
-
});
|
|
532
|
+
return sessionState;
|
|
496
533
|
}
|
|
497
|
-
|
|
498
|
-
if (!
|
|
499
|
-
|
|
534
|
+
getServedTracker(sessionState, key) {
|
|
535
|
+
if (!sessionState.servedRecordingIdsByKey.has(key)) {
|
|
536
|
+
sessionState.servedRecordingIdsByKey.set(key, /* @__PURE__ */ new Set());
|
|
500
537
|
}
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
)
|
|
506
|
-
|
|
538
|
+
return sessionState.servedRecordingIdsByKey.get(key);
|
|
539
|
+
}
|
|
540
|
+
selectReplayRecord(recordsWithKey, servedForThisKey, key, recordingId) {
|
|
541
|
+
for (const rec of recordsWithKey) {
|
|
542
|
+
if (!servedForThisKey.has(rec.recordingId)) {
|
|
543
|
+
return rec;
|
|
544
|
+
}
|
|
507
545
|
}
|
|
508
|
-
|
|
509
|
-
(
|
|
510
|
-
|
|
511
|
-
if (!record) {
|
|
512
|
-
console.error(
|
|
513
|
-
`recordResponseData: Could not find recording with ID ${recordingId} for ${req.method} ${req.url}`
|
|
546
|
+
if (recordsWithKey.length > 0) {
|
|
547
|
+
console.log(
|
|
548
|
+
`[REPLAY WARNING] All ${recordsWithKey.length} recordings already served for ${key} (session: ${recordingId}), reusing last one`
|
|
514
549
|
);
|
|
515
|
-
return
|
|
550
|
+
return recordsWithKey[recordsWithKey.length - 1];
|
|
516
551
|
}
|
|
517
|
-
|
|
518
|
-
statusCode: proxyRes.statusCode,
|
|
519
|
-
headers: proxyRes.headers,
|
|
520
|
-
body: body || null
|
|
521
|
-
};
|
|
522
|
-
console.log(
|
|
523
|
-
`recordResponseData: Recorded response for ${req.method} ${req.url} (recordingId: ${recordingId})`
|
|
524
|
-
);
|
|
525
|
-
return true;
|
|
552
|
+
return null;
|
|
526
553
|
}
|
|
527
554
|
async handleReplayRequest(req, res) {
|
|
528
|
-
const recordingId = this.
|
|
529
|
-
if (!recordingId)
|
|
530
|
-
const corsHeaders = this.getCorsHeaders(req);
|
|
531
|
-
res.writeHead(HTTP_STATUS_BAD_REQUEST, {
|
|
532
|
-
"Content-Type": "application/json",
|
|
533
|
-
...corsHeaders
|
|
534
|
-
});
|
|
535
|
-
res.end(JSON.stringify({ error: "No replay session active" }));
|
|
536
|
-
return;
|
|
537
|
-
}
|
|
555
|
+
const recordingId = this.getRecordingIdOrError(req, res);
|
|
556
|
+
if (!recordingId) return;
|
|
538
557
|
const key = getReqID(req);
|
|
539
558
|
const filePath = getRecordingPath(this.recordingsDir, recordingId);
|
|
540
559
|
try {
|
|
541
|
-
const sessionState = this.
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
}
|
|
560
|
+
const sessionState = await this.ensureSessionLoaded(
|
|
561
|
+
recordingId,
|
|
562
|
+
filePath
|
|
563
|
+
);
|
|
546
564
|
const session = sessionState.loadedSession;
|
|
547
|
-
|
|
548
|
-
sessionState.servedRecordingIdsByKey.set(key, /* @__PURE__ */ new Set());
|
|
549
|
-
}
|
|
550
|
-
const servedForThisKey = sessionState.servedRecordingIdsByKey.get(key);
|
|
565
|
+
const servedForThisKey = this.getServedTracker(sessionState, key);
|
|
551
566
|
const host = req.headers.host || "unknown";
|
|
552
567
|
const recordsWithKey = session.recordings.filter((r) => r.key === key && r.response).toSorted((a, b) => {
|
|
553
568
|
const aSeq = a.sequence !== void 0 ? a.sequence : a.recordingId;
|
|
@@ -576,30 +591,23 @@ var ProxyServer = class {
|
|
|
576
591
|
}
|
|
577
592
|
const requestCount = servedForThisKey.size + 1;
|
|
578
593
|
console.log(
|
|
579
|
-
`[replay request #${requestCount}] ${req.method} ${req.url} (session: ${recordingId}, total: ${recordsWithKey.length}, served: ${servedForThisKey.size})`
|
|
594
|
+
`[replay request #${requestCount}] ${req.method} ${req.url} (key: ${key}, session: ${recordingId}, total: ${recordsWithKey.length}, served: ${servedForThisKey.size})`
|
|
580
595
|
);
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
`[REPLAY WARNING] All ${recordsWithKey.length} recordings already served for ${key} (session: ${recordingId}), reusing last one`
|
|
596
|
+
const record = this.selectReplayRecord(
|
|
597
|
+
recordsWithKey,
|
|
598
|
+
servedForThisKey,
|
|
599
|
+
key,
|
|
600
|
+
recordingId
|
|
601
|
+
);
|
|
602
|
+
if (!record || !record.response) {
|
|
603
|
+
throw new Error(
|
|
604
|
+
`No response recorded for this request: ${req.method} ${host}${req.url}`
|
|
591
605
|
);
|
|
592
|
-
record = recordsWithKey[recordsWithKey.length - 1];
|
|
593
606
|
}
|
|
594
607
|
servedForThisKey.add(record.recordingId);
|
|
595
608
|
console.log(
|
|
596
609
|
`[replay serving] recordingId: ${record.recordingId}, session: ${recordingId}, body_len: ${record.response?.body?.length || 0}`
|
|
597
610
|
);
|
|
598
|
-
if (!record.response) {
|
|
599
|
-
throw new Error(
|
|
600
|
-
`No response recorded for this request: ${req.method} ${host}${req.url}`
|
|
601
|
-
);
|
|
602
|
-
}
|
|
603
611
|
const { statusCode, headers, body } = record.response;
|
|
604
612
|
const responseHeaders = {
|
|
605
613
|
...headers,
|
|
@@ -654,82 +662,124 @@ var ProxyServer = class {
|
|
|
654
662
|
const target = this.getTarget();
|
|
655
663
|
console.log(`[${this.mode}] ${req.method} ${req.url} -> ${target}`);
|
|
656
664
|
if (this.mode === Modes.record) {
|
|
657
|
-
this.
|
|
658
|
-
await this.bufferAndProxyRequest(req, res, target);
|
|
665
|
+
await this.recordAndProxyRequest(req, res, target);
|
|
659
666
|
} else {
|
|
660
667
|
this.proxy.web(req, res, { target });
|
|
661
668
|
}
|
|
662
669
|
}
|
|
663
|
-
//
|
|
664
|
-
async
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
chunks.push(chunk);
|
|
668
|
-
});
|
|
669
|
-
try {
|
|
670
|
-
await new Promise((resolve, reject) => {
|
|
671
|
-
req.on("end", () => resolve());
|
|
672
|
-
req.on("error", (err) => reject(err));
|
|
673
|
-
setTimeout(
|
|
674
|
-
() => reject(new Error("Request buffering timeout")),
|
|
675
|
-
3e4
|
|
676
|
-
);
|
|
677
|
-
});
|
|
678
|
-
} catch (error) {
|
|
679
|
-
console.error("Error buffering request:", error);
|
|
670
|
+
// Note: streaming requests are buffered before proxying; streaming passthrough is not yet implemented
|
|
671
|
+
async recordAndProxyRequest(req, res, target) {
|
|
672
|
+
if (!this.currentSession) {
|
|
673
|
+
return;
|
|
680
674
|
}
|
|
681
|
-
const
|
|
682
|
-
this.
|
|
683
|
-
const
|
|
684
|
-
|
|
685
|
-
const
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
const recorded = await this.recordResponseData(
|
|
704
|
-
req,
|
|
705
|
-
proxyRes,
|
|
706
|
-
responseBody.toString("utf8")
|
|
707
|
-
);
|
|
708
|
-
const responseHeaders = {
|
|
709
|
-
...proxyRes.headers,
|
|
710
|
-
...this.getCorsHeaders(req)
|
|
711
|
-
};
|
|
712
|
-
res.writeHead(proxyRes.statusCode || 200, responseHeaders);
|
|
713
|
-
res.end(responseBody);
|
|
714
|
-
if (recorded) {
|
|
715
|
-
console.log(`Recorded: ${req.method} ${req.url}`);
|
|
675
|
+
const key = getReqID(req);
|
|
676
|
+
const recordingId = this.recordingIdCounter++;
|
|
677
|
+
const sequence = this.sequenceCounterByKey.get(key) || 0;
|
|
678
|
+
this.sequenceCounterByKey.set(key, sequence + 1);
|
|
679
|
+
const recordingPromise = new Promise((resolve) => {
|
|
680
|
+
(async () => {
|
|
681
|
+
try {
|
|
682
|
+
const chunks = [];
|
|
683
|
+
req.on("data", (chunk) => {
|
|
684
|
+
chunks.push(chunk);
|
|
685
|
+
});
|
|
686
|
+
try {
|
|
687
|
+
await new Promise((resolveBuffer, rejectBuffer) => {
|
|
688
|
+
req.on("end", () => resolveBuffer());
|
|
689
|
+
req.on("error", (err) => rejectBuffer(err));
|
|
690
|
+
setTimeout(
|
|
691
|
+
() => rejectBuffer(new Error("Request buffering timeout")),
|
|
692
|
+
3e4
|
|
693
|
+
);
|
|
694
|
+
});
|
|
695
|
+
} catch (error) {
|
|
696
|
+
console.error("Error buffering request:", error);
|
|
716
697
|
}
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
698
|
+
const requestBody = Buffer.concat(chunks).toString("utf8");
|
|
699
|
+
const targetUrl = new URL(target);
|
|
700
|
+
const isHttps = targetUrl.protocol === "https:";
|
|
701
|
+
const requestModule = isHttps ? https__default.default : http__default.default;
|
|
702
|
+
const defaultPort = isHttps ? 443 : 80;
|
|
703
|
+
const proxyReq = requestModule.request(
|
|
704
|
+
{
|
|
705
|
+
hostname: targetUrl.hostname,
|
|
706
|
+
port: targetUrl.port || defaultPort,
|
|
707
|
+
path: req.url,
|
|
708
|
+
method: req.method,
|
|
709
|
+
headers: req.headers
|
|
710
|
+
},
|
|
711
|
+
(proxyRes) => {
|
|
712
|
+
this.addCorsHeaders(proxyRes, req);
|
|
713
|
+
const responseChunks = [];
|
|
714
|
+
proxyRes.on("data", (chunk) => {
|
|
715
|
+
responseChunks.push(chunk);
|
|
716
|
+
});
|
|
717
|
+
proxyRes.on("end", async () => {
|
|
718
|
+
try {
|
|
719
|
+
const responseBody = Buffer.concat(responseChunks);
|
|
720
|
+
const responseBodyStr = responseBody.toString("utf8");
|
|
721
|
+
const recording = {
|
|
722
|
+
request: {
|
|
723
|
+
method: req.method,
|
|
724
|
+
url: req.url,
|
|
725
|
+
headers: req.headers,
|
|
726
|
+
body: requestBody || null
|
|
727
|
+
},
|
|
728
|
+
response: {
|
|
729
|
+
statusCode: proxyRes.statusCode,
|
|
730
|
+
headers: proxyRes.headers,
|
|
731
|
+
body: responseBodyStr || null
|
|
732
|
+
},
|
|
733
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
734
|
+
key,
|
|
735
|
+
recordingId,
|
|
736
|
+
sequence
|
|
737
|
+
};
|
|
738
|
+
const responseHeaders = {
|
|
739
|
+
...proxyRes.headers,
|
|
740
|
+
...this.getCorsHeaders(req)
|
|
741
|
+
};
|
|
742
|
+
res.writeHead(proxyRes.statusCode || 200, responseHeaders);
|
|
743
|
+
res.end(responseBody);
|
|
744
|
+
console.log(
|
|
745
|
+
`Recorded: ${req.method} ${req.url} (recordingId: ${recordingId}, sequence: ${sequence})`
|
|
746
|
+
);
|
|
747
|
+
resolve(recording);
|
|
748
|
+
} catch (error) {
|
|
749
|
+
console.error("Error completing recording:", error);
|
|
750
|
+
resolve(null);
|
|
751
|
+
}
|
|
752
|
+
});
|
|
753
|
+
proxyRes.on("error", (err) => {
|
|
754
|
+
console.error("Proxy response error:", err);
|
|
755
|
+
if (!res.headersSent) {
|
|
756
|
+
this.handleProxyError(err, req, res);
|
|
757
|
+
}
|
|
758
|
+
resolve(null);
|
|
759
|
+
});
|
|
760
|
+
}
|
|
761
|
+
);
|
|
762
|
+
proxyReq.on("error", (err) => {
|
|
721
763
|
this.handleProxyError(err, req, res);
|
|
764
|
+
resolve(null);
|
|
765
|
+
});
|
|
766
|
+
if (chunks.length > 0) {
|
|
767
|
+
proxyReq.write(Buffer.concat(chunks));
|
|
722
768
|
}
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
769
|
+
proxyReq.end();
|
|
770
|
+
} catch (error) {
|
|
771
|
+
console.error("Error in recordAndProxyRequest:", error);
|
|
772
|
+
try {
|
|
773
|
+
this.handleProxyError(error, req, res);
|
|
774
|
+
} catch (error_) {
|
|
775
|
+
console.error("Failed to handle proxy error:", error_);
|
|
776
|
+
}
|
|
777
|
+
resolve(null);
|
|
778
|
+
}
|
|
779
|
+
})();
|
|
728
780
|
});
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
}
|
|
732
|
-
proxyReq.end();
|
|
781
|
+
this.recordingPromises.push(recordingPromise);
|
|
782
|
+
await recordingPromise;
|
|
733
783
|
}
|
|
734
784
|
handleUpgrade(req, socket, head) {
|
|
735
785
|
if (this.mode === Modes.replay) {
|
|
@@ -894,8 +944,6 @@ var ProxyServer = class {
|
|
|
894
944
|
);
|
|
895
945
|
}
|
|
896
946
|
};
|
|
897
|
-
|
|
898
|
-
// src/playwright/index.ts
|
|
899
947
|
function getProxyPort() {
|
|
900
948
|
const envPort = process.env.TEST_PROXY_RECORDER_PORT;
|
|
901
949
|
if (envPort) {
|
|
@@ -931,6 +979,30 @@ async function setProxyMode(mode, sessionId, timeout) {
|
|
|
931
979
|
throw error;
|
|
932
980
|
}
|
|
933
981
|
}
|
|
982
|
+
async function cleanupSession(sessionId) {
|
|
983
|
+
const proxyPort = getProxyPort();
|
|
984
|
+
try {
|
|
985
|
+
const body = {
|
|
986
|
+
cleanup: true,
|
|
987
|
+
id: sessionId
|
|
988
|
+
};
|
|
989
|
+
const response = await fetch(`http://127.0.0.1:${proxyPort}/__control`, {
|
|
990
|
+
method: "POST",
|
|
991
|
+
headers: { "Content-Type": "application/json" },
|
|
992
|
+
body: JSON.stringify(body)
|
|
993
|
+
});
|
|
994
|
+
if (!response.ok) {
|
|
995
|
+
const text = await response.text();
|
|
996
|
+
console.error(`Failed to cleanup session ${sessionId}:`, text);
|
|
997
|
+
throw new Error(`Failed to cleanup session: ${text}`);
|
|
998
|
+
}
|
|
999
|
+
await response.json();
|
|
1000
|
+
console.log(`Session cleaned up: ${sessionId}`);
|
|
1001
|
+
} catch (error) {
|
|
1002
|
+
console.error(`Error cleaning up session:`, error);
|
|
1003
|
+
throw error;
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
934
1006
|
function parseSpecFilePath(specPath) {
|
|
935
1007
|
const folderMatch = specPath.match(/^(.+?)\/([^/]+)\.(spec|test)\.ts$/);
|
|
936
1008
|
if (folderMatch) {
|
|
@@ -972,6 +1044,53 @@ async function stopProxy(testInfo) {
|
|
|
972
1044
|
const sessionId = generateSessionId(testInfo);
|
|
973
1045
|
await setProxyMode(Modes.transparent, sessionId);
|
|
974
1046
|
}
|
|
1047
|
+
var cachedRecordingsDir = null;
|
|
1048
|
+
async function getRecordingsDir() {
|
|
1049
|
+
if (cachedRecordingsDir) {
|
|
1050
|
+
return cachedRecordingsDir;
|
|
1051
|
+
}
|
|
1052
|
+
const proxyPort = getProxyPort();
|
|
1053
|
+
try {
|
|
1054
|
+
const response = await fetch(`http://127.0.0.1:${proxyPort}/__control`);
|
|
1055
|
+
if (response.ok) {
|
|
1056
|
+
const data = await response.json();
|
|
1057
|
+
if (data.recordingsDir) {
|
|
1058
|
+
cachedRecordingsDir = data.recordingsDir;
|
|
1059
|
+
return cachedRecordingsDir;
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
} catch (error) {
|
|
1063
|
+
console.warn(
|
|
1064
|
+
"Failed to get recordings directory from proxy, using default:",
|
|
1065
|
+
error
|
|
1066
|
+
);
|
|
1067
|
+
}
|
|
1068
|
+
cachedRecordingsDir = path2__default.default.join(process.cwd(), "e2e", "recordings");
|
|
1069
|
+
return cachedRecordingsDir;
|
|
1070
|
+
}
|
|
1071
|
+
async function setupClientSideRecording(page, sessionId, mode, url) {
|
|
1072
|
+
const harFileName = sessionId.replaceAll("/", "__");
|
|
1073
|
+
const recordingsDir = await getRecordingsDir();
|
|
1074
|
+
const harPath = path2__default.default.join(recordingsDir, `${harFileName}.har`);
|
|
1075
|
+
console.log(
|
|
1076
|
+
`[Client-Side Recording] Setting up HAR for session: ${sessionId}, mode: ${mode}, path: ${harPath}`
|
|
1077
|
+
);
|
|
1078
|
+
try {
|
|
1079
|
+
await page.routeFromHAR(harPath, {
|
|
1080
|
+
url,
|
|
1081
|
+
update: mode === Modes.record,
|
|
1082
|
+
updateContent: "embed"
|
|
1083
|
+
});
|
|
1084
|
+
} catch (error) {
|
|
1085
|
+
if (mode === Modes.replay) {
|
|
1086
|
+
console.error(
|
|
1087
|
+
`[Client-Side Replay] Failed to load HAR file. Run tests in record mode first.`,
|
|
1088
|
+
error
|
|
1089
|
+
);
|
|
1090
|
+
throw error;
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
975
1094
|
var playwrightProxy = {
|
|
976
1095
|
/**
|
|
977
1096
|
* Setup before test - sets the proxy mode and configures page with custom header
|
|
@@ -979,24 +1098,84 @@ var playwrightProxy = {
|
|
|
979
1098
|
* @param page - Playwright page object
|
|
980
1099
|
* @param testInfo - Playwright test info object
|
|
981
1100
|
* @param mode - The proxy mode to use for this test
|
|
982
|
-
* @param
|
|
1101
|
+
* @param options - Optional configuration including timeout and client-side recording patterns
|
|
983
1102
|
*/
|
|
984
|
-
async before(page, testInfo, mode,
|
|
1103
|
+
async before(page, testInfo, mode, options) {
|
|
1104
|
+
const timeout = typeof options === "number" ? options : options?.timeout;
|
|
1105
|
+
const clientSideOptions = typeof options === "object" && options !== null ? options : void 0;
|
|
985
1106
|
const sessionId = generateSessionId(testInfo);
|
|
986
1107
|
await page.setExtraHTTPHeaders({
|
|
987
1108
|
[RECORDING_ID_HEADER]: sessionId
|
|
988
1109
|
});
|
|
1110
|
+
console.log(`[Setup] Setting proxy mode: ${mode}, session: ${sessionId}`);
|
|
989
1111
|
await setProxyMode(mode, sessionId, timeout);
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1112
|
+
console.log(`[Setup] Proxy mode set successfully`);
|
|
1113
|
+
if (clientSideOptions?.url) {
|
|
1114
|
+
console.log(`[Setup] Setting up client-side recording with pattern: ${clientSideOptions.url}`);
|
|
1115
|
+
await setupClientSideRecording(
|
|
1116
|
+
page,
|
|
1117
|
+
sessionId,
|
|
1118
|
+
mode,
|
|
1119
|
+
clientSideOptions.url
|
|
1120
|
+
);
|
|
1121
|
+
console.log(`[Setup] Client-side recording setup complete`);
|
|
1122
|
+
}
|
|
1123
|
+
const proxyPort = process.env.TEST_PROXY_RECORDER_PORT || "8100";
|
|
1124
|
+
const proxyUrl = `localhost:${proxyPort}`;
|
|
1125
|
+
console.log(`[Setup] Registering proxy route handler for: ${proxyUrl}`);
|
|
1126
|
+
await page.route(
|
|
1127
|
+
(url) => {
|
|
1128
|
+
const urlStr = url.toString();
|
|
1129
|
+
const matches = urlStr.includes(proxyUrl);
|
|
1130
|
+
if (matches) {
|
|
1131
|
+
console.log(`[Route Matcher] Matched proxy request: ${urlStr}`);
|
|
1132
|
+
}
|
|
1133
|
+
return matches;
|
|
1134
|
+
},
|
|
1135
|
+
async (route) => {
|
|
1136
|
+
try {
|
|
1137
|
+
const url = route.request().url();
|
|
1138
|
+
const method = route.request().method();
|
|
1139
|
+
const headers = route.request().headers();
|
|
1140
|
+
const hadHeader = !!headers[RECORDING_ID_HEADER];
|
|
1141
|
+
headers[RECORDING_ID_HEADER] = sessionId;
|
|
1142
|
+
console.log(
|
|
1143
|
+
`[Route Intercept] ${method} ${url} (had header: ${hadHeader}, adding session: ${sessionId})`
|
|
1144
|
+
);
|
|
1145
|
+
await route.continue({ headers });
|
|
1146
|
+
} catch (error) {
|
|
1147
|
+
console.error(
|
|
1148
|
+
`[Route Handler Error] Failed to add ${RECORDING_ID_HEADER} header:`,
|
|
1149
|
+
error
|
|
1150
|
+
);
|
|
1151
|
+
await route.fallback();
|
|
1152
|
+
}
|
|
1153
|
+
},
|
|
1154
|
+
{ times: Infinity }
|
|
1155
|
+
// Ensure the handler applies to all matching requests
|
|
1156
|
+
);
|
|
1157
|
+
console.log(`[Setup] Proxy route handler registered`);
|
|
1158
|
+
const context = page.context();
|
|
1159
|
+
const contextId = context._guid || "default";
|
|
1160
|
+
const handlerKey = `cleanup_${contextId}`;
|
|
1161
|
+
if (!globalThis[handlerKey]) {
|
|
1162
|
+
globalThis[handlerKey] = true;
|
|
1163
|
+
context.on("close", async () => {
|
|
1164
|
+
try {
|
|
1165
|
+
console.log(
|
|
1166
|
+
`[Cleanup] Browser context closed, cleaning up session: ${sessionId}`
|
|
1167
|
+
);
|
|
1168
|
+
await cleanupSession(sessionId);
|
|
1169
|
+
} catch (error) {
|
|
1170
|
+
console.warn(
|
|
1171
|
+
`[Cleanup] Failed to cleanup session ${sessionId}:`,
|
|
1172
|
+
error
|
|
1173
|
+
);
|
|
1174
|
+
} finally {
|
|
1175
|
+
delete globalThis[handlerKey];
|
|
1176
|
+
}
|
|
1177
|
+
});
|
|
1178
|
+
}
|
|
1000
1179
|
},
|
|
1001
1180
|
/**
|
|
1002
1181
|
* Global teardown - switches proxy to transparent mode
|