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