test-proxy-recorder 0.3.1 → 0.3.3
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 +142 -17
- 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 +269 -63
- package/dist/index.d.cts +8 -2
- package/dist/index.d.ts +8 -2
- package/dist/index.mjs +268 -62
- package/dist/playwright/index.cjs +129 -14
- package/dist/playwright/index.d.cts +1 -1
- package/dist/playwright/index.d.ts +1 -1
- package/dist/playwright/index.mjs +125 -15
- package/dist/proxy.js +145 -45
- 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,7 +41,7 @@ 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");
|
|
@@ -82,16 +82,19 @@ async function saveRecordingSession(recordingsDir, session) {
|
|
|
82
82
|
`Saved ${processedRecordings.length} HTTP recordings and ${session.websocketRecordings?.length || 0} WebSocket recordings to ${filePath}`
|
|
83
83
|
);
|
|
84
84
|
}
|
|
85
|
-
function
|
|
86
|
-
const urlParts = req.url.split("?");
|
|
87
|
-
const pathname = urlParts[0];
|
|
88
|
-
const query = urlParts[1] || "";
|
|
85
|
+
function generateRecordingKey(pathname, query, method) {
|
|
89
86
|
const pathPart = pathname === "/" ? "root" : pathname.slice(1);
|
|
90
87
|
const normalizedPath = filenamify2(pathPart, { replacement: "_" });
|
|
91
88
|
const queryHash = generateQueryHash(query);
|
|
92
|
-
const filename = `${
|
|
89
|
+
const filename = `${method}_${normalizedPath}${queryHash}.json`;
|
|
93
90
|
return filenamify2(filename, { replacement: "_" });
|
|
94
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
|
+
}
|
|
95
98
|
function generateQueryHash(query) {
|
|
96
99
|
if (!query) {
|
|
97
100
|
return "";
|
|
@@ -133,6 +136,8 @@ var ProxyServer = class {
|
|
|
133
136
|
// Track multiple concurrent replay sessions by recording ID
|
|
134
137
|
recordingPromises;
|
|
135
138
|
// Stack of promises that resolve to completed recordings
|
|
139
|
+
flushPromise;
|
|
140
|
+
// Promise for in-progress flush operation
|
|
136
141
|
constructor(targets, recordingsDir) {
|
|
137
142
|
this.targets = targets;
|
|
138
143
|
this.currentTargetIndex = 0;
|
|
@@ -146,6 +151,7 @@ var ProxyServer = class {
|
|
|
146
151
|
this.recordingsDir = recordingsDir;
|
|
147
152
|
this.replaySessions = /* @__PURE__ */ new Map();
|
|
148
153
|
this.recordingPromises = [];
|
|
154
|
+
this.flushPromise = null;
|
|
149
155
|
this.proxy = httpProxy.createProxyServer({
|
|
150
156
|
secure: false,
|
|
151
157
|
changeOrigin: true,
|
|
@@ -244,7 +250,15 @@ var ProxyServer = class {
|
|
|
244
250
|
* @returns The recording ID, or null if not found
|
|
245
251
|
*/
|
|
246
252
|
getRecordingIdFromRequest(req) {
|
|
247
|
-
|
|
253
|
+
const fromHeader = this.getRecordingIdFromHeader(req);
|
|
254
|
+
const fromCookie = this.getRecordingIdFromCookie(req);
|
|
255
|
+
if (fromHeader) {
|
|
256
|
+
return fromHeader;
|
|
257
|
+
}
|
|
258
|
+
if (fromCookie) {
|
|
259
|
+
return fromCookie;
|
|
260
|
+
}
|
|
261
|
+
return null;
|
|
248
262
|
}
|
|
249
263
|
/**
|
|
250
264
|
* Get or create a replay session state for a given recording ID
|
|
@@ -260,7 +274,8 @@ var ProxyServer = class {
|
|
|
260
274
|
recordingId,
|
|
261
275
|
servedRecordingIdsByKey: /* @__PURE__ */ new Map(),
|
|
262
276
|
loadedSession: null,
|
|
263
|
-
lastAccessTime: Date.now()
|
|
277
|
+
lastAccessTime: Date.now(),
|
|
278
|
+
sortedRecordingsByKey: /* @__PURE__ */ new Map()
|
|
264
279
|
};
|
|
265
280
|
this.replaySessions.set(recordingId, session);
|
|
266
281
|
console.log(
|
|
@@ -269,6 +284,24 @@ var ProxyServer = class {
|
|
|
269
284
|
}
|
|
270
285
|
return session;
|
|
271
286
|
}
|
|
287
|
+
/**
|
|
288
|
+
* Clean up a session - removes it from memory and resets counters
|
|
289
|
+
* @param sessionId The session ID to clean up
|
|
290
|
+
*/
|
|
291
|
+
async cleanupSession(sessionId) {
|
|
292
|
+
if (this.replaySessions.has(sessionId)) {
|
|
293
|
+
this.replaySessions.delete(sessionId);
|
|
294
|
+
}
|
|
295
|
+
if (this.recordingId === sessionId) {
|
|
296
|
+
await this.saveCurrentSession();
|
|
297
|
+
this.currentSession = null;
|
|
298
|
+
this.recordingId = null;
|
|
299
|
+
}
|
|
300
|
+
if (this.replayId === sessionId) {
|
|
301
|
+
this.replayId = null;
|
|
302
|
+
}
|
|
303
|
+
console.log(`[CLEANUP] Session ${sessionId} cleaned up successfully`);
|
|
304
|
+
}
|
|
272
305
|
parseGetParams(req) {
|
|
273
306
|
const url = new URL(req.url || "", `http://${req.headers.host}`);
|
|
274
307
|
const mode = url.searchParams.get("mode");
|
|
@@ -292,9 +325,31 @@ var ProxyServer = class {
|
|
|
292
325
|
throw new Error("Unsupported control method");
|
|
293
326
|
}
|
|
294
327
|
async handleControlRequest(req, res) {
|
|
328
|
+
if (req.method === "GET") {
|
|
329
|
+
sendJsonResponse(res, HTTP_STATUS_OK, {
|
|
330
|
+
recordingsDir: this.recordingsDir,
|
|
331
|
+
mode: this.mode,
|
|
332
|
+
id: this.recordingId || this.replayId
|
|
333
|
+
});
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
295
336
|
try {
|
|
296
337
|
const data = await this.parseControlRequest(req);
|
|
297
|
-
const { mode, id, timeout: requestTimeout } = data;
|
|
338
|
+
const { mode, id, timeout: requestTimeout, cleanup } = data;
|
|
339
|
+
if (cleanup && id) {
|
|
340
|
+
await this.cleanupSession(id);
|
|
341
|
+
sendJsonResponse(res, HTTP_STATUS_OK, {
|
|
342
|
+
success: true,
|
|
343
|
+
message: `Session ${id} cleaned up`,
|
|
344
|
+
mode: this.mode
|
|
345
|
+
});
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
if (!mode) {
|
|
349
|
+
throw new Error(
|
|
350
|
+
"Mode parameter is required when cleanup is not specified"
|
|
351
|
+
);
|
|
352
|
+
}
|
|
298
353
|
const timeout = requestTimeout ?? DEFAULT_TIMEOUT_MS;
|
|
299
354
|
this.clearModeTimeout();
|
|
300
355
|
await this.switchMode(mode, id);
|
|
@@ -310,7 +365,8 @@ var ProxyServer = class {
|
|
|
310
365
|
success: true,
|
|
311
366
|
mode: this.mode,
|
|
312
367
|
id: this.recordingId || this.replayId,
|
|
313
|
-
timeout
|
|
368
|
+
timeout,
|
|
369
|
+
recordingsDir: this.recordingsDir
|
|
314
370
|
});
|
|
315
371
|
} catch (error) {
|
|
316
372
|
console.error("Control request error:", error);
|
|
@@ -375,16 +431,21 @@ var ProxyServer = class {
|
|
|
375
431
|
this.replayId = id;
|
|
376
432
|
this.recordingId = null;
|
|
377
433
|
this.currentSession = null;
|
|
378
|
-
const
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
434
|
+
const sessionState = this.getOrCreateReplaySession(id);
|
|
435
|
+
sessionState.servedRecordingIdsByKey.clear();
|
|
436
|
+
sessionState.sortedRecordingsByKey.clear();
|
|
437
|
+
const filePath = getRecordingPath(this.recordingsDir, id);
|
|
438
|
+
try {
|
|
439
|
+
sessionState.loadedSession = await loadRecordingSession(filePath);
|
|
440
|
+
console.log(`[REPLAY] Loaded recording session: ${id}`);
|
|
441
|
+
} catch (error) {
|
|
442
|
+
console.error(`[REPLAY ERROR] Failed to load session ${id}:`, error);
|
|
443
|
+
sessionState.loadedSession = null;
|
|
384
444
|
}
|
|
385
445
|
console.log(`Switched to replay mode with ID: ${id}`);
|
|
386
446
|
}
|
|
387
447
|
setupModeTimeout(timeout) {
|
|
448
|
+
clearTimeout(this.modeTimeout || 0);
|
|
388
449
|
this.modeTimeout = setTimeout(async () => {
|
|
389
450
|
console.log("Timeout reached, switching back to transparent mode");
|
|
390
451
|
await this.saveCurrentSession();
|
|
@@ -393,21 +454,32 @@ var ProxyServer = class {
|
|
|
393
454
|
}, timeout);
|
|
394
455
|
}
|
|
395
456
|
async flushPendingRecordings() {
|
|
457
|
+
if (this.flushPromise) {
|
|
458
|
+
await this.flushPromise;
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
396
461
|
if (this.recordingPromises.length === 0) {
|
|
397
462
|
return;
|
|
398
463
|
}
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
if (
|
|
403
|
-
|
|
464
|
+
this.flushPromise = (async () => {
|
|
465
|
+
try {
|
|
466
|
+
const results = await Promise.allSettled(this.recordingPromises);
|
|
467
|
+
if (this.currentSession) {
|
|
468
|
+
for (const result of results) {
|
|
469
|
+
if (result.status === "fulfilled" && result.value) {
|
|
470
|
+
this.currentSession.recordings.push(result.value);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
console.log(
|
|
474
|
+
`Flushed ${results.length} recordings to session (total: ${this.currentSession.recordings.length})`
|
|
475
|
+
);
|
|
404
476
|
}
|
|
477
|
+
this.recordingPromises = [];
|
|
478
|
+
} finally {
|
|
479
|
+
this.flushPromise = null;
|
|
405
480
|
}
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
);
|
|
409
|
-
}
|
|
410
|
-
this.recordingPromises = [];
|
|
481
|
+
})();
|
|
482
|
+
await this.flushPromise;
|
|
411
483
|
}
|
|
412
484
|
async saveCurrentSession() {
|
|
413
485
|
if (!this.currentSession) {
|
|
@@ -420,7 +492,29 @@ var ProxyServer = class {
|
|
|
420
492
|
await saveRecordingSession(this.recordingsDir, this.currentSession);
|
|
421
493
|
}
|
|
422
494
|
getRecordingIdOrError(req, res) {
|
|
423
|
-
const
|
|
495
|
+
const recordingIdFromRequest = this.getRecordingIdFromRequest(req);
|
|
496
|
+
if (recordingIdFromRequest) {
|
|
497
|
+
return recordingIdFromRequest;
|
|
498
|
+
}
|
|
499
|
+
if (this.replaySessions.size > 1) {
|
|
500
|
+
console.warn(
|
|
501
|
+
`[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)`
|
|
502
|
+
);
|
|
503
|
+
const corsHeaders = this.getCorsHeaders(req);
|
|
504
|
+
res.writeHead(HTTP_STATUS_BAD_REQUEST, {
|
|
505
|
+
"Content-Type": "application/json",
|
|
506
|
+
...corsHeaders
|
|
507
|
+
});
|
|
508
|
+
res.end(
|
|
509
|
+
JSON.stringify({
|
|
510
|
+
error: "Missing recording ID in concurrent replay mode. Ensure x-test-rcrd-id header is set.",
|
|
511
|
+
activeSessions: [...this.replaySessions.keys()],
|
|
512
|
+
hint: "This usually means page.setExtraHTTPHeaders() did not apply to this request type"
|
|
513
|
+
})
|
|
514
|
+
);
|
|
515
|
+
return null;
|
|
516
|
+
}
|
|
517
|
+
const recordingId = this.replayId;
|
|
424
518
|
if (!recordingId) {
|
|
425
519
|
const corsHeaders = this.getCorsHeaders(req);
|
|
426
520
|
res.writeHead(HTTP_STATUS_BAD_REQUEST, {
|
|
@@ -430,22 +524,30 @@ var ProxyServer = class {
|
|
|
430
524
|
res.end(JSON.stringify({ error: "No replay session active" }));
|
|
431
525
|
return null;
|
|
432
526
|
}
|
|
527
|
+
console.log(
|
|
528
|
+
`[FALLBACK] Using replayId fallback for ${req.method} ${req.url} -> session: ${recordingId} (single session mode)`
|
|
529
|
+
);
|
|
433
530
|
return recordingId;
|
|
434
531
|
}
|
|
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}`);
|
|
440
|
-
}
|
|
441
|
-
return sessionState;
|
|
442
|
-
}
|
|
443
532
|
getServedTracker(sessionState, key) {
|
|
444
533
|
if (!sessionState.servedRecordingIdsByKey.has(key)) {
|
|
445
534
|
sessionState.servedRecordingIdsByKey.set(key, /* @__PURE__ */ new Set());
|
|
446
535
|
}
|
|
447
536
|
return sessionState.servedRecordingIdsByKey.get(key);
|
|
448
537
|
}
|
|
538
|
+
getSortedRecordings(sessionState, key) {
|
|
539
|
+
if (sessionState.sortedRecordingsByKey.has(key)) {
|
|
540
|
+
return sessionState.sortedRecordingsByKey.get(key);
|
|
541
|
+
}
|
|
542
|
+
const session = sessionState.loadedSession;
|
|
543
|
+
const sortedRecords = session.recordings.filter((r) => r.key === key && r.response).toSorted((a, b) => {
|
|
544
|
+
const aSeq = a.sequence !== void 0 ? a.sequence : a.recordingId;
|
|
545
|
+
const bSeq = b.sequence !== void 0 ? b.sequence : b.recordingId;
|
|
546
|
+
return aSeq - bSeq;
|
|
547
|
+
});
|
|
548
|
+
sessionState.sortedRecordingsByKey.set(key, sortedRecords);
|
|
549
|
+
return sortedRecords;
|
|
550
|
+
}
|
|
449
551
|
selectReplayRecord(recordsWithKey, servedForThisKey, key, recordingId) {
|
|
450
552
|
for (const rec of recordsWithKey) {
|
|
451
553
|
if (!servedForThisKey.has(rec.recordingId)) {
|
|
@@ -466,18 +568,17 @@ var ProxyServer = class {
|
|
|
466
568
|
const key = getReqID(req);
|
|
467
569
|
const filePath = getRecordingPath(this.recordingsDir, recordingId);
|
|
468
570
|
try {
|
|
469
|
-
const sessionState =
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
571
|
+
const sessionState = this.getOrCreateReplaySession(recordingId);
|
|
572
|
+
if (!sessionState.loadedSession) {
|
|
573
|
+
const error = new Error(
|
|
574
|
+
`Recording session file not found: ${filePath}`
|
|
575
|
+
);
|
|
576
|
+
error.code = "ENOENT";
|
|
577
|
+
throw error;
|
|
578
|
+
}
|
|
474
579
|
const servedForThisKey = this.getServedTracker(sessionState, key);
|
|
475
580
|
const host = req.headers.host || "unknown";
|
|
476
|
-
const recordsWithKey =
|
|
477
|
-
const aSeq = a.sequence !== void 0 ? a.sequence : a.recordingId;
|
|
478
|
-
const bSeq = b.sequence !== void 0 ? b.sequence : b.recordingId;
|
|
479
|
-
return aSeq - bSeq;
|
|
480
|
-
});
|
|
581
|
+
const recordsWithKey = this.getSortedRecordings(sessionState, key);
|
|
481
582
|
if (recordsWithKey.length === 0) {
|
|
482
583
|
const errorMsg = `No recording found for ${key} at ${req.method} ${host}${req.url}`;
|
|
483
584
|
console.error(`[REPLAY ERROR] ${errorMsg} (session: ${recordingId})`);
|
|
@@ -688,7 +789,6 @@ var ProxyServer = class {
|
|
|
688
789
|
})();
|
|
689
790
|
});
|
|
690
791
|
this.recordingPromises.push(recordingPromise);
|
|
691
|
-
await recordingPromise;
|
|
692
792
|
}
|
|
693
793
|
handleUpgrade(req, socket, head) {
|
|
694
794
|
if (this.mode === Modes.replay) {
|
|
@@ -853,8 +953,6 @@ var ProxyServer = class {
|
|
|
853
953
|
);
|
|
854
954
|
}
|
|
855
955
|
};
|
|
856
|
-
|
|
857
|
-
// src/playwright/index.ts
|
|
858
956
|
function getProxyPort() {
|
|
859
957
|
const envPort = process.env.TEST_PROXY_RECORDER_PORT;
|
|
860
958
|
if (envPort) {
|
|
@@ -884,12 +982,34 @@ async function setProxyMode(mode, sessionId, timeout) {
|
|
|
884
982
|
throw new Error(`Failed to set proxy mode: ${text}`);
|
|
885
983
|
}
|
|
886
984
|
await response.json();
|
|
887
|
-
console.log(`Proxy mode set to: ${mode} (session: ${sessionId})`);
|
|
888
985
|
} catch (error) {
|
|
889
986
|
console.error(`Error setting proxy mode:`, error);
|
|
890
987
|
throw error;
|
|
891
988
|
}
|
|
892
989
|
}
|
|
990
|
+
async function cleanupSession(sessionId) {
|
|
991
|
+
const proxyPort = getProxyPort();
|
|
992
|
+
try {
|
|
993
|
+
const body = {
|
|
994
|
+
cleanup: true,
|
|
995
|
+
id: sessionId
|
|
996
|
+
};
|
|
997
|
+
const response = await fetch(`http://127.0.0.1:${proxyPort}/__control`, {
|
|
998
|
+
method: "POST",
|
|
999
|
+
headers: { "Content-Type": "application/json" },
|
|
1000
|
+
body: JSON.stringify(body)
|
|
1001
|
+
});
|
|
1002
|
+
if (!response.ok) {
|
|
1003
|
+
const text = await response.text();
|
|
1004
|
+
console.error(`Failed to cleanup session ${sessionId}:`, text);
|
|
1005
|
+
throw new Error(`Failed to cleanup session: ${text}`);
|
|
1006
|
+
}
|
|
1007
|
+
await response.json();
|
|
1008
|
+
} catch (error) {
|
|
1009
|
+
console.error(`Error cleaning up session: ${sessionId}`, error);
|
|
1010
|
+
throw error;
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
893
1013
|
function parseSpecFilePath(specPath) {
|
|
894
1014
|
const folderMatch = specPath.match(/^(.+?)\/([^/]+)\.(spec|test)\.ts$/);
|
|
895
1015
|
if (folderMatch) {
|
|
@@ -931,6 +1051,50 @@ async function stopProxy(testInfo) {
|
|
|
931
1051
|
const sessionId = generateSessionId(testInfo);
|
|
932
1052
|
await setProxyMode(Modes.transparent, sessionId);
|
|
933
1053
|
}
|
|
1054
|
+
var cachedRecordingsDir = null;
|
|
1055
|
+
async function getRecordingsDir() {
|
|
1056
|
+
if (cachedRecordingsDir) {
|
|
1057
|
+
return cachedRecordingsDir;
|
|
1058
|
+
}
|
|
1059
|
+
const proxyPort = getProxyPort();
|
|
1060
|
+
try {
|
|
1061
|
+
const response = await fetch(`http://127.0.0.1:${proxyPort}/__control`);
|
|
1062
|
+
if (response.ok) {
|
|
1063
|
+
const data = await response.json();
|
|
1064
|
+
if (data.recordingsDir) {
|
|
1065
|
+
cachedRecordingsDir = data.recordingsDir;
|
|
1066
|
+
return cachedRecordingsDir;
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
} catch (error) {
|
|
1070
|
+
console.warn(
|
|
1071
|
+
"Failed to get recordings directory from proxy, using default:",
|
|
1072
|
+
error
|
|
1073
|
+
);
|
|
1074
|
+
}
|
|
1075
|
+
cachedRecordingsDir = path2.join(process.cwd(), "e2e", "recordings");
|
|
1076
|
+
return cachedRecordingsDir;
|
|
1077
|
+
}
|
|
1078
|
+
async function setupClientSideRecording(page, sessionId, mode, url) {
|
|
1079
|
+
const harFileName = sessionId.replaceAll("/", "__");
|
|
1080
|
+
const recordingsDir = await getRecordingsDir();
|
|
1081
|
+
const harPath = path2.join(recordingsDir, `${harFileName}.har`);
|
|
1082
|
+
try {
|
|
1083
|
+
await page.routeFromHAR(harPath, {
|
|
1084
|
+
url,
|
|
1085
|
+
update: mode === Modes.record,
|
|
1086
|
+
updateContent: "embed"
|
|
1087
|
+
});
|
|
1088
|
+
} catch (error) {
|
|
1089
|
+
if (mode === Modes.replay) {
|
|
1090
|
+
console.error(
|
|
1091
|
+
`[Client-Side Replay] Failed to load HAR file. Run tests in record mode first.`,
|
|
1092
|
+
error
|
|
1093
|
+
);
|
|
1094
|
+
throw error;
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
934
1098
|
var playwrightProxy = {
|
|
935
1099
|
/**
|
|
936
1100
|
* Setup before test - sets the proxy mode and configures page with custom header
|
|
@@ -938,24 +1102,66 @@ var playwrightProxy = {
|
|
|
938
1102
|
* @param page - Playwright page object
|
|
939
1103
|
* @param testInfo - Playwright test info object
|
|
940
1104
|
* @param mode - The proxy mode to use for this test
|
|
941
|
-
* @param
|
|
1105
|
+
* @param options - Optional configuration including timeout and client-side recording patterns
|
|
942
1106
|
*/
|
|
943
|
-
async before(page, testInfo, mode,
|
|
1107
|
+
async before(page, testInfo, mode, options) {
|
|
1108
|
+
const timeout = typeof options === "number" ? options : options?.timeout;
|
|
1109
|
+
const clientSideOptions = typeof options === "object" && options !== null ? options : void 0;
|
|
944
1110
|
const sessionId = generateSessionId(testInfo);
|
|
945
1111
|
await page.setExtraHTTPHeaders({
|
|
946
1112
|
[RECORDING_ID_HEADER]: sessionId
|
|
947
1113
|
});
|
|
948
1114
|
await setProxyMode(mode, sessionId, timeout);
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
}
|
|
1115
|
+
if (clientSideOptions?.url) {
|
|
1116
|
+
await setupClientSideRecording(
|
|
1117
|
+
page,
|
|
1118
|
+
sessionId,
|
|
1119
|
+
mode,
|
|
1120
|
+
clientSideOptions.url
|
|
1121
|
+
);
|
|
1122
|
+
}
|
|
1123
|
+
const proxyPort = process.env.TEST_PROXY_RECORDER_PORT || "8100";
|
|
1124
|
+
const proxyUrl = `localhost:${proxyPort}`;
|
|
1125
|
+
await page.route(
|
|
1126
|
+
(url) => {
|
|
1127
|
+
const urlStr = url.toString();
|
|
1128
|
+
const matches = urlStr.includes(proxyUrl);
|
|
1129
|
+
return matches;
|
|
1130
|
+
},
|
|
1131
|
+
async (route) => {
|
|
1132
|
+
try {
|
|
1133
|
+
const headers = route.request().headers();
|
|
1134
|
+
headers[RECORDING_ID_HEADER] = sessionId;
|
|
1135
|
+
await route.continue({ headers });
|
|
1136
|
+
} catch (error) {
|
|
1137
|
+
console.error(
|
|
1138
|
+
`[Route Handler Error] Failed to add ${RECORDING_ID_HEADER} header:`,
|
|
1139
|
+
error
|
|
1140
|
+
);
|
|
1141
|
+
await route.fallback();
|
|
1142
|
+
}
|
|
1143
|
+
},
|
|
1144
|
+
{ times: Infinity }
|
|
1145
|
+
// Ensure the handler applies to all matching requests
|
|
1146
|
+
);
|
|
1147
|
+
const context = page.context();
|
|
1148
|
+
const contextId = context._guid || "default";
|
|
1149
|
+
const handlerKey = `cleanup_${contextId}`;
|
|
1150
|
+
if (!globalThis[handlerKey]) {
|
|
1151
|
+
globalThis[handlerKey] = true;
|
|
1152
|
+
context.on("close", async () => {
|
|
1153
|
+
try {
|
|
1154
|
+
await cleanupSession(sessionId);
|
|
1155
|
+
} catch (error) {
|
|
1156
|
+
console.warn(
|
|
1157
|
+
`[Cleanup] Failed to cleanup session ${sessionId}:`,
|
|
1158
|
+
error
|
|
1159
|
+
);
|
|
1160
|
+
} finally {
|
|
1161
|
+
delete globalThis[handlerKey];
|
|
1162
|
+
}
|
|
1163
|
+
});
|
|
1164
|
+
}
|
|
959
1165
|
},
|
|
960
1166
|
/**
|
|
961
1167
|
* Global teardown - switches proxy to transparent mode
|
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
var path = require('path');
|
|
4
|
+
|
|
5
|
+
function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
|
|
6
|
+
|
|
7
|
+
var path__default = /*#__PURE__*/_interopDefault(path);
|
|
8
|
+
|
|
9
|
+
// src/playwright/index.ts
|
|
4
10
|
var RECORDING_ID_HEADER = "x-test-rcrd-id";
|
|
5
11
|
|
|
6
12
|
// src/types.ts
|
|
@@ -40,12 +46,34 @@ async function setProxyMode(mode, sessionId, timeout) {
|
|
|
40
46
|
throw new Error(`Failed to set proxy mode: ${text}`);
|
|
41
47
|
}
|
|
42
48
|
await response.json();
|
|
43
|
-
console.log(`Proxy mode set to: ${mode} (session: ${sessionId})`);
|
|
44
49
|
} catch (error) {
|
|
45
50
|
console.error(`Error setting proxy mode:`, error);
|
|
46
51
|
throw error;
|
|
47
52
|
}
|
|
48
53
|
}
|
|
54
|
+
async function cleanupSession(sessionId) {
|
|
55
|
+
const proxyPort = getProxyPort();
|
|
56
|
+
try {
|
|
57
|
+
const body = {
|
|
58
|
+
cleanup: true,
|
|
59
|
+
id: sessionId
|
|
60
|
+
};
|
|
61
|
+
const response = await fetch(`http://127.0.0.1:${proxyPort}/__control`, {
|
|
62
|
+
method: "POST",
|
|
63
|
+
headers: { "Content-Type": "application/json" },
|
|
64
|
+
body: JSON.stringify(body)
|
|
65
|
+
});
|
|
66
|
+
if (!response.ok) {
|
|
67
|
+
const text = await response.text();
|
|
68
|
+
console.error(`Failed to cleanup session ${sessionId}:`, text);
|
|
69
|
+
throw new Error(`Failed to cleanup session: ${text}`);
|
|
70
|
+
}
|
|
71
|
+
await response.json();
|
|
72
|
+
} catch (error) {
|
|
73
|
+
console.error(`Error cleaning up session: ${sessionId}`, error);
|
|
74
|
+
throw error;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
49
77
|
function parseSpecFilePath(specPath) {
|
|
50
78
|
const folderMatch = specPath.match(/^(.+?)\/([^/]+)\.(spec|test)\.ts$/);
|
|
51
79
|
if (folderMatch) {
|
|
@@ -87,6 +115,50 @@ async function stopProxy(testInfo) {
|
|
|
87
115
|
const sessionId = generateSessionId(testInfo);
|
|
88
116
|
await setProxyMode(Modes.transparent, sessionId);
|
|
89
117
|
}
|
|
118
|
+
var cachedRecordingsDir = null;
|
|
119
|
+
async function getRecordingsDir() {
|
|
120
|
+
if (cachedRecordingsDir) {
|
|
121
|
+
return cachedRecordingsDir;
|
|
122
|
+
}
|
|
123
|
+
const proxyPort = getProxyPort();
|
|
124
|
+
try {
|
|
125
|
+
const response = await fetch(`http://127.0.0.1:${proxyPort}/__control`);
|
|
126
|
+
if (response.ok) {
|
|
127
|
+
const data = await response.json();
|
|
128
|
+
if (data.recordingsDir) {
|
|
129
|
+
cachedRecordingsDir = data.recordingsDir;
|
|
130
|
+
return cachedRecordingsDir;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
} catch (error) {
|
|
134
|
+
console.warn(
|
|
135
|
+
"Failed to get recordings directory from proxy, using default:",
|
|
136
|
+
error
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
cachedRecordingsDir = path__default.default.join(process.cwd(), "e2e", "recordings");
|
|
140
|
+
return cachedRecordingsDir;
|
|
141
|
+
}
|
|
142
|
+
async function setupClientSideRecording(page, sessionId, mode, url) {
|
|
143
|
+
const harFileName = sessionId.replaceAll("/", "__");
|
|
144
|
+
const recordingsDir = await getRecordingsDir();
|
|
145
|
+
const harPath = path__default.default.join(recordingsDir, `${harFileName}.har`);
|
|
146
|
+
try {
|
|
147
|
+
await page.routeFromHAR(harPath, {
|
|
148
|
+
url,
|
|
149
|
+
update: mode === Modes.record,
|
|
150
|
+
updateContent: "embed"
|
|
151
|
+
});
|
|
152
|
+
} catch (error) {
|
|
153
|
+
if (mode === Modes.replay) {
|
|
154
|
+
console.error(
|
|
155
|
+
`[Client-Side Replay] Failed to load HAR file. Run tests in record mode first.`,
|
|
156
|
+
error
|
|
157
|
+
);
|
|
158
|
+
throw error;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
90
162
|
var playwrightProxy = {
|
|
91
163
|
/**
|
|
92
164
|
* Setup before test - sets the proxy mode and configures page with custom header
|
|
@@ -94,24 +166,66 @@ var playwrightProxy = {
|
|
|
94
166
|
* @param page - Playwright page object
|
|
95
167
|
* @param testInfo - Playwright test info object
|
|
96
168
|
* @param mode - The proxy mode to use for this test
|
|
97
|
-
* @param
|
|
169
|
+
* @param options - Optional configuration including timeout and client-side recording patterns
|
|
98
170
|
*/
|
|
99
|
-
async before(page, testInfo, mode,
|
|
171
|
+
async before(page, testInfo, mode, options) {
|
|
172
|
+
const timeout = typeof options === "number" ? options : options?.timeout;
|
|
173
|
+
const clientSideOptions = typeof options === "object" && options !== null ? options : void 0;
|
|
100
174
|
const sessionId = generateSessionId(testInfo);
|
|
101
175
|
await page.setExtraHTTPHeaders({
|
|
102
176
|
[RECORDING_ID_HEADER]: sessionId
|
|
103
177
|
});
|
|
104
178
|
await setProxyMode(mode, sessionId, timeout);
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
}
|
|
179
|
+
if (clientSideOptions?.url) {
|
|
180
|
+
await setupClientSideRecording(
|
|
181
|
+
page,
|
|
182
|
+
sessionId,
|
|
183
|
+
mode,
|
|
184
|
+
clientSideOptions.url
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
const proxyPort = process.env.TEST_PROXY_RECORDER_PORT || "8100";
|
|
188
|
+
const proxyUrl = `localhost:${proxyPort}`;
|
|
189
|
+
await page.route(
|
|
190
|
+
(url) => {
|
|
191
|
+
const urlStr = url.toString();
|
|
192
|
+
const matches = urlStr.includes(proxyUrl);
|
|
193
|
+
return matches;
|
|
194
|
+
},
|
|
195
|
+
async (route) => {
|
|
196
|
+
try {
|
|
197
|
+
const headers = route.request().headers();
|
|
198
|
+
headers[RECORDING_ID_HEADER] = sessionId;
|
|
199
|
+
await route.continue({ headers });
|
|
200
|
+
} catch (error) {
|
|
201
|
+
console.error(
|
|
202
|
+
`[Route Handler Error] Failed to add ${RECORDING_ID_HEADER} header:`,
|
|
203
|
+
error
|
|
204
|
+
);
|
|
205
|
+
await route.fallback();
|
|
206
|
+
}
|
|
207
|
+
},
|
|
208
|
+
{ times: Infinity }
|
|
209
|
+
// Ensure the handler applies to all matching requests
|
|
210
|
+
);
|
|
211
|
+
const context = page.context();
|
|
212
|
+
const contextId = context._guid || "default";
|
|
213
|
+
const handlerKey = `cleanup_${contextId}`;
|
|
214
|
+
if (!globalThis[handlerKey]) {
|
|
215
|
+
globalThis[handlerKey] = true;
|
|
216
|
+
context.on("close", async () => {
|
|
217
|
+
try {
|
|
218
|
+
await cleanupSession(sessionId);
|
|
219
|
+
} catch (error) {
|
|
220
|
+
console.warn(
|
|
221
|
+
`[Cleanup] Failed to cleanup session ${sessionId}:`,
|
|
222
|
+
error
|
|
223
|
+
);
|
|
224
|
+
} finally {
|
|
225
|
+
delete globalThis[handlerKey];
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
}
|
|
115
229
|
},
|
|
116
230
|
/**
|
|
117
231
|
* Global teardown - switches proxy to transparent mode
|
|
@@ -122,6 +236,7 @@ var playwrightProxy = {
|
|
|
122
236
|
}
|
|
123
237
|
};
|
|
124
238
|
|
|
239
|
+
exports.cleanupSession = cleanupSession;
|
|
125
240
|
exports.generateSessionId = generateSessionId;
|
|
126
241
|
exports.playwrightProxy = playwrightProxy;
|
|
127
242
|
exports.setProxyMode = setProxyMode;
|
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
import '@playwright/test';
|
|
2
|
-
export { P as PlaywrightTestInfo, g as generateSessionId, p as playwrightProxy, s as setProxyMode, b as startRecording, c as startReplay, d as stopProxy } from '../index-
|
|
2
|
+
export { f as ClientSideRecordingOptions, P as PlaywrightTestInfo, e as cleanupSession, g as generateSessionId, p as playwrightProxy, s as setProxyMode, b as startRecording, c as startReplay, d as stopProxy } from '../index-BlBWqSE4.cjs';
|
|
3
3
|
import 'node:http';
|