test-proxy-recorder 0.1.9 → 0.1.11
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 +2 -2
- package/dist/{index-CG-XcFDa.d.cts → index-Cx_Kflfl.d.cts} +1 -1
- package/dist/{index-CG-XcFDa.d.ts → index-Cx_Kflfl.d.ts} +1 -1
- package/dist/index.cjs +173 -70
- package/dist/index.d.cts +15 -3
- package/dist/index.d.ts +15 -3
- package/dist/index.mjs +171 -68
- package/dist/playwright/index.d.cts +1 -1
- package/dist/playwright/index.d.ts +1 -1
- package/dist/proxy.js +172 -69
- package/package.json +1 -1
package/dist/proxy.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import
|
|
1
|
+
import path from 'path';
|
|
2
2
|
import { Command } from 'commander';
|
|
3
3
|
import fs from 'fs/promises';
|
|
4
4
|
import http from 'http';
|
|
@@ -6,7 +6,7 @@ import https from 'https';
|
|
|
6
6
|
import httpProxy from 'http-proxy';
|
|
7
7
|
import { WebSocket, WebSocketServer } from 'ws';
|
|
8
8
|
import crypto from 'crypto';
|
|
9
|
-
import
|
|
9
|
+
import filenamify2 from 'filenamify';
|
|
10
10
|
|
|
11
11
|
// src/cli.ts
|
|
12
12
|
var DEFAULT_PORT = 8e3;
|
|
@@ -39,7 +39,7 @@ function parseCliArgs() {
|
|
|
39
39
|
if (targets2.length === 0) {
|
|
40
40
|
program.help();
|
|
41
41
|
}
|
|
42
|
-
const recordingsDir2 =
|
|
42
|
+
const recordingsDir2 = path.resolve(process.cwd(), options.recordingsDir);
|
|
43
43
|
return { targets: targets2, port: port2, recordingsDir: recordingsDir2 };
|
|
44
44
|
}
|
|
45
45
|
|
|
@@ -58,23 +58,53 @@ var Modes = {
|
|
|
58
58
|
replay: "replay"
|
|
59
59
|
};
|
|
60
60
|
var JSON_INDENT_SPACES = 2;
|
|
61
|
+
var EXTENSION = ".mock.json";
|
|
62
|
+
var MAX_FILENAME_LENGTH = 255 - EXTENSION.length;
|
|
63
|
+
var HASH_LENGTH = 8;
|
|
64
|
+
function generateHash(str) {
|
|
65
|
+
return crypto.createHash("shake256", { outputLength: HASH_LENGTH / 2 }).update(str).digest("hex");
|
|
66
|
+
}
|
|
61
67
|
function getRecordingPath(recordingsDir2, id) {
|
|
62
|
-
|
|
68
|
+
let processedId = id.replaceAll("/", "__");
|
|
69
|
+
if (processedId.length > MAX_FILENAME_LENGTH) {
|
|
70
|
+
const hash = generateHash(id);
|
|
71
|
+
const maxBaseLength = MAX_FILENAME_LENGTH - HASH_LENGTH - 1;
|
|
72
|
+
processedId = `${processedId.slice(0, maxBaseLength)}_${hash}`;
|
|
73
|
+
}
|
|
74
|
+
const sanitizedId = filenamify2(processedId, {
|
|
75
|
+
replacement: "_",
|
|
76
|
+
maxLength: 255
|
|
77
|
+
// Set explicit max to prevent filenamify's default truncation
|
|
78
|
+
});
|
|
79
|
+
return path.join(recordingsDir2, `${sanitizedId}${EXTENSION}`);
|
|
63
80
|
}
|
|
64
81
|
async function loadRecordingSession(filePath) {
|
|
65
82
|
const fileContent = await fs.readFile(filePath, "utf8");
|
|
66
83
|
return JSON.parse(fileContent);
|
|
67
84
|
}
|
|
85
|
+
function processRecordings(recordings) {
|
|
86
|
+
const keySequenceMap = /* @__PURE__ */ new Map();
|
|
87
|
+
return recordings.map((recording) => {
|
|
88
|
+
const key = recording.key;
|
|
89
|
+
const currentSeq = keySequenceMap.get(key) || 0;
|
|
90
|
+
keySequenceMap.set(key, currentSeq + 1);
|
|
91
|
+
return { ...recording, sequence: currentSeq };
|
|
92
|
+
});
|
|
93
|
+
}
|
|
68
94
|
async function saveRecordingSession(recordingsDir2, session) {
|
|
69
95
|
const filePath = getRecordingPath(recordingsDir2, session.id);
|
|
70
|
-
|
|
71
|
-
|
|
96
|
+
await fs.mkdir(recordingsDir2, { recursive: true });
|
|
97
|
+
const processedRecordings = processRecordings(session.recordings);
|
|
98
|
+
const processedSession = {
|
|
99
|
+
...session,
|
|
100
|
+
recordings: processedRecordings
|
|
101
|
+
};
|
|
72
102
|
await fs.writeFile(
|
|
73
103
|
filePath,
|
|
74
|
-
JSON.stringify(
|
|
104
|
+
JSON.stringify(processedSession, null, JSON_INDENT_SPACES)
|
|
75
105
|
);
|
|
76
106
|
console.log(
|
|
77
|
-
`Saved ${
|
|
107
|
+
`Saved ${processedRecordings.length} HTTP recordings and ${session.websocketRecordings?.length || 0} WebSocket recordings to ${filePath}`
|
|
78
108
|
);
|
|
79
109
|
}
|
|
80
110
|
function getReqID(req) {
|
|
@@ -82,10 +112,10 @@ function getReqID(req) {
|
|
|
82
112
|
const pathname = urlParts[0];
|
|
83
113
|
const query = urlParts[1] || "";
|
|
84
114
|
const pathPart = pathname === "/" ? "root" : pathname.slice(1);
|
|
85
|
-
const normalizedPath =
|
|
115
|
+
const normalizedPath = filenamify2(pathPart, { replacement: "_" });
|
|
86
116
|
const queryHash = generateQueryHash(query);
|
|
87
117
|
const filename = `${req.method}_${normalizedPath}${queryHash}.json`;
|
|
88
|
-
return
|
|
118
|
+
return filenamify2(filename, { replacement: "_" });
|
|
89
119
|
}
|
|
90
120
|
function generateQueryHash(query) {
|
|
91
121
|
if (!query) {
|
|
@@ -120,12 +150,10 @@ var ProxyServer = class {
|
|
|
120
150
|
proxy;
|
|
121
151
|
currentSession;
|
|
122
152
|
recordingsDir;
|
|
123
|
-
requestSequenceMap;
|
|
124
|
-
// Track sequence per request key
|
|
125
|
-
replaySequenceMap;
|
|
126
|
-
// Track replay position per request key
|
|
127
153
|
recordingIdCounter;
|
|
128
154
|
// Unique ID for each recording entry
|
|
155
|
+
replaySessions;
|
|
156
|
+
// Track multiple concurrent replay sessions by recording ID
|
|
129
157
|
constructor(targets2, recordingsDir2) {
|
|
130
158
|
this.targets = targets2;
|
|
131
159
|
this.currentTargetIndex = 0;
|
|
@@ -136,11 +164,11 @@ var ProxyServer = class {
|
|
|
136
164
|
this.modeTimeout = null;
|
|
137
165
|
this.currentSession = null;
|
|
138
166
|
this.recordingsDir = recordingsDir2;
|
|
139
|
-
this.
|
|
140
|
-
this.replaySequenceMap = /* @__PURE__ */ new Map();
|
|
167
|
+
this.replaySessions = /* @__PURE__ */ new Map();
|
|
141
168
|
this.proxy = httpProxy.createProxyServer({
|
|
142
169
|
secure: false,
|
|
143
|
-
changeOrigin: true
|
|
170
|
+
changeOrigin: true,
|
|
171
|
+
ws: true
|
|
144
172
|
});
|
|
145
173
|
this.setupProxyEventHandlers();
|
|
146
174
|
}
|
|
@@ -208,6 +236,43 @@ var ProxyServer = class {
|
|
|
208
236
|
this.currentTargetIndex = (this.currentTargetIndex + 1) % this.targets.length;
|
|
209
237
|
return target;
|
|
210
238
|
}
|
|
239
|
+
/**
|
|
240
|
+
* Extract recording ID from request cookie
|
|
241
|
+
* Used for concurrent replay session routing
|
|
242
|
+
* @param req The incoming HTTP request
|
|
243
|
+
* @returns The recording ID from cookie, or null if not found
|
|
244
|
+
*/
|
|
245
|
+
getRecordingIdFromCookie(req) {
|
|
246
|
+
const cookies = req.headers.cookie;
|
|
247
|
+
if (!cookies) {
|
|
248
|
+
return null;
|
|
249
|
+
}
|
|
250
|
+
const match = cookies.match(/proxy-recording-id=([^;]+)/);
|
|
251
|
+
return match ? decodeURIComponent(match[1]) : null;
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* Get or create a replay session state for a given recording ID
|
|
255
|
+
* @param recordingId The recording ID to get/create session for
|
|
256
|
+
* @returns The replay session state
|
|
257
|
+
*/
|
|
258
|
+
getOrCreateReplaySession(recordingId) {
|
|
259
|
+
let session = this.replaySessions.get(recordingId);
|
|
260
|
+
if (session) {
|
|
261
|
+
session.lastAccessTime = Date.now();
|
|
262
|
+
} else {
|
|
263
|
+
session = {
|
|
264
|
+
recordingId,
|
|
265
|
+
servedRecordingIdsByKey: /* @__PURE__ */ new Map(),
|
|
266
|
+
loadedSession: null,
|
|
267
|
+
lastAccessTime: Date.now()
|
|
268
|
+
};
|
|
269
|
+
this.replaySessions.set(recordingId, session);
|
|
270
|
+
console.log(
|
|
271
|
+
`[CONCURRENT REPLAY] Created new session for recording: ${recordingId}`
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
return session;
|
|
275
|
+
}
|
|
211
276
|
parseGetParams(req) {
|
|
212
277
|
const url = new URL(req.url || "", `http://${req.headers.host}`);
|
|
213
278
|
const mode = url.searchParams.get("mode");
|
|
@@ -224,16 +289,25 @@ var ProxyServer = class {
|
|
|
224
289
|
let data;
|
|
225
290
|
if (req.method === "GET") {
|
|
226
291
|
data = this.parseGetParams(req);
|
|
227
|
-
} else {
|
|
292
|
+
} else if (req.method === "POST") {
|
|
228
293
|
const body = await readRequestBody(req);
|
|
229
|
-
console.log(
|
|
294
|
+
console.log(`MODE CHANGE (${req.method})`, body);
|
|
230
295
|
data = JSON.parse(body);
|
|
296
|
+
} else {
|
|
297
|
+
return;
|
|
231
298
|
}
|
|
232
299
|
const { mode, id, timeout: requestTimeout } = data;
|
|
233
300
|
const timeout = requestTimeout ?? DEFAULT_TIMEOUT_MS;
|
|
234
301
|
this.clearModeTimeout();
|
|
235
302
|
await this.switchMode(mode, id);
|
|
236
303
|
this.setupModeTimeout(timeout);
|
|
304
|
+
if (mode === Modes.replay && id) {
|
|
305
|
+
res.setHeader(
|
|
306
|
+
"Set-Cookie",
|
|
307
|
+
`proxy-recording-id=${encodeURIComponent(id)}; HttpOnly; Path=/; SameSite=Lax`
|
|
308
|
+
);
|
|
309
|
+
console.log(`[CONCURRENT REPLAY] Set cookie for recording: ${id}`);
|
|
310
|
+
}
|
|
237
311
|
sendJsonResponse(res, HTTP_STATUS_OK, {
|
|
238
312
|
success: true,
|
|
239
313
|
mode: this.mode,
|
|
@@ -248,14 +322,12 @@ var ProxyServer = class {
|
|
|
248
322
|
}
|
|
249
323
|
}
|
|
250
324
|
clearModeTimeout() {
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
this.modeTimeout = null;
|
|
254
|
-
}
|
|
325
|
+
clearTimeout(this.modeTimeout || 0);
|
|
326
|
+
this.modeTimeout = null;
|
|
255
327
|
}
|
|
256
328
|
async switchMode(mode, id) {
|
|
257
|
-
|
|
258
|
-
|
|
329
|
+
console.log(`Switching to ${mode.toUpperCase()} mode`);
|
|
330
|
+
if (this.currentSession && this.mode === Modes.record) {
|
|
259
331
|
await this.saveCurrentSession(true);
|
|
260
332
|
console.log("Session saved, continuing with mode switch");
|
|
261
333
|
}
|
|
@@ -265,11 +337,17 @@ var ProxyServer = class {
|
|
|
265
337
|
break;
|
|
266
338
|
}
|
|
267
339
|
case Modes.record: {
|
|
340
|
+
if (!id) {
|
|
341
|
+
throw new Error("Record ID is required");
|
|
342
|
+
}
|
|
268
343
|
this.switchToRecordMode(id);
|
|
269
344
|
break;
|
|
270
345
|
}
|
|
271
346
|
case Modes.replay: {
|
|
272
|
-
|
|
347
|
+
if (!id) {
|
|
348
|
+
throw new Error("Replay ID is required");
|
|
349
|
+
}
|
|
350
|
+
await this.switchToReplayMode(id);
|
|
273
351
|
break;
|
|
274
352
|
}
|
|
275
353
|
default: {
|
|
@@ -286,36 +364,33 @@ var ProxyServer = class {
|
|
|
286
364
|
console.log("Switched to transparent mode");
|
|
287
365
|
}
|
|
288
366
|
switchToRecordMode(id) {
|
|
289
|
-
if (!id) {
|
|
290
|
-
throw new Error("Record ID is required");
|
|
291
|
-
}
|
|
292
367
|
this.mode = Modes.record;
|
|
293
368
|
this.recordingId = id;
|
|
294
369
|
this.replayId = null;
|
|
295
370
|
this.currentSession = { id, recordings: [], websocketRecordings: [] };
|
|
296
|
-
this.requestSequenceMap.clear();
|
|
297
371
|
console.log(`Switched to record mode with ID: ${id}`);
|
|
298
372
|
}
|
|
299
|
-
switchToReplayMode(id) {
|
|
300
|
-
if (!id) {
|
|
301
|
-
throw new Error("Replay ID is required");
|
|
302
|
-
}
|
|
373
|
+
async switchToReplayMode(id) {
|
|
303
374
|
this.mode = Modes.replay;
|
|
304
375
|
this.replayId = id;
|
|
305
376
|
this.recordingId = null;
|
|
306
377
|
this.currentSession = null;
|
|
307
|
-
this.
|
|
378
|
+
const session = this.replaySessions.get(id);
|
|
379
|
+
if (session) {
|
|
380
|
+
session.servedRecordingIdsByKey.clear();
|
|
381
|
+
console.log(`Reset served recordings tracker for session: ${id}`);
|
|
382
|
+
} else {
|
|
383
|
+
this.getOrCreateReplaySession(id);
|
|
384
|
+
}
|
|
308
385
|
console.log(`Switched to replay mode with ID: ${id}`);
|
|
309
386
|
}
|
|
310
387
|
setupModeTimeout(timeout) {
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
}, timeout);
|
|
318
|
-
}
|
|
388
|
+
this.modeTimeout = setTimeout(async () => {
|
|
389
|
+
console.log("Timeout reached, switching back to transparent mode");
|
|
390
|
+
await this.saveCurrentSession(true);
|
|
391
|
+
this.switchToTransparentMode();
|
|
392
|
+
this.modeTimeout = null;
|
|
393
|
+
}, timeout);
|
|
319
394
|
}
|
|
320
395
|
async saveCurrentSession(filterIncomplete = false) {
|
|
321
396
|
if (!this.currentSession) {
|
|
@@ -341,9 +416,6 @@ var ProxyServer = class {
|
|
|
341
416
|
return;
|
|
342
417
|
}
|
|
343
418
|
const key = getReqID(req);
|
|
344
|
-
const currentSequence = this.requestSequenceMap.get(key) || 0;
|
|
345
|
-
const sequence = currentSequence;
|
|
346
|
-
this.requestSequenceMap.set(key, currentSequence + 1);
|
|
347
419
|
const recordingId = this.recordingIdCounter++;
|
|
348
420
|
req.__recordingId = recordingId;
|
|
349
421
|
const record = {
|
|
@@ -355,13 +427,12 @@ var ProxyServer = class {
|
|
|
355
427
|
},
|
|
356
428
|
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
357
429
|
key,
|
|
358
|
-
sequence,
|
|
359
430
|
recordingId
|
|
360
431
|
};
|
|
361
432
|
this.currentSession.recordings.push(record);
|
|
362
433
|
console.log(
|
|
363
434
|
// eslint-disable-next-line sonarjs/no-nested-template-literals
|
|
364
|
-
`saveRequestRecordSync: Saved ${req.method} ${req.url} (key: ${key},
|
|
435
|
+
`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})`
|
|
365
436
|
);
|
|
366
437
|
}
|
|
367
438
|
updateRequestBodySync(req, body) {
|
|
@@ -421,7 +492,7 @@ var ProxyServer = class {
|
|
|
421
492
|
body: body || null
|
|
422
493
|
};
|
|
423
494
|
console.log(
|
|
424
|
-
`Recorded: ${req.method} ${req.url} (
|
|
495
|
+
`Recorded: ${req.method} ${req.url} (recordingId: ${recordingId})`
|
|
425
496
|
);
|
|
426
497
|
});
|
|
427
498
|
}
|
|
@@ -451,46 +522,77 @@ var ProxyServer = class {
|
|
|
451
522
|
body: body || null
|
|
452
523
|
};
|
|
453
524
|
console.log(
|
|
454
|
-
`recordResponseData: Recorded response for ${req.method} ${req.url} (
|
|
525
|
+
`recordResponseData: Recorded response for ${req.method} ${req.url} (recordingId: ${recordingId})`
|
|
455
526
|
);
|
|
456
527
|
return true;
|
|
457
528
|
}
|
|
458
529
|
async handleReplayRequest(req, res) {
|
|
530
|
+
const recordingId = this.getRecordingIdFromCookie(req) || this.replayId;
|
|
531
|
+
if (!recordingId) {
|
|
532
|
+
const corsHeaders = this.getCorsHeaders(req);
|
|
533
|
+
res.writeHead(HTTP_STATUS_BAD_REQUEST, {
|
|
534
|
+
"Content-Type": "application/json",
|
|
535
|
+
...corsHeaders
|
|
536
|
+
});
|
|
537
|
+
res.end(JSON.stringify({ error: "No replay session active" }));
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
459
540
|
const key = getReqID(req);
|
|
460
|
-
const filePath = getRecordingPath(this.recordingsDir,
|
|
541
|
+
const filePath = getRecordingPath(this.recordingsDir, recordingId);
|
|
461
542
|
try {
|
|
462
|
-
const
|
|
543
|
+
const sessionState = this.getOrCreateReplaySession(recordingId);
|
|
544
|
+
if (!sessionState.loadedSession) {
|
|
545
|
+
sessionState.loadedSession = await loadRecordingSession(filePath);
|
|
546
|
+
console.log(`[REPLAY] Loaded recording session: ${recordingId}`);
|
|
547
|
+
}
|
|
548
|
+
const session = sessionState.loadedSession;
|
|
549
|
+
if (!sessionState.servedRecordingIdsByKey.has(key)) {
|
|
550
|
+
sessionState.servedRecordingIdsByKey.set(key, /* @__PURE__ */ new Set());
|
|
551
|
+
}
|
|
552
|
+
const servedForThisKey = sessionState.servedRecordingIdsByKey.get(key);
|
|
463
553
|
const host = req.headers.host || "unknown";
|
|
464
|
-
const recordsWithKey = session.recordings.filter((r) => r.key === key && r.response).toSorted((a, b) => a.
|
|
554
|
+
const recordsWithKey = session.recordings.filter((r) => r.key === key && r.response).toSorted((a, b) => a.recordingId - b.recordingId);
|
|
465
555
|
if (recordsWithKey.length === 0) {
|
|
466
|
-
|
|
467
|
-
|
|
556
|
+
const errorMsg = `No recording found for ${key} at ${req.method} ${host}${req.url}`;
|
|
557
|
+
console.error(`[REPLAY ERROR] ${errorMsg} (session: ${recordingId})`);
|
|
558
|
+
console.error(
|
|
559
|
+
`[REPLAY ERROR] This request was not made during recording - possible test non-determinism`
|
|
468
560
|
);
|
|
469
|
-
const
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
}
|
|
561
|
+
const errorResponse = {
|
|
562
|
+
error: "No recording found",
|
|
563
|
+
message: errorMsg,
|
|
564
|
+
key,
|
|
565
|
+
sessionId: recordingId
|
|
566
|
+
};
|
|
475
567
|
const corsHeaders = this.getCorsHeaders(req);
|
|
476
|
-
res.writeHead(
|
|
568
|
+
res.writeHead(HTTP_STATUS_NOT_FOUND, {
|
|
477
569
|
"Content-Type": "application/json",
|
|
478
570
|
...corsHeaders
|
|
479
571
|
});
|
|
480
|
-
res.end(JSON.stringify(
|
|
572
|
+
res.end(JSON.stringify(errorResponse));
|
|
481
573
|
return;
|
|
482
574
|
}
|
|
483
|
-
const
|
|
575
|
+
const requestCount = servedForThisKey.size + 1;
|
|
576
|
+
console.log(
|
|
577
|
+
`[REPLAY REQUEST #${requestCount}] ${req.method} ${req.url} (session: ${recordingId}, total: ${recordsWithKey.length}, served: ${servedForThisKey.size})`
|
|
578
|
+
);
|
|
484
579
|
let record;
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
580
|
+
for (const rec of recordsWithKey) {
|
|
581
|
+
if (!servedForThisKey.has(rec.recordingId)) {
|
|
582
|
+
record = rec;
|
|
583
|
+
break;
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
if (!record) {
|
|
587
|
+
console.log(
|
|
588
|
+
`[REPLAY WARNING] All ${recordsWithKey.length} recordings already served for ${key} (session: ${recordingId}), reusing last one`
|
|
589
|
+
);
|
|
488
590
|
record = recordsWithKey[recordsWithKey.length - 1];
|
|
489
591
|
}
|
|
592
|
+
servedForThisKey.add(record.recordingId);
|
|
490
593
|
console.log(
|
|
491
|
-
`
|
|
594
|
+
`[REPLAY SERVING] recordingId: ${record.recordingId}, session: ${recordingId}, body_len: ${record.response?.body?.length || 0}`
|
|
492
595
|
);
|
|
493
|
-
this.replaySequenceMap.set(key, usageCount + 1);
|
|
494
596
|
if (!record.response) {
|
|
495
597
|
throw new Error(
|
|
496
598
|
`No response recorded for this request: ${req.method} ${host}${req.url}`
|
|
@@ -556,6 +658,7 @@ var ProxyServer = class {
|
|
|
556
658
|
this.proxy.web(req, res, { target });
|
|
557
659
|
}
|
|
558
660
|
}
|
|
661
|
+
// TODO: check if can handle streaming requests
|
|
559
662
|
async bufferAndProxyRequest(req, res, target) {
|
|
560
663
|
const chunks = [];
|
|
561
664
|
req.on("data", (chunk) => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "test-proxy-recorder",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.11",
|
|
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",
|