test-proxy-recorder 0.3.4 → 0.3.5
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 +180 -524
- package/dist/index.cjs +95 -77
- package/dist/index.d.cts +8 -4
- package/dist/index.d.ts +8 -4
- package/dist/index.mjs +95 -77
- package/dist/playwright/index.cjs +7 -8
- package/dist/playwright/index.mjs +7 -8
- package/dist/proxy.js +114 -84
- package/package.json +8 -2
package/dist/proxy.js
CHANGED
|
@@ -8,6 +8,17 @@ import { WebSocket, WebSocketServer } from 'ws';
|
|
|
8
8
|
import crypto from 'crypto';
|
|
9
9
|
import filenamify2 from 'filenamify';
|
|
10
10
|
|
|
11
|
+
// src/cli.ts
|
|
12
|
+
|
|
13
|
+
// src/constants.ts
|
|
14
|
+
var DEFAULT_TIMEOUT_MS = 120 * 1e3;
|
|
15
|
+
var HTTP_STATUS_BAD_GATEWAY = 502;
|
|
16
|
+
var HTTP_STATUS_OK = 200;
|
|
17
|
+
var HTTP_STATUS_BAD_REQUEST = 400;
|
|
18
|
+
var HTTP_STATUS_NOT_FOUND = 404;
|
|
19
|
+
var CONTROL_ENDPOINT = "/__control";
|
|
20
|
+
var RECORDING_ID_HEADER = "x-test-rcrd-id";
|
|
21
|
+
|
|
11
22
|
// src/cli.ts
|
|
12
23
|
var DEFAULT_PORT = 8e3;
|
|
13
24
|
var DEFAULT_RECORDINGS_DIR = "./recordings";
|
|
@@ -26,6 +37,10 @@ function parseCliArgs() {
|
|
|
26
37
|
"-d, --dir <path>",
|
|
27
38
|
"Directory to store recordings (relative to CWD)",
|
|
28
39
|
DEFAULT_RECORDINGS_DIR
|
|
40
|
+
).option(
|
|
41
|
+
"-t, --timeout <ms>",
|
|
42
|
+
"Session timeout in milliseconds",
|
|
43
|
+
String(DEFAULT_TIMEOUT_MS)
|
|
29
44
|
).action(() => {
|
|
30
45
|
});
|
|
31
46
|
program.parse();
|
|
@@ -36,22 +51,18 @@ function parseCliArgs() {
|
|
|
36
51
|
console.error("Error: Invalid port number. Must be between 1 and 65535");
|
|
37
52
|
process.exit(1);
|
|
38
53
|
}
|
|
54
|
+
const timeout2 = Number.parseInt(options.timeout, 10);
|
|
55
|
+
if (Number.isNaN(timeout2) || timeout2 < 0) {
|
|
56
|
+
console.error("Error: Invalid timeout. Must be a non-negative number");
|
|
57
|
+
process.exit(1);
|
|
58
|
+
}
|
|
39
59
|
if (!target2) {
|
|
40
60
|
program.help();
|
|
41
61
|
}
|
|
42
62
|
const recordingsDir2 = path.resolve(process.cwd(), options.dir);
|
|
43
|
-
return { target: target2, port: port2, recordingsDir: recordingsDir2 };
|
|
63
|
+
return { target: target2, port: port2, recordingsDir: recordingsDir2, timeout: timeout2 };
|
|
44
64
|
}
|
|
45
65
|
|
|
46
|
-
// src/constants.ts
|
|
47
|
-
var DEFAULT_TIMEOUT_MS = 120 * 1e3;
|
|
48
|
-
var HTTP_STATUS_BAD_GATEWAY = 502;
|
|
49
|
-
var HTTP_STATUS_OK = 200;
|
|
50
|
-
var HTTP_STATUS_BAD_REQUEST = 400;
|
|
51
|
-
var HTTP_STATUS_NOT_FOUND = 404;
|
|
52
|
-
var CONTROL_ENDPOINT = "/__control";
|
|
53
|
-
var RECORDING_ID_HEADER = "x-test-rcrd-id";
|
|
54
|
-
|
|
55
66
|
// src/types.ts
|
|
56
67
|
var Modes = {
|
|
57
68
|
transparent: "transparent",
|
|
@@ -95,9 +106,9 @@ function processRecordings(recordings) {
|
|
|
95
106
|
const processedRecordings = [];
|
|
96
107
|
for (const [_key, keyRecordings] of recordingsByKey) {
|
|
97
108
|
keyRecordings.sort((a, b) => a.recordingId - b.recordingId);
|
|
98
|
-
keyRecordings.
|
|
109
|
+
for (const [index, recording] of keyRecordings.entries()) {
|
|
99
110
|
processedRecordings.push({ ...recording, sequence: index });
|
|
100
|
-
}
|
|
111
|
+
}
|
|
101
112
|
}
|
|
102
113
|
processedRecordings.sort((a, b) => a.recordingId - b.recordingId);
|
|
103
114
|
return processedRecordings;
|
|
@@ -163,18 +174,22 @@ var ProxyServer = class {
|
|
|
163
174
|
proxy;
|
|
164
175
|
currentSession;
|
|
165
176
|
recordingsDir;
|
|
177
|
+
timeoutMs;
|
|
166
178
|
recordingIdCounter;
|
|
167
179
|
// Unique ID for each recording entry
|
|
168
180
|
sequenceCounterByKey;
|
|
169
181
|
// Sequence counter per key (endpoint)
|
|
170
182
|
replaySessions;
|
|
171
183
|
// Track multiple concurrent replay sessions by recording ID
|
|
184
|
+
sessionEvictionTimer;
|
|
185
|
+
// Periodic timer to evict idle replay sessions
|
|
172
186
|
recordingPromises;
|
|
173
187
|
// Stack of promises that resolve to completed recordings
|
|
174
188
|
flushPromise;
|
|
175
189
|
// Promise for in-progress flush operation
|
|
176
|
-
constructor(target2, recordingsDir2) {
|
|
190
|
+
constructor(target2, recordingsDir2, timeoutMs) {
|
|
177
191
|
this.target = target2;
|
|
192
|
+
this.timeoutMs = timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
178
193
|
this.mode = Modes.transparent;
|
|
179
194
|
this.recordingId = null;
|
|
180
195
|
this.recordingIdCounter = 0;
|
|
@@ -184,6 +199,7 @@ var ProxyServer = class {
|
|
|
184
199
|
this.currentSession = null;
|
|
185
200
|
this.recordingsDir = recordingsDir2;
|
|
186
201
|
this.replaySessions = /* @__PURE__ */ new Map();
|
|
202
|
+
this.sessionEvictionTimer = null;
|
|
187
203
|
this.recordingPromises = [];
|
|
188
204
|
this.flushPromise = null;
|
|
189
205
|
this.proxy = httpProxy.createProxyServer({
|
|
@@ -246,9 +262,6 @@ var ProxyServer = class {
|
|
|
246
262
|
const corsHeaders = this.getCorsHeaders(req);
|
|
247
263
|
Object.assign(proxyRes.headers, corsHeaders);
|
|
248
264
|
}
|
|
249
|
-
getTarget() {
|
|
250
|
-
return this.target;
|
|
251
|
-
}
|
|
252
265
|
/**
|
|
253
266
|
* Extract recording ID from custom HTTP header
|
|
254
267
|
* Used for concurrent replay session routing, especially with Next.js
|
|
@@ -284,13 +297,7 @@ var ProxyServer = class {
|
|
|
284
297
|
getRecordingIdFromRequest(req) {
|
|
285
298
|
const fromHeader = this.getRecordingIdFromHeader(req);
|
|
286
299
|
const fromCookie = this.getRecordingIdFromCookie(req);
|
|
287
|
-
|
|
288
|
-
return fromHeader;
|
|
289
|
-
}
|
|
290
|
-
if (fromCookie) {
|
|
291
|
-
return fromCookie;
|
|
292
|
-
}
|
|
293
|
-
return null;
|
|
300
|
+
return fromHeader ?? fromCookie ?? null;
|
|
294
301
|
}
|
|
295
302
|
/**
|
|
296
303
|
* Get or create a replay session state for a given recording ID
|
|
@@ -310,6 +317,7 @@ var ProxyServer = class {
|
|
|
310
317
|
sortedRecordingsByKey: /* @__PURE__ */ new Map()
|
|
311
318
|
};
|
|
312
319
|
this.replaySessions.set(recordingId, session);
|
|
320
|
+
this.startSessionEvictionTimer();
|
|
313
321
|
console.log(
|
|
314
322
|
`[CONCURRENT REPLAY] Created new session for recording: ${recordingId}`
|
|
315
323
|
);
|
|
@@ -321,8 +329,9 @@ var ProxyServer = class {
|
|
|
321
329
|
* @param sessionId The session ID to clean up
|
|
322
330
|
*/
|
|
323
331
|
async cleanupSession(sessionId) {
|
|
324
|
-
|
|
325
|
-
|
|
332
|
+
this.replaySessions.delete(sessionId);
|
|
333
|
+
if (this.replaySessions.size === 0) {
|
|
334
|
+
this.stopSessionEvictionTimer();
|
|
326
335
|
}
|
|
327
336
|
if (this.recordingId === sessionId) {
|
|
328
337
|
await this.saveCurrentSession();
|
|
@@ -334,29 +343,44 @@ var ProxyServer = class {
|
|
|
334
343
|
}
|
|
335
344
|
console.log(`[CLEANUP] Session ${sessionId} cleaned up successfully`);
|
|
336
345
|
}
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
const id = url.searchParams.get("id") || void 0;
|
|
341
|
-
const timeoutParam = url.searchParams.get("timeout");
|
|
342
|
-
const timeout = timeoutParam ? Number.parseInt(timeoutParam, 10) : void 0;
|
|
343
|
-
if (!mode) {
|
|
344
|
-
throw new Error("Mode parameter is required");
|
|
346
|
+
startSessionEvictionTimer() {
|
|
347
|
+
if (this.sessionEvictionTimer) {
|
|
348
|
+
return;
|
|
345
349
|
}
|
|
346
|
-
|
|
350
|
+
const CHECK_INTERVAL_MS = 3e4;
|
|
351
|
+
this.sessionEvictionTimer = setInterval(() => {
|
|
352
|
+
const now = Date.now();
|
|
353
|
+
for (const [id, session] of this.replaySessions) {
|
|
354
|
+
if (now - session.lastAccessTime >= this.timeoutMs) {
|
|
355
|
+
console.log(
|
|
356
|
+
`[EVICTION] Evicting idle replay session: ${id} (idle for ${Math.round((now - session.lastAccessTime) / 1e3)}s)`
|
|
357
|
+
);
|
|
358
|
+
this.replaySessions.delete(id);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
if (this.replaySessions.size === 0) {
|
|
362
|
+
this.stopSessionEvictionTimer();
|
|
363
|
+
}
|
|
364
|
+
}, CHECK_INTERVAL_MS);
|
|
365
|
+
this.sessionEvictionTimer.unref();
|
|
347
366
|
}
|
|
348
|
-
|
|
349
|
-
if (
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
if (req.method === "POST") {
|
|
353
|
-
const body = await readRequestBody(req);
|
|
354
|
-
console.log(`MODE CHANGE (${req.method})`, body);
|
|
355
|
-
return JSON.parse(body);
|
|
367
|
+
stopSessionEvictionTimer() {
|
|
368
|
+
if (this.sessionEvictionTimer) {
|
|
369
|
+
clearInterval(this.sessionEvictionTimer);
|
|
370
|
+
this.sessionEvictionTimer = null;
|
|
356
371
|
}
|
|
357
|
-
|
|
372
|
+
}
|
|
373
|
+
async parseControlBody(req) {
|
|
374
|
+
const body = await readRequestBody(req);
|
|
375
|
+
console.log(`MODE CHANGE (${req.method})`, body);
|
|
376
|
+
return JSON.parse(body);
|
|
358
377
|
}
|
|
359
378
|
async handleControlRequest(req, res) {
|
|
379
|
+
if (req.method === "HEAD") {
|
|
380
|
+
res.writeHead(HTTP_STATUS_OK);
|
|
381
|
+
res.end();
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
360
384
|
if (req.method === "GET") {
|
|
361
385
|
sendJsonResponse(res, HTTP_STATUS_OK, {
|
|
362
386
|
recordingsDir: this.recordingsDir,
|
|
@@ -365,8 +389,11 @@ var ProxyServer = class {
|
|
|
365
389
|
});
|
|
366
390
|
return;
|
|
367
391
|
}
|
|
392
|
+
await this.handleControlPost(req, res);
|
|
393
|
+
}
|
|
394
|
+
async handleControlPost(req, res) {
|
|
368
395
|
try {
|
|
369
|
-
const data = await this.
|
|
396
|
+
const data = await this.parseControlBody(req);
|
|
370
397
|
const { mode, id, timeout: requestTimeout, cleanup } = data;
|
|
371
398
|
if (cleanup && id) {
|
|
372
399
|
await this.cleanupSession(id);
|
|
@@ -377,29 +404,7 @@ var ProxyServer = class {
|
|
|
377
404
|
});
|
|
378
405
|
return;
|
|
379
406
|
}
|
|
380
|
-
|
|
381
|
-
throw new Error(
|
|
382
|
-
"Mode parameter is required when cleanup is not specified"
|
|
383
|
-
);
|
|
384
|
-
}
|
|
385
|
-
const timeout = requestTimeout ?? DEFAULT_TIMEOUT_MS;
|
|
386
|
-
this.clearModeTimeout();
|
|
387
|
-
await this.switchMode(mode, id);
|
|
388
|
-
this.setupModeTimeout(timeout);
|
|
389
|
-
if (mode === Modes.replay && id) {
|
|
390
|
-
res.setHeader(
|
|
391
|
-
"Set-Cookie",
|
|
392
|
-
`proxy-recording-id=${encodeURIComponent(id)}; HttpOnly; Path=/; SameSite=Lax`
|
|
393
|
-
);
|
|
394
|
-
console.log(`[CONCURRENT REPLAY] Set cookie for recording: ${id}`);
|
|
395
|
-
}
|
|
396
|
-
sendJsonResponse(res, HTTP_STATUS_OK, {
|
|
397
|
-
success: true,
|
|
398
|
-
mode: this.mode,
|
|
399
|
-
id: this.recordingId || this.replayId,
|
|
400
|
-
timeout,
|
|
401
|
-
recordingsDir: this.recordingsDir
|
|
402
|
-
});
|
|
407
|
+
await this.applyModeChange(res, mode, id, requestTimeout);
|
|
403
408
|
} catch (error) {
|
|
404
409
|
console.error("Control request error:", error);
|
|
405
410
|
sendJsonResponse(res, HTTP_STATUS_BAD_REQUEST, {
|
|
@@ -407,6 +412,31 @@ var ProxyServer = class {
|
|
|
407
412
|
});
|
|
408
413
|
}
|
|
409
414
|
}
|
|
415
|
+
async applyModeChange(res, mode, id, requestTimeout) {
|
|
416
|
+
if (!mode) {
|
|
417
|
+
throw new Error(
|
|
418
|
+
"Mode parameter is required when cleanup is not specified"
|
|
419
|
+
);
|
|
420
|
+
}
|
|
421
|
+
const timeout2 = requestTimeout ?? this.timeoutMs;
|
|
422
|
+
this.clearModeTimeout();
|
|
423
|
+
await this.switchMode(mode, id);
|
|
424
|
+
this.setupModeTimeout(timeout2);
|
|
425
|
+
if (mode === Modes.replay && id) {
|
|
426
|
+
res.setHeader(
|
|
427
|
+
"Set-Cookie",
|
|
428
|
+
`proxy-recording-id=${encodeURIComponent(id)}; HttpOnly; Path=/; SameSite=Lax`
|
|
429
|
+
);
|
|
430
|
+
console.log(`[CONCURRENT REPLAY] Set cookie for recording: ${id}`);
|
|
431
|
+
}
|
|
432
|
+
sendJsonResponse(res, HTTP_STATUS_OK, {
|
|
433
|
+
success: true,
|
|
434
|
+
mode: this.mode,
|
|
435
|
+
id: this.recordingId || this.replayId,
|
|
436
|
+
timeout: timeout2,
|
|
437
|
+
recordingsDir: this.recordingsDir
|
|
438
|
+
});
|
|
439
|
+
}
|
|
410
440
|
clearModeTimeout() {
|
|
411
441
|
clearTimeout(this.modeTimeout || 0);
|
|
412
442
|
this.modeTimeout = null;
|
|
@@ -446,7 +476,7 @@ var ProxyServer = class {
|
|
|
446
476
|
this.recordingId = null;
|
|
447
477
|
this.replayId = null;
|
|
448
478
|
this.currentSession = null;
|
|
449
|
-
|
|
479
|
+
this.clearModeTimeout();
|
|
450
480
|
console.log("Switched to transparent mode");
|
|
451
481
|
}
|
|
452
482
|
switchToRecordMode(id) {
|
|
@@ -476,14 +506,14 @@ var ProxyServer = class {
|
|
|
476
506
|
}
|
|
477
507
|
console.log(`Switched to replay mode with ID: ${id}`);
|
|
478
508
|
}
|
|
479
|
-
setupModeTimeout(
|
|
480
|
-
|
|
509
|
+
setupModeTimeout(timeout2) {
|
|
510
|
+
this.clearModeTimeout();
|
|
481
511
|
this.modeTimeout = setTimeout(async () => {
|
|
482
512
|
console.log("Timeout reached, switching back to transparent mode");
|
|
483
513
|
await this.saveCurrentSession();
|
|
484
514
|
this.switchToTransparentMode();
|
|
485
515
|
this.modeTimeout = null;
|
|
486
|
-
},
|
|
516
|
+
}, timeout2);
|
|
487
517
|
}
|
|
488
518
|
async flushPendingRecordings() {
|
|
489
519
|
if (this.flushPromise) {
|
|
@@ -602,11 +632,10 @@ var ProxyServer = class {
|
|
|
602
632
|
try {
|
|
603
633
|
const sessionState = this.getOrCreateReplaySession(recordingId);
|
|
604
634
|
if (!sessionState.loadedSession) {
|
|
605
|
-
|
|
606
|
-
`Recording session file not found: ${filePath}`
|
|
635
|
+
throw Object.assign(
|
|
636
|
+
new Error(`Recording session file not found: ${filePath}`),
|
|
637
|
+
{ code: "ENOENT" }
|
|
607
638
|
);
|
|
608
|
-
error.code = "ENOENT";
|
|
609
|
-
throw error;
|
|
610
639
|
}
|
|
611
640
|
const servedForThisKey = this.getServedTracker(sessionState, key);
|
|
612
641
|
const host = req.headers.host || "unknown";
|
|
@@ -701,7 +730,7 @@ var ProxyServer = class {
|
|
|
701
730
|
res.end();
|
|
702
731
|
}
|
|
703
732
|
async handleProxyRequest(req, res) {
|
|
704
|
-
const target2 = this.
|
|
733
|
+
const target2 = this.target;
|
|
705
734
|
console.log(`[${this.mode}] ${req.method} ${req.url} -> ${target2}`);
|
|
706
735
|
if (this.mode === Modes.record) {
|
|
707
736
|
await this.recordAndProxyRequest(req, res, target2);
|
|
@@ -827,7 +856,7 @@ var ProxyServer = class {
|
|
|
827
856
|
this.handleReplayWebSocket(req, socket);
|
|
828
857
|
return;
|
|
829
858
|
}
|
|
830
|
-
const target2 = this.
|
|
859
|
+
const target2 = this.target;
|
|
831
860
|
console.log(`[${this.mode}] WebSocket upgrade ${req.url} -> ${target2}`);
|
|
832
861
|
if (this.mode === Modes.record) {
|
|
833
862
|
this.handleRecordWebSocket(req, socket, head, target2);
|
|
@@ -900,11 +929,12 @@ var ProxyServer = class {
|
|
|
900
929
|
console.error("WebSocket server error:", err);
|
|
901
930
|
});
|
|
902
931
|
}
|
|
903
|
-
handleReplayWebSocket(req, socket) {
|
|
932
|
+
async handleReplayWebSocket(req, socket) {
|
|
904
933
|
const url = req.url || "/";
|
|
905
934
|
const key = `WS_${url.replaceAll("/", "_")}`;
|
|
906
935
|
const filePath = getRecordingPath(this.recordingsDir, this.replayId);
|
|
907
|
-
|
|
936
|
+
try {
|
|
937
|
+
const session = await loadRecordingSession(filePath);
|
|
908
938
|
const wsRecording = session.websocketRecordings.find(
|
|
909
939
|
(r) => r.key === key
|
|
910
940
|
);
|
|
@@ -970,11 +1000,11 @@ var ProxyServer = class {
|
|
|
970
1000
|
console.log("Replay WebSocket closed");
|
|
971
1001
|
});
|
|
972
1002
|
});
|
|
973
|
-
}
|
|
1003
|
+
} catch (error) {
|
|
974
1004
|
console.error("Replay error:", error);
|
|
975
1005
|
socket.write("HTTP/1.1 404 Not Found\r\n\r\n");
|
|
976
1006
|
socket.destroy();
|
|
977
|
-
}
|
|
1007
|
+
}
|
|
978
1008
|
}
|
|
979
1009
|
logServerStartup(port2) {
|
|
980
1010
|
console.log(`Proxy server running on http://localhost:${port2}`);
|
|
@@ -986,9 +1016,9 @@ var ProxyServer = class {
|
|
|
986
1016
|
}
|
|
987
1017
|
};
|
|
988
1018
|
|
|
989
|
-
// src/proxy.ts
|
|
990
|
-
var { target, port, recordingsDir } = parseCliArgs();
|
|
991
|
-
var proxy = new ProxyServer(target, recordingsDir);
|
|
1019
|
+
// src/proxy-cli.ts
|
|
1020
|
+
var { target, port, recordingsDir, timeout } = parseCliArgs();
|
|
1021
|
+
var proxy = new ProxyServer(target, recordingsDir, timeout);
|
|
992
1022
|
await proxy.init();
|
|
993
1023
|
proxy.listen(port);
|
|
994
1024
|
console.log(`Recordings will be saved to: ${recordingsDir}`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "test-proxy-recorder",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.5",
|
|
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",
|
|
@@ -38,13 +38,18 @@
|
|
|
38
38
|
"LICENSE"
|
|
39
39
|
],
|
|
40
40
|
"pnpm": {
|
|
41
|
-
"onlyBuiltDependencies": ["esbuild"]
|
|
41
|
+
"onlyBuiltDependencies": ["esbuild", "sharp"]
|
|
42
42
|
},
|
|
43
43
|
"packageManager": "pnpm@10.20.0+sha512.cf9998222162dd85864d0a8102e7892e7ba4ceadebbf5a31f9c2fce48dfce317a9c53b9f6464d1ef9042cba2e02ae02a9f7c143a2b438cd93c91840f0192b9dd",
|
|
44
44
|
"scripts": {
|
|
45
45
|
"start": "node dist/proxy.js",
|
|
46
46
|
"dev": "tsx src/proxy.ts",
|
|
47
47
|
"build": "tsup",
|
|
48
|
+
"example:dev": "pnpm --filter example-nextjs16 dev",
|
|
49
|
+
"example:build": "pnpm --filter example-nextjs16 build",
|
|
50
|
+
"example:services": "pnpm build && pnpm --filter example-nextjs16 start:all",
|
|
51
|
+
"example:test:e2e": "pnpm --filter example-nextjs16 test:e2e",
|
|
52
|
+
"example:test:e2e:record": "pnpm --filter example-nextjs16 test:e2e:record",
|
|
48
53
|
"prepublish": "pnpm run build && pnpm run test:run && pnpm run lint",
|
|
49
54
|
"lint": "eslint src --ext .ts",
|
|
50
55
|
"lint:fix": "eslint src --ext .ts --fix",
|
|
@@ -95,6 +100,7 @@
|
|
|
95
100
|
"@playwright/test": ">=1.0.0"
|
|
96
101
|
},
|
|
97
102
|
"devDependencies": {
|
|
103
|
+
"@playwright/test": "^1.59.1",
|
|
98
104
|
"@types/http-proxy": "^1.17.15",
|
|
99
105
|
"@types/node": "^22.0.0",
|
|
100
106
|
"@types/ws": "^8.18.1",
|