test-proxy-recorder 0.3.8 → 0.4.0

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/dist/proxy.js CHANGED
@@ -1,5 +1,8 @@
1
- import path from 'path';
1
+ import path3 from 'path';
2
2
  import { Command } from 'commander';
3
+ import { existsSync, readFileSync, mkdirSync, writeFileSync } from 'fs';
4
+ import { createJiti } from 'jiti';
5
+ import { spawnSync } from 'child_process';
3
6
  import fs from 'fs/promises';
4
7
  import http2 from 'http';
5
8
  import httpProxy from 'http-proxy';
@@ -9,6 +12,39 @@ import filenamify2 from 'filenamify';
9
12
  import { WebSocket, WebSocketServer } from 'ws';
10
13
 
11
14
  // src/cli.ts
15
+ var CONFIG_BASENAME = "test-proxy-recorder.config";
16
+ var CONFIG_EXTENSIONS = ["ts", "mts", "js", "mjs", "cjs"];
17
+ function findConfigFile(cwd) {
18
+ for (const ext of CONFIG_EXTENSIONS) {
19
+ const candidate = path3.join(cwd, `${CONFIG_BASENAME}.${ext}`);
20
+ if (existsSync(candidate)) {
21
+ return candidate;
22
+ }
23
+ }
24
+ return null;
25
+ }
26
+ async function loadConfig(explicitPath, cwd = process.cwd()) {
27
+ let filePath;
28
+ if (explicitPath) {
29
+ filePath = path3.resolve(cwd, explicitPath);
30
+ if (!existsSync(filePath)) {
31
+ throw new Error(`Config file not found: ${filePath}`);
32
+ }
33
+ } else {
34
+ filePath = findConfigFile(cwd);
35
+ }
36
+ if (!filePath) {
37
+ return null;
38
+ }
39
+ const jiti = createJiti(import.meta.url);
40
+ const config = await jiti.import(filePath, { default: true });
41
+ if (typeof config !== "object" || config === null) {
42
+ throw new Error(
43
+ `Config file ${filePath} must export a config object (use \`export default defineConfig({ ... })\`)`
44
+ );
45
+ }
46
+ return config;
47
+ }
12
48
 
13
49
  // src/constants.ts
14
50
  var DEFAULT_TIMEOUT_MS = 120 * 1e3;
@@ -22,45 +58,517 @@ var RECORDING_ID_HEADER = "x-test-rcrd-id";
22
58
  // src/cli.ts
23
59
  var DEFAULT_PORT = 8e3;
24
60
  var DEFAULT_RECORDINGS_DIR = "./recordings";
