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/README.md +163 -5
- package/dist/{index-BloXCw69.d.cts → index-BnkejxM_.d.cts} +1 -1
- package/dist/{index-BloXCw69.d.ts → index-BnkejxM_.d.ts} +1 -1
- package/dist/index.cjs +161 -9
- package/dist/index.d.cts +88 -3
- package/dist/index.d.ts +88 -3
- package/dist/index.mjs +158 -10
- package/dist/playwright/index.d.cts +1 -1
- package/dist/playwright/index.d.ts +1 -1
- package/dist/proxy.js +695 -40
- package/package.json +3 -2
- package/skills/proxy-setup/SKILL.md +80 -3
package/dist/proxy.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
|
-
import
|
|
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
|
|
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("
|
|
84
|
+
program.name("test-proxy-recorder").description(
|
|
28
85
|
"Development proxy server with recording and replay capabilities"
|
|
29
86
|
).argument(
|
|
30
|
-
"
|
|
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
|
-
|
|
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
|
-
"-
|
|
42
|
-
"
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
|
63
|
-
|
|
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
|
|
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
|
-
|
|
360
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
1101
|
-
|
|
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}`);
|