test-proxy-recorder 0.3.1 → 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 +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 +234 -26
- package/dist/index.d.cts +6 -1
- package/dist/index.d.ts +6 -1
- package/dist/index.mjs +233 -25
- 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 +88 -9
- package/package.json +1 -1
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
|
|
1
|
+
import path from 'path';
|
|
2
|
+
|
|
3
|
+
// src/playwright/index.ts
|
|
2
4
|
var RECORDING_ID_HEADER = "x-test-rcrd-id";
|
|
3
5
|
|
|
4
6
|
// src/types.ts
|
|
@@ -44,6 +46,30 @@ async function setProxyMode(mode, sessionId, timeout) {
|
|
|
44
46
|
throw error;
|
|
45
47
|
}
|
|
46
48
|
}
|
|
49
|
+
async function cleanupSession(sessionId) {
|
|
50
|
+
const proxyPort = getProxyPort();
|
|
51
|
+
try {
|
|
52
|
+
const body = {
|
|
53
|
+
cleanup: true,
|
|
54
|
+
id: sessionId
|
|
55
|
+
};
|
|
56
|
+
const response = await fetch(`http://127.0.0.1:${proxyPort}/__control`, {
|
|
57
|
+
method: "POST",
|
|
58
|
+
headers: { "Content-Type": "application/json" },
|
|
59
|
+
body: JSON.stringify(body)
|
|
60
|
+
});
|
|
61
|
+
if (!response.ok) {
|
|
62
|
+
const text = await response.text();
|
|
63
|
+
console.error(`Failed to cleanup session ${sessionId}:`, text);
|
|
64
|
+
throw new Error(`Failed to cleanup session: ${text}`);
|
|
65
|
+
}
|
|
66
|
+
await response.json();
|
|
67
|
+
console.log(`Session cleaned up: ${sessionId}`);
|
|
68
|
+
} catch (error) {
|
|
69
|
+
console.error(`Error cleaning up session:`, error);
|
|
70
|
+
throw error;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
47
73
|
function parseSpecFilePath(specPath) {
|
|
48
74
|
const folderMatch = specPath.match(/^(.+?)\/([^/]+)\.(spec|test)\.ts$/);
|
|
49
75
|
if (folderMatch) {
|
|
@@ -85,6 +111,53 @@ async function stopProxy(testInfo) {
|
|
|
85
111
|
const sessionId = generateSessionId(testInfo);
|
|
86
112
|
await setProxyMode(Modes.transparent, sessionId);
|
|
87
113
|
}
|
|
114
|
+
var cachedRecordingsDir = null;
|
|
115
|
+
async function getRecordingsDir() {
|
|
116
|
+
if (cachedRecordingsDir) {
|
|
117
|
+
return cachedRecordingsDir;
|
|
118
|
+
}
|
|
119
|
+
const proxyPort = getProxyPort();
|
|
120
|
+
try {
|
|
121
|
+
const response = await fetch(`http://127.0.0.1:${proxyPort}/__control`);
|
|
122
|
+
if (response.ok) {
|
|
123
|
+
const data = await response.json();
|
|
124
|
+
if (data.recordingsDir) {
|
|
125
|
+
cachedRecordingsDir = data.recordingsDir;
|
|
126
|
+
return cachedRecordingsDir;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
} catch (error) {
|
|
130
|
+
console.warn(
|
|
131
|
+
"Failed to get recordings directory from proxy, using default:",
|
|
132
|
+
error
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
cachedRecordingsDir = path.join(process.cwd(), "e2e", "recordings");
|
|
136
|
+
return cachedRecordingsDir;
|
|
137
|
+
}
|
|
138
|
+
async function setupClientSideRecording(page, sessionId, mode, url) {
|
|
139
|
+
const harFileName = sessionId.replaceAll("/", "__");
|
|
140
|
+
const recordingsDir = await getRecordingsDir();
|
|
141
|
+
const harPath = path.join(recordingsDir, `${harFileName}.har`);
|
|
142
|
+
console.log(
|
|
143
|
+
`[Client-Side Recording] Setting up HAR for session: ${sessionId}, mode: ${mode}, path: ${harPath}`
|
|
144
|
+
);
|
|
145
|
+
try {
|
|
146
|
+
await page.routeFromHAR(harPath, {
|
|
147
|
+
url,
|
|
148
|
+
update: mode === Modes.record,
|
|
149
|
+
updateContent: "embed"
|
|
150
|
+
});
|
|
151
|
+
} catch (error) {
|
|
152
|
+
if (mode === Modes.replay) {
|
|
153
|
+
console.error(
|
|
154
|
+
`[Client-Side Replay] Failed to load HAR file. Run tests in record mode first.`,
|
|
155
|
+
error
|
|
156
|
+
);
|
|
157
|
+
throw error;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
88
161
|
var playwrightProxy = {
|
|
89
162
|
/**
|
|
90
163
|
* Setup before test - sets the proxy mode and configures page with custom header
|
|
@@ -92,24 +165,84 @@ var playwrightProxy = {
|
|
|
92
165
|
* @param page - Playwright page object
|
|
93
166
|
* @param testInfo - Playwright test info object
|
|
94
167
|
* @param mode - The proxy mode to use for this test
|
|
95
|
-
* @param
|
|
168
|
+
* @param options - Optional configuration including timeout and client-side recording patterns
|
|
96
169
|
*/
|
|
97
|
-
async before(page, testInfo, mode,
|
|
170
|
+
async before(page, testInfo, mode, options) {
|
|
171
|
+
const timeout = typeof options === "number" ? options : options?.timeout;
|
|
172
|
+
const clientSideOptions = typeof options === "object" && options !== null ? options : void 0;
|
|
98
173
|
const sessionId = generateSessionId(testInfo);
|
|
99
174
|
await page.setExtraHTTPHeaders({
|
|
100
175
|
[RECORDING_ID_HEADER]: sessionId
|
|
101
176
|
});
|
|
177
|
+
console.log(`[Setup] Setting proxy mode: ${mode}, session: ${sessionId}`);
|
|
102
178
|
await setProxyMode(mode, sessionId, timeout);
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
179
|
+
console.log(`[Setup] Proxy mode set successfully`);
|
|
180
|
+
if (clientSideOptions?.url) {
|
|
181
|
+
console.log(`[Setup] Setting up client-side recording with pattern: ${clientSideOptions.url}`);
|
|
182
|
+
await setupClientSideRecording(
|
|
183
|
+
page,
|
|
184
|
+
sessionId,
|
|
185
|
+
mode,
|
|
186
|
+
clientSideOptions.url
|
|
187
|
+
);
|
|
188
|
+
console.log(`[Setup] Client-side recording setup complete`);
|
|
189
|
+
}
|
|
190
|
+
const proxyPort = process.env.TEST_PROXY_RECORDER_PORT || "8100";
|
|
191
|
+
const proxyUrl = `localhost:${proxyPort}`;
|
|
192
|
+
console.log(`[Setup] Registering proxy route handler for: ${proxyUrl}`);
|
|
193
|
+
await page.route(
|
|
194
|
+
(url) => {
|
|
195
|
+
const urlStr = url.toString();
|
|
196
|
+
const matches = urlStr.includes(proxyUrl);
|
|
197
|
+
if (matches) {
|
|
198
|
+
console.log(`[Route Matcher] Matched proxy request: ${urlStr}`);
|
|
199
|
+
}
|
|
200
|
+
return matches;
|
|
201
|
+
},
|
|
202
|
+
async (route) => {
|
|
203
|
+
try {
|
|
204
|
+
const url = route.request().url();
|
|
205
|
+
const method = route.request().method();
|
|
206
|
+
const headers = route.request().headers();
|
|
207
|
+
const hadHeader = !!headers[RECORDING_ID_HEADER];
|
|
208
|
+
headers[RECORDING_ID_HEADER] = sessionId;
|
|
209
|
+
console.log(
|
|
210
|
+
`[Route Intercept] ${method} ${url} (had header: ${hadHeader}, adding session: ${sessionId})`
|
|
211
|
+
);
|
|
212
|
+
await route.continue({ headers });
|
|
213
|
+
} catch (error) {
|
|
214
|
+
console.error(
|
|
215
|
+
`[Route Handler Error] Failed to add ${RECORDING_ID_HEADER} header:`,
|
|
216
|
+
error
|
|
217
|
+
);
|
|
218
|
+
await route.fallback();
|
|
219
|
+
}
|
|
220
|
+
},
|
|
221
|
+
{ times: Infinity }
|
|
222
|
+
// Ensure the handler applies to all matching requests
|
|
223
|
+
);
|
|
224
|
+
console.log(`[Setup] Proxy route handler registered`);
|
|
225
|
+
const context = page.context();
|
|
226
|
+
const contextId = context._guid || "default";
|
|
227
|
+
const handlerKey = `cleanup_${contextId}`;
|
|
228
|
+
if (!globalThis[handlerKey]) {
|
|
229
|
+
globalThis[handlerKey] = true;
|
|
230
|
+
context.on("close", async () => {
|
|
231
|
+
try {
|
|
232
|
+
console.log(
|
|
233
|
+
`[Cleanup] Browser context closed, cleaning up session: ${sessionId}`
|
|
234
|
+
);
|
|
235
|
+
await cleanupSession(sessionId);
|
|
236
|
+
} catch (error) {
|
|
237
|
+
console.warn(
|
|
238
|
+
`[Cleanup] Failed to cleanup session ${sessionId}:`,
|
|
239
|
+
error
|
|
240
|
+
);
|
|
241
|
+
} finally {
|
|
242
|
+
delete globalThis[handlerKey];
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
}
|
|
113
246
|
},
|
|
114
247
|
/**
|
|
115
248
|
* Global teardown - switches proxy to transparent mode
|
|
@@ -120,6 +253,6 @@ var playwrightProxy = {
|
|
|
120
253
|
}
|
|
121
254
|
};
|
|
122
255
|
|
|
123
|
-
export { generateSessionId, playwrightProxy, setProxyMode, startRecording, startReplay, stopProxy };
|
|
256
|
+
export { cleanupSession, generateSessionId, playwrightProxy, setProxyMode, startRecording, startReplay, stopProxy };
|
|
124
257
|
//# sourceMappingURL=index.mjs.map
|
|
125
258
|
//# sourceMappingURL=index.mjs.map
|
package/dist/proxy.js
CHANGED
|
@@ -118,16 +118,19 @@ async function saveRecordingSession(recordingsDir2, session) {
|
|
|
118
118
|
`Saved ${processedRecordings.length} HTTP recordings and ${session.websocketRecordings?.length || 0} WebSocket recordings to ${filePath}`
|
|
119
119
|
);
|
|
120
120
|
}
|
|
121
|
-
function
|
|
122
|
-
const urlParts = req.url.split("?");
|
|
123
|
-
const pathname = urlParts[0];
|
|
124
|
-
const query = urlParts[1] || "";
|
|
121
|
+
function generateRecordingKey(pathname, query, method) {
|
|
125
122
|
const pathPart = pathname === "/" ? "root" : pathname.slice(1);
|
|
126
123
|
const normalizedPath = filenamify2(pathPart, { replacement: "_" });
|
|
127
124
|
const queryHash = generateQueryHash(query);
|
|
128
|
-
const filename = `${
|
|
125
|
+
const filename = `${method}_${normalizedPath}${queryHash}.json`;
|
|
129
126
|
return filenamify2(filename, { replacement: "_" });
|
|
130
127
|
}
|
|
128
|
+
function getReqID(req) {
|
|
129
|
+
const urlParts = req.url.split("?");
|
|
130
|
+
const pathname = urlParts[0];
|
|
131
|
+
const query = urlParts[1] || "";
|
|
132
|
+
return generateRecordingKey(pathname, query, req.method);
|
|
133
|
+
}
|
|
131
134
|
function generateQueryHash(query) {
|
|
132
135
|
if (!query) {
|
|
133
136
|
return "";
|
|
@@ -280,7 +283,15 @@ var ProxyServer = class {
|
|
|
280
283
|
* @returns The recording ID, or null if not found
|
|
281
284
|
*/
|
|
282
285
|
getRecordingIdFromRequest(req) {
|
|
283
|
-
|
|
286
|
+
const fromHeader = this.getRecordingIdFromHeader(req);
|
|
287
|
+
const fromCookie = this.getRecordingIdFromCookie(req);
|
|
288
|
+
if (fromHeader) {
|
|
289
|
+
return fromHeader;
|
|
290
|
+
}
|
|
291
|
+
if (fromCookie) {
|
|
292
|
+
return fromCookie;
|
|
293
|
+
}
|
|
294
|
+
return null;
|
|
284
295
|
}
|
|
285
296
|
/**
|
|
286
297
|
* Get or create a replay session state for a given recording ID
|
|
@@ -305,6 +316,27 @@ var ProxyServer = class {
|
|
|
305
316
|
}
|
|
306
317
|
return session;
|
|
307
318
|
}
|
|
319
|
+
/**
|
|
320
|
+
* Clean up a session - removes it from memory and resets counters
|
|
321
|
+
* @param sessionId The session ID to clean up
|
|
322
|
+
*/
|
|
323
|
+
async cleanupSession(sessionId) {
|
|
324
|
+
if (this.replaySessions.has(sessionId)) {
|
|
325
|
+
console.log(`[CLEANUP] Removing replay session: ${sessionId}`);
|
|
326
|
+
this.replaySessions.delete(sessionId);
|
|
327
|
+
}
|
|
328
|
+
if (this.recordingId === sessionId) {
|
|
329
|
+
console.log(`[CLEANUP] Saving and clearing active recording session: ${sessionId}`);
|
|
330
|
+
await this.saveCurrentSession();
|
|
331
|
+
this.currentSession = null;
|
|
332
|
+
this.recordingId = null;
|
|
333
|
+
}
|
|
334
|
+
if (this.replayId === sessionId) {
|
|
335
|
+
console.log(`[CLEANUP] Clearing active replay session: ${sessionId}`);
|
|
336
|
+
this.replayId = null;
|
|
337
|
+
}
|
|
338
|
+
console.log(`[CLEANUP] Session ${sessionId} cleaned up successfully`);
|
|
339
|
+
}
|
|
308
340
|
parseGetParams(req) {
|
|
309
341
|
const url = new URL(req.url || "", `http://${req.headers.host}`);
|
|
310
342
|
const mode = url.searchParams.get("mode");
|
|
@@ -328,9 +360,29 @@ var ProxyServer = class {
|
|
|
328
360
|
throw new Error("Unsupported control method");
|
|
329
361
|
}
|
|
330
362
|
async handleControlRequest(req, res) {
|
|
363
|
+
if (req.method === "GET") {
|
|
364
|
+
sendJsonResponse(res, HTTP_STATUS_OK, {
|
|
365
|
+
recordingsDir: this.recordingsDir,
|
|
366
|
+
mode: this.mode,
|
|
367
|
+
id: this.recordingId || this.replayId
|
|
368
|
+
});
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
331
371
|
try {
|
|
332
372
|
const data = await this.parseControlRequest(req);
|
|
333
|
-
const { mode, id, timeout: requestTimeout } = data;
|
|
373
|
+
const { mode, id, timeout: requestTimeout, cleanup } = data;
|
|
374
|
+
if (cleanup && id) {
|
|
375
|
+
await this.cleanupSession(id);
|
|
376
|
+
sendJsonResponse(res, HTTP_STATUS_OK, {
|
|
377
|
+
success: true,
|
|
378
|
+
message: `Session ${id} cleaned up`,
|
|
379
|
+
mode: this.mode
|
|
380
|
+
});
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
if (!mode) {
|
|
384
|
+
throw new Error("Mode parameter is required when cleanup is not specified");
|
|
385
|
+
}
|
|
334
386
|
const timeout = requestTimeout ?? DEFAULT_TIMEOUT_MS;
|
|
335
387
|
this.clearModeTimeout();
|
|
336
388
|
await this.switchMode(mode, id);
|
|
@@ -346,7 +398,8 @@ var ProxyServer = class {
|
|
|
346
398
|
success: true,
|
|
347
399
|
mode: this.mode,
|
|
348
400
|
id: this.recordingId || this.replayId,
|
|
349
|
-
timeout
|
|
401
|
+
timeout,
|
|
402
|
+
recordingsDir: this.recordingsDir
|
|
350
403
|
});
|
|
351
404
|
} catch (error) {
|
|
352
405
|
console.error("Control request error:", error);
|
|
@@ -421,6 +474,7 @@ var ProxyServer = class {
|
|
|
421
474
|
console.log(`Switched to replay mode with ID: ${id}`);
|
|
422
475
|
}
|
|
423
476
|
setupModeTimeout(timeout) {
|
|
477
|
+
clearTimeout(this.modeTimeout || 0);
|
|
424
478
|
this.modeTimeout = setTimeout(async () => {
|
|
425
479
|
console.log("Timeout reached, switching back to transparent mode");
|
|
426
480
|
await this.saveCurrentSession();
|
|
@@ -456,7 +510,29 @@ var ProxyServer = class {
|
|
|
456
510
|
await saveRecordingSession(this.recordingsDir, this.currentSession);
|
|
457
511
|
}
|
|
458
512
|
getRecordingIdOrError(req, res) {
|
|
459
|
-
const
|
|
513
|
+
const recordingIdFromRequest = this.getRecordingIdFromRequest(req);
|
|
514
|
+
if (recordingIdFromRequest) {
|
|
515
|
+
return recordingIdFromRequest;
|
|
516
|
+
}
|
|
517
|
+
if (this.replaySessions.size > 1) {
|
|
518
|
+
console.warn(
|
|
519
|
+
`[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)`
|
|
520
|
+
);
|
|
521
|
+
const corsHeaders = this.getCorsHeaders(req);
|
|
522
|
+
res.writeHead(HTTP_STATUS_BAD_REQUEST, {
|
|
523
|
+
"Content-Type": "application/json",
|
|
524
|
+
...corsHeaders
|
|
525
|
+
});
|
|
526
|
+
res.end(
|
|
527
|
+
JSON.stringify({
|
|
528
|
+
error: "Missing recording ID in concurrent replay mode. Ensure x-test-rcrd-id header is set.",
|
|
529
|
+
activeSessions: [...this.replaySessions.keys()],
|
|
530
|
+
hint: "This usually means page.setExtraHTTPHeaders() did not apply to this request type"
|
|
531
|
+
})
|
|
532
|
+
);
|
|
533
|
+
return null;
|
|
534
|
+
}
|
|
535
|
+
const recordingId = this.replayId;
|
|
460
536
|
if (!recordingId) {
|
|
461
537
|
const corsHeaders = this.getCorsHeaders(req);
|
|
462
538
|
res.writeHead(HTTP_STATUS_BAD_REQUEST, {
|
|
@@ -466,6 +542,9 @@ var ProxyServer = class {
|
|
|
466
542
|
res.end(JSON.stringify({ error: "No replay session active" }));
|
|
467
543
|
return null;
|
|
468
544
|
}
|
|
545
|
+
console.log(
|
|
546
|
+
`[FALLBACK] Using replayId fallback for ${req.method} ${req.url} -> session: ${recordingId} (single session mode)`
|
|
547
|
+
);
|
|
469
548
|
return recordingId;
|
|
470
549
|
}
|
|
471
550
|
async ensureSessionLoaded(recordingId, filePath) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "test-proxy-recorder",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.2",
|
|
4
4
|
"description": "HTTP proxy server for recording and replaying network requests in testing. Works seamlessly with Playwright testing framework.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.mjs",
|