25
- function parseCliArgs() {
61
+ function splitList(value) {
62
+ return (value ?? "").split(",").map((item) => item.trim()).filter(Boolean);
63
+ }
64
+ function resolveNumber(cliValue, configValue, defaultValue, isValid, errorMessage) {
65
+ const value = cliValue !== void 0 ? Number.parseInt(cliValue, 10) : configValue ?? defaultValue;
66
+ if (Number.isNaN(value) || !isValid(value)) {
67
+ console.error(errorMessage);
68
+ process.exit(1);
69
+ }
70
+ return value;
71
+ }
72
+ function resolveRedaction(options, configRedaction) {
73
+ return {
74
+ enabled: options.redact === false ? false : configRedaction?.enabled ?? true,
75
+ headers: options.redactHeaders !== void 0 ? splitList(options.redactHeaders) : configRedaction?.headers ?? [],
76
+ bodyPatterns: options.redactBody !== void 0 ? splitList(options.redactBody) : configRedaction?.bodyPatterns ?? [],
77
+ allowHeaders: options.allowHeaders !== void 0 ? splitList(options.allowHeaders) : configRedaction?.allowHeaders ?? [],
78
+ allowCookies: options.allowCookies !== void 0 ? splitList(options.allowCookies) : configRedaction?.allowCookies ?? [],
79
+ placeholder: configRedaction?.placeholder
80
+ };
81
+ }
82
+ async function parseCliArgs(argv) {
26
83
  const program = new Command();
27
- program.name("dev-proxy").description(
84
+ program.name("test-proxy-recorder").description(
28
85
  "Development proxy server with recording and replay capabilities"
29
86
  ).argument(
30
- "<target>",
31
- "Target API service URL (e.g., http://localhost:3000)"
32
- ).option(
33
- "-p, --port <number>",
34
- "Port number for the proxy server",
35
- String(DEFAULT_PORT)
87
+ "[target]",
88
+ "Target API service URL (e.g., http://localhost:3000). Overrides `target` from the config file."
36
89
  ).option(
90
+ "-c, --config <path>",
91
+ "Path to a config file (default: auto-detect test-proxy-recorder.config.{ts,js,mjs} in the current directory)"
92
+ ).option("-p, --port <number>", "Port number for the proxy server").option(
37
93
  "-d, --dir <path>",
38
- "Directory to store recordings (relative to CWD)",
39
- DEFAULT_RECORDINGS_DIR
94
+ "Directory to store recordings (relative to CWD)"
95
+ ).option("-t, --timeout <ms>", "Session timeout in milliseconds").option(
96
+ "--no-redact",
97
+ "Disable secret redaction (commit raw Authorization/Cookie headers \u2014 not recommended)"
40
98
  ).option(
41
- "-t, --timeout <ms>",
42
- "Session timeout in milliseconds",
43
- String(DEFAULT_TIMEOUT_MS)
44
- ).action(() => {
45
- });
46
- program.parse();
47
- const target2 = program.args[0];
99
+ "--redact-headers <names>",
100
+ "Comma-separated extra header names to redact (merged with the defaults)"
101
+ ).option(
102
+ "--redact-body <patterns>",
103
+ "Comma-separated regex patterns to redact from request/response bodies"
104
+ ).option(
105
+ "--allow-headers <names>",
106
+ "Comma-separated header names to exempt from redaction"
107
+ ).option(
108
+ "--allow-cookies <names>",
109
+ "Comma-separated cookie names to keep unredacted inside Cookie/Set-Cookie"
110
+ );
111
+ program.parse(argv);
48
112
  const options = program.opts();
49
- const port2 = Number.parseInt(options.port, 10);
50
- if (Number.isNaN(port2) || port2 < 1025 || port2 > 65535) {
51
- console.error("Error: Invalid port number. Must be between 1 and 65535");
52
- process.exit(1);
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");
113
+ let config;
114
+ try {
115
+ config = await loadConfig(options.config);
116
+ } catch (error) {
117
+ console.error(
118
+ `Error: ${error instanceof Error ? error.message : String(error)}`
119
+ );
57
120
  process.exit(1);
58
121
  }
122
+ const target2 = program.args[0] ?? config?.target;
59
123
  if (!target2) {
124
+ console.error(
125
+ "Error: target is required. Pass it as an argument or set `target` in the config file."
126
+ );
60
127
  program.help();
61
128
  }
62
- const recordingsDir2 = path.resolve(process.cwd(), options.dir);
63
- return { target: target2, port: port2, recordingsDir: recordingsDir2, timeout: timeout2 };
129
+ const port2 = resolveNumber(
130
+ options.port,
131
+ config?.port,
132
+ DEFAULT_PORT,
133
+ (n) => n >= 1025 && n <= 65535,
134
+ "Error: Invalid port number. Must be between 1025 and 65535"
135
+ );
136
+ const timeout2 = resolveNumber(
137
+ options.timeout,
138
+ config?.timeout,
139
+ DEFAULT_TIMEOUT_MS,
140
+ (n) => n >= 0,
141
+ "Error: Invalid timeout. Must be a non-negative number"
142
+ );
143
+ const dir = options.dir ?? config?.recordingsDir ?? DEFAULT_RECORDINGS_DIR;
144
+ const recordingsDir2 = path3.resolve(process.cwd(), dir);
145
+ const redaction2 = resolveRedaction(options, config?.redaction);
146
+ return { target: target2, port: port2, recordingsDir: recordingsDir2, timeout: timeout2, redaction: redaction2 };
147
+ }
148
+ var CONFIG_FILENAME = "test-proxy-recorder.config.ts";
149
+ var PLAYWRIGHT_CONFIG_NAMES = [
150
+ "playwright.config.ts",
151
+ "playwright.config.mts",
152
+ "playwright.config.cts",
153
+ "playwright.config.js",
154
+ "playwright.config.mjs",
155
+ "playwright.config.cjs"
156
+ ];
157
+ var DEFAULT_TARGET = "http://localhost:3000";
158
+ var DEFAULT_PORT2 = 8100;
159
+ var DEFAULT_DIR = "./e2e/recordings";
160
+ function renderConfig(options) {
161
+ return `import { defineConfig } from 'test-proxy-recorder';
162
+
163
+ // Generated by \`test-proxy-recorder init\`.
164
+ // Auto-discovered when you run \`test-proxy-recorder\` with no arguments.
165
+ // CLI flags always override the values set here.
166
+ export default defineConfig({
167
+ // Backend the proxy records against. Point your app's API base URL here too.
168
+ target: '${options.target}',
169
+
170
+ // Port the proxy listens on. Matches the Playwright client default
171
+ // (override both with the TEST_PROXY_RECORDER_PORT env var).
172
+ port: ${options.port},
173
+
174
+ // Where .mock.json recordings are written, relative to this file.
175
+ // Commit this directory \u2014 CI replays from it.
176
+ recordingsDir: '${options.dir}',
177
+
178
+ // Secrets are redacted automatically (Authorization / Cookie / Set-Cookie).
179
+ // Uncomment to redact extra headers or tokens embedded in bodies.
180
+ // redaction: {
181
+ // headers: ['x-api-key'],
182
+ // bodyPatterns: [/sk_live_\\w+/g],
183
+ // allowCookies: ['theme'],
184
+ // },
185
+ });
186
+ `;
187
+ }
188
+ function renderPlaywrightConfig(options) {
189
+ return `import { defineConfig } from '@playwright/test';
190
+
191
+ // Generated by \`test-proxy-recorder init\`.
192
+ export default defineConfig({
193
+ testDir: './e2e',
194
+ // Resets the proxy to transparent mode after the run.
195
+ globalTeardown: './e2e/global-teardown.ts',
196
+ // Boots the recorder; it reads target/port/dir from
197
+ // test-proxy-recorder.config.ts. Health-check hits /__control, which is
198
+ // always available (the proxy root forwards to your backend).
199
+ webServer: {
200
+ command: 'test-proxy-recorder',
201
+ url: 'http://localhost:${options.port}/__control',
202
+ reuseExistingServer: true,
203
+ timeout: 15_000,
204
+ },
205
+ });
206
+ `;
207
+ }
208
+ function renderFixtures() {
209
+ return String.raw`import { test as base, type Page } from '@playwright/test';
210
+ import { playwrightProxy } from 'test-proxy-recorder';
211
+
212
+ // External domains the browser calls directly (auth, CDN, analytics, ...).
213
+ // Server-side fetches through the proxy are recorded automatically — this is
214
+ // only for browser-side requests that never touch the proxy.
215
+ const CLIENT_SIDE_URL = /api\.example\.com/;
216
+
217
+ // Change to 'record' to hit the real API and refresh recordings, then switch
218
+ // back to 'replay' and commit. Record with a single worker (--workers 1).
219
+ const MODE = 'replay' as const;
220
+
221
+ export const test = base.extend<{ page: Page }>({
222
+ page: async ({ context }, use, testInfo) => {
223
+ const page = await context.newPage();
224
+ await playwrightProxy.before(page, testInfo, MODE, { url: CLIENT_SIDE_URL });
225
+ await use(page);
226
+ },
227
+ });
228
+
229
+ export { expect } from '@playwright/test';
230
+ `;
231
+ }
232
+ function renderTeardown() {
233
+ return `import { playwrightProxy } from 'test-proxy-recorder';
234
+
235
+ // Runs once after the whole suite \u2014 resets the proxy to transparent mode.
236
+ // Do NOT call teardown() per-test (afterAll): it flips the global mode and
237
+ // breaks parallel replay.
238
+ export default async function globalTeardown() {
239
+ await playwrightProxy
240
+ .teardown()
241
+ .catch((err) => console.warn('test-proxy-recorder teardown', err));
242
+ }
243
+ `;
244
+ }
245
+ function scaffoldScripts() {
246
+ return {
247
+ proxy: "test-proxy-recorder",
248
+ "test:e2e": "playwright test",
249
+ "test:e2e:record": "playwright test --workers 1 --ui"
250
+ };
251
+ }
252
+ function injectProxyIntoConfig(source, options) {
253
+ if (source.includes("test-proxy-recorder")) {
254
+ return {
255
+ contents: source,
256
+ changed: false,
257
+ reason: "already references test-proxy-recorder"
258
+ };
259
+ }
260
+ if (/\bwebServer\s*:/.test(source)) {
261
+ return {
262
+ contents: source,
263
+ changed: false,
264
+ reason: "already defines webServer \u2014 add the proxy command manually"
265
+ };
266
+ }
267
+ const opener = source.match(/defineConfig\s*\(\s*\{/) ?? source.match(/export\s+default\s*\{/);
268
+ if (opener?.index === void 0) {
269
+ return {
270
+ contents: source,
271
+ changed: false,
272
+ reason: "could not locate the config object"
273
+ };
274
+ }
275
+ const hasTeardown = /\bglobalTeardown\s*:/.test(source);
276
+ const teardownLine = hasTeardown ? "" : `
277
+ globalTeardown: './e2e/global-teardown.ts',`;
278
+ const block = `
279
+ // Added by test-proxy-recorder init
280
+ webServer: {
281
+ command: 'test-proxy-recorder',
282
+ url: 'http://localhost:${options.port}/__control',
283
+ reuseExistingServer: true,
284
+ timeout: 15_000,
285
+ },${teardownLine}`;
286
+ const at = opener.index + opener[0].length;
287
+ const contents = source.slice(0, at) + block + source.slice(at);
288
+ return { contents, changed: true };
289
+ }
290
+ function findPlaywrightConfig(cwd) {
291
+ for (const name of PLAYWRIGHT_CONFIG_NAMES) {
292
+ const candidate = path3.join(cwd, name);
293
+ if (existsSync(candidate)) return candidate;
294
+ }
295
+ return null;
296
+ }
297
+ function writeFile(cwd, relPath, contents, force) {
298
+ const absPath = path3.join(cwd, relPath);
299
+ const overwritten = existsSync(absPath);
300
+ if (overwritten && !force) {
301
+ return { relPath, status: "skipped", detail: "already exists" };
302
+ }
303
+ mkdirSync(path3.dirname(absPath), { recursive: true });
304
+ writeFileSync(absPath, contents, "utf8");
305
+ return {
306
+ relPath,
307
+ status: "created",
308
+ detail: overwritten ? "overwritten" : void 0
309
+ };
310
+ }
311
+ function scaffoldPlaywright(cwd, options) {
312
+ const existing = findPlaywrightConfig(cwd);
313
+ if (!existing) {
314
+ return writeFile(
315
+ cwd,
316
+ "playwright.config.ts",
317
+ renderPlaywrightConfig(options),
318
+ options.force
319
+ );
320
+ }
321
+ const relPath = path3.relative(cwd, existing) || path3.basename(existing);
322
+ const source = readFileSync(existing, "utf8");
323
+ const { contents, changed, reason } = injectProxyIntoConfig(source, options);
324
+ if (!changed) {
325
+ return { relPath, status: "skipped", detail: reason };
326
+ }
327
+ writeFileSync(existing, contents, "utf8");
328
+ return {
329
+ relPath,
330
+ status: "updated",
331
+ detail: "added webServer + globalTeardown"
332
+ };
333
+ }
334
+ function detectIndent(source) {
335
+ const match = source.match(/\n([ \t]+)"/);
336
+ return match ? match[1] : " ";
337
+ }
338
+ var DEV_APP_SCRIPT = "dev:app";
339
+ function runScriptPrefix(pm) {
340
+ return pm === "pnpm" || pm === "yarn" ? pm : `${pm} run`;
341
+ }
342
+ function mergeScripts(existing, scripts, force) {
343
+ const added = [];
344
+ const skipped = [];
345
+ for (const [name, command] of Object.entries(scripts)) {
346
+ const conflicts = existing[name] !== void 0 && !force;
347
+ (conflicts ? skipped : added).push(name);
348
+ if (!conflicts) existing[name] = command;
349
+ }
350
+ return { added, skipped };
351
+ }
352
+ function wrapDevScript(scripts, pm) {
353
+ const dev = scripts.dev;
354
+ if (dev === void 0) return false;
355
+ if (dev.includes("test-proxy-recorder") || dev.includes("concurrently")) {
356
+ return false;
357
+ }
358
+ if (scripts[DEV_APP_SCRIPT] !== void 0) return false;
359
+ const run = runScriptPrefix(pm);
360
+ scripts[DEV_APP_SCRIPT] = dev;
361
+ scripts.dev = `concurrently --kill-others "${run} proxy" "${run} ${DEV_APP_SCRIPT}"`;
362
+ return true;
363
+ }
364
+ function ensureConcurrentlyDep(pkg) {
365
+ if (pkg.dependencies?.concurrently !== void 0 || pkg.devDependencies?.concurrently !== void 0) {
366
+ return false;
367
+ }
368
+ pkg.devDependencies ??= {};
369
+ pkg.devDependencies.concurrently = "^9.0.0";
370
+ return true;
371
+ }
372
+ function updatePackageScripts(cwd, scripts, force) {
373
+ const relPath = "package.json";
374
+ const absPath = path3.join(cwd, relPath);
375
+ if (!existsSync(absPath)) {
376
+ return { relPath, status: "skipped", detail: "no package.json found" };
377
+ }
378
+ const source = readFileSync(absPath, "utf8");
379
+ const pkg = JSON.parse(source);
380
+ pkg.scripts ??= {};
381
+ const { added, skipped } = mergeScripts(pkg.scripts, scripts, force);
382
+ const devWrapped = wrapDevScript(pkg.scripts, detectPackageManager(cwd));
383
+ if (devWrapped) ensureConcurrentlyDep(pkg);
384
+ if (added.length === 0 && !devWrapped) {
385
+ return {
386
+ relPath,
387
+ status: "skipped",
388
+ detail: `scripts already present (${skipped.join(", ")})`
389
+ };
390
+ }
391
+ const indent = detectIndent(source);
392
+ const trailingNewline = source.endsWith("\n") ? "\n" : "";
393
+ writeFileSync(absPath, JSON.stringify(pkg, null, indent) + trailingNewline);
394
+ return {
395
+ relPath,
396
+ status: "updated",
397
+ detail: describeEdit(added, skipped, devWrapped)
398
+ };
399
+ }
400
+ function describeEdit(added, skipped, devWrapped) {
401
+ const parts = [];
402
+ if (added.length > 0) parts.push(`added ${added.join(", ")}`);
403
+ if (skipped.length > 0) parts.push(`kept existing ${skipped.join(", ")}`);
404
+ if (devWrapped) parts.push(`wrapped dev (original \u2192 ${DEV_APP_SCRIPT})`);
405
+ return parts.join("; ");
406
+ }
407
+ function runInit(options, cwd = process.cwd()) {
408
+ const actions = [
409
+ writeFile(cwd, CONFIG_FILENAME, renderConfig(options), options.force),
410
+ scaffoldPlaywright(cwd, options),
411
+ writeFile(cwd, "e2e/fixtures.ts", renderFixtures(), options.force),
412
+ writeFile(cwd, "e2e/global-teardown.ts", renderTeardown(), options.force),
413
+ updatePackageScripts(cwd, scaffoldScripts(), options.force)
414
+ ];
415
+ return { actions };
416
+ }
417
+ function playwrightInstalled(cwd) {
418
+ if (existsSync(path3.join(cwd, "node_modules", "@playwright", "test"))) {
419
+ return true;
420
+ }
421
+ const pkgPath = path3.join(cwd, "package.json");
422
+ if (!existsSync(pkgPath)) return false;
423
+ try {
424
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
425
+ return Boolean(
426
+ pkg.dependencies?.["@playwright/test"] ?? pkg.devDependencies?.["@playwright/test"]
427
+ );
428
+ } catch {
429
+ return false;
430
+ }
431
+ }
432
+ function detectPackageManager(cwd) {
433
+ const ua = process.env.npm_config_user_agent ?? "";
434
+ if (ua.startsWith("pnpm") || existsSync(path3.join(cwd, "pnpm-lock.yaml"))) {
435
+ return "pnpm";
436
+ }
437
+ if (ua.startsWith("yarn") || existsSync(path3.join(cwd, "yarn.lock"))) {
438
+ return "yarn";
439
+ }
440
+ if (ua.startsWith("bun") || existsSync(path3.join(cwd, "bun.lockb"))) {
441
+ return "bun";
442
+ }
443
+ return "npm";
444
+ }
445
+ function playwrightCreateCommand(pm) {
446
+ const flags = ["--quiet", "--browser=chromium"];
447
+ switch (pm) {
448
+ case "pnpm": {
449
+ return { cmd: "pnpm", args: ["create", "playwright", "--", ...flags] };
450
+ }
451
+ case "yarn": {
452
+ return { cmd: "yarn", args: ["create", "playwright", ...flags] };
453
+ }
454
+ case "bun": {
455
+ return { cmd: "bun", args: ["create", "playwright", ...flags] };
456
+ }
457
+ default: {
458
+ return {
459
+ cmd: "npm",
460
+ args: ["init", "playwright@latest", "--", ...flags]
461
+ };
462
+ }
463
+ }
464
+ }
465
+ function maybeInstallPlaywright(options, cwd) {
466
+ if (!options.install) return;
467
+ if (findPlaywrightConfig(cwd)) return;
468
+ if (playwrightInstalled(cwd)) return;
469
+ const { cmd, args } = playwrightCreateCommand(detectPackageManager(cwd));
470
+ console.log(
471
+ `No Playwright config found and Playwright is not installed.
472
+ Running \`${cmd} ${args.join(" ")}\` (pass --no-install to skip)...`
473
+ );
474
+ let failed = true;
475
+ try {
476
+ const result = spawnSync(cmd, args, { cwd, stdio: "inherit" });
477
+ failed = result.status !== 0;
478
+ } catch {
479
+ failed = true;
480
+ }
481
+ if (failed) {
482
+ console.warn(
483
+ "Playwright CLI scaffold did not complete \u2014 writing a default playwright.config.ts instead. Install Playwright with `npm install -D @playwright/test`."
484
+ );
485
+ }
486
+ }
487
+ function parseInitArgs(argv) {
488
+ const program = new Command();
489
+ program.name("test-proxy-recorder init").description(
490
+ "Scaffold test-proxy-recorder config + Playwright setup in the current directory"
491
+ ).argument(
492
+ "[target]",
493
+ "Backend API URL the proxy records against",
494
+ DEFAULT_TARGET
495
+ ).option("-p, --port <number>", "Proxy port", String(DEFAULT_PORT2)).option("-d, --dir <path>", "Recordings directory", DEFAULT_DIR).option(
496
+ "-f, --force",
497
+ "Overwrite existing files and package.json scripts",
498
+ false
499
+ ).option(
500
+ "--no-install",
501
+ "Do not run the Playwright CLI when Playwright is missing"
502
+ ).allowExcessArguments(false);
503
+ program.parse(argv, { from: "user" });
504
+ const target2 = program.args[0] ?? DEFAULT_TARGET;
505
+ const opts = program.opts();
506
+ const port2 = Number.parseInt(opts.port, 10);
507
+ if (Number.isNaN(port2) || port2 < 1025 || port2 > 65535) {
508
+ console.error("Error: Invalid port number. Must be between 1025 and 65535");
509
+ process.exit(1);
510
+ }
511
+ return {
512
+ target: target2,
513
+ port: port2,
514
+ dir: opts.dir,
515
+ force: opts.force,
516
+ install: opts.install
517
+ };
518
+ }
519
+ var STATUS_LABEL = {
520
+ created: "Created",
521
+ updated: "Updated",
522
+ skipped: "Skipped"
523
+ };
524
+ function initCommand(argv, cwd = process.cwd()) {
525
+ const options = parseInitArgs(argv);
526
+ maybeInstallPlaywright(options, cwd);
527
+ const { actions } = runInit(options, cwd);
528
+ console.log("");
529
+ for (const action of actions) {
530
+ const label = STATUS_LABEL[action.status];
531
+ const suffix = action.detail ? ` \u2014 ${action.detail}` : "";
532
+ console.log(`${label.padEnd(7)} ${action.relPath}${suffix}`);
533
+ }
534
+ printNextSteps(actions, options);
535
+ }
536
+ function backendPointingLines(options, devWrapped) {
537
+ const proxyUrl = `http://localhost:${options.port}`;
538
+ const lines = [
539
+ " 1. Point your app's backend calls at the proxy \u2014 in dev/test only, never in production.",
540
+ ` Set whatever env var your app reads its API base URL from to: ${proxyUrl}`,
541
+ ` (it talks to ${options.target} directly today; the proxy forwards there while`,
542
+ " recording and serves recordings on replay.)"
543
+ ];
544
+ const example = devWrapped ? ` "dev:app": "API_BASE_URL=${proxyUrl} <your dev command>"` : ` API_BASE_URL=${proxyUrl} <your dev/start command>`;
545
+ lines.push(
546
+ " e.g. (use cross-env to make this work on Windows):",
547
+ example
548
+ );
549
+ return lines;
550
+ }
551
+ function printNextSteps(actions, options) {
552
+ const pkgAction = actions.find((a) => a.relPath === "package.json");
553
+ const pwAction = actions.find(
554
+ (a) => a.relPath.startsWith("playwright.config")
555
+ );
556
+ const devWrapped = pkgAction?.detail?.includes("wrapped dev") ?? false;
557
+ const step2 = pwAction?.status === "skipped" ? ` 2. Wire the proxy into your Playwright config (${pwAction.detail}):
558
+ webServer: { command: 'test-proxy-recorder', url: 'http://localhost:${options.port}/__control', reuseExistingServer: true }` : " 2. Record: set MODE = 'record' in e2e/fixtures.ts, then run your record script.";
559
+ const step3 = pkgAction?.detail === "no package.json found" ? ' 3. Add scripts manually: "proxy": "test-proxy-recorder", "test:e2e": "playwright test".' : ` 3. Switch MODE back to 'replay' and commit ${options.dir}.`;
560
+ console.log("");
561
+ console.log("Next steps:");
562
+ for (const line of backendPointingLines(options, devWrapped)) {
563
+ console.log(line);
564
+ }
565
+ console.log(step2);
566
+ console.log(step3);
567
+ if (devWrapped) {
568
+ console.log(
569
+ " \u2022 `dev` now runs the proxy + your app together (install `concurrently` if needed)."
570
+ );
571
+ }
64
572
  }
65
573
 
66
574
  // src/utils/cors.ts
@@ -307,6 +815,139 @@ var Modes = {
307
815
  record: "record",
308
816
  replay: "replay"
309
817
  };
818
+
819
+ // src/utils/redact.ts
820
+ var DEFAULT_REDACTED_HEADERS = [
821
+ "authorization",
822
+ "cookie",
823
+ "set-cookie"
824
+ ];
825
+ var REDACTED_PLACEHOLDER = "[REDACTED]";
826
+ var COOKIE_HEADERS = /* @__PURE__ */ new Set(["cookie", "set-cookie"]);
827
+ function resolveRedaction2(config) {
828
+ const extra = (config?.headers ?? []).map((name) => name.toLowerCase());
829
+ const headerSet = /* @__PURE__ */ new Set([...DEFAULT_REDACTED_HEADERS, ...extra]);
830
+ for (const name of config?.allowHeaders ?? []) {
831
+ headerSet.delete(name.toLowerCase());
832
+ }
833
+ return {
834
+ headerSet,
835
+ allowCookies: new Set(
836
+ (config?.allowCookies ?? []).map((name) => name.toLowerCase())
837
+ ),
838
+ regexes: toGlobalRegexes(config?.bodyPatterns),
839
+ placeholder: config?.placeholder ?? REDACTED_PLACEHOLDER
840
+ };
841
+ }
842
+ function toGlobalRegexes(patterns) {
843
+ if (!patterns || patterns.length === 0) {
844
+ return [];
845
+ }
846
+ return patterns.map((pattern) => {
847
+ if (typeof pattern === "string") {
848
+ return new RegExp(pattern, "g");
849
+ }
850
+ return pattern.flags.includes("g") ? pattern : new RegExp(pattern.source, `${pattern.flags}g`);
851
+ });
852
+ }
853
+ function redactCookieHeader(value, allowCookies, placeholder) {
854
+ return value.split(";").map((part) => part.trim()).filter(Boolean).map((pair) => {
855
+ const eq = pair.indexOf("=");
856
+ if (eq === -1) {
857
+ return pair;
858
+ }
859
+ const name = pair.slice(0, eq);
860
+ return allowCookies.has(name.toLowerCase()) ? pair : `${name}=${placeholder}`;
861
+ }).join("; ");
862
+ }
863
+ function redactSetCookieValue(value, allowCookies, placeholder) {
864
+ const semicolon = value.indexOf(";");
865
+ const firstPair = semicolon === -1 ? value : value.slice(0, semicolon);
866
+ const attributes = semicolon === -1 ? "" : value.slice(semicolon);
867
+ const eq = firstPair.indexOf("=");
868
+ if (eq === -1) {
869
+ return value;
870
+ }
871
+ const name = firstPair.slice(0, eq).trim();
872
+ if (allowCookies.has(name.toLowerCase())) {
873
+ return value;
874
+ }
875
+ return `${name}=${placeholder}${attributes}`;
876
+ }
877
+ function redactCookieAware(lower, value, resolved) {
878
+ const { allowCookies, placeholder } = resolved;
879
+ const redactOne = (cookie) => lower === "cookie" ? redactCookieHeader(cookie, allowCookies, placeholder) : redactSetCookieValue(cookie, allowCookies, placeholder);
880
+ return Array.isArray(value) ? value.map((v) => redactOne(v)) : redactOne(String(value));
881
+ }
882
+ function redactHeaderValue(name, value, resolved) {
883
+ const lower = name.toLowerCase();
884
+ if (resolved.allowCookies.size > 0 && COOKIE_HEADERS.has(lower)) {
885
+ return redactCookieAware(lower, value, resolved);
886
+ }
887
+ return Array.isArray(value) ? value.map(() => resolved.placeholder) : resolved.placeholder;
888
+ }
889
+ function redactHeaders(headers, resolved) {
890
+ const result = {};
891
+ for (const [name, value] of Object.entries(headers)) {
892
+ result[name] = resolved.headerSet.has(name.toLowerCase()) ? redactHeaderValue(name, value, resolved) : value;
893
+ }
894
+ return result;
895
+ }
896
+ function redactBody(body, regexes, placeholder) {
897
+ if (!body || regexes.length === 0) {
898
+ return body ?? null;
899
+ }
900
+ let result = body;
901
+ for (const regex of regexes) {
902
+ regex.lastIndex = 0;
903
+ result = result.replace(regex, placeholder);
904
+ }
905
+ return result;
906
+ }
907
+ function redactRecording(recording, resolved) {
908
+ const { regexes, placeholder } = resolved;
909
+ return {
910
+ ...recording,
911
+ request: {
912
+ ...recording.request,
913
+ headers: redactHeaders(recording.request.headers, resolved),
914
+ body: redactBody(recording.request.body, regexes, placeholder)
915
+ },
916
+ response: recording.response && {
917
+ ...recording.response,
918
+ headers: redactHeaders(recording.response.headers, resolved),
919
+ body: redactBody(recording.response.body, regexes, placeholder)
920
+ }
921
+ };
922
+ }
923
+ function redactWebSocketRecording(recording, resolved) {
924
+ const { regexes, placeholder } = resolved;
925
+ return {
926
+ ...recording,
927
+ headers: recording.headers ? redactHeaders(recording.headers, resolved) : recording.headers,
928
+ messages: recording.messages.map((message) => ({
929
+ ...message,
930
+ data: redactBody(message.data, regexes, placeholder) ?? message.data
931
+ }))
932
+ };
933
+ }
934
+ function redactSession(session, config) {
935
+ if (config?.enabled === false) {
936
+ return session;
937
+ }
938
+ const resolved = resolveRedaction2(config);
939
+ return {
940
+ ...session,
941
+ recordings: session.recordings.map(
942
+ (recording) => redactRecording(recording, resolved)
943
+ ),
944
+ websocketRecordings: (session.websocketRecordings ?? []).map(
945
+ (recording) => redactWebSocketRecording(recording, resolved)
946
+ )
947
+ };
948
+ }
949
+
950
+ // src/utils/fileUtils.ts
310
951
  var JSON_INDENT_SPACES = 2;
311
952
  var EXTENSION = ".mock.json";
312
953
  var MAX_FILENAME_LENGTH = 255 - EXTENSION.length;
@@ -326,7 +967,7 @@ function getRecordingPath(recordingsDir2, id) {
326
967
  maxLength: 255
327
968
  // Set explicit max to prevent filenamify's default truncation
328
969
  });
329
- return path.join(recordingsDir2, `${sanitizedId}${EXTENSION}`);
970
+ return path3.join(recordingsDir2, `${sanitizedId}${EXTENSION}`);
330
971
  }
331
972
  async function loadRecordingSession(filePath) {
332
973
  const fileContent = await fs.readFile(filePath, "utf8");
@@ -351,14 +992,17 @@ function processRecordings(recordings) {
351
992
  processedRecordings.sort((a, b) => a.recordingId - b.recordingId);
352
993
  return processedRecordings;
353
994
  }
354
- async function saveRecordingSession(recordingsDir2, session) {
995
+ async function saveRecordingSession(recordingsDir2, session, redaction2) {
355
996
  const filePath = getRecordingPath(recordingsDir2, session.id);
356
997
  await fs.mkdir(recordingsDir2, { recursive: true });
357
998
  const processedRecordings = processRecordings(session.recordings);
358
- const processedSession = {
359
- ...session,
360
- recordings: processedRecordings
361
- };
999
+ const processedSession = redactSession(
1000
+ {
1001
+ ...session,
1002
+ recordings: processedRecordings
1003
+ },
1004
+ redaction2
1005
+ );
362
1006
  await fs.writeFile(
363
1007
  filePath,
364
1008
  JSON.stringify(processedSession, null, JSON_INDENT_SPACES)
@@ -587,19 +1231,22 @@ var ProxyServer = class {
587
1231
  currentSession;
588
1232
  recordingsDir;
589
1233
  timeoutMs;
590
- recordingIdCounter;
591
1234
  // Unique ID for each recording entry
592
- sequenceCounterByKey;
1235
+ recordingIdCounter;
593
1236
  // Sequence counter per key (endpoint)
1237
+ sequenceCounterByKey;
594
1238
  replaySessions;
595
1239
  // Track multiple concurrent replay sessions by recording ID
596
1240
  recordingPromises;
597
1241
  // Stack of promises that resolve to completed recordings
598
1242
  flushPromise;
599
1243
  // Promise for in-progress flush operation
600
- constructor(target2, recordingsDir2, timeoutMs) {
1244
+ redaction;
1245
+ // Secret-redaction config applied before saving
1246
+ constructor(target2, recordingsDir2, timeoutMs, redaction2) {
601
1247
  this.target = target2;
602
1248
  this.timeoutMs = timeoutMs ?? DEFAULT_TIMEOUT_MS;
1249
+ this.redaction = redaction2;
603
1250
  this.mode = Modes.transparent;
604
1251
  this.recordingId = null;
605
1252
  this.recordingIdCounter = 0;
@@ -849,7 +1496,11 @@ var ProxyServer = class {
849
1496
  console.log(
850
1497
  `Saving session with ${this.currentSession.recordings.length} HTTP and ${this.currentSession.websocketRecordings.length} WebSocket recordings`
851
1498
  );
852
- await saveRecordingSession(this.recordingsDir, this.currentSession);
1499
+ await saveRecordingSession(
1500
+ this.recordingsDir,
1501
+ this.currentSession,
1502
+ this.redaction
1503
+ );
853
1504
  }
854
1505
  getRecordingIdOrError(req, res) {
855
1506
  const recordingIdFromRequest = getRecordingIdFromRequest(req);
@@ -1097,8 +1748,12 @@ var ProxyServer = class {
1097
1748
  };
1098
1749
 
1099
1750
  // src/proxy-cli.ts
1100
- var { target, port, recordingsDir, timeout } = parseCliArgs();
1101
- var proxy = new ProxyServer(target, recordingsDir, timeout);
1751
+ if (process.argv[2] === "init") {
1752
+ initCommand(process.argv.slice(3));
1753
+ process.exit(0);
1754
+ }
1755
+ var { target, port, recordingsDir, timeout, redaction } = await parseCliArgs();
1756
+ var proxy = new ProxyServer(target, recordingsDir, timeout, redaction);
1102
1757
  await proxy.init();
1103
1758
  proxy.listen(port);
1104
1759
  console.log(`Recordings will be saved to: ${recordingsDir}`);