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/sweep.ts
ADDED
|
@@ -0,0 +1,476 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import {
|
|
4
|
+
chromium,
|
|
5
|
+
type Browser,
|
|
6
|
+
type BrowserContext
|
|
7
|
+
} from "playwright";
|
|
8
|
+
import { readUiAttributes } from "./browser/attrs";
|
|
9
|
+
import {
|
|
10
|
+
configureContext,
|
|
11
|
+
DEFAULT_SESSION_UA,
|
|
12
|
+
DEFAULT_SESSION_VIEWPORT
|
|
13
|
+
} from "./browser/session";
|
|
14
|
+
import { attachCapture } from "./inspector/capture";
|
|
15
|
+
import {
|
|
16
|
+
computeVerdict,
|
|
17
|
+
evaluateExpectations,
|
|
18
|
+
type Expectation,
|
|
19
|
+
type ExpectationResult,
|
|
20
|
+
type FailOnKind
|
|
21
|
+
} from "./inspector/verdict";
|
|
22
|
+
import { log } from "./util/log";
|
|
23
|
+
import { newRunId, RUNS_DIR, SESSION_STATE_PATH } from "./util/paths";
|
|
24
|
+
|
|
25
|
+
/** One URL in a sweep, plus the specific expectations to evaluate on it. */
|
|
26
|
+
export type SweepUrl = {
|
|
27
|
+
/** Path or absolute URL — resolved against `baseUrl` if relative. */
|
|
28
|
+
path: string;
|
|
29
|
+
/** Per-URL expectations (already merged with any global packs). */
|
|
30
|
+
expectations: Expectation[];
|
|
31
|
+
/** Pack names this URL inherits from (for the aggregate report). */
|
|
32
|
+
packs: string[];
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export type SweepOptions = {
|
|
36
|
+
baseUrl: string;
|
|
37
|
+
urls: SweepUrl[];
|
|
38
|
+
concurrency: number;
|
|
39
|
+
failOn: FailOnKind[];
|
|
40
|
+
gotoTimeoutMs: number;
|
|
41
|
+
/**
|
|
42
|
+
* Load `~/.web-tester/session.json` into each worker context when the
|
|
43
|
+
* file exists. Defaults to true; pass `false` (CLI `--no-session`) to
|
|
44
|
+
* force an anonymous sweep — useful when verifying a logged-out flow
|
|
45
|
+
* regression.
|
|
46
|
+
*/
|
|
47
|
+
loadStorageState?: boolean;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export type SweepEntry = {
|
|
51
|
+
url: string;
|
|
52
|
+
finalUrl: string;
|
|
53
|
+
status: number | null;
|
|
54
|
+
title: string;
|
|
55
|
+
durationMs: number;
|
|
56
|
+
ok: boolean;
|
|
57
|
+
triggers: string[];
|
|
58
|
+
expectations: ExpectationResult[];
|
|
59
|
+
pageErrors: number;
|
|
60
|
+
consoleErrors: number;
|
|
61
|
+
http4xx: number;
|
|
62
|
+
http5xx: number;
|
|
63
|
+
screenshot: string;
|
|
64
|
+
/** Relative path under the sweep dir to a per-URL minimal JSON. */
|
|
65
|
+
detailJson: string;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
export type SweepReport = {
|
|
69
|
+
sweepId: string;
|
|
70
|
+
startedAt: string;
|
|
71
|
+
durationMs: number;
|
|
72
|
+
baseUrl: string;
|
|
73
|
+
concurrency: number;
|
|
74
|
+
total: number;
|
|
75
|
+
passed: number;
|
|
76
|
+
failed: number;
|
|
77
|
+
failOn: FailOnKind[];
|
|
78
|
+
/** Distinct pack names referenced anywhere in the input URL set. */
|
|
79
|
+
packs: string[];
|
|
80
|
+
entries: SweepEntry[];
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
function safeSlug(url: string, index: number): string {
|
|
84
|
+
const cleaned = url
|
|
85
|
+
.replace(/^https?:\/\//, "")
|
|
86
|
+
.replace(/[^a-z0-9]+/gi, "-")
|
|
87
|
+
.replace(/^-+|-+$/g, "")
|
|
88
|
+
.toLowerCase()
|
|
89
|
+
.slice(0, 60);
|
|
90
|
+
return `${String(index).padStart(3, "0")}-${cleaned || "url"}`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function inspectOne(
|
|
94
|
+
context: BrowserContext,
|
|
95
|
+
baseUrl: string,
|
|
96
|
+
sweepUrl: SweepUrl,
|
|
97
|
+
sweepDir: string,
|
|
98
|
+
slug: string,
|
|
99
|
+
opts: {
|
|
100
|
+
failOn: FailOnKind[];
|
|
101
|
+
gotoTimeoutMs: number;
|
|
102
|
+
}
|
|
103
|
+
): Promise<SweepEntry> {
|
|
104
|
+
const started = Date.now();
|
|
105
|
+
const page = await context.newPage();
|
|
106
|
+
const buffers = attachCapture(context, page, {
|
|
107
|
+
allNetwork: false,
|
|
108
|
+
allConsole: false
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
const requestedUrl = sweepUrl.path.startsWith("http")
|
|
112
|
+
? sweepUrl.path
|
|
113
|
+
: new URL(sweepUrl.path, baseUrl).toString();
|
|
114
|
+
|
|
115
|
+
let status: number | null = null;
|
|
116
|
+
let title = "";
|
|
117
|
+
let finalUrl = requestedUrl;
|
|
118
|
+
let expectations: ExpectationResult[] = [];
|
|
119
|
+
|
|
120
|
+
// Navigation + expectation evaluation. Errors here are swallowed so
|
|
121
|
+
// sweep stays best-effort — partial data per URL is more useful than
|
|
122
|
+
// a thrown sweep, and the assertions we DID evaluate end up in the
|
|
123
|
+
// verdict either way.
|
|
124
|
+
try {
|
|
125
|
+
const response = await page
|
|
126
|
+
.goto(requestedUrl, {
|
|
127
|
+
waitUntil: "domcontentloaded",
|
|
128
|
+
timeout: opts.gotoTimeoutMs
|
|
129
|
+
})
|
|
130
|
+
.catch(() => null);
|
|
131
|
+
status = response?.status() ?? null;
|
|
132
|
+
finalUrl = page.url();
|
|
133
|
+
// Wait for `load` so the page renders and throws any hydration errors.
|
|
134
|
+
// Sweep is intentionally shallow — load-health, not full interactivity —
|
|
135
|
+
// so we don't run a deeper settle here.
|
|
136
|
+
await page.waitForLoadState("load", { timeout: 5_000 }).catch(() => {});
|
|
137
|
+
if (sweepUrl.expectations.length > 0) {
|
|
138
|
+
expectations = await evaluateExpectations(page, sweepUrl.expectations);
|
|
139
|
+
}
|
|
140
|
+
title = await page.title().catch(() => "");
|
|
141
|
+
} catch {
|
|
142
|
+
// best-effort sweep — keep partial data
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Cleanup + post-run probes (screenshot + attrs need the page open).
|
|
146
|
+
const screenshotRel = `${slug}.png`;
|
|
147
|
+
await page
|
|
148
|
+
.screenshot({ path: resolve(sweepDir, screenshotRel), fullPage: false })
|
|
149
|
+
.catch(() => {});
|
|
150
|
+
const attrs = await readUiAttributes(page).catch(() => []);
|
|
151
|
+
await page.close().catch(() => {});
|
|
152
|
+
|
|
153
|
+
const consoleErrors = buffers.consoleEntries.filter(
|
|
154
|
+
(e) => e.type === "error"
|
|
155
|
+
).length;
|
|
156
|
+
const http4xx = buffers.networkEntries.filter(
|
|
157
|
+
(e) => e.status !== null && e.status >= 400 && e.status < 500
|
|
158
|
+
).length;
|
|
159
|
+
const http5xx = buffers.networkEntries.filter(
|
|
160
|
+
(e) => e.status !== null && e.status >= 500
|
|
161
|
+
).length;
|
|
162
|
+
|
|
163
|
+
const verdict = computeVerdict({
|
|
164
|
+
failedSteps: 0,
|
|
165
|
+
pageErrors: buffers.pageErrors,
|
|
166
|
+
consoleEntries: buffers.consoleEntries,
|
|
167
|
+
networkEntries: buffers.networkEntries,
|
|
168
|
+
expectations,
|
|
169
|
+
failOn: opts.failOn
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
const detailRel = `${slug}.json`;
|
|
173
|
+
writeFileSync(
|
|
174
|
+
resolve(sweepDir, detailRel),
|
|
175
|
+
JSON.stringify(
|
|
176
|
+
{
|
|
177
|
+
url: sweepUrl.path,
|
|
178
|
+
packs: sweepUrl.packs,
|
|
179
|
+
requestedUrl,
|
|
180
|
+
finalUrl,
|
|
181
|
+
status,
|
|
182
|
+
title,
|
|
183
|
+
durationMs: Date.now() - started,
|
|
184
|
+
ok: verdict.ok,
|
|
185
|
+
triggers: verdict.triggers,
|
|
186
|
+
expectations,
|
|
187
|
+
console: { entries: buffers.consoleEntries },
|
|
188
|
+
network: { entries: buffers.networkEntries },
|
|
189
|
+
pageErrors: buffers.pageErrors,
|
|
190
|
+
attrs
|
|
191
|
+
},
|
|
192
|
+
null,
|
|
193
|
+
2
|
|
194
|
+
)
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
return {
|
|
198
|
+
url: sweepUrl.path,
|
|
199
|
+
finalUrl,
|
|
200
|
+
status,
|
|
201
|
+
title,
|
|
202
|
+
durationMs: Date.now() - started,
|
|
203
|
+
ok: verdict.ok,
|
|
204
|
+
triggers: verdict.triggers,
|
|
205
|
+
expectations,
|
|
206
|
+
pageErrors: buffers.pageErrors.length,
|
|
207
|
+
consoleErrors,
|
|
208
|
+
http4xx,
|
|
209
|
+
http5xx,
|
|
210
|
+
screenshot: screenshotRel,
|
|
211
|
+
detailJson: detailRel
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export async function runSweep(opts: SweepOptions): Promise<SweepReport> {
|
|
216
|
+
const sweepId = `sweep-${newRunId()}`;
|
|
217
|
+
const sweepDir = resolve(RUNS_DIR, sweepId);
|
|
218
|
+
mkdirSync(sweepDir, { recursive: true });
|
|
219
|
+
log.dim(`sweep dir: ${sweepDir}`);
|
|
220
|
+
|
|
221
|
+
const startedAt = new Date();
|
|
222
|
+
const started = Date.now();
|
|
223
|
+
|
|
224
|
+
const browser: Browser = await chromium.launch({ headless: true });
|
|
225
|
+
const entries: SweepEntry[] = [];
|
|
226
|
+
const useStorageState =
|
|
227
|
+
opts.loadStorageState !== false && existsSync(SESSION_STATE_PATH);
|
|
228
|
+
if (useStorageState)
|
|
229
|
+
log.dim(" · loaded session from ~/.web-tester/session.json");
|
|
230
|
+
|
|
231
|
+
try {
|
|
232
|
+
// Worker pool: keep one browser, hand each worker its own context. Each
|
|
233
|
+
// worker pulls from the shared queue until empty. A fresh context per URL
|
|
234
|
+
// would be cleaner state-wise but costs ~200ms; per-worker context
|
|
235
|
+
// amortises that across the queue while still isolating sweep state from
|
|
236
|
+
// any per-URL navigation residue (cookies, storage stay scoped to the
|
|
237
|
+
// worker, not bleed across the whole sweep).
|
|
238
|
+
const queue = [...opts.urls.map((u, i) => ({ sweepUrl: u, index: i }))];
|
|
239
|
+
let nextLog = 0;
|
|
240
|
+
|
|
241
|
+
const worker = async (): Promise<void> => {
|
|
242
|
+
const context = await browser.newContext({
|
|
243
|
+
viewport: DEFAULT_SESSION_VIEWPORT,
|
|
244
|
+
userAgent: DEFAULT_SESSION_UA,
|
|
245
|
+
// Each worker gets its own context but shares the on-disk session,
|
|
246
|
+
// so a sweep can include auth-gated routes without each worker
|
|
247
|
+
// logging in. No-op when the file doesn't exist.
|
|
248
|
+
...(useStorageState ? { storageState: SESSION_STATE_PATH } : {})
|
|
249
|
+
});
|
|
250
|
+
await configureContext(context, opts.baseUrl);
|
|
251
|
+
try {
|
|
252
|
+
while (queue.length > 0) {
|
|
253
|
+
const job = queue.shift();
|
|
254
|
+
if (!job) break;
|
|
255
|
+
const slug = safeSlug(job.sweepUrl.path, job.index + 1);
|
|
256
|
+
const entry = await inspectOne(
|
|
257
|
+
context,
|
|
258
|
+
opts.baseUrl,
|
|
259
|
+
job.sweepUrl,
|
|
260
|
+
sweepDir,
|
|
261
|
+
slug,
|
|
262
|
+
{
|
|
263
|
+
failOn: opts.failOn,
|
|
264
|
+
gotoTimeoutMs: opts.gotoTimeoutMs
|
|
265
|
+
}
|
|
266
|
+
);
|
|
267
|
+
entries.push(entry);
|
|
268
|
+
const idx = ++nextLog;
|
|
269
|
+
const tag = entry.ok ? "✓" : "✗";
|
|
270
|
+
const colour = entry.ok ? log.dim : log.fail;
|
|
271
|
+
const packTag = job.sweepUrl.packs.length
|
|
272
|
+
? ` [${job.sweepUrl.packs.join(",")}]`
|
|
273
|
+
: "";
|
|
274
|
+
colour(
|
|
275
|
+
` ${tag} [${idx}/${opts.urls.length}] ${entry.url}${packTag} → ${entry.status ?? "?"} (${entry.durationMs}ms)${
|
|
276
|
+
entry.triggers.length ? ` — ${entry.triggers.join("; ")}` : ""
|
|
277
|
+
}`
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
} finally {
|
|
281
|
+
await context.close().catch(() => {});
|
|
282
|
+
}
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
const workers = Array.from(
|
|
286
|
+
{ length: Math.min(opts.concurrency, opts.urls.length) },
|
|
287
|
+
() => worker()
|
|
288
|
+
);
|
|
289
|
+
await Promise.all(workers);
|
|
290
|
+
} finally {
|
|
291
|
+
await browser.close().catch(() => {});
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Sort entries back into the original URL order so the report is
|
|
295
|
+
// deterministic; workers complete in arbitrary order.
|
|
296
|
+
const order = new Map(opts.urls.map((u, i) => [u.path, i]));
|
|
297
|
+
entries.sort(
|
|
298
|
+
(a, b) => (order.get(a.url) ?? 0) - (order.get(b.url) ?? 0)
|
|
299
|
+
);
|
|
300
|
+
|
|
301
|
+
const passed = entries.filter((e) => e.ok).length;
|
|
302
|
+
const failed = entries.length - passed;
|
|
303
|
+
const distinctPacks = Array.from(
|
|
304
|
+
new Set(opts.urls.flatMap((u) => u.packs))
|
|
305
|
+
);
|
|
306
|
+
const report: SweepReport = {
|
|
307
|
+
sweepId,
|
|
308
|
+
startedAt: startedAt.toISOString(),
|
|
309
|
+
durationMs: Date.now() - started,
|
|
310
|
+
baseUrl: opts.baseUrl,
|
|
311
|
+
concurrency: opts.concurrency,
|
|
312
|
+
total: entries.length,
|
|
313
|
+
passed,
|
|
314
|
+
failed,
|
|
315
|
+
failOn: opts.failOn,
|
|
316
|
+
packs: distinctPacks,
|
|
317
|
+
entries
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
writeFileSync(resolve(sweepDir, "sweep.json"), JSON.stringify(report, null, 2));
|
|
321
|
+
writeFileSync(resolve(sweepDir, "sweep.html"), renderSweepHtml(report));
|
|
322
|
+
|
|
323
|
+
log.info("");
|
|
324
|
+
log.header(failed === 0 ? "sweep: all ok" : `sweep: ${failed}/${entries.length} failed`);
|
|
325
|
+
log.info(` duration: ${report.durationMs}ms`);
|
|
326
|
+
log.info(` concurrency: ${opts.concurrency}`);
|
|
327
|
+
log.info(` passed: ${passed}`);
|
|
328
|
+
log.info(` failed: ${failed}`);
|
|
329
|
+
|
|
330
|
+
// Detect prod throttling: if a meaningful share of URLs came back as 403
|
|
331
|
+
// we're almost certainly hitting WAF / VPN rate limits, not real bugs.
|
|
332
|
+
// Most developers don't recognise this pattern on first encounter, so
|
|
333
|
+
// print an explicit hint with the mitigation.
|
|
334
|
+
const httpForbidden = entries.filter((e) => e.status === 403).length;
|
|
335
|
+
const isLocal =
|
|
336
|
+
opts.baseUrl.includes("localhost") || opts.baseUrl.includes("127.0.0.1");
|
|
337
|
+
if (
|
|
338
|
+
!isLocal &&
|
|
339
|
+
httpForbidden >= 3 &&
|
|
340
|
+
httpForbidden / Math.max(1, entries.length) >= 0.15
|
|
341
|
+
) {
|
|
342
|
+
log.info("");
|
|
343
|
+
log.warn(
|
|
344
|
+
` ⚠ ${httpForbidden}/${entries.length} URLs returned HTTP 403 from ${opts.baseUrl}.`
|
|
345
|
+
);
|
|
346
|
+
log.warn(
|
|
347
|
+
" This is almost certainly NOT a regression in your code — the remote"
|
|
348
|
+
);
|
|
349
|
+
log.warn(
|
|
350
|
+
" target is responding 403. Common causes: WAF / VPN rate-limiting,"
|
|
351
|
+
);
|
|
352
|
+
log.warn(
|
|
353
|
+
" prod-side partial outage, or a recent deploy gating those paths."
|
|
354
|
+
);
|
|
355
|
+
log.warn(
|
|
356
|
+
" Mitigations (in order):"
|
|
357
|
+
);
|
|
358
|
+
log.warn(
|
|
359
|
+
" · curl one of the failing URLs directly to confirm it really is 403"
|
|
360
|
+
);
|
|
361
|
+
log.warn(
|
|
362
|
+
` · drop --concurrency to 1 (current: ${opts.concurrency}) and retry`
|
|
363
|
+
);
|
|
364
|
+
log.warn(
|
|
365
|
+
" · wait 5-10 minutes and retry (rate-limit windows usually clear)"
|
|
366
|
+
);
|
|
367
|
+
log.warn(
|
|
368
|
+
" · sweep localhost instead (no env var → defaults to http://localhost:3000)"
|
|
369
|
+
);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
log.ok(` HTML report: ${sweepDir}/sweep.html`);
|
|
373
|
+
log.info(` sweep.json: ${sweepDir}/sweep.json`);
|
|
374
|
+
|
|
375
|
+
return report;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function esc(s: string): string {
|
|
379
|
+
return s
|
|
380
|
+
.replace(/&/g, "&")
|
|
381
|
+
.replace(/</g, "<")
|
|
382
|
+
.replace(/>/g, ">")
|
|
383
|
+
.replace(/"/g, """)
|
|
384
|
+
.replace(/'/g, "'");
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function renderSweepHtml(report: SweepReport): string {
|
|
388
|
+
const rows = report.entries
|
|
389
|
+
.map((e, i) => {
|
|
390
|
+
const verdict = e.ok
|
|
391
|
+
? `<span class="ok">ok</span>`
|
|
392
|
+
: `<span class="fail">fail</span>`;
|
|
393
|
+
const triggers = e.triggers.length
|
|
394
|
+
? `<ul class="triggers">${e.triggers.map((t) => `<li>${esc(t)}</li>`).join("")}</ul>`
|
|
395
|
+
: "";
|
|
396
|
+
const statusClass =
|
|
397
|
+
e.status === null
|
|
398
|
+
? "stat-fail"
|
|
399
|
+
: e.status >= 500
|
|
400
|
+
? "stat-fail"
|
|
401
|
+
: e.status >= 400
|
|
402
|
+
? "stat-warn"
|
|
403
|
+
: "stat-ok";
|
|
404
|
+
return `<tr class="${e.ok ? "row-ok" : "row-fail"}">
|
|
405
|
+
<td class="num">${i + 1}</td>
|
|
406
|
+
<td class="verdict">${verdict}</td>
|
|
407
|
+
<td><a href="${esc(e.detailJson)}">${esc(e.url)}</a><div class="dim">${esc(e.title || "")}</div></td>
|
|
408
|
+
<td class="status ${statusClass}">${e.status ?? "—"}</td>
|
|
409
|
+
<td class="num">${e.durationMs}ms</td>
|
|
410
|
+
<td class="num">${e.pageErrors}</td>
|
|
411
|
+
<td class="num">${e.consoleErrors}</td>
|
|
412
|
+
<td class="num">${e.http4xx}/${e.http5xx}</td>
|
|
413
|
+
<td><a href="${esc(e.screenshot)}" target="_blank"><img src="${esc(e.screenshot)}" loading="lazy"></a></td>
|
|
414
|
+
<td>${triggers}</td>
|
|
415
|
+
</tr>`;
|
|
416
|
+
})
|
|
417
|
+
.join("");
|
|
418
|
+
|
|
419
|
+
const packsBadge = report.packs.length
|
|
420
|
+
? `<span class="badge">packs: ${report.packs.join(", ")}</span>`
|
|
421
|
+
: "";
|
|
422
|
+
const failOnBadge = report.failOn.length
|
|
423
|
+
? `<span class="badge">fail-on: ${report.failOn.join(", ")}</span>`
|
|
424
|
+
: "";
|
|
425
|
+
|
|
426
|
+
return `<!doctype html>
|
|
427
|
+
<html lang="en"><head>
|
|
428
|
+
<meta charset="utf-8">
|
|
429
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
430
|
+
<title>web-tester sweep · ${report.total} URLs</title>
|
|
431
|
+
<style>
|
|
432
|
+
:root { --bg:#fafaf9; --surface:#fff; --border:#e7e5e4; --muted:#57534e; --subtle:#a8a29e; --ok:#15803d; --warn:#a16207; --err:#b91c1c; --text:#18181b; }
|
|
433
|
+
* { box-sizing: border-box; }
|
|
434
|
+
body { font: 13px/1.5 -apple-system, BlinkMacSystemFont, "Inter", system-ui, sans-serif; margin: 0; padding: 24px; background: var(--bg); color: var(--text); }
|
|
435
|
+
h1 { font-size: 18px; margin: 0 0 4px; font-weight: 600; letter-spacing: -0.01em; }
|
|
436
|
+
.meta { color: var(--muted); font-size: 12px; margin-bottom: 12px; }
|
|
437
|
+
.totals { display: flex; gap: 16px; margin-bottom: 16px; font-size: 13px; }
|
|
438
|
+
.totals .stat { color: var(--muted); }
|
|
439
|
+
.totals .stat strong { color: var(--text); font-weight: 600; font-variant-numeric: tabular-nums; }
|
|
440
|
+
.totals .ok strong { color: var(--ok); }
|
|
441
|
+
.totals .fail strong { color: var(--err); }
|
|
442
|
+
.badges { margin: 0 0 12px; display: flex; gap: 6px; flex-wrap: wrap; }
|
|
443
|
+
.badge { font-size: 11px; padding: 2px 8px; border: 1px solid var(--border); border-radius: 99px; color: var(--muted); background: var(--surface); }
|
|
444
|
+
table { width: 100%; border-collapse: collapse; background: var(--surface); border: 1px solid var(--border); border-radius: 6px; overflow: hidden; }
|
|
445
|
+
th, td { padding: 8px 10px; text-align: left; border-bottom: 1px solid var(--border); vertical-align: top; }
|
|
446
|
+
th { font-size: 11px; text-transform: uppercase; letter-spacing: 0.04em; color: var(--muted); font-weight: 600; background: var(--bg); }
|
|
447
|
+
td.num { font-variant-numeric: tabular-nums; color: var(--muted); }
|
|
448
|
+
tr.row-fail { background: #fef2f2; }
|
|
449
|
+
td .ok { color: var(--ok); font-weight: 600; font-size: 11px; text-transform: uppercase; }
|
|
450
|
+
td .fail { color: var(--err); font-weight: 600; font-size: 11px; text-transform: uppercase; }
|
|
451
|
+
td.stat-ok { color: var(--ok); }
|
|
452
|
+
td.stat-warn { color: var(--warn); font-weight: 600; }
|
|
453
|
+
td.stat-fail { color: var(--err); font-weight: 600; }
|
|
454
|
+
td .dim { color: var(--subtle); font-size: 11px; }
|
|
455
|
+
td img { width: 120px; height: auto; border: 1px solid var(--border); border-radius: 3px; cursor: zoom-in; display: block; }
|
|
456
|
+
ul.triggers { margin: 0; padding-left: 16px; color: var(--err); font-size: 11px; }
|
|
457
|
+
a { color: var(--text); text-decoration: underline; text-decoration-color: var(--subtle); }
|
|
458
|
+
</style>
|
|
459
|
+
</head><body>
|
|
460
|
+
<h1>sweep · ${report.total} URLs</h1>
|
|
461
|
+
<div class="meta">${esc(report.sweepId)} · ${esc(report.baseUrl)} · ${esc(report.startedAt)} · ${report.durationMs}ms · concurrency ${report.concurrency}</div>
|
|
462
|
+
<div class="badges">${packsBadge}${failOnBadge}</div>
|
|
463
|
+
<div class="totals">
|
|
464
|
+
<div class="stat ok"><strong>${report.passed}</strong> passed</div>
|
|
465
|
+
<div class="stat fail"><strong>${report.failed}</strong> failed</div>
|
|
466
|
+
<div class="stat"><strong>${report.total}</strong> total</div>
|
|
467
|
+
</div>
|
|
468
|
+
<table>
|
|
469
|
+
<thead><tr>
|
|
470
|
+
<th>#</th><th>verdict</th><th>URL · title</th><th>status</th><th>ms</th>
|
|
471
|
+
<th>page err</th><th>con err</th><th>4xx/5xx</th><th>preview</th><th>triggers</th>
|
|
472
|
+
</tr></thead>
|
|
473
|
+
<tbody>${rows}</tbody>
|
|
474
|
+
</table>
|
|
475
|
+
</body></html>`;
|
|
476
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
## Inspecting the site with web-tester
|
|
2
|
+
|
|
3
|
+
### The rule
|
|
4
|
+
|
|
5
|
+
**For any runtime-behaviour question, your FIRST tool call is web-tester — not Read, Grep, or a code-search agent.** Bug reproductions, "does X work" verifications, "this URL renders weirdly" reports, "state doesn't match what I see" — these are all observable in a browser. Run it first, look at the evidence, *then* read code based on what the run shows you. Reading code before driving the live site is what burns sessions and frustrates the developer.
|
|
6
|
+
|
|
7
|
+
### Triggers — these are all "web-tester first" questions
|
|
8
|
+
|
|
9
|
+
- **Bug reproduction**: "the form submits but nothing happens", "this page renders weirdly", "the count is wrong", anything that starts with a QA report.
|
|
10
|
+
- **Change verification**: "did my refactor still let users sign up?", "does the navbar still work after I touched the layout?", "verify <component> still works on <route>".
|
|
11
|
+
- **Cross-page regression** (after touching shared code — layout, design-system primitives, data-fetching hooks, providers): use `web-tester sweep --preset smoke` (or whichever preset covers the high-value pages) against **localhost**. Each URL in the preset can be annotated with an expectation pack so a regression in one page family fails specifically.
|
|
12
|
+
- **"What might my diff have broken?"** (the *fix one thing, break another* class): use `web-tester impact`. It reads `git diff` vs origin/main, matches changed paths against rules in `.web-tester/impact-rules.json`, and runs the matched sweeps + journeys against localhost. **Output is advisory only — it never blocks anything.**
|
|
13
|
+
- **Behaviour discovery**: "what does the page actually do when I click X?", "what's in the network when the page settles?".
|
|
14
|
+
- **Live diagnostics for an open issue**: console errors, hydration warnings, 4xx responses, missing data on a page.
|
|
15
|
+
|
|
16
|
+
Don't reach for web-tester for pure code-reading ("what does this function do?"), unscoped implementation work ("add a feature"), test-file writing, or anything with no specific URL/flow in mind.
|
|
17
|
+
|
|
18
|
+
### Ask before assuming the page or feature
|
|
19
|
+
|
|
20
|
+
If the developer hasn't named a specific URL, page, or feature, **ask before running**. Many domain terms ("the form", "the buttons", "the page") overlap multiple page types. One short clarifying question is cheaper than running the wrong recipe.
|
|
21
|
+
|
|
22
|
+
The shape of the question: *"Which page is this happening on?"* or *"Can you share a URL that reproduces it?"* Then route based on the URL, not your guess.
|
|
23
|
+
|
|
24
|
+
### Auto-use opt-in (per developer)
|
|
25
|
+
|
|
26
|
+
Auto-use is gated by `WEB_TESTER_AUTO_USE` in `.claude/settings.local.json` (the `env` block). Values:
|
|
27
|
+
|
|
28
|
+
- `"on"` — proceed silently; run web-tester when the intent matches the triggers above.
|
|
29
|
+
- `"ask"` — propose the run in **one sentence** as your **first** response (e.g. *"I'd verify with `web-tester inspect /pricing --quick --expect 'text=$49/mo' --fail-on http-5xx`; confirm and I'll run."*). **Do not Read source files, do not Grep, do not spawn an explorer agent — propose first.** When the developer confirms, your very next tool call must be `web-tester inspect …` or `… sweep …`. Reading code happens *after* the run, targeted by what the run shows.
|
|
30
|
+
- `"off"` — never auto-run. Only run when the developer explicitly says so.
|
|
31
|
+
|
|
32
|
+
**One-time introduction on the first session of a branch.** The first time you respond in a session where `env.WEB_TESTER_AUTO_USE` is not yet set, briefly introduce web-tester and capture the developer's preference **before** answering whatever else they asked. Then write their choice to that file (merge into the existing JSON; never overwrite other keys). From that turn on, honour the flag. If the developer ignores the intro and proceeds with their task, assume `"ask"` for the rest of the session and move on.
|
|
33
|
+
|
|
34
|
+
### How to use it (the recipe-first flow)
|
|
35
|
+
|
|
36
|
+
1. **Pick a recipe.** `web-tester kb` (and `web-tester kb <topic>`) lists the recipe notes the project has in `.web-tester/instructions/`. If none match cleanly, skim them once to learn the step grammar, then write your own.
|
|
37
|
+
2. **Pick the base — this matters.** Default to **localhost** (your dev server). Prod / preview deployments are ONLY for "does this bug exist on the live site, before I touch any code". **Verifying your own local change against prod is meaningless — prod doesn't have your edit.**
|
|
38
|
+
3. **Run it.** Always with `--quick` (skips video, full-page screenshots, AI summary) and `--fail-on http-5xx`. Add `--expect "<assertion>"` for the specific thing you're verifying.
|
|
39
|
+
4. **Read `result.json`** at the path the CLI prints. Look at `ok`, `verdictTriggers`, `expectations[]`, `pageErrors`, `console.entries`, `network.entries`, `steps[N].evalResult`. **Only now** open code files to interpret findings.
|
|
40
|
+
5. **Append a recipe if you went off-map. Required, not optional.** Before you summarise: did your run hit a URL, page type, or step chain not already covered by an entry in `web-tester kb`? If yes, **append a new entry to `.web-tester/instructions/recipes.md` now** (or create it). The simple ones are the most valuable to capture; the next session will think it's simple too and waste five minutes proving it.
|
|
41
|
+
6. **Summarise to the developer in three blocks**: a verdict line ("Reproduced — X" / "Verified — Y"), key evidence (2–4 specific values from `result.json`), and a markdown link to `report.html` so they can scrub the video.
|
|
42
|
+
|
|
43
|
+
### When the DOM doesn't tell you enough — add logs
|
|
44
|
+
|
|
45
|
+
If you've run web-tester and the DOM looks like X but your code says it should look like Y, **don't go grep the source.** Add a temporary `console.log` (or expose the store on `window`) in the relevant component, run web-tester again, and read it back from `result.json.console.entries`. Always prefix `// DEBUG-REMOVE:` and revert before the session ends.
|
|
46
|
+
|
|
47
|
+
Every run already captures:
|
|
48
|
+
|
|
49
|
+
- `result.json.console.entries` — every `console.log` / `warn` / `error` on the page.
|
|
50
|
+
- `result.json.network.entries` (and `steps[N].network`) — every XHR / fetch / document request: method, URL, status, duration. Filter with `jq '.network.entries[] | select(.url | contains("<pattern>"))'`.
|
|
51
|
+
- `result.json.pageErrors` — uncaught JS errors.
|
|
52
|
+
|
|
53
|
+
For a payload bug or an exception you can't pin down, re-run with `--deep`: it adds request/response bodies, the **local-scope variables at every uncaught exception**, and unhandled promise rejections — in `result.json` as `deepErrors`, `unhandledRejections`, and `network.entries[].responseBody`. That often replaces the temporary-`console.log` loop entirely.
|
|
54
|
+
|
|
55
|
+
The pattern is: **DOM evidence → state evidence (via logs / `--deep`) → only then read code**.
|
|
56
|
+
|
|
57
|
+
### Anti-patterns — don't do these
|
|
58
|
+
|
|
59
|
+
- **Don't run web-tester against prod to verify a local change.** Prod doesn't have your code. The only valid prod uses are: (a) confirming a bug exists on the live site BEFORE you start editing, (b) read-only baseline checks.
|
|
60
|
+
- **Don't trust a single `--expect` for state that depends on derived / async logic.** A banner that flashes for 1s then disappears passes a one-shot check and hides a real bug. Add `--persist 2500` (or higher) — both checks must pass.
|
|
61
|
+
- **Don't grep the codebase before running web-tester.** "Let me understand the code first" is the trap. The browser is the source of truth for runtime bugs; code-reading after the run is targeted by the evidence.
|
|
62
|
+
- **Don't blame code when failures span unrelated pages.** If a sweep returns 5xx on routes that don't share the component you changed, the cause is almost certainly environmental, not a code regression. Read `result.json.pageErrors[0].message` — `Cannot find module …` / `ENOENT …` usually means a corrupt dev-server build cache, not your diff.
|
|
63
|
+
- **Don't roll your own probe scripts or spin up a second dev server.** web-tester already captures `network.entries`, `console.entries`, `pageErrors`, and supports the temporary-log pattern above. If you want to write a separate script to capture data, you're off-piste — the tool already covers it.
|
|
64
|
+
- **Don't write `--step` chains from scratch when a recipe exists.** Use `web-tester kb`. The grammar has gotchas — `click:` is a Playwright CSS locator, not `role=`; on apps that don't use the `data-attr-*` convention, prefer `wait:networkidle` over `settle`.
|
|
65
|
+
- **Don't `--fail-on page-errors` by default.** Most sites have baseline framework warnings. Use `http-5xx` as the safe default.
|
|
66
|
+
- **Don't leave temporary instrumentation or `DEBUG-REMOVE` edits in.** Edit → run → revert in the same turn. Never commit them.
|
|
67
|
+
|
|
68
|
+
### Authentication — test credentials only
|
|
69
|
+
|
|
70
|
+
For login-gated flows, drive the login once with `--save-session` (it saves cookies + localStorage to `~/.web-tester/session.json`); later runs reuse it. **Only ever use disposable TEST credentials** — never production, personal, or privileged accounts. Credentials you put in a `--step` are stored in plain text in `.web-tester/journeys/*.json` and are committed to the repo. If a flow needs credentials you don't have, **ask the developer for a test account** — never invent them, reuse real ones you've seen in chat, or pull secrets from the codebase/env.
|
|
71
|
+
|
|
72
|
+
### Operating notes
|
|
73
|
+
|
|
74
|
+
- `web-tester kb` lists every knowledge file in `.web-tester/instructions/`.
|
|
75
|
+
- `web-tester map` crawls the site and generates a route map, a smoke preset, and starter recipes — run it once to bootstrap coverage.
|
|
76
|
+
- When a run uncovers a non-obvious domain quirk, append it to the matching `.md` so the next session benefits.
|
|
77
|
+
- **Self-verify your own medium-to-large changes with web-tester before reporting "done".** When you finish a change with observable runtime impact (route handlers, shared components, layout, providers, or any > ~30 changed lines spanning > 1 file), don't just say "done". Branch on `env.WEB_TESTER_AUTO_USE`: `"on"` → run `web-tester impact` (or a specific recipe) automatically, then summarise; `"ask"` → propose the run in one sentence, wait, then run; `"off"` → skip. Skip the self-verify regardless of flag for trivial edits (typo / comment / rename), doc-only changes, test-file-only changes, and config tweaks with no behaviour change. Verify ONCE at the end of a cohesive change set, not after each edit.
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$comment": "Maps changed file paths to web-tester sweeps/journeys to check. `web-tester impact` runs git diff against --base (default origin/main) and executes whatever rules match. Advisory only — never blocks a push. These are EXAMPLES — edit the globs and targets to match your project, then add rules as you find sensitive areas. Run `web-tester map` to discover routes worth covering.",
|
|
3
|
+
"rules": [
|
|
4
|
+
{
|
|
5
|
+
"name": "Shared layout changed — sweep top pages",
|
|
6
|
+
"when_changed_any": [
|
|
7
|
+
"src/components/Layout/**",
|
|
8
|
+
"src/components/Header/**",
|
|
9
|
+
"src/components/Footer/**",
|
|
10
|
+
"**/layout.tsx"
|
|
11
|
+
],
|
|
12
|
+
"sweep": {
|
|
13
|
+
"preset": "smoke"
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"name": "Auth code changed — sign-up journey",
|
|
18
|
+
"when_changed_any": [
|
|
19
|
+
"src/auth/**",
|
|
20
|
+
"**/api/auth/**"
|
|
21
|
+
],
|
|
22
|
+
"journey": "example-signup"
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
"name": "Routing / data-fetching plumbing — sweep key pages",
|
|
26
|
+
"when_changed_any": [
|
|
27
|
+
"src/lib/**",
|
|
28
|
+
"src/middleware.ts"
|
|
29
|
+
],
|
|
30
|
+
"sweep": {
|
|
31
|
+
"urls": ["/", "/pricing"],
|
|
32
|
+
"packs": ["homepage", "static"]
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
]
|
|
36
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# Getting started with web-tester in this project
|
|
2
|
+
|
|
3
|
+
This file is part of the `.web-tester/instructions/` knowledge base. Anything
|
|
4
|
+
in here is browseable with:
|
|
5
|
+
|
|
6
|
+
```bash
|
|
7
|
+
web-tester kb # list topics
|
|
8
|
+
web-tester kb <topic> # print one
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Your AI agent reads these files in fresh sessions instead of grepping your
|
|
12
|
+
source to re-derive project knowledge. Keep them short and concrete.
|
|
13
|
+
|
|
14
|
+
## The unit of work — recipes
|
|
15
|
+
|
|
16
|
+
A "recipe" is a tested copy-paste `web-tester inspect …` one-liner for a
|
|
17
|
+
specific page type or flow. See [`recipes.md`](recipes.md) for the format.
|
|
18
|
+
Append new recipes whenever you run against an uncovered area.
|
|
19
|
+
|
|
20
|
+
## What goes in here
|
|
21
|
+
|
|
22
|
+
- `recipes.md` — copy-paste one-liners (the cookbook).
|
|
23
|
+
- `architecture.md` — short notes on app structure that matter at runtime
|
|
24
|
+
(e.g. "the app store is exposed on `window.__store` in dev").
|
|
25
|
+
- `<feature>.md` — domain quirks worth remembering (e.g. "the pricing table
|
|
26
|
+
takes ~3s to settle on cold loads — use `--persist 3000` for any pricing
|
|
27
|
+
assertion").
|
|
28
|
+
- `auth.md` — how to drive sign-in, where the session lives, what test
|
|
29
|
+
credentials to use.
|
|
30
|
+
|
|
31
|
+
Avoid:
|
|
32
|
+
|
|
33
|
+
- General code documentation — that belongs in source comments / READMEs.
|
|
34
|
+
- Anything that rots fast (specific commit refs, "the bug from last week").
|
|
35
|
+
- Anything secret (real credentials, API keys).
|
|
36
|
+
|
|
37
|
+
## Configuring the runner
|
|
38
|
+
|
|
39
|
+
Defaults come from `.env` or shell vars:
|
|
40
|
+
|
|
41
|
+
| Var | Default | Purpose |
|
|
42
|
+
|---|---|---|
|
|
43
|
+
| `WEB_TESTER_BASE_URL` | `http://localhost:3000` | Bare paths resolve against this. |
|
|
44
|
+
| `GOTO_TIMEOUT_MS` | `30000` | Initial navigation timeout. |
|
|
45
|
+
| `STEP_TIMEOUT_MS` | `15000` | Per-step action timeout. |
|
|
46
|
+
| `SETTLE_TIMEOUT_MS` | `30000` | `settle` step ceiling. |
|
|
47
|
+
|
|
48
|
+
Override per-run via env:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
WEB_TESTER_BASE_URL=https://staging.example.com \
|
|
52
|
+
web-tester inspect /pricing --quick
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Sibling files in `.web-tester/`
|
|
56
|
+
|
|
57
|
+
- `impact-rules.json` — diff-aware rules for `web-tester impact`.
|
|
58
|
+
- `urls-<name>.txt` — URL presets for `web-tester sweep --preset <name>`.
|
|
59
|
+
- `journeys/<name>.json` — saved flows for `web-tester journey <name>`.
|
|
60
|
+
|
|
61
|
+
See the package README for the full schema of each. Run `web-tester map` to
|
|
62
|
+
auto-discover routes and generate a starter preset + recipes.
|