web-tester-for-claude 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/src/cli.ts ADDED
@@ -0,0 +1,1488 @@
1
+ import { config as loadEnv } from "dotenv";
2
+ import { spawnSync } from "node:child_process";
3
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
4
+ import { resolve } from "node:path";
5
+ import {
6
+ getBuiltInPack,
7
+ listBuiltInPackNames,
8
+ parseUrlLine
9
+ } from "./inspector/packs";
10
+ import { runInspect, type InspectResult } from "./inspector/run";
11
+ import { parseStep, type Step } from "./inspector/steps";
12
+ import {
13
+ defaultImpactRulesPath,
14
+ getChangedFiles,
15
+ loadImpactRules,
16
+ matchRules,
17
+ printPlan
18
+ } from "./impact";
19
+ import { listJourneys, loadJourney, saveJourney, type Journey } from "./journeys";
20
+ import {
21
+ parseExpectation,
22
+ parseFailOn,
23
+ type Expectation,
24
+ type FailOnKind
25
+ } from "./inspector/verdict";
26
+ import { listKnowledge, readKnowledge } from "./kb";
27
+ import { runInit, type AutoUse, type InitResult } from "./init";
28
+ import { runMap } from "./map/run";
29
+ import { fetchSitemapPaths } from "./sitemap";
30
+ import { runSweep, type SweepOptions } from "./sweep";
31
+ import { log } from "./util/log";
32
+ import { ask, choice, confirm, isInteractive } from "./util/prompt";
33
+ import { readProjectConfig, userConfigDir, userPresetsDir } from "./util/paths";
34
+
35
+ loadEnv();
36
+
37
+ const DEFAULT_BASE_URL = "http://localhost:3000";
38
+ // Base URL precedence: env var → .web-tester/config.json → built-in default.
39
+ const BASE_URL = (
40
+ process.env.WEB_TESTER_BASE_URL ??
41
+ readProjectConfig().baseUrl ??
42
+ DEFAULT_BASE_URL
43
+ ).replace(/\/$/, "");
44
+ const GOTO_TIMEOUT_MS = Number(process.env.GOTO_TIMEOUT_MS ?? 30_000);
45
+
46
+ type InspectArgs = {
47
+ url: string;
48
+ steps: Step[];
49
+ headed: boolean;
50
+ captureHtml: boolean;
51
+ captureStorage: boolean;
52
+ captureAllNetwork: boolean;
53
+ captureAllConsole: boolean;
54
+ recordVideo: boolean;
55
+ fullPageScreenshots: boolean;
56
+ summary: boolean;
57
+ expectations: Expectation[];
58
+ persistMs: number;
59
+ failOn: FailOnKind[];
60
+ deep: boolean;
61
+ jsonStdout: boolean;
62
+ loadStorageState: boolean;
63
+ /** Save the browser session after a clean run (`--save-session`). */
64
+ saveSession: boolean;
65
+ /** If set, persist this run's flow as `.web-tester/journeys/<name>.json`. */
66
+ saveJourney?: string;
67
+ /** Raw `--step` strings (for `--save-journey`). */
68
+ rawSteps: string[];
69
+ /** Raw `--expect` strings (for `--save-journey`). */
70
+ rawExpectations: string[];
71
+ };
72
+
73
+ function printHelp(): void {
74
+ log.raw(`web-tester — drive your dev site with Playwright, capture everything,
75
+ hand it to your AI agent (or you).
76
+
77
+ USAGE
78
+ web-tester init [opts] interactive first-run setup:
79
+ scaffold .web-tester/, write a
80
+ Claude Code skill + CLAUDE.md
81
+ section, save config & prefs
82
+ -y, --yes non-interactive (defaults)
83
+ --base-url <url> dev server base URL
84
+ --auto-use <v> on|ask|off (Claude usage)
85
+ --no-skill skip the .claude/ skill
86
+ --no-agent skip CLAUDE.md/AGENTS.md
87
+ --agent-file <p> target a specific file
88
+ --install-browser fetch Chromium now
89
+ --force overwrite existing files
90
+ web-tester map [base] [opts] crawl the site, classify pages,
91
+ generate a preset + recipes +
92
+ draft journeys + an HTML map
93
+ web-tester inspect <url> [--step <op>]… drive one page, capture
94
+ console + network + DOM
95
+ + screenshots + video
96
+ web-tester sweep [opts] inspect many URLs concurrently
97
+ web-tester journey <name> run a saved journey from
98
+ .web-tester/journeys/<name>.json
99
+ web-tester journey list available journeys
100
+ web-tester impact [opts] diff-aware advisory run
101
+ (ALWAYS exits 0)
102
+ --base <ref> diff vs ref
103
+ (default origin/main)
104
+ --plan-only print the
105
+ matched rules
106
+ and stop
107
+ --rules <path> override
108
+ .web-tester/impact-rules.json
109
+ web-tester kb list .md files in .web-tester/
110
+ (or .web-tester/instructions/)
111
+ web-tester kb <topic> print one .md file
112
+ web-tester help this screen
113
+
114
+ INSPECT — captures per run, under runs/<id>/
115
+ result.json structured report (console, network, attrs, storage, per-step
116
+ state, verdict, expectations)
117
+ initial.png final.png viewport screenshots
118
+ steps/NN-*.png one screenshot per step
119
+ report.html self-contained HTML report (video + timeline + per-step slices)
120
+ console.json network.json raw streams
121
+
122
+ URLs may be absolute (http://…) or paths; paths resolve against
123
+ WEB_TESTER_BASE_URL (default http://localhost:3000).
124
+
125
+ STEP GRAMMAR — --step can be repeated, executed in order
126
+ goto:<url> navigate
127
+ reload reload
128
+ wait:<load|domcontentloaded|networkidle>
129
+ wait:<ms> sleep N ms
130
+ wait:<selector> wait for selector
131
+ wait:text=<exact text> wait for matching text
132
+ wait:url-stable[=<ms>] wait for URL to change at least once and
133
+ then stay still for <ms> (default 250)
134
+ wait:url-contains:<sub>[@<ms>] wait until URL contains <sub>
135
+ (default timeout 10000ms; use @ not = so
136
+ the substring can contain '=')
137
+ settle[:<ms>] wait for data-attr-selected-label to
138
+ populate on any [data-attr-name] element.
139
+ Fast-paths in ~3s if none are present.
140
+ Apps that don't use the convention should
141
+ prefer 'wait:networkidle' instead.
142
+ click:<selector> click first match (Playwright locator —
143
+ use CSS, optionally with :has-text())
144
+ hover:<selector>
145
+ fill:<selector>=<value> native input
146
+ react-fill:<selector>=<value> React-controlled input (calls native
147
+ value setter + dispatches synthetic
148
+ input/change/blur events)
149
+ press:<selector>=<key> keyboard press
150
+ select:<selector>=<value> native <select>
151
+ scroll:<top|bottom|<px>>
152
+ screenshot[:<name>] viewport screenshot
153
+ screenshot-full[:<name>] full-page screenshot
154
+ eval:<JS expression> run in page context; result attached
155
+ to the step
156
+
157
+ VERDICT & ASSERTIONS — the real "did it work" surface
158
+ --fail-on <list> comma-sep: page-errors, console-errors,
159
+ 4xx, 5xx. run.ok flips false on any
160
+ triggered signal.
161
+ --expect <kind>=<value> repeatable; evaluated on final page
162
+ text=<text> must be visible
163
+ no-text=<text> must NOT be visible
164
+ selector=<sel> must be visible
165
+ no-selector=<sel> must NOT be visible
166
+ attr=<Name>:<value> data-attr-name=Name
167
+ must match value or label
168
+ --persist <ms> re-run every --expect after waiting <ms>;
169
+ both checks must pass. Catches transient
170
+ states (e.g. a toast that flashes then
171
+ disappears).
172
+
173
+ SPEED PRESETS
174
+ --quick smoke mode: no video, no full-page
175
+ screenshots, no HTML capture, no AI summary
176
+ --summary opt in to a model-written TL;DR at the
177
+ top of the report (off by default)
178
+
179
+ OTHER FLAGS
180
+ --headed show the browser
181
+ --no-video skip the screen recording
182
+ --no-session force an anonymous context (ignore the
183
+ saved ~/.web-tester/session.json)
184
+ --save-session after a clean run, save cookies +
185
+ localStorage to ~/.web-tester/session.json
186
+ — run your login flow once to authenticate
187
+ later runs (TEST credentials only)
188
+ --html also save initial.html / final.html
189
+ --storage snapshot localStorage / sessionStorage /
190
+ cookies
191
+ --all-network keep every request (default: XHR/fetch/
192
+ document only, noise filtered)
193
+ --all-console keep every console line (default: CSP /
194
+ tracker noise filtered)
195
+ --deep deeper capture: request/response bodies,
196
+ plus a CDP debugger that dumps the local
197
+ scope of any uncaught exception and
198
+ records unhandled promise rejections
199
+ --json print full result.json to stdout
200
+ --steps-file <path> load steps from a JSON array of strings
201
+ --save-journey <name> save this flow as a reusable journey
202
+ (.web-tester/journeys/<name>.json); rerun
203
+ it later with 'web-tester journey <name>'
204
+
205
+ SWEEP — bulk URL health checks
206
+ --urls-file <path> newline-separated URLs/paths
207
+ ('#' comments + '#pack=' annotations ok)
208
+ --url <path> repeatable; alternative to --urls-file
209
+ --preset <name> load .web-tester/urls-<name>.txt
210
+ --sitemap [<url>] fetch sitemap.xml + use every <loc>.
211
+ No arg = <BASE_URL>/sitemap.xml
212
+ --filter <regex> keep only matching paths
213
+ --exclude <regex> drop matching paths
214
+ --limit <n> cap total URLs (after filter/exclude)
215
+ --concurrency <n> parallel contexts (max 32; auto when
216
+ omitted — heavier on localhost, lighter
217
+ on remote targets)
218
+ --fail-on, --expect applied to every URL
219
+ --pack <name> built-in expectation pack, applied to
220
+ every URL (repeatable). Built-in packs:
221
+ ${listBuiltInPackNames().join(", ")}
222
+ --no-session anonymous contexts (ignore saved session)
223
+
224
+ MAP — crawl a site and bootstrap coverage
225
+ web-tester map [base] crawl from <base> (default BASE_URL; a
226
+ path maps that subtree) and write:
227
+ .web-tester/urls-map.txt (preset 'map')
228
+ .web-tester/journeys/* (drafts)
229
+ .web-tester/instructions/recipes.md
230
+ runs/map-<id>/map.html + map.json
231
+ --limit <n> max pages to fetch (default 50)
232
+ --depth <n> max link hops from a seed (default 3)
233
+ --per-template <n> max pages per dynamic route (default 3)
234
+ --max-journeys <n> max draft journeys (default 12)
235
+ --concurrency <n> parallel workers (auto when omitted)
236
+ --sitemap [<url>] seed from sitemap.xml (default; on by
237
+ default — BASE_URL/sitemap.xml)
238
+ --no-sitemap crawl from <base> by following links only
239
+ --no-screenshots skip per-page screenshots (faster)
240
+ --no-session crawl anonymously
241
+ --filter <regex> --exclude <regex> keep / drop matching paths
242
+ --force overwrite existing draft journeys
243
+
244
+ ENV
245
+ WEB_TESTER_BASE_URL default http://localhost:3000
246
+ WEB_TESTER_RUNS_DIR where run artifacts go (default ./runs)
247
+ GOTO_TIMEOUT_MS default 30000
248
+ STEP_TIMEOUT_MS default 15000
249
+ SETTLE_TIMEOUT_MS default 30000
250
+
251
+ PROJECT FILES (cwd-relative, all optional)
252
+ .web-tester/impact-rules.json rules consumed by 'impact'
253
+ .web-tester/urls-<name>.txt URL presets consumed by 'sweep --preset'
254
+ .web-tester/journeys/<name>.json saved journeys consumed by 'journey'
255
+ .web-tester/instructions/*.md knowledge base, browsed via 'kb'
256
+ (or .web-tester/*.md for small projects)
257
+ `);
258
+ }
259
+
260
+ type CommonFlags = {
261
+ expectations: Expectation[];
262
+ failOn: FailOnKind[];
263
+ };
264
+
265
+ function applyExpectFlag(args: CommonFlags, value: string): void {
266
+ args.expectations.push(parseExpectation(value));
267
+ }
268
+
269
+ function applyFailOnFlag(args: CommonFlags, value: string): void {
270
+ for (const kind of parseFailOn(value)) {
271
+ if (!args.failOn.includes(kind)) args.failOn.push(kind);
272
+ }
273
+ }
274
+
275
+ function parseInspectArgs(rest: string[]): InspectArgs {
276
+ let url = "";
277
+ const steps: Step[] = [];
278
+ let headed = false;
279
+ let captureHtml = false;
280
+ let captureStorage = false;
281
+ let captureAllNetwork = false;
282
+ let captureAllConsole = false;
283
+ let recordVideo = true;
284
+ let fullPageScreenshots = true;
285
+ let summary = false;
286
+ let quick = false;
287
+ let jsonStdout = false;
288
+ let persistMs = 0;
289
+ let loadStorageState = true;
290
+ let saveSession = false;
291
+ let deep = false;
292
+ let saveJourney: string | undefined;
293
+ const expectations: Expectation[] = [];
294
+ // Raw `--step` / `--expect` strings, kept so `--save-journey` can persist the
295
+ // exact flow the user ran (not the parsed objects).
296
+ const rawSteps: string[] = [];
297
+ const rawExpectations: string[] = [];
298
+ const failOn: FailOnKind[] = [];
299
+
300
+ for (let i = 0; i < rest.length; i++) {
301
+ const arg = rest[i] ?? "";
302
+ if (arg === "--step") {
303
+ const next = rest[++i];
304
+ if (next === undefined) throw new Error("--step needs a value");
305
+ steps.push(parseStep(next));
306
+ rawSteps.push(next);
307
+ continue;
308
+ }
309
+ if (arg.startsWith("--step=")) {
310
+ const raw = arg.slice("--step=".length);
311
+ steps.push(parseStep(raw));
312
+ rawSteps.push(raw);
313
+ continue;
314
+ }
315
+ if (arg === "--expect") {
316
+ const next = rest[++i];
317
+ if (next === undefined) throw new Error("--expect needs a value");
318
+ applyExpectFlag({ expectations, failOn }, next);
319
+ rawExpectations.push(next);
320
+ continue;
321
+ }
322
+ if (arg.startsWith("--expect=")) {
323
+ const raw = arg.slice("--expect=".length);
324
+ applyExpectFlag({ expectations, failOn }, raw);
325
+ rawExpectations.push(raw);
326
+ continue;
327
+ }
328
+ if (arg === "--fail-on") {
329
+ const next = rest[++i];
330
+ if (next === undefined) throw new Error("--fail-on needs a value");
331
+ applyFailOnFlag({ expectations, failOn }, next);
332
+ continue;
333
+ }
334
+ if (arg.startsWith("--fail-on=")) {
335
+ applyFailOnFlag({ expectations, failOn }, arg.slice("--fail-on=".length));
336
+ continue;
337
+ }
338
+ if (arg === "--headed") {
339
+ headed = true;
340
+ continue;
341
+ }
342
+ if (arg === "--html") {
343
+ captureHtml = true;
344
+ continue;
345
+ }
346
+ if (arg === "--storage") {
347
+ captureStorage = true;
348
+ continue;
349
+ }
350
+ if (arg === "--all-network") {
351
+ captureAllNetwork = true;
352
+ continue;
353
+ }
354
+ if (arg === "--all-console") {
355
+ captureAllConsole = true;
356
+ continue;
357
+ }
358
+ if (arg === "--no-summary") {
359
+ summary = false;
360
+ continue;
361
+ }
362
+ if (arg === "--summary") {
363
+ summary = true;
364
+ continue;
365
+ }
366
+ if (arg === "--no-video") {
367
+ recordVideo = false;
368
+ continue;
369
+ }
370
+ if (arg === "--no-session") {
371
+ loadStorageState = false;
372
+ continue;
373
+ }
374
+ if (arg === "--save-session") {
375
+ saveSession = true;
376
+ continue;
377
+ }
378
+ if (arg === "--deep") {
379
+ deep = true;
380
+ continue;
381
+ }
382
+ if (arg === "--quick") {
383
+ quick = true;
384
+ continue;
385
+ }
386
+ if (arg === "--persist") {
387
+ const next = rest[++i];
388
+ if (next === undefined) throw new Error("--persist needs a value (ms)");
389
+ persistMs = Number(next);
390
+ if (!Number.isFinite(persistMs) || persistMs < 0)
391
+ throw new Error("--persist must be a non-negative integer (ms)");
392
+ continue;
393
+ }
394
+ if (arg.startsWith("--persist=")) {
395
+ persistMs = Number(arg.slice("--persist=".length));
396
+ if (!Number.isFinite(persistMs) || persistMs < 0)
397
+ throw new Error("--persist must be a non-negative integer (ms)");
398
+ continue;
399
+ }
400
+ if (arg === "--json") {
401
+ jsonStdout = true;
402
+ continue;
403
+ }
404
+ if (arg === "--save-journey") {
405
+ const next = rest[++i];
406
+ if (next === undefined) throw new Error("--save-journey needs a name");
407
+ saveJourney = next;
408
+ continue;
409
+ }
410
+ if (arg.startsWith("--save-journey=")) {
411
+ saveJourney = arg.slice("--save-journey=".length);
412
+ continue;
413
+ }
414
+ if (arg === "--steps-file") {
415
+ const next = rest[++i];
416
+ if (next === undefined) throw new Error("--steps-file needs a path");
417
+ const raw = readFileSync(resolve(next), "utf-8");
418
+ const parsed = JSON.parse(raw);
419
+ if (!Array.isArray(parsed))
420
+ throw new Error("--steps-file JSON must be an array of strings");
421
+ for (const s of parsed) {
422
+ if (typeof s !== "string")
423
+ throw new Error("--steps-file entries must be strings");
424
+ steps.push(parseStep(s));
425
+ rawSteps.push(s);
426
+ }
427
+ continue;
428
+ }
429
+ if (arg.startsWith("--")) {
430
+ throw new Error(`unknown flag: ${arg}`);
431
+ }
432
+ if (!url) {
433
+ url = arg;
434
+ } else {
435
+ throw new Error(`unexpected positional arg: ${arg}`);
436
+ }
437
+ }
438
+
439
+ if (!url) throw new Error("inspect needs a URL");
440
+
441
+ // --quick is a speed preset: it forces the heavy capture options off
442
+ // regardless of where it appears among the flags.
443
+ if (quick) {
444
+ if (recordVideo) recordVideo = false;
445
+ if (fullPageScreenshots) fullPageScreenshots = false;
446
+ if (summary) summary = false;
447
+ if (captureHtml) captureHtml = false;
448
+ }
449
+
450
+ return {
451
+ url,
452
+ steps,
453
+ headed,
454
+ captureHtml,
455
+ captureStorage,
456
+ captureAllNetwork,
457
+ captureAllConsole,
458
+ recordVideo,
459
+ fullPageScreenshots,
460
+ summary,
461
+ expectations,
462
+ persistMs,
463
+ failOn,
464
+ deep,
465
+ jsonStdout,
466
+ loadStorageState,
467
+ saveSession,
468
+ ...(saveJourney !== undefined ? { saveJourney } : {}),
469
+ rawSteps,
470
+ rawExpectations
471
+ };
472
+ }
473
+
474
+ function summariseConsole(result: InspectResult): string {
475
+ const { totals } = result.console;
476
+ const parts = Object.entries(totals).map(([k, v]) => `${k}=${v}`);
477
+ return parts.length ? parts.join(", ") : "0";
478
+ }
479
+
480
+ function topNetworkFailures(result: InspectResult, limit = 5): string[] {
481
+ return result.network.entries
482
+ .filter(
483
+ (e) => (e.status !== null && e.status >= 400) || e.failureText !== null
484
+ )
485
+ .slice(0, limit)
486
+ .map((e) =>
487
+ e.failureText
488
+ ? `${e.method} ${e.url} — ${e.failureText}`
489
+ : `${e.status} ${e.method} ${e.url}`
490
+ );
491
+ }
492
+
493
+ function printSummary(result: InspectResult): void {
494
+ log.header(result.ok ? "result: ok" : "result: issues");
495
+ log.info(` URL: ${result.requestedUrl}`);
496
+ log.info(` finalURL: ${result.finalUrl}`);
497
+ if (result.title) log.info(` title: ${result.title}`);
498
+ log.info(` duration: ${result.durationMs}ms`);
499
+ log.info(` steps: ${result.steps.length} (${result.failedSteps} failed)`);
500
+ log.info(` console: ${summariseConsole(result)}`);
501
+ log.info(
502
+ ` network: ${result.network.count} (${result.network.failedCount} failed/blocked)`
503
+ );
504
+ if (result.verdictTriggers.length > 0) {
505
+ log.fail(` verdict: fail`);
506
+ for (const t of result.verdictTriggers) log.fail(` · ${t}`);
507
+ } else if (result.expectations.length > 0 || result.failOn.length > 0) {
508
+ log.ok(` verdict: pass`);
509
+ }
510
+ if (result.expectations.length > 0) {
511
+ log.info(` expectations:`);
512
+ for (const r of result.expectations) {
513
+ const tag = r.ok ? "✓" : "✗";
514
+ const desc = describeExpectation(r.expectation);
515
+ if (r.ok) log.dim(` ${tag} ${desc}`);
516
+ else log.fail(` ${tag} ${desc} — ${r.detail ?? "failed"}`);
517
+ }
518
+ }
519
+ if (result.pageErrors.length) {
520
+ const grouped = new Map<string, number>();
521
+ for (const e of result.pageErrors)
522
+ grouped.set(e.message, (grouped.get(e.message) ?? 0) + 1);
523
+ log.fail(` pageErrors: ${result.pageErrors.length} (${grouped.size} unique)`);
524
+ for (const [msg, count] of Array.from(grouped.entries()).slice(0, 5)) {
525
+ const tag = count > 1 ? ` (×${count})` : "";
526
+ log.fail(` · ${msg.split("\n")[0]}${tag}`);
527
+ }
528
+ }
529
+ const failures = topNetworkFailures(result);
530
+ if (failures.length) {
531
+ log.warn(` failed requests:`);
532
+ for (const f of failures) log.warn(` · ${f}`);
533
+ }
534
+ if (result.deepErrors?.length) {
535
+ log.fail(` uncaught exceptions (with scope):`);
536
+ for (const e of result.deepErrors.slice(0, 5)) {
537
+ log.fail(` · ${e.reason} — in ${e.functionName}${e.location ? ` (${e.location})` : ""}`);
538
+ for (const scope of e.scopes) {
539
+ const vars = Object.entries(scope.vars)
540
+ .slice(0, 6)
541
+ .map(([k, v]) => `${k}=${v}`)
542
+ .join(", ");
543
+ if (vars) log.dim(` ${scope.type}: ${vars}`);
544
+ }
545
+ }
546
+ }
547
+ if (result.unhandledRejections?.length) {
548
+ log.fail(` unhandled rejections:`);
549
+ for (const r of result.unhandledRejections.slice(0, 5))
550
+ log.fail(` · ${r}`);
551
+ }
552
+ const evals = result.steps.filter((s) => s.evalResult !== undefined);
553
+ if (evals.length) {
554
+ log.info(` evals:`);
555
+ for (const s of evals) {
556
+ const json = JSON.stringify(s.evalResult);
557
+ const compact = json.length > 200 ? `${json.slice(0, 200)}…` : json;
558
+ log.info(` step ${s.index}: ${compact}`);
559
+ }
560
+ }
561
+ if (result.final.attrs.length) {
562
+ log.dim(` attrs: ${result.final.attrs.length} marked on page`);
563
+ for (const a of result.final.attrs.slice(0, 6))
564
+ log.dim(` · ${a.name}=${a.label || a.value}`);
565
+ if (result.final.attrs.length > 6)
566
+ log.dim(` … ${result.final.attrs.length - 6} more in result.json`);
567
+ }
568
+ if (result.summary) {
569
+ log.info("");
570
+ log.info(` summary:`);
571
+ for (const line of result.summary.split("\n"))
572
+ log.raw(` ${line}`);
573
+ }
574
+ log.info("");
575
+ log.ok(` HTML report: ${result.runDir}/report.html`);
576
+ log.info(` result.json: ${result.runDir}/result.json`);
577
+ if (result.video) log.info(` video: ${result.runDir}/${result.video}`);
578
+ log.dim(` (open the HTML report to see steps, video, console + network — the JSON is for programmatic reads)`);
579
+ }
580
+
581
+ function describeExpectation(e: Expectation): string {
582
+ if (e.kind === "text") return `text="${e.text}"`;
583
+ if (e.kind === "no-text") return `no-text="${e.text}"`;
584
+ if (e.kind === "selector") return `selector="${e.selector}"`;
585
+ if (e.kind === "no-selector") return `no-selector="${e.selector}"`;
586
+ return `attr ${e.name}="${e.value}"`;
587
+ }
588
+
589
+ async function commandInspect(rest: string[]): Promise<void> {
590
+ const args = parseInspectArgs(rest);
591
+ log.header(`inspect ${args.url}`);
592
+ log.dim(`base: ${BASE_URL}`);
593
+
594
+ const result = await runInspect({
595
+ baseUrl: BASE_URL,
596
+ url: args.url,
597
+ steps: args.steps,
598
+ headed: args.headed,
599
+ captureHtml: args.captureHtml,
600
+ captureStorage: args.captureStorage,
601
+ captureAllNetwork: args.captureAllNetwork,
602
+ captureAllConsole: args.captureAllConsole,
603
+ recordVideo: args.recordVideo,
604
+ fullPageScreenshots: args.fullPageScreenshots,
605
+ summary: args.summary,
606
+ expectations: args.expectations,
607
+ persistMs: args.persistMs,
608
+ failOn: args.failOn,
609
+ deep: args.deep,
610
+ gotoTimeoutMs: GOTO_TIMEOUT_MS,
611
+ loadStorageState: args.loadStorageState,
612
+ saveSession: args.saveSession
613
+ });
614
+
615
+ if (args.jsonStdout) {
616
+ log.raw(JSON.stringify(result, null, 2));
617
+ } else {
618
+ printSummary(result);
619
+ }
620
+
621
+ // Persist this run's flow as a reusable journey (plain text — url + steps +
622
+ // assertions only, no run artifacts). Reruns replay it with `journey <name>`.
623
+ if (args.saveJourney) {
624
+ const journey: Journey = {
625
+ description: `Saved from \`web-tester inspect ${args.url}\`.`,
626
+ url: args.url,
627
+ steps: args.rawSteps,
628
+ ...(args.rawExpectations.length ? { expectations: args.rawExpectations } : {}),
629
+ ...(args.failOn.length ? { failOn: args.failOn.join(",") } : {}),
630
+ ...(args.persistMs > 0 ? { persistMs: args.persistMs } : {})
631
+ };
632
+ try {
633
+ const path = saveJourney(args.saveJourney, journey);
634
+ log.ok(` saved journey: ${path}`);
635
+ log.dim(` rerun it anytime: web-tester journey ${args.saveJourney.replace(/\.json$/i, "")}`);
636
+ } catch (err) {
637
+ log.fail(` could not save journey: ${err instanceof Error ? err.message : String(err)}`);
638
+ }
639
+ }
640
+
641
+ if (!result.ok) process.exitCode = 1;
642
+ }
643
+
644
+ type SweepArgs = {
645
+ /** Raw URL lines (may carry `#pack=<name>` annotations). */
646
+ urls: string[];
647
+ concurrency: number;
648
+ failOn: FailOnKind[];
649
+ /** Global expectations applied to every URL on top of any inline pack. */
650
+ expectations: Expectation[];
651
+ /** Default packs (names) applied to every URL on top of any inline pack. */
652
+ defaultPacks: string[];
653
+ /** Load the saved session into each worker context (false = anonymous). */
654
+ loadStorageState: boolean;
655
+ };
656
+
657
+ function loadUrlsFromFile(path: string): string[] {
658
+ const raw = readFileSync(path, "utf-8");
659
+ const out: string[] = [];
660
+ for (const line of raw.split("\n")) {
661
+ const trimmed = line.trim();
662
+ if (!trimmed || trimmed.startsWith("#")) continue;
663
+ out.push(trimmed);
664
+ }
665
+ return out;
666
+ }
667
+
668
+ function resolvePreset(name: string): string {
669
+ // Presets live as `urls-<name>.txt` inside the user's `.web-tester/` so
670
+ // they sit alongside impact-rules.json and journeys/ for the project.
671
+ const candidate = resolve(userPresetsDir(), `urls-${name}.txt`);
672
+ if (!existsSync(candidate)) {
673
+ const known = listPresets();
674
+ const help =
675
+ known.length > 0
676
+ ? `Available presets: ${known.join(", ")}`
677
+ : "No presets found. Create a file at .web-tester/urls-<name>.txt and re-run with --preset <name>.";
678
+ throw new Error(
679
+ `unknown --preset "${name}". Looked for ${candidate}. ${help}`
680
+ );
681
+ }
682
+ return candidate;
683
+ }
684
+
685
+ function listPresets(): string[] {
686
+ try {
687
+ const dir = userPresetsDir();
688
+ if (!existsSync(dir)) return [];
689
+ return readdirSync(dir)
690
+ .filter((f) => f.startsWith("urls-") && f.endsWith(".txt"))
691
+ .map((f) => f.slice("urls-".length, -".txt".length));
692
+ } catch {
693
+ return [];
694
+ }
695
+ }
696
+
697
+ async function parseSweepArgs(rest: string[]): Promise<SweepArgs> {
698
+ const urls: string[] = [];
699
+ // -1 = auto (computed in commandSweep from URL count + target).
700
+ let concurrency = -1;
701
+ const expectations: Expectation[] = [];
702
+ const failOn: FailOnKind[] = [];
703
+ const sitemapSources: string[] = [];
704
+ const defaultPacks: string[] = [];
705
+ let filter: RegExp | undefined;
706
+ let exclude: RegExp | undefined;
707
+ let limit = 0;
708
+ let loadStorageState = true;
709
+
710
+ for (let i = 0; i < rest.length; i++) {
711
+ const arg = rest[i] ?? "";
712
+ if (arg === "--url") {
713
+ const next = rest[++i];
714
+ if (next === undefined) throw new Error("--url needs a value");
715
+ urls.push(next);
716
+ continue;
717
+ }
718
+ if (arg === "--urls-file") {
719
+ const next = rest[++i];
720
+ if (next === undefined) throw new Error("--urls-file needs a path");
721
+ for (const u of loadUrlsFromFile(resolve(next))) urls.push(u);
722
+ continue;
723
+ }
724
+ if (arg === "--preset") {
725
+ const next = rest[++i];
726
+ if (next === undefined) throw new Error("--preset needs a name");
727
+ for (const u of loadUrlsFromFile(resolvePreset(next))) urls.push(u);
728
+ continue;
729
+ }
730
+ if (arg === "--sitemap") {
731
+ // `--sitemap` alone → use <BASE_URL>/sitemap.xml.
732
+ // `--sitemap <http(s):// ...>` → use that explicit URL.
733
+ const next = rest[i + 1];
734
+ if (next && next.startsWith("http")) {
735
+ sitemapSources.push(next);
736
+ i++;
737
+ } else {
738
+ sitemapSources.push(`${BASE_URL}/sitemap.xml`);
739
+ }
740
+ continue;
741
+ }
742
+ if (arg === "--filter") {
743
+ const next = rest[++i];
744
+ if (next === undefined) throw new Error("--filter needs a regex");
745
+ filter = new RegExp(next);
746
+ continue;
747
+ }
748
+ if (arg === "--exclude") {
749
+ const next = rest[++i];
750
+ if (next === undefined) throw new Error("--exclude needs a regex");
751
+ exclude = new RegExp(next);
752
+ continue;
753
+ }
754
+ if (arg === "--limit") {
755
+ const next = rest[++i];
756
+ if (next === undefined) throw new Error("--limit needs a number");
757
+ limit = Number.parseInt(next, 10);
758
+ if (!Number.isFinite(limit) || limit < 1)
759
+ throw new Error(`--limit must be a positive integer: ${next}`);
760
+ continue;
761
+ }
762
+ if (arg === "--concurrency") {
763
+ const next = rest[++i];
764
+ if (next === undefined) throw new Error("--concurrency needs a number");
765
+ const parsed = Number.parseInt(next, 10);
766
+ if (!Number.isFinite(parsed) || parsed < 1)
767
+ throw new Error(`--concurrency must be a positive integer: ${next}`);
768
+ // Cap at 32 — beyond that you tend to saturate one Chromium's memory
769
+ // and the target server's per-IP connection limits, so wall time stops
770
+ // improving meaningfully.
771
+ concurrency = Math.min(32, parsed);
772
+ if (parsed > 32) log.warn(`--concurrency ${parsed} clamped to 32`);
773
+ continue;
774
+ }
775
+ if (arg === "--expect") {
776
+ const next = rest[++i];
777
+ if (next === undefined) throw new Error("--expect needs a value");
778
+ applyExpectFlag({ expectations, failOn }, next);
779
+ continue;
780
+ }
781
+ if (arg === "--fail-on") {
782
+ const next = rest[++i];
783
+ if (next === undefined) throw new Error("--fail-on needs a value");
784
+ applyFailOnFlag({ expectations, failOn }, next);
785
+ continue;
786
+ }
787
+ if (arg === "--pack") {
788
+ const next = rest[++i];
789
+ if (next === undefined) throw new Error("--pack needs a name");
790
+ // Validate eagerly so a typo doesn't silently disable assertions.
791
+ getBuiltInPack(next);
792
+ defaultPacks.push(next);
793
+ continue;
794
+ }
795
+ if (arg === "--no-session") {
796
+ loadStorageState = false;
797
+ continue;
798
+ }
799
+ if (arg.startsWith("--")) throw new Error(`unknown flag: ${arg}`);
800
+ urls.push(arg);
801
+ }
802
+
803
+ // Fetch every requested sitemap and append its paths. Done after arg
804
+ // parsing so --filter / --exclude (applied below) cover sitemap-sourced
805
+ // URLs uniformly with explicit ones.
806
+ for (const sm of sitemapSources) {
807
+ log.dim(`fetching sitemap: ${sm}`);
808
+ const paths = await fetchSitemapPaths({ url: sm });
809
+ log.dim(` + ${paths.length} URLs from sitemap`);
810
+ for (const p of paths) urls.push(p);
811
+ }
812
+
813
+ // Parse `#pack=...` annotations off each line, then dedupe by path
814
+ // (later occurrences override earlier — useful for "override the
815
+ // bundled preset's pack on one URL"). filter/exclude/limit apply
816
+ // uniformly across all sources.
817
+ const parsedByPath = new Map<string, { path: string; packs: string[] }>();
818
+ for (const line of urls) {
819
+ const parsed = parseUrlLine(line);
820
+ if (!parsed.path) continue;
821
+ parsedByPath.set(parsed.path, parsed);
822
+ }
823
+ let parsedList = Array.from(parsedByPath.values());
824
+ if (filter) parsedList = parsedList.filter((u) => filter!.test(u.path));
825
+ if (exclude) parsedList = parsedList.filter((u) => !exclude!.test(u.path));
826
+ if (limit > 0 && parsedList.length > limit)
827
+ parsedList = parsedList.slice(0, limit);
828
+
829
+ if (parsedList.length === 0)
830
+ throw new Error(
831
+ "sweep needs at least one URL via --url, --urls-file, --preset, or --sitemap (after --filter/--exclude)"
832
+ );
833
+
834
+ // Eagerly validate every pack name the URL list references so a typo
835
+ // doesn't silently disable assertions for whole pages.
836
+ for (const u of parsedList)
837
+ for (const name of u.packs) getBuiltInPack(name);
838
+
839
+ // Flatten back to raw lines for the SweepArgs.urls shape. commandSweep
840
+ // re-parses to produce the final SweepUrl[] (so it can layer in
841
+ // default packs + global expectations).
842
+ const flatLines = parsedList.map((u) =>
843
+ u.packs.length ? `${u.path} ${u.packs.map((p) => `#pack=${p}`).join(" ")}` : u.path
844
+ );
845
+ return {
846
+ urls: flatLines,
847
+ concurrency,
848
+ failOn,
849
+ expectations,
850
+ defaultPacks,
851
+ loadStorageState
852
+ };
853
+ }
854
+
855
+ /**
856
+ * Pick a sensible concurrency from URL count + target. Used when the user
857
+ * didn't pass `--concurrency` explicitly. Reasoning:
858
+ *
859
+ * - **localhost**: CPU-bound. The dev server can typically handle 8 concurrent
860
+ * browser contexts comfortably.
861
+ * - **anything else** (staging / preview / prod): network throughput and
862
+ * any upstream rate limits become the binding constraint. Scale gently
863
+ * with URL count.
864
+ */
865
+ function autoConcurrency(baseUrl: string, urlCount: number): {
866
+ value: number;
867
+ reason: string;
868
+ } {
869
+ if (baseUrl.includes("localhost") || baseUrl.includes("127.0.0.1")) {
870
+ const value = Math.min(8, Math.max(2, urlCount));
871
+ return { value, reason: `local dev (${urlCount} URLs)` };
872
+ }
873
+ if (urlCount >= 50) return { value: 2, reason: `remote target, ${urlCount} URLs` };
874
+ if (urlCount >= 20) return { value: 3, reason: `remote target, ${urlCount} URLs` };
875
+ return { value: 4, reason: `remote target, ${urlCount} URLs` };
876
+ }
877
+
878
+ async function commandSweep(rest: string[]): Promise<void> {
879
+ const args = await parseSweepArgs(rest);
880
+
881
+ // Resolve each URL line into a SweepUrl with its full expectation set:
882
+ // global --expect + global --pack expectations + inline #pack=...
883
+ // expectations. Dedupe inline packs against global packs so the same
884
+ // pack isn't applied twice.
885
+ const sweepUrls = args.urls.map((line) => {
886
+ const parsed = parseUrlLine(line);
887
+ const allPackNames = Array.from(
888
+ new Set([...args.defaultPacks, ...parsed.packs])
889
+ );
890
+ const packExpectations = allPackNames.flatMap((name) =>
891
+ getBuiltInPack(name)
892
+ );
893
+ return {
894
+ path: parsed.path,
895
+ packs: allPackNames,
896
+ expectations: [...args.expectations, ...packExpectations]
897
+ };
898
+ });
899
+
900
+ let concurrency = args.concurrency;
901
+ if (concurrency === -1) {
902
+ const auto = autoConcurrency(BASE_URL, sweepUrls.length);
903
+ concurrency = auto.value;
904
+ log.dim(`concurrency: ${concurrency} (auto — ${auto.reason})`);
905
+ }
906
+
907
+ log.header(`sweep ${sweepUrls.length} URLs × ${concurrency} workers`);
908
+ const opts: SweepOptions = {
909
+ baseUrl: BASE_URL,
910
+ urls: sweepUrls,
911
+ concurrency,
912
+ failOn: args.failOn,
913
+ gotoTimeoutMs: GOTO_TIMEOUT_MS,
914
+ loadStorageState: args.loadStorageState
915
+ };
916
+ const report = await runSweep(opts);
917
+ if (report.failed > 0) process.exitCode = 1;
918
+ }
919
+
920
+ async function commandJourney(rest: string[]): Promise<void> {
921
+ const [name, ...flags] = rest;
922
+ if (!name || name === "--help" || name === "-h") {
923
+ log.header("journeys");
924
+ const journeys = listJourneys();
925
+ if (journeys.length === 0) {
926
+ log.info(" (none — add JSON files to .web-tester/journeys/)");
927
+ return;
928
+ }
929
+ for (const j of journeys) {
930
+ log.info(` ${j.name.padEnd(28)} ${j.description}`);
931
+ }
932
+ log.dim("");
933
+ log.dim(" web-tester journey <name> # run a journey");
934
+ log.dim(" web-tester journey <name> --headed # see the browser");
935
+ return;
936
+ }
937
+
938
+ const journey = loadJourney(name);
939
+ log.header(`journey: ${name}`);
940
+ if (journey.description) log.dim(` ${journey.description}`);
941
+
942
+ // Forward only the flags the user passed (e.g. --headed, --no-quick).
943
+ // Journey JSON is the source of truth for url/steps/expectations/failOn;
944
+ // CLI flags after the journey name only tweak run-time options.
945
+ const stepArgs = journey.steps.flatMap((s) => ["--step", s]);
946
+ const expectArgs = (journey.expectations ?? []).flatMap((e) => [
947
+ "--expect",
948
+ e
949
+ ]);
950
+ const failOnArgs = journey.failOn ? ["--fail-on", journey.failOn] : [];
951
+ const persistArgs =
952
+ journey.persistMs && journey.persistMs > 0
953
+ ? ["--persist", String(journey.persistMs)]
954
+ : [];
955
+ // Journeys default to --quick unless the caller already passed something
956
+ // that overrides it.
957
+ const quickArg = flags.includes("--no-quick") ? [] : ["--quick"];
958
+ const userFlags = flags.filter((f) => f !== "--no-quick");
959
+
960
+ const argv = [
961
+ journey.url,
962
+ ...stepArgs,
963
+ ...expectArgs,
964
+ ...failOnArgs,
965
+ ...persistArgs,
966
+ ...quickArg,
967
+ ...userFlags
968
+ ];
969
+ await commandInspect(argv);
970
+ }
971
+
972
+ type ImpactArgs = {
973
+ base: string;
974
+ rulesPath: string;
975
+ planOnly: boolean;
976
+ };
977
+
978
+ function parseImpactArgs(rest: string[]): ImpactArgs {
979
+ let base = "origin/main";
980
+ let rulesPath = defaultImpactRulesPath();
981
+ let planOnly = false;
982
+ for (let i = 0; i < rest.length; i++) {
983
+ const arg = rest[i] ?? "";
984
+ if (arg === "--base") {
985
+ const next = rest[++i];
986
+ if (next === undefined) throw new Error("--base needs a git ref");
987
+ base = next;
988
+ continue;
989
+ }
990
+ if (arg === "--rules") {
991
+ const next = rest[++i];
992
+ if (next === undefined) throw new Error("--rules needs a path");
993
+ rulesPath = resolve(next);
994
+ continue;
995
+ }
996
+ if (arg === "--plan-only") {
997
+ planOnly = true;
998
+ continue;
999
+ }
1000
+ throw new Error(`unknown flag: ${arg}`);
1001
+ }
1002
+ return { base, rulesPath, planOnly };
1003
+ }
1004
+
1005
+ async function commandImpact(rest: string[]): Promise<void> {
1006
+ const args = parseImpactArgs(rest);
1007
+
1008
+ if (!existsSync(args.rulesPath)) {
1009
+ log.header("impact");
1010
+ log.dim(` no rules file at ${args.rulesPath}`);
1011
+ log.dim(
1012
+ " create .web-tester/impact-rules.json to enable diff-aware runs."
1013
+ );
1014
+ log.dim(
1015
+ " see README — 'impact-rules.json' section — for the schema and examples."
1016
+ );
1017
+ return;
1018
+ }
1019
+
1020
+ const rules = loadImpactRules(args.rulesPath);
1021
+ const changed = getChangedFiles(args.base, process.cwd());
1022
+ const matched = matchRules(rules, changed);
1023
+
1024
+ printPlan(matched, changed, args.base);
1025
+
1026
+ if (args.planOnly) return;
1027
+ if (matched.length === 0) return;
1028
+
1029
+ log.header("running impact suite");
1030
+ log.dim(
1031
+ " advisory — output is informational; exit code stays 0 either way"
1032
+ );
1033
+ log.info("");
1034
+
1035
+ const findings: string[] = [];
1036
+ let runIndex = 0;
1037
+ for (const m of matched) {
1038
+ runIndex++;
1039
+ log.info(
1040
+ ` [${runIndex}/${matched.length}] ${m.rule.name}`
1041
+ );
1042
+ try {
1043
+ if (m.rule.sweep) {
1044
+ const sweepArgs: string[] = [];
1045
+ if (m.rule.sweep.preset) sweepArgs.push("--preset", m.rule.sweep.preset);
1046
+ for (const u of m.rule.sweep.urls ?? []) sweepArgs.push("--url", u);
1047
+ for (const p of m.rule.sweep.packs ?? []) sweepArgs.push("--pack", p);
1048
+ sweepArgs.push("--fail-on", "http-5xx");
1049
+ const beforeExit = process.exitCode ?? 0;
1050
+ await commandSweep(sweepArgs);
1051
+ const afterExit = process.exitCode ?? 0;
1052
+ if (afterExit !== beforeExit) {
1053
+ findings.push(
1054
+ `${m.rule.name} (sweep) — one or more URLs failed; see the sweep report above`
1055
+ );
1056
+ }
1057
+ process.exitCode = beforeExit;
1058
+ } else if (m.rule.journey) {
1059
+ const beforeExit = process.exitCode ?? 0;
1060
+ await commandJourney([m.rule.journey]);
1061
+ const afterExit = process.exitCode ?? 0;
1062
+ if (afterExit !== beforeExit) {
1063
+ findings.push(
1064
+ `${m.rule.name} (journey ${m.rule.journey}) — journey reported a failed assertion or 5xx`
1065
+ );
1066
+ }
1067
+ process.exitCode = beforeExit;
1068
+ }
1069
+ } catch (err) {
1070
+ const msg = err instanceof Error ? err.message : String(err);
1071
+ findings.push(`${m.rule.name} — runner threw: ${msg}`);
1072
+ }
1073
+ log.info("");
1074
+ }
1075
+
1076
+ // Advisory summary. Always exits 0.
1077
+ log.header(
1078
+ findings.length === 0
1079
+ ? "impact: nothing flagged"
1080
+ : `impact: ${findings.length} advisory finding(s)`
1081
+ );
1082
+ if (findings.length === 0) {
1083
+ log.dim(
1084
+ " nothing in the matched rules tripped. Reminder: impact only checks"
1085
+ );
1086
+ log.dim(
1087
+ " the areas wired up in .web-tester/impact-rules.json — it's not exhaustive."
1088
+ );
1089
+ } else {
1090
+ log.dim(" these are advisory — your push will proceed regardless.");
1091
+ for (const f of findings) log.warn(` ⚠ ${f}`);
1092
+ log.info("");
1093
+ log.dim(
1094
+ " Open the HTML reports linked above to see screenshots/video/network."
1095
+ );
1096
+ }
1097
+ // Force exit 0 — advisory only.
1098
+ process.exitCode = 0;
1099
+ }
1100
+
1101
+ type InitFlags = {
1102
+ force: boolean;
1103
+ agentFile: string | null | undefined;
1104
+ yes: boolean;
1105
+ baseUrl: string | undefined;
1106
+ autoUse: AutoUse | undefined;
1107
+ skill: boolean;
1108
+ installBrowser: boolean | undefined;
1109
+ };
1110
+
1111
+ function parseInitArgs(rest: string[]): InitFlags {
1112
+ const flags: InitFlags = {
1113
+ force: false,
1114
+ agentFile: undefined,
1115
+ yes: false,
1116
+ baseUrl: undefined,
1117
+ autoUse: undefined,
1118
+ skill: true,
1119
+ installBrowser: undefined
1120
+ };
1121
+ const takeAutoUse = (v: string | undefined): AutoUse => {
1122
+ if (v !== "on" && v !== "ask" && v !== "off")
1123
+ throw new Error(`--auto-use must be on|ask|off (got "${v}")`);
1124
+ return v;
1125
+ };
1126
+ for (let i = 0; i < rest.length; i++) {
1127
+ const arg = rest[i] ?? "";
1128
+ if (arg === "--force") flags.force = true;
1129
+ else if (arg === "--yes" || arg === "-y") flags.yes = true;
1130
+ else if (arg === "--no-agent") flags.agentFile = null;
1131
+ else if (arg === "--no-skill") flags.skill = false;
1132
+ else if (arg === "--install-browser") flags.installBrowser = true;
1133
+ else if (arg === "--no-install-browser") flags.installBrowser = false;
1134
+ else if (arg === "--agent-file") {
1135
+ const next = rest[++i];
1136
+ if (next === undefined) throw new Error("--agent-file needs a path");
1137
+ flags.agentFile = next;
1138
+ } else if (arg.startsWith("--agent-file=")) {
1139
+ flags.agentFile = arg.slice("--agent-file=".length);
1140
+ } else if (arg === "--base-url") {
1141
+ const next = rest[++i];
1142
+ if (next === undefined) throw new Error("--base-url needs a value");
1143
+ flags.baseUrl = next;
1144
+ } else if (arg.startsWith("--base-url=")) {
1145
+ flags.baseUrl = arg.slice("--base-url=".length);
1146
+ } else if (arg === "--auto-use") {
1147
+ flags.autoUse = takeAutoUse(rest[++i]);
1148
+ } else if (arg.startsWith("--auto-use=")) {
1149
+ flags.autoUse = takeAutoUse(arg.slice("--auto-use=".length));
1150
+ } else throw new Error(`unknown flag: ${arg}`);
1151
+ }
1152
+ return flags;
1153
+ }
1154
+
1155
+ function installChromium(): void {
1156
+ log.info("");
1157
+ log.header("installing chromium");
1158
+ const res = spawnSync("npx", ["playwright", "install", "chromium"], {
1159
+ stdio: "inherit"
1160
+ });
1161
+ if (res.status !== 0)
1162
+ log.warn(
1163
+ " chromium install didn't complete — run `npx playwright install chromium` yourself."
1164
+ );
1165
+ }
1166
+
1167
+ function reportInit(result: InitResult): void {
1168
+ if (result.written.length) {
1169
+ log.ok(` wrote ${result.written.length} file(s):`);
1170
+ for (const f of result.written) log.info(` + ${f}`);
1171
+ }
1172
+ if (result.skipped.length) {
1173
+ log.dim(
1174
+ ` skipped ${result.skipped.length} existing file(s) (--force to overwrite):`
1175
+ );
1176
+ for (const f of result.skipped) log.dim(` · ${f}`);
1177
+ }
1178
+ if (result.agentFile)
1179
+ log.ok(
1180
+ ` ${result.agentAdded ? "added" : "updated"} web-tester section in ${result.agentFile}`
1181
+ );
1182
+ if (result.skillFile && result.written.includes(result.skillFile))
1183
+ log.ok(` generated Claude Code skill: ${result.skillFile}`);
1184
+ if (result.autoUse)
1185
+ log.ok(` set WEB_TESTER_AUTO_USE="${result.autoUse}" in .claude/settings.local.json`);
1186
+ for (const w of result.warnings) log.warn(` ⚠ ${w}`);
1187
+ }
1188
+
1189
+ async function commandInit(
1190
+ rest: string[],
1191
+ opts: { firstRun?: boolean } = {}
1192
+ ): Promise<void> {
1193
+ const flags = parseInitArgs(rest);
1194
+ const interactive = isInteractive() && !flags.yes;
1195
+
1196
+ let baseUrl = flags.baseUrl ?? readProjectConfig().baseUrl ?? DEFAULT_BASE_URL;
1197
+ let agentFile = flags.agentFile;
1198
+ let autoUse: AutoUse = flags.autoUse ?? "ask";
1199
+ let skill = flags.skill;
1200
+ let installBrowser = flags.installBrowser ?? false;
1201
+
1202
+ if (interactive) {
1203
+ log.header(
1204
+ opts.firstRun
1205
+ ? "Welcome to web-tester — let's set up this project"
1206
+ : "web-tester setup"
1207
+ );
1208
+ log.dim(" Press Enter to accept the [DEFAULT]. Ctrl-C to cancel.");
1209
+ log.info("");
1210
+ baseUrl = await ask("Dev server base URL", baseUrl);
1211
+ if (agentFile === undefined) {
1212
+ const pick = await choice(
1213
+ "Write agent instructions to",
1214
+ ["claude", "agents", "none"] as const,
1215
+ "claude"
1216
+ );
1217
+ agentFile = pick === "none" ? null : pick === "agents" ? "AGENTS.md" : "CLAUDE.md";
1218
+ }
1219
+ if (flags.autoUse === undefined) {
1220
+ autoUse = await choice(
1221
+ "When should Claude use web-tester? (on=auto, ask=propose first, off=manual)",
1222
+ ["on", "ask", "off"] as const,
1223
+ "ask"
1224
+ );
1225
+ }
1226
+ skill = await confirm(
1227
+ "Generate a Claude Code skill so Claude can run it natively?",
1228
+ skill
1229
+ );
1230
+ if (flags.installBrowser === undefined) {
1231
+ installBrowser = await confirm(
1232
+ "Install the Playwright Chromium browser now (~150 MB)?",
1233
+ false
1234
+ );
1235
+ }
1236
+ log.info("");
1237
+ }
1238
+
1239
+ log.header("init");
1240
+ let result: InitResult;
1241
+ try {
1242
+ result = runInit({ cwd: process.cwd(), force: flags.force, agentFile, baseUrl, autoUse, skill });
1243
+ } catch (err) {
1244
+ log.fail(` ${err instanceof Error ? err.message : String(err)}`);
1245
+ process.exitCode = 1;
1246
+ return;
1247
+ }
1248
+ reportInit(result);
1249
+
1250
+ if (installBrowser) installChromium();
1251
+
1252
+ log.info("");
1253
+ log.header("next steps");
1254
+ log.info(` 1. Start your dev server (serving ${baseUrl}).`);
1255
+ log.info(" 2. Map the site — auto-generates a preset, recipes, and journeys:");
1256
+ log.dim(" npx web-tester-for-claude map");
1257
+ log.info(" 3. Smoke-check a page:");
1258
+ log.dim(' npx web-tester-for-claude inspect / --quick --expect "selector=main" --fail-on http-5xx');
1259
+ if (result.skillFile)
1260
+ log.dim(" Claude Code picks up the new skill automatically in its next session.");
1261
+ }
1262
+
1263
+ type MapArgs = {
1264
+ baseUrl: string;
1265
+ limit: number;
1266
+ depth: number;
1267
+ concurrency: number;
1268
+ perTemplate: number;
1269
+ maxJourneys: number;
1270
+ useSitemap: boolean;
1271
+ sitemapUrl?: string;
1272
+ captureScreenshots: boolean;
1273
+ loadStorageState: boolean;
1274
+ force: boolean;
1275
+ filter?: RegExp;
1276
+ exclude?: RegExp;
1277
+ };
1278
+
1279
+ function parseMapArgs(rest: string[]): MapArgs {
1280
+ let baseArg = "";
1281
+ let limit = 50;
1282
+ let depth = 3;
1283
+ let concurrency = -1;
1284
+ let perTemplate = 3;
1285
+ let maxJourneys = 12;
1286
+ let useSitemap = true;
1287
+ let sitemapUrl: string | undefined;
1288
+ let captureScreenshots = true;
1289
+ let loadStorageState = true;
1290
+ let force = false;
1291
+ let filter: RegExp | undefined;
1292
+ let exclude: RegExp | undefined;
1293
+
1294
+ const intFlag = (next: string | undefined, name: string): number => {
1295
+ if (next === undefined) throw new Error(`${name} needs a number`);
1296
+ const n = Number.parseInt(next, 10);
1297
+ if (!Number.isFinite(n) || n < 1)
1298
+ throw new Error(`${name} must be a positive integer: ${next}`);
1299
+ return n;
1300
+ };
1301
+
1302
+ for (let i = 0; i < rest.length; i++) {
1303
+ const arg = rest[i] ?? "";
1304
+ if (arg === "--limit") {
1305
+ limit = intFlag(rest[++i], "--limit");
1306
+ continue;
1307
+ }
1308
+ if (arg === "--depth") {
1309
+ depth = intFlag(rest[++i], "--depth");
1310
+ continue;
1311
+ }
1312
+ if (arg === "--concurrency") {
1313
+ concurrency = Math.min(32, intFlag(rest[++i], "--concurrency"));
1314
+ continue;
1315
+ }
1316
+ if (arg === "--per-template") {
1317
+ perTemplate = intFlag(rest[++i], "--per-template");
1318
+ continue;
1319
+ }
1320
+ if (arg === "--max-journeys") {
1321
+ maxJourneys = intFlag(rest[++i], "--max-journeys");
1322
+ continue;
1323
+ }
1324
+ if (arg === "--no-sitemap") {
1325
+ useSitemap = false;
1326
+ continue;
1327
+ }
1328
+ if (arg === "--sitemap") {
1329
+ const next = rest[i + 1];
1330
+ if (next && next.startsWith("http")) {
1331
+ sitemapUrl = next;
1332
+ i++;
1333
+ }
1334
+ useSitemap = true;
1335
+ continue;
1336
+ }
1337
+ if (arg === "--no-screenshots") {
1338
+ captureScreenshots = false;
1339
+ continue;
1340
+ }
1341
+ if (arg === "--no-session") {
1342
+ loadStorageState = false;
1343
+ continue;
1344
+ }
1345
+ if (arg === "--force") {
1346
+ force = true;
1347
+ continue;
1348
+ }
1349
+ if (arg === "--filter") {
1350
+ const next = rest[++i];
1351
+ if (next === undefined) throw new Error("--filter needs a regex");
1352
+ filter = new RegExp(next);
1353
+ continue;
1354
+ }
1355
+ if (arg === "--exclude") {
1356
+ const next = rest[++i];
1357
+ if (next === undefined) throw new Error("--exclude needs a regex");
1358
+ exclude = new RegExp(next);
1359
+ continue;
1360
+ }
1361
+ if (arg.startsWith("--")) throw new Error(`unknown flag: ${arg}`);
1362
+ if (!baseArg) baseArg = arg;
1363
+ else throw new Error(`unexpected positional arg: ${arg}`);
1364
+ }
1365
+
1366
+ // A positional arg can be a full URL or a path to map a subtree of BASE_URL.
1367
+ const baseUrl = baseArg
1368
+ ? baseArg.startsWith("http")
1369
+ ? baseArg
1370
+ : new URL(baseArg, BASE_URL).toString()
1371
+ : BASE_URL;
1372
+
1373
+ return {
1374
+ baseUrl,
1375
+ limit,
1376
+ depth,
1377
+ concurrency,
1378
+ perTemplate,
1379
+ maxJourneys,
1380
+ useSitemap,
1381
+ ...(sitemapUrl ? { sitemapUrl } : {}),
1382
+ captureScreenshots,
1383
+ loadStorageState,
1384
+ force,
1385
+ ...(filter ? { filter } : {}),
1386
+ ...(exclude ? { exclude } : {})
1387
+ };
1388
+ }
1389
+
1390
+ async function commandMap(rest: string[]): Promise<void> {
1391
+ const args = parseMapArgs(rest);
1392
+ let concurrency = args.concurrency;
1393
+ if (concurrency === -1) {
1394
+ const auto = autoConcurrency(args.baseUrl, args.limit);
1395
+ concurrency = auto.value;
1396
+ log.dim(`concurrency: ${concurrency} (auto — ${auto.reason})`);
1397
+ }
1398
+ await runMap({
1399
+ baseUrl: args.baseUrl,
1400
+ limit: args.limit,
1401
+ depth: args.depth,
1402
+ concurrency,
1403
+ perTemplate: args.perTemplate,
1404
+ maxJourneys: args.maxJourneys,
1405
+ useSitemap: args.useSitemap,
1406
+ ...(args.sitemapUrl ? { sitemapUrl: args.sitemapUrl } : {}),
1407
+ captureScreenshots: args.captureScreenshots,
1408
+ loadStorageState: args.loadStorageState,
1409
+ force: args.force,
1410
+ gotoTimeoutMs: GOTO_TIMEOUT_MS,
1411
+ ...(args.filter ? { filter: args.filter } : {}),
1412
+ ...(args.exclude ? { exclude: args.exclude } : {})
1413
+ });
1414
+ }
1415
+
1416
+ function commandKb(rest: string[]): void {
1417
+ const [topic] = rest;
1418
+ if (!topic) {
1419
+ const all = listKnowledge();
1420
+ log.header("knowledge files");
1421
+ if (all.length === 0) {
1422
+ log.info(" (none — add .md files to .web-tester/instructions/ or .web-tester/)");
1423
+ return;
1424
+ }
1425
+ for (const k of all) {
1426
+ log.info(` ${k.topic.padEnd(28)} ${k.title}`);
1427
+ }
1428
+ log.dim("");
1429
+ log.dim(` web-tester kb <topic> # print full contents`);
1430
+ return;
1431
+ }
1432
+ const k = readKnowledge(topic);
1433
+ log.raw(k.contents);
1434
+ }
1435
+
1436
+ async function main(): Promise<void> {
1437
+ const [command, ...rest] = process.argv.slice(2);
1438
+ switch (command) {
1439
+ case "init":
1440
+ await commandInit(rest);
1441
+ break;
1442
+ case "map":
1443
+ await commandMap(rest);
1444
+ break;
1445
+ case "inspect":
1446
+ await commandInspect(rest);
1447
+ break;
1448
+ case "sweep":
1449
+ await commandSweep(rest);
1450
+ break;
1451
+ case "journey":
1452
+ await commandJourney(rest);
1453
+ break;
1454
+ case "impact":
1455
+ await commandImpact(rest);
1456
+ break;
1457
+ case "kb":
1458
+ commandKb(rest);
1459
+ break;
1460
+ case undefined:
1461
+ // Bare `web-tester` in a fresh project (no .web-tester/) on a terminal
1462
+ // drops straight into first-run setup; otherwise it prints help.
1463
+ if (!existsSync(userConfigDir()) && isInteractive()) {
1464
+ await commandInit([], { firstRun: true });
1465
+ } else {
1466
+ printHelp();
1467
+ }
1468
+ break;
1469
+ case "help":
1470
+ case "--help":
1471
+ case "-h":
1472
+ printHelp();
1473
+ break;
1474
+ default:
1475
+ log.fail(`unknown command: ${command}`);
1476
+ log.info("");
1477
+ log.info(
1478
+ "Known commands: init, map, inspect, sweep, journey, impact, kb, help"
1479
+ );
1480
+ log.dim(" Run `web-tester help` for the full reference.");
1481
+ process.exit(1);
1482
+ }
1483
+ }
1484
+
1485
+ main().catch((err) => {
1486
+ log.fail(err instanceof Error ? err.stack ?? err.message : String(err));
1487
+ process.exit(1);
1488
+ });