test-proxy-recorder 0.2.0 → 0.3.1
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 +134 -33
- package/dist/{index-nmNRt1WE.d.cts → index-CVuiglPk.d.cts} +6 -10
- package/dist/{index-nmNRt1WE.d.ts → index-CVuiglPk.d.ts} +6 -10
- package/dist/index.cjs +291 -258
- package/dist/index.d.cts +25 -8
- package/dist/index.d.ts +25 -8
- package/dist/index.mjs +288 -259
- package/dist/nextjs/index.cjs +43 -0
- package/dist/nextjs/index.d.cts +89 -0
- package/dist/nextjs/index.d.ts +89 -0
- package/dist/nextjs/index.mjs +38 -0
- package/dist/playwright/index.cjs +20 -11
- package/dist/playwright/index.d.cts +1 -1
- package/dist/playwright/index.d.ts +1 -1
- package/dist/playwright/index.mjs +20 -11
- package/dist/proxy.js +239 -246
- package/package.json +6 -1
package/dist/index.cjs
CHANGED
|
@@ -19,8 +19,6 @@ var crypto__default = /*#__PURE__*/_interopDefault(crypto);
|
|
|
19
19
|
var path__default = /*#__PURE__*/_interopDefault(path);
|
|
20
20
|
var filenamify2__default = /*#__PURE__*/_interopDefault(filenamify2);
|
|
21
21
|
|
|
22
|
-
// src/ProxyServer.ts
|
|
23
|
-
|
|
24
22
|
// src/constants.ts
|
|
25
23
|
var DEFAULT_TIMEOUT_MS = 120 * 1e3;
|
|
26
24
|
var HTTP_STATUS_BAD_GATEWAY = 502;
|
|
@@ -28,6 +26,7 @@ var HTTP_STATUS_OK = 200;
|
|
|
28
26
|
var HTTP_STATUS_BAD_REQUEST = 400;
|
|
29
27
|
var HTTP_STATUS_NOT_FOUND = 404;
|
|
30
28
|
var CONTROL_ENDPOINT = "/__control";
|
|
29
|
+
var RECORDING_ID_HEADER = "x-test-rcrd-id";
|
|
31
30
|
|
|
32
31
|
// src/types.ts
|
|
33
32
|
var Modes = {
|
|
@@ -61,13 +60,23 @@ async function loadRecordingSession(filePath) {
|
|
|
61
60
|
return JSON.parse(fileContent);
|
|
62
61
|
}
|
|
63
62
|
function processRecordings(recordings) {
|
|
64
|
-
const
|
|
65
|
-
|
|
63
|
+
const recordingsByKey = /* @__PURE__ */ new Map();
|
|
64
|
+
for (const recording of recordings) {
|
|
66
65
|
const key = recording.key;
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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;
|
|
71
80
|
}
|
|
72
81
|
async function saveRecordingSession(recordingsDir, session) {
|
|
73
82
|
const filePath = getRecordingPath(recordingsDir, session.id);
|
|
@@ -130,19 +139,25 @@ var ProxyServer = class {
|
|
|
130
139
|
recordingsDir;
|
|
131
140
|
recordingIdCounter;
|
|
132
141
|
// Unique ID for each recording entry
|
|
142
|
+
sequenceCounterByKey;
|
|
143
|
+
// Sequence counter per key (endpoint)
|
|
133
144
|
replaySessions;
|
|
134
145
|
// Track multiple concurrent replay sessions by recording ID
|
|
146
|
+
recordingPromises;
|
|
147
|
+
// Stack of promises that resolve to completed recordings
|
|
135
148
|
constructor(targets, recordingsDir) {
|
|
136
149
|
this.targets = targets;
|
|
137
150
|
this.currentTargetIndex = 0;
|
|
138
151
|
this.mode = Modes.transparent;
|
|
139
152
|
this.recordingId = null;
|
|
140
153
|
this.recordingIdCounter = 0;
|
|
154
|
+
this.sequenceCounterByKey = /* @__PURE__ */ new Map();
|
|
141
155
|
this.replayId = null;
|
|
142
156
|
this.modeTimeout = null;
|
|
143
157
|
this.currentSession = null;
|
|
144
158
|
this.recordingsDir = recordingsDir;
|
|
145
159
|
this.replaySessions = /* @__PURE__ */ new Map();
|
|
160
|
+
this.recordingPromises = [];
|
|
146
161
|
this.proxy = httpProxy__default.default.createProxyServer({
|
|
147
162
|
secure: false,
|
|
148
163
|
changeOrigin: true,
|
|
@@ -168,7 +183,7 @@ var ProxyServer = class {
|
|
|
168
183
|
}
|
|
169
184
|
setupProxyEventHandlers() {
|
|
170
185
|
this.proxy.on("error", this.handleProxyError.bind(this));
|
|
171
|
-
this.proxy.on("proxyRes", this.
|
|
186
|
+
this.proxy.on("proxyRes", this.addCorsHeaders.bind(this));
|
|
172
187
|
}
|
|
173
188
|
handleProxyError(err, req, res) {
|
|
174
189
|
console.error("Proxy error:", err);
|
|
@@ -184,12 +199,6 @@ var ProxyServer = class {
|
|
|
184
199
|
}
|
|
185
200
|
res.end(JSON.stringify({ error: "Proxy error", message: err.message }));
|
|
186
201
|
}
|
|
187
|
-
handleProxyResponse(proxyRes, req) {
|
|
188
|
-
this.addCorsHeaders(proxyRes, req);
|
|
189
|
-
if (this.mode === Modes.record && this.recordingId) {
|
|
190
|
-
this.recordResponse(req, proxyRes);
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
202
|
/**
|
|
194
203
|
* Get CORS headers for a given request
|
|
195
204
|
* @param req The incoming HTTP request
|
|
@@ -200,7 +209,7 @@ var ProxyServer = class {
|
|
|
200
209
|
return {
|
|
201
210
|
"access-control-allow-origin": origin || "*",
|
|
202
211
|
"access-control-allow-credentials": "true",
|
|
203
|
-
"access-control-allow-headers": req.headers["access-control-request-headers"] ||
|
|
212
|
+
"access-control-allow-headers": req.headers["access-control-request-headers"] || `Origin, X-Requested-With, Content-Type, Accept, Authorization, ${RECORDING_ID_HEADER}`,
|
|
204
213
|
"access-control-allow-methods": "GET, POST, PUT, DELETE, PATCH, OPTIONS",
|
|
205
214
|
"access-control-expose-headers": "*"
|
|
206
215
|
};
|
|
@@ -214,9 +223,22 @@ var ProxyServer = class {
|
|
|
214
223
|
this.currentTargetIndex = (this.currentTargetIndex + 1) % this.targets.length;
|
|
215
224
|
return target;
|
|
216
225
|
}
|
|
226
|
+
/**
|
|
227
|
+
* Extract recording ID from custom HTTP header
|
|
228
|
+
* Used for concurrent replay session routing, especially with Next.js
|
|
229
|
+
* @param req The incoming HTTP request
|
|
230
|
+
* @returns The recording ID from header, or null if not found
|
|
231
|
+
*/
|
|
232
|
+
getRecordingIdFromHeader(req) {
|
|
233
|
+
const headerValue = req.headers[RECORDING_ID_HEADER];
|
|
234
|
+
if (!headerValue) {
|
|
235
|
+
return null;
|
|
236
|
+
}
|
|
237
|
+
return Array.isArray(headerValue) ? headerValue[0] : headerValue;
|
|
238
|
+
}
|
|
217
239
|
/**
|
|
218
240
|
* Extract recording ID from request cookie
|
|
219
|
-
* Used for concurrent replay session routing
|
|
241
|
+
* Used for concurrent replay session routing (fallback method)
|
|
220
242
|
* @param req The incoming HTTP request
|
|
221
243
|
* @returns The recording ID from cookie, or null if not found
|
|
222
244
|
*/
|
|
@@ -228,6 +250,14 @@ var ProxyServer = class {
|
|
|
228
250
|
const match = cookies.match(/proxy-recording-id=([^;]+)/);
|
|
229
251
|
return match ? decodeURIComponent(match[1]) : null;
|
|
230
252
|
}
|
|
253
|
+
/**
|
|
254
|
+
* Extract recording ID from request using custom header (preferred) or cookie (fallback)
|
|
255
|
+
* @param req The incoming HTTP request
|
|
256
|
+
* @returns The recording ID, or null if not found
|
|
257
|
+
*/
|
|
258
|
+
getRecordingIdFromRequest(req) {
|
|
259
|
+
return this.getRecordingIdFromHeader(req) || this.getRecordingIdFromCookie(req);
|
|
260
|
+
}
|
|
231
261
|
/**
|
|
232
262
|
* Get or create a replay session state for a given recording ID
|
|
233
263
|
* @param recordingId The recording ID to get/create session for
|
|
@@ -262,18 +292,20 @@ var ProxyServer = class {
|
|
|
262
292
|
}
|
|
263
293
|
return { mode, id, timeout };
|
|
264
294
|
}
|
|
295
|
+
async parseControlRequest(req) {
|
|
296
|
+
if (req.method === "GET") {
|
|
297
|
+
return this.parseGetParams(req);
|
|
298
|
+
}
|
|
299
|
+
if (req.method === "POST") {
|
|
300
|
+
const body = await readRequestBody(req);
|
|
301
|
+
console.log(`MODE CHANGE (${req.method})`, body);
|
|
302
|
+
return JSON.parse(body);
|
|
303
|
+
}
|
|
304
|
+
throw new Error("Unsupported control method");
|
|
305
|
+
}
|
|
265
306
|
async handleControlRequest(req, res) {
|
|
266
307
|
try {
|
|
267
|
-
|
|
268
|
-
if (req.method === "GET") {
|
|
269
|
-
data = this.parseGetParams(req);
|
|
270
|
-
} else if (req.method === "POST") {
|
|
271
|
-
const body = await readRequestBody(req);
|
|
272
|
-
console.log(`MODE CHANGE (${req.method})`, body);
|
|
273
|
-
data = JSON.parse(body);
|
|
274
|
-
} else {
|
|
275
|
-
return;
|
|
276
|
-
}
|
|
308
|
+
const data = await this.parseControlRequest(req);
|
|
277
309
|
const { mode, id, timeout: requestTimeout } = data;
|
|
278
310
|
const timeout = requestTimeout ?? DEFAULT_TIMEOUT_MS;
|
|
279
311
|
this.clearModeTimeout();
|
|
@@ -306,7 +338,7 @@ var ProxyServer = class {
|
|
|
306
338
|
async switchMode(mode, id) {
|
|
307
339
|
console.log(`Switching to ${mode.toUpperCase()} mode`);
|
|
308
340
|
if (this.currentSession && this.mode === Modes.record) {
|
|
309
|
-
await this.saveCurrentSession(
|
|
341
|
+
await this.saveCurrentSession();
|
|
310
342
|
console.log("Session saved, continuing with mode switch");
|
|
311
343
|
}
|
|
312
344
|
switch (mode) {
|
|
@@ -346,6 +378,8 @@ var ProxyServer = class {
|
|
|
346
378
|
this.recordingId = id;
|
|
347
379
|
this.replayId = null;
|
|
348
380
|
this.currentSession = { id, recordings: [], websocketRecordings: [] };
|
|
381
|
+
this.recordingIdCounter = 0;
|
|
382
|
+
this.sequenceCounterByKey.clear();
|
|
349
383
|
console.log(`Switched to record mode with ID: ${id}`);
|
|
350
384
|
}
|
|
351
385
|
async switchToReplayMode(id) {
|
|
@@ -365,169 +399,91 @@ var ProxyServer = class {
|
|
|
365
399
|
setupModeTimeout(timeout) {
|
|
366
400
|
this.modeTimeout = setTimeout(async () => {
|
|
367
401
|
console.log("Timeout reached, switching back to transparent mode");
|
|
368
|
-
await this.saveCurrentSession(
|
|
402
|
+
await this.saveCurrentSession();
|
|
369
403
|
this.switchToTransparentMode();
|
|
370
404
|
this.modeTimeout = null;
|
|
371
405
|
}, timeout);
|
|
372
406
|
}
|
|
373
|
-
async
|
|
374
|
-
if (
|
|
407
|
+
async flushPendingRecordings() {
|
|
408
|
+
if (this.recordingPromises.length === 0) {
|
|
375
409
|
return;
|
|
376
410
|
}
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
(r) => r.response
|
|
384
|
-
);
|
|
411
|
+
const results = await Promise.allSettled(this.recordingPromises);
|
|
412
|
+
if (this.currentSession) {
|
|
413
|
+
for (const result of results) {
|
|
414
|
+
if (result.status === "fulfilled" && result.value) {
|
|
415
|
+
this.currentSession.recordings.push(result.value);
|
|
416
|
+
}
|
|
385
417
|
}
|
|
418
|
+
console.log(
|
|
419
|
+
`Flushed ${results.length} recordings to session (total: ${this.currentSession.recordings.length})`
|
|
420
|
+
);
|
|
386
421
|
}
|
|
387
|
-
|
|
388
|
-
`Saving session with ${this.currentSession.recordings.length} HTTP and ${this.currentSession.websocketRecordings.length} WebSocket recordings`
|
|
389
|
-
);
|
|
390
|
-
await saveRecordingSession(this.recordingsDir, this.currentSession);
|
|
422
|
+
this.recordingPromises = [];
|
|
391
423
|
}
|
|
392
|
-
|
|
424
|
+
async saveCurrentSession() {
|
|
393
425
|
if (!this.currentSession) {
|
|
394
426
|
return;
|
|
395
427
|
}
|
|
396
|
-
|
|
397
|
-
const recordingId = this.recordingIdCounter++;
|
|
398
|
-
req.__recordingId = recordingId;
|
|
399
|
-
const record = {
|
|
400
|
-
request: {
|
|
401
|
-
method: req.method,
|
|
402
|
-
url: req.url,
|
|
403
|
-
headers: req.headers,
|
|
404
|
-
body: body || null
|
|
405
|
-
},
|
|
406
|
-
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
407
|
-
key,
|
|
408
|
-
recordingId
|
|
409
|
-
};
|
|
410
|
-
this.currentSession.recordings.push(record);
|
|
428
|
+
await this.flushPendingRecordings();
|
|
411
429
|
console.log(
|
|
412
|
-
|
|
413
|
-
`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})`
|
|
430
|
+
`Saving session with ${this.currentSession.recordings.length} HTTP and ${this.currentSession.websocketRecordings.length} WebSocket recordings`
|
|
414
431
|
);
|
|
432
|
+
await saveRecordingSession(this.recordingsDir, this.currentSession);
|
|
415
433
|
}
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
);
|
|
425
|
-
return;
|
|
426
|
-
}
|
|
427
|
-
const record = this.currentSession.recordings.find(
|
|
428
|
-
(r) => r.recordingId === recordingId
|
|
429
|
-
);
|
|
430
|
-
if (!record) {
|
|
431
|
-
console.error(
|
|
432
|
-
`updateRequestBodySync: Could not find recording with ID ${recordingId} for ${req.method} ${req.url}`
|
|
433
|
-
);
|
|
434
|
-
return;
|
|
434
|
+
getRecordingIdOrError(req, res) {
|
|
435
|
+
const recordingId = this.getRecordingIdFromRequest(req) || this.replayId;
|
|
436
|
+
if (!recordingId) {
|
|
437
|
+
const corsHeaders = this.getCorsHeaders(req);
|
|
438
|
+
res.writeHead(HTTP_STATUS_BAD_REQUEST, {
|
|
439
|
+
"Content-Type": "application/json",
|
|
440
|
+
...corsHeaders
|
|
441
|
+
});
|
|
442
|
+
res.end(JSON.stringify({ error: "No replay session active" }));
|
|
443
|
+
return null;
|
|
435
444
|
}
|
|
436
|
-
|
|
437
|
-
console.log(
|
|
438
|
-
`updateRequestBodySync: Updated body for ${req.method} ${req.url} (${body.length} chars, recordingId: ${recordingId})`
|
|
439
|
-
);
|
|
445
|
+
return recordingId;
|
|
440
446
|
}
|
|
441
|
-
async
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
if (recordingId === void 0) {
|
|
447
|
-
console.error(
|
|
448
|
-
`recordResponse: No recording ID found on request ${req.method} ${req.url}`
|
|
449
|
-
);
|
|
450
|
-
return;
|
|
447
|
+
async ensureSessionLoaded(recordingId, filePath) {
|
|
448
|
+
const sessionState = this.getOrCreateReplaySession(recordingId);
|
|
449
|
+
if (!sessionState.loadedSession) {
|
|
450
|
+
sessionState.loadedSession = await loadRecordingSession(filePath);
|
|
451
|
+
console.log(`[REPLAY] Loaded recording session: ${recordingId}`);
|
|
451
452
|
}
|
|
452
|
-
|
|
453
|
-
(r) => r.recordingId === recordingId
|
|
454
|
-
);
|
|
455
|
-
if (!record) {
|
|
456
|
-
console.error(
|
|
457
|
-
`recordResponse: Could not find recording with ID ${recordingId} for ${req.method} ${req.url}`
|
|
458
|
-
);
|
|
459
|
-
return;
|
|
460
|
-
}
|
|
461
|
-
const chunks = [];
|
|
462
|
-
proxyRes.on("data", (chunk) => {
|
|
463
|
-
chunks.push(chunk);
|
|
464
|
-
});
|
|
465
|
-
proxyRes.on("end", async () => {
|
|
466
|
-
const body = Buffer.concat(chunks).toString("utf8");
|
|
467
|
-
record.response = {
|
|
468
|
-
statusCode: proxyRes.statusCode,
|
|
469
|
-
headers: proxyRes.headers,
|
|
470
|
-
body: body || null
|
|
471
|
-
};
|
|
472
|
-
console.log(
|
|
473
|
-
`Recorded: ${req.method} ${req.url} (recordingId: ${recordingId})`
|
|
474
|
-
);
|
|
475
|
-
});
|
|
453
|
+
return sessionState;
|
|
476
454
|
}
|
|
477
|
-
|
|
478
|
-
if (!
|
|
479
|
-
|
|
455
|
+
getServedTracker(sessionState, key) {
|
|
456
|
+
if (!sessionState.servedRecordingIdsByKey.has(key)) {
|
|
457
|
+
sessionState.servedRecordingIdsByKey.set(key, /* @__PURE__ */ new Set());
|
|
480
458
|
}
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
)
|
|
486
|
-
|
|
459
|
+
return sessionState.servedRecordingIdsByKey.get(key);
|
|
460
|
+
}
|
|
461
|
+
selectReplayRecord(recordsWithKey, servedForThisKey, key, recordingId) {
|
|
462
|
+
for (const rec of recordsWithKey) {
|
|
463
|
+
if (!servedForThisKey.has(rec.recordingId)) {
|
|
464
|
+
return rec;
|
|
465
|
+
}
|
|
487
466
|
}
|
|
488
|
-
|
|
489
|
-
(
|
|
490
|
-
|
|
491
|
-
if (!record) {
|
|
492
|
-
console.error(
|
|
493
|
-
`recordResponseData: Could not find recording with ID ${recordingId} for ${req.method} ${req.url}`
|
|
467
|
+
if (recordsWithKey.length > 0) {
|
|
468
|
+
console.log(
|
|
469
|
+
`[REPLAY WARNING] All ${recordsWithKey.length} recordings already served for ${key} (session: ${recordingId}), reusing last one`
|
|
494
470
|
);
|
|
495
|
-
return
|
|
471
|
+
return recordsWithKey[recordsWithKey.length - 1];
|
|
496
472
|
}
|
|
497
|
-
|
|
498
|
-
statusCode: proxyRes.statusCode,
|
|
499
|
-
headers: proxyRes.headers,
|
|
500
|
-
body: body || null
|
|
501
|
-
};
|
|
502
|
-
console.log(
|
|
503
|
-
`recordResponseData: Recorded response for ${req.method} ${req.url} (recordingId: ${recordingId})`
|
|
504
|
-
);
|
|
505
|
-
return true;
|
|
473
|
+
return null;
|
|
506
474
|
}
|
|
507
475
|
async handleReplayRequest(req, res) {
|
|
508
|
-
const recordingId = this.
|
|
509
|
-
if (!recordingId)
|
|
510
|
-
const corsHeaders = this.getCorsHeaders(req);
|
|
511
|
-
res.writeHead(HTTP_STATUS_BAD_REQUEST, {
|
|
512
|
-
"Content-Type": "application/json",
|
|
513
|
-
...corsHeaders
|
|
514
|
-
});
|
|
515
|
-
res.end(JSON.stringify({ error: "No replay session active" }));
|
|
516
|
-
return;
|
|
517
|
-
}
|
|
476
|
+
const recordingId = this.getRecordingIdOrError(req, res);
|
|
477
|
+
if (!recordingId) return;
|
|
518
478
|
const key = getReqID(req);
|
|
519
479
|
const filePath = getRecordingPath(this.recordingsDir, recordingId);
|
|
520
480
|
try {
|
|
521
|
-
const sessionState = this.
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
}
|
|
481
|
+
const sessionState = await this.ensureSessionLoaded(
|
|
482
|
+
recordingId,
|
|
483
|
+
filePath
|
|
484
|
+
);
|
|
526
485
|
const session = sessionState.loadedSession;
|
|
527
|
-
|
|
528
|
-
sessionState.servedRecordingIdsByKey.set(key, /* @__PURE__ */ new Set());
|
|
529
|
-
}
|
|
530
|
-
const servedForThisKey = sessionState.servedRecordingIdsByKey.get(key);
|
|
486
|
+
const servedForThisKey = this.getServedTracker(sessionState, key);
|
|
531
487
|
const host = req.headers.host || "unknown";
|
|
532
488
|
const recordsWithKey = session.recordings.filter((r) => r.key === key && r.response).toSorted((a, b) => {
|
|
533
489
|
const aSeq = a.sequence !== void 0 ? a.sequence : a.recordingId;
|
|
@@ -556,30 +512,23 @@ var ProxyServer = class {
|
|
|
556
512
|
}
|
|
557
513
|
const requestCount = servedForThisKey.size + 1;
|
|
558
514
|
console.log(
|
|
559
|
-
`[replay request #${requestCount}] ${req.method} ${req.url} (session: ${recordingId}, total: ${recordsWithKey.length}, served: ${servedForThisKey.size})`
|
|
515
|
+
`[replay request #${requestCount}] ${req.method} ${req.url} (key: ${key}, session: ${recordingId}, total: ${recordsWithKey.length}, served: ${servedForThisKey.size})`
|
|
560
516
|
);
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
`[REPLAY WARNING] All ${recordsWithKey.length} recordings already served for ${key} (session: ${recordingId}), reusing last one`
|
|
517
|
+
const record = this.selectReplayRecord(
|
|
518
|
+
recordsWithKey,
|
|
519
|
+
servedForThisKey,
|
|
520
|
+
key,
|
|
521
|
+
recordingId
|
|
522
|
+
);
|
|
523
|
+
if (!record || !record.response) {
|
|
524
|
+
throw new Error(
|
|
525
|
+
`No response recorded for this request: ${req.method} ${host}${req.url}`
|
|
571
526
|
);
|
|
572
|
-
record = recordsWithKey[recordsWithKey.length - 1];
|
|
573
527
|
}
|
|
574
528
|
servedForThisKey.add(record.recordingId);
|
|
575
529
|
console.log(
|
|
576
530
|
`[replay serving] recordingId: ${record.recordingId}, session: ${recordingId}, body_len: ${record.response?.body?.length || 0}`
|
|
577
531
|
);
|
|
578
|
-
if (!record.response) {
|
|
579
|
-
throw new Error(
|
|
580
|
-
`No response recorded for this request: ${req.method} ${host}${req.url}`
|
|
581
|
-
);
|
|
582
|
-
}
|
|
583
532
|
const { statusCode, headers, body } = record.response;
|
|
584
533
|
const responseHeaders = {
|
|
585
534
|
...headers,
|
|
@@ -634,82 +583,124 @@ var ProxyServer = class {
|
|
|
634
583
|
const target = this.getTarget();
|
|
635
584
|
console.log(`[${this.mode}] ${req.method} ${req.url} -> ${target}`);
|
|
636
585
|
if (this.mode === Modes.record) {
|
|
637
|
-
this.
|
|
638
|
-
await this.bufferAndProxyRequest(req, res, target);
|
|
586
|
+
await this.recordAndProxyRequest(req, res, target);
|
|
639
587
|
} else {
|
|
640
588
|
this.proxy.web(req, res, { target });
|
|
641
589
|
}
|
|
642
590
|
}
|
|
643
|
-
//
|
|
644
|
-
async
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
port: targetUrl.port || defaultPort,
|
|
671
|
-
path: req.url,
|
|
672
|
-
method: req.method,
|
|
673
|
-
headers: req.headers
|
|
674
|
-
},
|
|
675
|
-
(proxyRes) => {
|
|
676
|
-
this.addCorsHeaders(proxyRes, req);
|
|
677
|
-
const responseChunks = [];
|
|
678
|
-
proxyRes.on("data", (chunk) => {
|
|
679
|
-
responseChunks.push(chunk);
|
|
680
|
-
});
|
|
681
|
-
proxyRes.on("end", async () => {
|
|
682
|
-
const responseBody = Buffer.concat(responseChunks);
|
|
683
|
-
const recorded = await this.recordResponseData(
|
|
684
|
-
req,
|
|
685
|
-
proxyRes,
|
|
686
|
-
responseBody.toString("utf8")
|
|
687
|
-
);
|
|
688
|
-
const responseHeaders = {
|
|
689
|
-
...proxyRes.headers,
|
|
690
|
-
...this.getCorsHeaders(req)
|
|
691
|
-
};
|
|
692
|
-
res.writeHead(proxyRes.statusCode || 200, responseHeaders);
|
|
693
|
-
res.end(responseBody);
|
|
694
|
-
if (recorded) {
|
|
695
|
-
console.log(`Recorded: ${req.method} ${req.url}`);
|
|
591
|
+
// Note: streaming requests are buffered before proxying; streaming passthrough is not yet implemented
|
|
592
|
+
async recordAndProxyRequest(req, res, target) {
|
|
593
|
+
if (!this.currentSession) {
|
|
594
|
+
return;
|
|
595
|
+
}
|
|
596
|
+
const key = getReqID(req);
|
|
597
|
+
const recordingId = this.recordingIdCounter++;
|
|
598
|
+
const sequence = this.sequenceCounterByKey.get(key) || 0;
|
|
599
|
+
this.sequenceCounterByKey.set(key, sequence + 1);
|
|
600
|
+
const recordingPromise = new Promise((resolve) => {
|
|
601
|
+
(async () => {
|
|
602
|
+
try {
|
|
603
|
+
const chunks = [];
|
|
604
|
+
req.on("data", (chunk) => {
|
|
605
|
+
chunks.push(chunk);
|
|
606
|
+
});
|
|
607
|
+
try {
|
|
608
|
+
await new Promise((resolveBuffer, rejectBuffer) => {
|
|
609
|
+
req.on("end", () => resolveBuffer());
|
|
610
|
+
req.on("error", (err) => rejectBuffer(err));
|
|
611
|
+
setTimeout(
|
|
612
|
+
() => rejectBuffer(new Error("Request buffering timeout")),
|
|
613
|
+
3e4
|
|
614
|
+
);
|
|
615
|
+
});
|
|
616
|
+
} catch (error) {
|
|
617
|
+
console.error("Error buffering request:", error);
|
|
696
618
|
}
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
619
|
+
const requestBody = Buffer.concat(chunks).toString("utf8");
|
|
620
|
+
const targetUrl = new URL(target);
|
|
621
|
+
const isHttps = targetUrl.protocol === "https:";
|
|
622
|
+
const requestModule = isHttps ? https__default.default : http__default.default;
|
|
623
|
+
const defaultPort = isHttps ? 443 : 80;
|
|
624
|
+
const proxyReq = requestModule.request(
|
|
625
|
+
{
|
|
626
|
+
hostname: targetUrl.hostname,
|
|
627
|
+
port: targetUrl.port || defaultPort,
|
|
628
|
+
path: req.url,
|
|
629
|
+
method: req.method,
|
|
630
|
+
headers: req.headers
|
|
631
|
+
},
|
|
632
|
+
(proxyRes) => {
|
|
633
|
+
this.addCorsHeaders(proxyRes, req);
|
|
634
|
+
const responseChunks = [];
|
|
635
|
+
proxyRes.on("data", (chunk) => {
|
|
636
|
+
responseChunks.push(chunk);
|
|
637
|
+
});
|
|
638
|
+
proxyRes.on("end", async () => {
|
|
639
|
+
try {
|
|
640
|
+
const responseBody = Buffer.concat(responseChunks);
|
|
641
|
+
const responseBodyStr = responseBody.toString("utf8");
|
|
642
|
+
const recording = {
|
|
643
|
+
request: {
|
|
644
|
+
method: req.method,
|
|
645
|
+
url: req.url,
|
|
646
|
+
headers: req.headers,
|
|
647
|
+
body: requestBody || null
|
|
648
|
+
},
|
|
649
|
+
response: {
|
|
650
|
+
statusCode: proxyRes.statusCode,
|
|
651
|
+
headers: proxyRes.headers,
|
|
652
|
+
body: responseBodyStr || null
|
|
653
|
+
},
|
|
654
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
655
|
+
key,
|
|
656
|
+
recordingId,
|
|
657
|
+
sequence
|
|
658
|
+
};
|
|
659
|
+
const responseHeaders = {
|
|
660
|
+
...proxyRes.headers,
|
|
661
|
+
...this.getCorsHeaders(req)
|
|
662
|
+
};
|
|
663
|
+
res.writeHead(proxyRes.statusCode || 200, responseHeaders);
|
|
664
|
+
res.end(responseBody);
|
|
665
|
+
console.log(
|
|
666
|
+
`Recorded: ${req.method} ${req.url} (recordingId: ${recordingId}, sequence: ${sequence})`
|
|
667
|
+
);
|
|
668
|
+
resolve(recording);
|
|
669
|
+
} catch (error) {
|
|
670
|
+
console.error("Error completing recording:", error);
|
|
671
|
+
resolve(null);
|
|
672
|
+
}
|
|
673
|
+
});
|
|
674
|
+
proxyRes.on("error", (err) => {
|
|
675
|
+
console.error("Proxy response error:", err);
|
|
676
|
+
if (!res.headersSent) {
|
|
677
|
+
this.handleProxyError(err, req, res);
|
|
678
|
+
}
|
|
679
|
+
resolve(null);
|
|
680
|
+
});
|
|
681
|
+
}
|
|
682
|
+
);
|
|
683
|
+
proxyReq.on("error", (err) => {
|
|
701
684
|
this.handleProxyError(err, req, res);
|
|
685
|
+
resolve(null);
|
|
686
|
+
});
|
|
687
|
+
if (chunks.length > 0) {
|
|
688
|
+
proxyReq.write(Buffer.concat(chunks));
|
|
702
689
|
}
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
690
|
+
proxyReq.end();
|
|
691
|
+
} catch (error) {
|
|
692
|
+
console.error("Error in recordAndProxyRequest:", error);
|
|
693
|
+
try {
|
|
694
|
+
this.handleProxyError(error, req, res);
|
|
695
|
+
} catch (error_) {
|
|
696
|
+
console.error("Failed to handle proxy error:", error_);
|
|
697
|
+
}
|
|
698
|
+
resolve(null);
|
|
699
|
+
}
|
|
700
|
+
})();
|
|
708
701
|
});
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
}
|
|
712
|
-
proxyReq.end();
|
|
702
|
+
this.recordingPromises.push(recordingPromise);
|
|
703
|
+
await recordingPromise;
|
|
713
704
|
}
|
|
714
705
|
handleUpgrade(req, socket, head) {
|
|
715
706
|
if (this.mode === Modes.replay) {
|
|
@@ -954,23 +945,29 @@ async function stopProxy(testInfo) {
|
|
|
954
945
|
}
|
|
955
946
|
var playwrightProxy = {
|
|
956
947
|
/**
|
|
957
|
-
* Setup before test - sets the proxy mode
|
|
948
|
+
* Setup before test - sets the proxy mode and configures page with custom header
|
|
949
|
+
* Automatically sets up page.on('close') handler for cleanup
|
|
950
|
+
* @param page - Playwright page object
|
|
958
951
|
* @param testInfo - Playwright test info object
|
|
959
952
|
* @param mode - The proxy mode to use for this test
|
|
960
953
|
* @param timeout - Optional timeout in milliseconds
|
|
961
954
|
*/
|
|
962
|
-
async before(testInfo, mode, timeout) {
|
|
955
|
+
async before(page, testInfo, mode, timeout) {
|
|
963
956
|
const sessionId = generateSessionId(testInfo);
|
|
957
|
+
await page.setExtraHTTPHeaders({
|
|
958
|
+
[RECORDING_ID_HEADER]: sessionId
|
|
959
|
+
});
|
|
964
960
|
await setProxyMode(mode, sessionId, timeout);
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
961
|
+
page.on("close", async () => {
|
|
962
|
+
try {
|
|
963
|
+
await setProxyMode(Modes.replay, sessionId);
|
|
964
|
+
console.log(
|
|
965
|
+
`[Cleanup] Switched to replay mode for session: ${sessionId}`
|
|
966
|
+
);
|
|
967
|
+
} catch (error) {
|
|
968
|
+
console.error("[Cleanup] Error during page close cleanup:", error);
|
|
969
|
+
}
|
|
970
|
+
});
|
|
974
971
|
},
|
|
975
972
|
/**
|
|
976
973
|
* Global teardown - switches proxy to transparent mode
|
|
@@ -981,9 +978,45 @@ var playwrightProxy = {
|
|
|
981
978
|
}
|
|
982
979
|
};
|
|
983
980
|
|
|
981
|
+
// src/nextjs/middleware.ts
|
|
982
|
+
function isRecorderEnabled() {
|
|
983
|
+
const isProduction = process.env.NODE_ENV === "production";
|
|
984
|
+
const isExplicitlyEnabled = process.env.TEST_PROXY_RECORDER_ENABLED === "true" || Number.parseInt(process.env.TEST_PROXY_RECORDER_ENABLED || "") === 1;
|
|
985
|
+
return !isProduction || isExplicitlyEnabled;
|
|
986
|
+
}
|
|
987
|
+
function setNextProxyHeaders(request, response) {
|
|
988
|
+
if (!isRecorderEnabled()) {
|
|
989
|
+
return;
|
|
990
|
+
}
|
|
991
|
+
const recordingId = request.headers.get(RECORDING_ID_HEADER);
|
|
992
|
+
if (recordingId) {
|
|
993
|
+
response.headers.set(RECORDING_ID_HEADER, recordingId);
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
function getRecordingId(requestHeaders) {
|
|
997
|
+
if (requestHeaders instanceof Headers) {
|
|
998
|
+
return requestHeaders.get(RECORDING_ID_HEADER);
|
|
999
|
+
}
|
|
1000
|
+
return requestHeaders.headers.get(RECORDING_ID_HEADER);
|
|
1001
|
+
}
|
|
1002
|
+
function createHeadersWithRecordingId(requestHeaders, additionalHeaders = {}) {
|
|
1003
|
+
if (!isRecorderEnabled()) {
|
|
1004
|
+
return additionalHeaders;
|
|
1005
|
+
}
|
|
1006
|
+
const recordingId = getRecordingId(requestHeaders);
|
|
1007
|
+
return {
|
|
1008
|
+
...additionalHeaders,
|
|
1009
|
+
...recordingId && { [RECORDING_ID_HEADER]: recordingId }
|
|
1010
|
+
};
|
|
1011
|
+
}
|
|
1012
|
+
|
|
984
1013
|
exports.ProxyServer = ProxyServer;
|
|
1014
|
+
exports.RECORDING_ID_HEADER = RECORDING_ID_HEADER;
|
|
1015
|
+
exports.createHeadersWithRecordingId = createHeadersWithRecordingId;
|
|
985
1016
|
exports.generateSessionId = generateSessionId;
|
|
1017
|
+
exports.getRecordingId = getRecordingId;
|
|
986
1018
|
exports.playwrightProxy = playwrightProxy;
|
|
1019
|
+
exports.setNextProxyHeaders = setNextProxyHeaders;
|
|
987
1020
|
exports.setProxyMode = setProxyMode;
|
|
988
1021
|
exports.startRecording = startRecording;
|
|
989
1022
|
exports.startReplay = startReplay;
|