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/LICENSE +21 -0
- package/README.md +651 -0
- package/bin/web-tester.js +35 -0
- package/package.json +64 -0
- package/src/browser/attrs.ts +79 -0
- package/src/browser/session.ts +139 -0
- package/src/cli.ts +1488 -0
- package/src/impact.ts +165 -0
- package/src/init.ts +260 -0
- package/src/inspector/capture.ts +293 -0
- package/src/inspector/deep.ts +147 -0
- package/src/inspector/packs.ts +98 -0
- package/src/inspector/report.ts +667 -0
- package/src/inspector/run.ts +544 -0
- package/src/inspector/steps.ts +380 -0
- package/src/inspector/summarise.ts +178 -0
- package/src/inspector/verdict.ts +275 -0
- package/src/journeys.ts +78 -0
- package/src/kb.ts +84 -0
- package/src/map/classify.ts +149 -0
- package/src/map/crawl.ts +394 -0
- package/src/map/generate.ts +253 -0
- package/src/map/report.ts +112 -0
- package/src/map/run.ts +219 -0
- package/src/sitemap.ts +75 -0
- package/src/sweep.ts +476 -0
- package/src/templates/agent-section.md +77 -0
- package/src/templates/dot-web-tester/impact-rules.json +36 -0
- package/src/templates/dot-web-tester/instructions/getting-started.md +62 -0
- package/src/templates/dot-web-tester/instructions/recipes.md +105 -0
- package/src/templates/dot-web-tester/journeys/example-signup.json +17 -0
- package/src/templates/dot-web-tester/urls-smoke.txt +19 -0
- package/src/templates/skill.md +59 -0
- package/src/util/log.ts +26 -0
- package/src/util/paths.ts +141 -0
- package/src/util/prompt.ts +50 -0
- package/tsconfig.json +14 -0
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
|
+
});
|