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.mjs
CHANGED
|
@@ -7,8 +7,6 @@ import crypto from 'crypto';
|
|
|
7
7
|
import path from 'path';
|
|
8
8
|
import filenamify2 from 'filenamify';
|
|
9
9
|
|
|
10
|
-
// src/ProxyServer.ts
|
|
11
|
-
|
|
12
10
|
// src/constants.ts
|
|
13
11
|
var DEFAULT_TIMEOUT_MS = 120 * 1e3;
|
|
14
12
|
var HTTP_STATUS_BAD_GATEWAY = 502;
|
|
@@ -16,6 +14,7 @@ var HTTP_STATUS_OK = 200;
|
|
|
16
14
|
var HTTP_STATUS_BAD_REQUEST = 400;
|
|
17
15
|
var HTTP_STATUS_NOT_FOUND = 404;
|
|
18
16
|
var CONTROL_ENDPOINT = "/__control";
|
|
17
|
+
var RECORDING_ID_HEADER = "x-test-rcrd-id";
|
|
19
18
|
|
|
20
19
|
// src/types.ts
|
|
21
20
|
var Modes = {
|
|
@@ -49,13 +48,23 @@ async function loadRecordingSession(filePath) {
|
|
|
49
48
|
return JSON.parse(fileContent);
|
|
50
49
|
}
|
|
51
50
|
function processRecordings(recordings) {
|
|
52
|
-
const
|
|
53
|
-
|
|
51
|
+
const recordingsByKey = /* @__PURE__ */ new Map();
|
|
52
|
+
for (const recording of recordings) {
|
|
54
53
|
const key = recording.key;
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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;
|
|
59
68
|
}
|
|
60
69
|
async function saveRecordingSession(recordingsDir, session) {
|
|
61
70
|
const filePath = getRecordingPath(recordingsDir, session.id);
|
|
@@ -118,19 +127,25 @@ var ProxyServer = class {
|
|
|
118
127
|
recordingsDir;
|
|
119
128
|
recordingIdCounter;
|
|
120
129
|
// Unique ID for each recording entry
|
|
130
|
+
sequenceCounterByKey;
|
|
131
|
+
// Sequence counter per key (endpoint)
|
|
121
132
|
replaySessions;
|
|
122
133
|
// Track multiple concurrent replay sessions by recording ID
|
|
134
|
+
recordingPromises;
|
|
135
|
+
// Stack of promises that resolve to completed recordings
|
|
123
136
|
constructor(targets, recordingsDir) {
|
|
124
137
|
this.targets = targets;
|
|
125
138
|
this.currentTargetIndex = 0;
|
|
126
139
|
this.mode = Modes.transparent;
|
|
127
140
|
this.recordingId = null;
|
|
128
141
|
this.recordingIdCounter = 0;
|
|
142
|
+
this.sequenceCounterByKey = /* @__PURE__ */ new Map();
|
|
129
143
|
this.replayId = null;
|
|
130
144
|
this.modeTimeout = null;
|
|
131
145
|
this.currentSession = null;
|
|
132
146
|
this.recordingsDir = recordingsDir;
|
|
133
147
|
this.replaySessions = /* @__PURE__ */ new Map();
|
|
148
|
+
this.recordingPromises = [];
|
|
134
149
|
this.proxy = httpProxy.createProxyServer({
|
|
135
150
|
secure: false,
|
|
136
151
|
changeOrigin: true,
|
|
@@ -156,7 +171,7 @@ var ProxyServer = class {
|
|
|
156
171
|
}
|
|
157
172
|
setupProxyEventHandlers() {
|
|
158
173
|
this.proxy.on("error", this.handleProxyError.bind(this));
|
|
159
|
-
this.proxy.on("proxyRes", this.
|
|
174
|
+
this.proxy.on("proxyRes", this.addCorsHeaders.bind(this));
|
|
160
175
|
}
|
|
161
176
|
handleProxyError(err, req, res) {
|
|
162
177
|
console.error("Proxy error:", err);
|
|
@@ -172,12 +187,6 @@ var ProxyServer = class {
|
|
|
172
187
|
}
|
|
173
188
|
res.end(JSON.stringify({ error: "Proxy error", message: err.message }));
|
|
174
189
|
}
|
|
175
|
-
handleProxyResponse(proxyRes, req) {
|
|
176
|
-
this.addCorsHeaders(proxyRes, req);
|
|
177
|
-
if (this.mode === Modes.record && this.recordingId) {
|
|
178
|
-
this.recordResponse(req, proxyRes);
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
190
|
/**
|
|
182
191
|
* Get CORS headers for a given request
|
|
183
192
|
* @param req The incoming HTTP request
|
|
@@ -188,7 +197,7 @@ var ProxyServer = class {
|
|
|
188
197
|
return {
|
|
189
198
|
"access-control-allow-origin": origin || "*",
|
|
190
199
|
"access-control-allow-credentials": "true",
|
|
191
|
-
"access-control-allow-headers": req.headers["access-control-request-headers"] ||
|
|
200
|
+
"access-control-allow-headers": req.headers["access-control-request-headers"] || `Origin, X-Requested-With, Content-Type, Accept, Authorization, ${RECORDING_ID_HEADER}`,
|
|
192
201
|
"access-control-allow-methods": "GET, POST, PUT, DELETE, PATCH, OPTIONS",
|
|
193
202
|
"access-control-expose-headers": "*"
|
|
194
203
|
};
|
|
@@ -202,9 +211,22 @@ var ProxyServer = class {
|
|
|
202
211
|
this.currentTargetIndex = (this.currentTargetIndex + 1) % this.targets.length;
|
|
203
212
|
return target;
|
|
204
213
|
}
|
|
214
|
+
/**
|
|
215
|
+
* Extract recording ID from custom HTTP header
|
|
216
|
+
* Used for concurrent replay session routing, especially with Next.js
|
|
217
|
+
* @param req The incoming HTTP request
|
|
218
|
+
* @returns The recording ID from header, or null if not found
|
|
219
|
+
*/
|
|
220
|
+
getRecordingIdFromHeader(req) {
|
|
221
|
+
const headerValue = req.headers[RECORDING_ID_HEADER];
|
|
222
|
+
if (!headerValue) {
|
|
223
|
+
return null;
|
|
224
|
+
}
|
|
225
|
+
return Array.isArray(headerValue) ? headerValue[0] : headerValue;
|
|
226
|
+
}
|
|
205
227
|
/**
|
|
206
228
|
* Extract recording ID from request cookie
|
|
207
|
-
* Used for concurrent replay session routing
|
|
229
|
+
* Used for concurrent replay session routing (fallback method)
|
|
208
230
|
* @param req The incoming HTTP request
|
|
209
231
|
* @returns The recording ID from cookie, or null if not found
|
|
210
232
|
*/
|
|
@@ -216,6 +238,14 @@ var ProxyServer = class {
|
|
|
216
238
|
const match = cookies.match(/proxy-recording-id=([^;]+)/);
|
|
217
239
|
return match ? decodeURIComponent(match[1]) : null;
|
|
218
240
|
}
|
|
241
|
+
/**
|
|
242
|
+
* Extract recording ID from request using custom header (preferred) or cookie (fallback)
|
|
243
|
+
* @param req The incoming HTTP request
|
|
244
|
+
* @returns The recording ID, or null if not found
|
|
245
|
+
*/
|
|
246
|
+
getRecordingIdFromRequest(req) {
|
|
247
|
+
return this.getRecordingIdFromHeader(req) || this.getRecordingIdFromCookie(req);
|
|
248
|
+
}
|
|
219
249
|
/**
|
|
220
250
|
* Get or create a replay session state for a given recording ID
|
|
221
251
|
* @param recordingId The recording ID to get/create session for
|
|
@@ -250,18 +280,20 @@ var ProxyServer = class {
|
|
|
250
280
|
}
|
|
251
281
|
return { mode, id, timeout };
|
|
252
282
|
}
|
|
283
|
+
async parseControlRequest(req) {
|
|
284
|
+
if (req.method === "GET") {
|
|
285
|
+
return this.parseGetParams(req);
|
|
286
|
+
}
|
|
287
|
+
if (req.method === "POST") {
|
|
288
|
+
const body = await readRequestBody(req);
|
|
289
|
+
console.log(`MODE CHANGE (${req.method})`, body);
|
|
290
|
+
return JSON.parse(body);
|
|
291
|
+
}
|
|
292
|
+
throw new Error("Unsupported control method");
|
|
293
|
+
}
|
|
253
294
|
async handleControlRequest(req, res) {
|
|
254
295
|
try {
|
|
255
|
-
|
|
256
|
-
if (req.method === "GET") {
|
|
257
|
-
data = this.parseGetParams(req);
|
|
258
|
-
} else if (req.method === "POST") {
|
|
259
|
-
const body = await readRequestBody(req);
|
|
260
|
-
console.log(`MODE CHANGE (${req.method})`, body);
|
|
261
|
-
data = JSON.parse(body);
|
|
262
|
-
} else {
|
|
263
|
-
return;
|
|
264
|
-
}
|
|
296
|
+
const data = await this.parseControlRequest(req);
|
|
265
297
|
const { mode, id, timeout: requestTimeout } = data;
|
|
266
298
|
const timeout = requestTimeout ?? DEFAULT_TIMEOUT_MS;
|
|
267
299
|
this.clearModeTimeout();
|
|
@@ -294,7 +326,7 @@ var ProxyServer = class {
|
|
|
294
326
|
async switchMode(mode, id) {
|
|
295
327
|
console.log(`Switching to ${mode.toUpperCase()} mode`);
|
|
296
328
|
if (this.currentSession && this.mode === Modes.record) {
|
|
297
|
-
await this.saveCurrentSession(
|
|
329
|
+
await this.saveCurrentSession();
|
|
298
330
|
console.log("Session saved, continuing with mode switch");
|
|
299
331
|
}
|
|
300
332
|
switch (mode) {
|
|
@@ -334,6 +366,8 @@ var ProxyServer = class {
|
|
|
334
366
|
this.recordingId = id;
|
|
335
367
|
this.replayId = null;
|
|
336
368
|
this.currentSession = { id, recordings: [], websocketRecordings: [] };
|
|
369
|
+
this.recordingIdCounter = 0;
|
|
370
|
+
this.sequenceCounterByKey.clear();
|
|
337
371
|
console.log(`Switched to record mode with ID: ${id}`);
|
|
338
372
|
}
|
|
339
373
|
async switchToReplayMode(id) {
|
|
@@ -353,169 +387,91 @@ var ProxyServer = class {
|
|
|
353
387
|
setupModeTimeout(timeout) {
|
|
354
388
|
this.modeTimeout = setTimeout(async () => {
|
|
355
389
|
console.log("Timeout reached, switching back to transparent mode");
|
|
356
|
-
await this.saveCurrentSession(
|
|
390
|
+
await this.saveCurrentSession();
|
|
357
391
|
this.switchToTransparentMode();
|
|
358
392
|
this.modeTimeout = null;
|
|
359
393
|
}, timeout);
|
|
360
394
|
}
|
|
361
|
-
async
|
|
362
|
-
if (
|
|
395
|
+
async flushPendingRecordings() {
|
|
396
|
+
if (this.recordingPromises.length === 0) {
|
|
363
397
|
return;
|
|
364
398
|
}
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
(r) => r.response
|
|
372
|
-
);
|
|
399
|
+
const results = await Promise.allSettled(this.recordingPromises);
|
|
400
|
+
if (this.currentSession) {
|
|
401
|
+
for (const result of results) {
|
|
402
|
+
if (result.status === "fulfilled" && result.value) {
|
|
403
|
+
this.currentSession.recordings.push(result.value);
|
|
404
|
+
}
|
|
373
405
|
}
|
|
406
|
+
console.log(
|
|
407
|
+
`Flushed ${results.length} recordings to session (total: ${this.currentSession.recordings.length})`
|
|
408
|
+
);
|
|
374
409
|
}
|
|
375
|
-
|
|
376
|
-
`Saving session with ${this.currentSession.recordings.length} HTTP and ${this.currentSession.websocketRecordings.length} WebSocket recordings`
|
|
377
|
-
);
|
|
378
|
-
await saveRecordingSession(this.recordingsDir, this.currentSession);
|
|
410
|
+
this.recordingPromises = [];
|
|
379
411
|
}
|
|
380
|
-
|
|
412
|
+
async saveCurrentSession() {
|
|
381
413
|
if (!this.currentSession) {
|
|
382
414
|
return;
|
|
383
415
|
}
|
|
384
|
-
|
|
385
|
-
const recordingId = this.recordingIdCounter++;
|
|
386
|
-
req.__recordingId = recordingId;
|
|
387
|
-
const record = {
|
|
388
|
-
request: {
|
|
389
|
-
method: req.method,
|
|
390
|
-
url: req.url,
|
|
391
|
-
headers: req.headers,
|
|
392
|
-
body: body || null
|
|
393
|
-
},
|
|
394
|
-
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
395
|
-
key,
|
|
396
|
-
recordingId
|
|
397
|
-
};
|
|
398
|
-
this.currentSession.recordings.push(record);
|
|
416
|
+
await this.flushPendingRecordings();
|
|
399
417
|
console.log(
|
|
400
|
-
|
|
401
|
-
`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})`
|
|
418
|
+
`Saving session with ${this.currentSession.recordings.length} HTTP and ${this.currentSession.websocketRecordings.length} WebSocket recordings`
|
|
402
419
|
);
|
|
420
|
+
await saveRecordingSession(this.recordingsDir, this.currentSession);
|
|
403
421
|
}
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
);
|
|
413
|
-
return;
|
|
414
|
-
}
|
|
415
|
-
const record = this.currentSession.recordings.find(
|
|
416
|
-
(r) => r.recordingId === recordingId
|
|
417
|
-
);
|
|
418
|
-
if (!record) {
|
|
419
|
-
console.error(
|
|
420
|
-
`updateRequestBodySync: Could not find recording with ID ${recordingId} for ${req.method} ${req.url}`
|
|
421
|
-
);
|
|
422
|
-
return;
|
|
422
|
+
getRecordingIdOrError(req, res) {
|
|
423
|
+
const recordingId = this.getRecordingIdFromRequest(req) || this.replayId;
|
|
424
|
+
if (!recordingId) {
|
|
425
|
+
const corsHeaders = this.getCorsHeaders(req);
|
|
426
|
+
res.writeHead(HTTP_STATUS_BAD_REQUEST, {
|
|
427
|
+
"Content-Type": "application/json",
|
|
428
|
+
...corsHeaders
|
|
429
|
+
});
|
|
430
|
+
res.end(JSON.stringify({ error: "No replay session active" }));
|
|
431
|
+
return null;
|
|
423
432
|
}
|
|
424
|
-
|
|
425
|
-
console.log(
|
|
426
|
-
`updateRequestBodySync: Updated body for ${req.method} ${req.url} (${body.length} chars, recordingId: ${recordingId})`
|
|
427
|
-
);
|
|
433
|
+
return recordingId;
|
|
428
434
|
}
|
|
429
|
-
async
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
if (recordingId === void 0) {
|
|
435
|
-
console.error(
|
|
436
|
-
`recordResponse: No recording ID found on request ${req.method} ${req.url}`
|
|
437
|
-
);
|
|
438
|
-
return;
|
|
435
|
+
async ensureSessionLoaded(recordingId, filePath) {
|
|
436
|
+
const sessionState = this.getOrCreateReplaySession(recordingId);
|
|
437
|
+
if (!sessionState.loadedSession) {
|
|
438
|
+
sessionState.loadedSession = await loadRecordingSession(filePath);
|
|
439
|
+
console.log(`[REPLAY] Loaded recording session: ${recordingId}`);
|
|
439
440
|
}
|
|
440
|
-
|
|
441
|
-
(r) => r.recordingId === recordingId
|
|
442
|
-
);
|
|
443
|
-
if (!record) {
|
|
444
|
-
console.error(
|
|
445
|
-
`recordResponse: Could not find recording with ID ${recordingId} for ${req.method} ${req.url}`
|
|
446
|
-
);
|
|
447
|
-
return;
|
|
448
|
-
}
|
|
449
|
-
const chunks = [];
|
|
450
|
-
proxyRes.on("data", (chunk) => {
|
|
451
|
-
chunks.push(chunk);
|
|
452
|
-
});
|
|
453
|
-
proxyRes.on("end", async () => {
|
|
454
|
-
const body = Buffer.concat(chunks).toString("utf8");
|
|
455
|
-
record.response = {
|
|
456
|
-
statusCode: proxyRes.statusCode,
|
|
457
|
-
headers: proxyRes.headers,
|
|
458
|
-
body: body || null
|
|
459
|
-
};
|
|
460
|
-
console.log(
|
|
461
|
-
`Recorded: ${req.method} ${req.url} (recordingId: ${recordingId})`
|
|
462
|
-
);
|
|
463
|
-
});
|
|
441
|
+
return sessionState;
|
|
464
442
|
}
|
|
465
|
-
|
|
466
|
-
if (!
|
|
467
|
-
|
|
443
|
+
getServedTracker(sessionState, key) {
|
|
444
|
+
if (!sessionState.servedRecordingIdsByKey.has(key)) {
|
|
445
|
+
sessionState.servedRecordingIdsByKey.set(key, /* @__PURE__ */ new Set());
|
|
468
446
|
}
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
)
|
|
474
|
-
|
|
447
|
+
return sessionState.servedRecordingIdsByKey.get(key);
|
|
448
|
+
}
|
|
449
|
+
selectReplayRecord(recordsWithKey, servedForThisKey, key, recordingId) {
|
|
450
|
+
for (const rec of recordsWithKey) {
|
|
451
|
+
if (!servedForThisKey.has(rec.recordingId)) {
|
|
452
|
+
return rec;
|
|
453
|
+
}
|
|
475
454
|
}
|
|
476
|
-
|
|
477
|
-
(
|
|
478
|
-
|
|
479
|
-
if (!record) {
|
|
480
|
-
console.error(
|
|
481
|
-
`recordResponseData: Could not find recording with ID ${recordingId} for ${req.method} ${req.url}`
|
|
455
|
+
if (recordsWithKey.length > 0) {
|
|
456
|
+
console.log(
|
|
457
|
+
`[REPLAY WARNING] All ${recordsWithKey.length} recordings already served for ${key} (session: ${recordingId}), reusing last one`
|
|
482
458
|
);
|
|
483
|
-
return
|
|
459
|
+
return recordsWithKey[recordsWithKey.length - 1];
|
|
484
460
|
}
|
|
485
|
-
|
|
486
|
-
statusCode: proxyRes.statusCode,
|
|
487
|
-
headers: proxyRes.headers,
|
|
488
|
-
body: body || null
|
|
489
|
-
};
|
|
490
|
-
console.log(
|
|
491
|
-
`recordResponseData: Recorded response for ${req.method} ${req.url} (recordingId: ${recordingId})`
|
|
492
|
-
);
|
|
493
|
-
return true;
|
|
461
|
+
return null;
|
|
494
462
|
}
|
|
495
463
|
async handleReplayRequest(req, res) {
|
|
496
|
-
const recordingId = this.
|
|
497
|
-
if (!recordingId)
|
|
498
|
-
const corsHeaders = this.getCorsHeaders(req);
|
|
499
|
-
res.writeHead(HTTP_STATUS_BAD_REQUEST, {
|
|
500
|
-
"Content-Type": "application/json",
|
|
501
|
-
...corsHeaders
|
|
502
|
-
});
|
|
503
|
-
res.end(JSON.stringify({ error: "No replay session active" }));
|
|
504
|
-
return;
|
|
505
|
-
}
|
|
464
|
+
const recordingId = this.getRecordingIdOrError(req, res);
|
|
465
|
+
if (!recordingId) return;
|
|
506
466
|
const key = getReqID(req);
|
|
507
467
|
const filePath = getRecordingPath(this.recordingsDir, recordingId);
|
|
508
468
|
try {
|
|
509
|
-
const sessionState = this.
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
}
|
|
469
|
+
const sessionState = await this.ensureSessionLoaded(
|
|
470
|
+
recordingId,
|
|
471
|
+
filePath
|
|
472
|
+
);
|
|
514
473
|
const session = sessionState.loadedSession;
|
|
515
|
-
|
|
516
|
-
sessionState.servedRecordingIdsByKey.set(key, /* @__PURE__ */ new Set());
|
|
517
|
-
}
|
|
518
|
-
const servedForThisKey = sessionState.servedRecordingIdsByKey.get(key);
|
|
474
|
+
const servedForThisKey = this.getServedTracker(sessionState, key);
|
|
519
475
|
const host = req.headers.host || "unknown";
|
|
520
476
|
const recordsWithKey = session.recordings.filter((r) => r.key === key && r.response).toSorted((a, b) => {
|
|
521
477
|
const aSeq = a.sequence !== void 0 ? a.sequence : a.recordingId;
|
|
@@ -544,30 +500,23 @@ var ProxyServer = class {
|
|
|
544
500
|
}
|
|
545
501
|
const requestCount = servedForThisKey.size + 1;
|
|
546
502
|
console.log(
|
|
547
|
-
`[replay request #${requestCount}] ${req.method} ${req.url} (session: ${recordingId}, total: ${recordsWithKey.length}, served: ${servedForThisKey.size})`
|
|
503
|
+
`[replay request #${requestCount}] ${req.method} ${req.url} (key: ${key}, session: ${recordingId}, total: ${recordsWithKey.length}, served: ${servedForThisKey.size})`
|
|
548
504
|
);
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
`[REPLAY WARNING] All ${recordsWithKey.length} recordings already served for ${key} (session: ${recordingId}), reusing last one`
|
|
505
|
+
const record = this.selectReplayRecord(
|
|
506
|
+
recordsWithKey,
|
|
507
|
+
servedForThisKey,
|
|
508
|
+
key,
|
|
509
|
+
recordingId
|
|
510
|
+
);
|
|
511
|
+
if (!record || !record.response) {
|
|
512
|
+
throw new Error(
|
|
513
|
+
`No response recorded for this request: ${req.method} ${host}${req.url}`
|
|
559
514
|
);
|
|
560
|
-
record = recordsWithKey[recordsWithKey.length - 1];
|
|
561
515
|
}
|
|
562
516
|
servedForThisKey.add(record.recordingId);
|
|
563
517
|
console.log(
|
|
564
518
|
`[replay serving] recordingId: ${record.recordingId}, session: ${recordingId}, body_len: ${record.response?.body?.length || 0}`
|
|
565
519
|
);
|
|
566
|
-
if (!record.response) {
|
|
567
|
-
throw new Error(
|
|
568
|
-
`No response recorded for this request: ${req.method} ${host}${req.url}`
|
|
569
|
-
);
|
|
570
|
-
}
|
|
571
520
|
const { statusCode, headers, body } = record.response;
|
|
572
521
|
const responseHeaders = {
|
|
573
522
|
...headers,
|
|
@@ -622,82 +571,124 @@ var ProxyServer = class {
|
|
|
622
571
|
const target = this.getTarget();
|
|
623
572
|
console.log(`[${this.mode}] ${req.method} ${req.url} -> ${target}`);
|
|
624
573
|
if (this.mode === Modes.record) {
|
|
625
|
-
this.
|
|
626
|
-
await this.bufferAndProxyRequest(req, res, target);
|
|
574
|
+
await this.recordAndProxyRequest(req, res, target);
|
|
627
575
|
} else {
|
|
628
576
|
this.proxy.web(req, res, { target });
|
|
629
577
|
}
|
|
630
578
|
}
|
|
631
|
-
//
|
|
632
|
-
async
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
port: targetUrl.port || defaultPort,
|
|
659
|
-
path: req.url,
|
|
660
|
-
method: req.method,
|
|
661
|
-
headers: req.headers
|
|
662
|
-
},
|
|
663
|
-
(proxyRes) => {
|
|
664
|
-
this.addCorsHeaders(proxyRes, req);
|
|
665
|
-
const responseChunks = [];
|
|
666
|
-
proxyRes.on("data", (chunk) => {
|
|
667
|
-
responseChunks.push(chunk);
|
|
668
|
-
});
|
|
669
|
-
proxyRes.on("end", async () => {
|
|
670
|
-
const responseBody = Buffer.concat(responseChunks);
|
|
671
|
-
const recorded = await this.recordResponseData(
|
|
672
|
-
req,
|
|
673
|
-
proxyRes,
|
|
674
|
-
responseBody.toString("utf8")
|
|
675
|
-
);
|
|
676
|
-
const responseHeaders = {
|
|
677
|
-
...proxyRes.headers,
|
|
678
|
-
...this.getCorsHeaders(req)
|
|
679
|
-
};
|
|
680
|
-
res.writeHead(proxyRes.statusCode || 200, responseHeaders);
|
|
681
|
-
res.end(responseBody);
|
|
682
|
-
if (recorded) {
|
|
683
|
-
console.log(`Recorded: ${req.method} ${req.url}`);
|
|
579
|
+
// Note: streaming requests are buffered before proxying; streaming passthrough is not yet implemented
|
|
580
|
+
async recordAndProxyRequest(req, res, target) {
|
|
581
|
+
if (!this.currentSession) {
|
|
582
|
+
return;
|
|
583
|
+
}
|
|
584
|
+
const key = getReqID(req);
|
|
585
|
+
const recordingId = this.recordingIdCounter++;
|
|
586
|
+
const sequence = this.sequenceCounterByKey.get(key) || 0;
|
|
587
|
+
this.sequenceCounterByKey.set(key, sequence + 1);
|
|
588
|
+
const recordingPromise = new Promise((resolve) => {
|
|
589
|
+
(async () => {
|
|
590
|
+
try {
|
|
591
|
+
const chunks = [];
|
|
592
|
+
req.on("data", (chunk) => {
|
|
593
|
+
chunks.push(chunk);
|
|
594
|
+
});
|
|
595
|
+
try {
|
|
596
|
+
await new Promise((resolveBuffer, rejectBuffer) => {
|
|
597
|
+
req.on("end", () => resolveBuffer());
|
|
598
|
+
req.on("error", (err) => rejectBuffer(err));
|
|
599
|
+
setTimeout(
|
|
600
|
+
() => rejectBuffer(new Error("Request buffering timeout")),
|
|
601
|
+
3e4
|
|
602
|
+
);
|
|
603
|
+
});
|
|
604
|
+
} catch (error) {
|
|
605
|
+
console.error("Error buffering request:", error);
|
|
684
606
|
}
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
607
|
+
const requestBody = Buffer.concat(chunks).toString("utf8");
|
|
608
|
+
const targetUrl = new URL(target);
|
|
609
|
+
const isHttps = targetUrl.protocol === "https:";
|
|
610
|
+
const requestModule = isHttps ? https : http;
|
|
611
|
+
const defaultPort = isHttps ? 443 : 80;
|
|
612
|
+
const proxyReq = requestModule.request(
|
|
613
|
+
{
|
|
614
|
+
hostname: targetUrl.hostname,
|
|
615
|
+
port: targetUrl.port || defaultPort,
|
|
616
|
+
path: req.url,
|
|
617
|
+
method: req.method,
|
|
618
|
+
headers: req.headers
|
|
619
|
+
},
|
|
620
|
+
(proxyRes) => {
|
|
621
|
+
this.addCorsHeaders(proxyRes, req);
|
|
622
|
+
const responseChunks = [];
|
|
623
|
+
proxyRes.on("data", (chunk) => {
|
|
624
|
+
responseChunks.push(chunk);
|
|
625
|
+
});
|
|
626
|
+
proxyRes.on("end", async () => {
|
|
627
|
+
try {
|
|
628
|
+
const responseBody = Buffer.concat(responseChunks);
|
|
629
|
+
const responseBodyStr = responseBody.toString("utf8");
|
|
630
|
+
const recording = {
|
|
631
|
+
request: {
|
|
632
|
+
method: req.method,
|
|
633
|
+
url: req.url,
|
|
634
|
+
headers: req.headers,
|
|
635
|
+
body: requestBody || null
|
|
636
|
+
},
|
|
637
|
+
response: {
|
|
638
|
+
statusCode: proxyRes.statusCode,
|
|
639
|
+
headers: proxyRes.headers,
|
|
640
|
+
body: responseBodyStr || null
|
|
641
|
+
},
|
|
642
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
643
|
+
key,
|
|
644
|
+
recordingId,
|
|
645
|
+
sequence
|
|
646
|
+
};
|
|
647
|
+
const responseHeaders = {
|
|
648
|
+
...proxyRes.headers,
|
|
649
|
+
...this.getCorsHeaders(req)
|
|
650
|
+
};
|
|
651
|
+
res.writeHead(proxyRes.statusCode || 200, responseHeaders);
|
|
652
|
+
res.end(responseBody);
|
|
653
|
+
console.log(
|
|
654
|
+
`Recorded: ${req.method} ${req.url} (recordingId: ${recordingId}, sequence: ${sequence})`
|
|
655
|
+
);
|
|
656
|
+
resolve(recording);
|
|
657
|
+
} catch (error) {
|
|
658
|
+
console.error("Error completing recording:", error);
|
|
659
|
+
resolve(null);
|
|
660
|
+
}
|
|
661
|
+
});
|
|
662
|
+
proxyRes.on("error", (err) => {
|
|
663
|
+
console.error("Proxy response error:", err);
|
|
664
|
+
if (!res.headersSent) {
|
|
665
|
+
this.handleProxyError(err, req, res);
|
|
666
|
+
}
|
|
667
|
+
resolve(null);
|
|
668
|
+
});
|
|
669
|
+
}
|
|
670
|
+
);
|
|
671
|
+
proxyReq.on("error", (err) => {
|
|
689
672
|
this.handleProxyError(err, req, res);
|
|
673
|
+
resolve(null);
|
|
674
|
+
});
|
|
675
|
+
if (chunks.length > 0) {
|
|
676
|
+
proxyReq.write(Buffer.concat(chunks));
|
|
690
677
|
}
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
678
|
+
proxyReq.end();
|
|
679
|
+
} catch (error) {
|
|
680
|
+
console.error("Error in recordAndProxyRequest:", error);
|
|
681
|
+
try {
|
|
682
|
+
this.handleProxyError(error, req, res);
|
|
683
|
+
} catch (error_) {
|
|
684
|
+
console.error("Failed to handle proxy error:", error_);
|
|
685
|
+
}
|
|
686
|
+
resolve(null);
|
|
687
|
+
}
|
|
688
|
+
})();
|
|
696
689
|
});
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
}
|
|
700
|
-
proxyReq.end();
|
|
690
|
+
this.recordingPromises.push(recordingPromise);
|
|
691
|
+
await recordingPromise;
|
|
701
692
|
}
|
|
702
693
|
handleUpgrade(req, socket, head) {
|
|
703
694
|
if (this.mode === Modes.replay) {
|
|
@@ -942,23 +933,29 @@ async function stopProxy(testInfo) {
|
|
|
942
933
|
}
|
|
943
934
|
var playwrightProxy = {
|
|
944
935
|
/**
|
|
945
|
-
* Setup before test - sets the proxy mode
|
|
936
|
+
* Setup before test - sets the proxy mode and configures page with custom header
|
|
937
|
+
* Automatically sets up page.on('close') handler for cleanup
|
|
938
|
+
* @param page - Playwright page object
|
|
946
939
|
* @param testInfo - Playwright test info object
|
|
947
940
|
* @param mode - The proxy mode to use for this test
|
|
948
941
|
* @param timeout - Optional timeout in milliseconds
|
|
949
942
|
*/
|
|
950
|
-
async before(testInfo, mode, timeout) {
|
|
943
|
+
async before(page, testInfo, mode, timeout) {
|
|
951
944
|
const sessionId = generateSessionId(testInfo);
|
|
945
|
+
await page.setExtraHTTPHeaders({
|
|
946
|
+
[RECORDING_ID_HEADER]: sessionId
|
|
947
|
+
});
|
|
952
948
|
await setProxyMode(mode, sessionId, timeout);
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
949
|
+
page.on("close", async () => {
|
|
950
|
+
try {
|
|
951
|
+
await setProxyMode(Modes.replay, sessionId);
|
|
952
|
+
console.log(
|
|
953
|
+
`[Cleanup] Switched to replay mode for session: ${sessionId}`
|
|
954
|
+
);
|
|
955
|
+
} catch (error) {
|
|
956
|
+
console.error("[Cleanup] Error during page close cleanup:", error);
|
|
957
|
+
}
|
|
958
|
+
});
|
|
962
959
|
},
|
|
963
960
|
/**
|
|
964
961
|
* Global teardown - switches proxy to transparent mode
|
|
@@ -969,6 +966,38 @@ var playwrightProxy = {
|
|
|
969
966
|
}
|
|
970
967
|
};
|
|
971
968
|
|
|
972
|
-
|
|
969
|
+
// src/nextjs/middleware.ts
|
|
970
|
+
function isRecorderEnabled() {
|
|
971
|
+
const isProduction = process.env.NODE_ENV === "production";
|
|
972
|
+
const isExplicitlyEnabled = process.env.TEST_PROXY_RECORDER_ENABLED === "true" || Number.parseInt(process.env.TEST_PROXY_RECORDER_ENABLED || "") === 1;
|
|
973
|
+
return !isProduction || isExplicitlyEnabled;
|
|
974
|
+
}
|
|
975
|
+
function setNextProxyHeaders(request, response) {
|
|
976
|
+
if (!isRecorderEnabled()) {
|
|
977
|
+
return;
|
|
978
|
+
}
|
|
979
|
+
const recordingId = request.headers.get(RECORDING_ID_HEADER);
|
|
980
|
+
if (recordingId) {
|
|
981
|
+
response.headers.set(RECORDING_ID_HEADER, recordingId);
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
function getRecordingId(requestHeaders) {
|
|
985
|
+
if (requestHeaders instanceof Headers) {
|
|
986
|
+
return requestHeaders.get(RECORDING_ID_HEADER);
|
|
987
|
+
}
|
|
988
|
+
return requestHeaders.headers.get(RECORDING_ID_HEADER);
|
|
989
|
+
}
|
|
990
|
+
function createHeadersWithRecordingId(requestHeaders, additionalHeaders = {}) {
|
|
991
|
+
if (!isRecorderEnabled()) {
|
|
992
|
+
return additionalHeaders;
|
|
993
|
+
}
|
|
994
|
+
const recordingId = getRecordingId(requestHeaders);
|
|
995
|
+
return {
|
|
996
|
+
...additionalHeaders,
|
|
997
|
+
...recordingId && { [RECORDING_ID_HEADER]: recordingId }
|
|
998
|
+
};
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
export { ProxyServer, RECORDING_ID_HEADER, createHeadersWithRecordingId, generateSessionId, getRecordingId, playwrightProxy, setNextProxyHeaders, setProxyMode, startRecording, startReplay, stopProxy };
|
|
973
1002
|
//# sourceMappingURL=index.mjs.map
|
|
974
1003
|
//# sourceMappingURL=index.mjs.map
|