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
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
import type { Page } from "playwright";
|
|
2
|
+
import { waitForAttrsReady } from "../browser/attrs";
|
|
3
|
+
|
|
4
|
+
export type WaitTarget =
|
|
5
|
+
| { kind: "loadState"; state: "load" | "domcontentloaded" | "networkidle" }
|
|
6
|
+
| { kind: "ms"; ms: number }
|
|
7
|
+
| { kind: "selector"; selector: string }
|
|
8
|
+
| { kind: "text"; text: string }
|
|
9
|
+
| { kind: "urlStable"; quietMs: number }
|
|
10
|
+
| { kind: "urlContains"; substring: string; timeoutMs: number };
|
|
11
|
+
|
|
12
|
+
export type Step =
|
|
13
|
+
| { kind: "goto"; url: string }
|
|
14
|
+
| { kind: "click"; selector: string }
|
|
15
|
+
| { kind: "fill"; selector: string; value: string }
|
|
16
|
+
| { kind: "reactFill"; selector: string; value: string }
|
|
17
|
+
| { kind: "press"; selector: string; key: string }
|
|
18
|
+
| { kind: "select"; selector: string; value: string }
|
|
19
|
+
| { kind: "hover"; selector: string }
|
|
20
|
+
| { kind: "scroll"; target: "top" | "bottom" | "px"; px?: number }
|
|
21
|
+
| { kind: "wait"; target: WaitTarget }
|
|
22
|
+
| { kind: "settle"; timeoutMs?: number }
|
|
23
|
+
| { kind: "screenshot"; name?: string; fullPage?: boolean }
|
|
24
|
+
| { kind: "eval"; script: string }
|
|
25
|
+
| { kind: "reload" };
|
|
26
|
+
|
|
27
|
+
const LOAD_STATES = new Set(["load", "domcontentloaded", "networkidle"]);
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Split a `<selector>=<value>` step argument on the first `=` that sits
|
|
31
|
+
* outside any `[...]`, `(...)`, or quotes. Attribute selectors like
|
|
32
|
+
* `input[name=email]` and `:has-text("a=b")` keep their inner `=`; only the
|
|
33
|
+
* real separator splits the value off. Returns null when there is no
|
|
34
|
+
* top-level `=` (i.e. no value was supplied).
|
|
35
|
+
*/
|
|
36
|
+
function splitSelectorValue(
|
|
37
|
+
arg: string
|
|
38
|
+
): { selector: string; value: string } | null {
|
|
39
|
+
let depth = 0;
|
|
40
|
+
let quote: string | null = null;
|
|
41
|
+
for (let i = 0; i < arg.length; i++) {
|
|
42
|
+
const c = arg[i];
|
|
43
|
+
if (quote) {
|
|
44
|
+
// A closing quote only counts if preceded by an even number of
|
|
45
|
+
// backslashes (so `\"` stays escaped but `\\"` closes the string).
|
|
46
|
+
if (c === quote) {
|
|
47
|
+
let backslashes = 0;
|
|
48
|
+
for (let j = i - 1; j >= 0 && arg[j] === "\\"; j--) backslashes++;
|
|
49
|
+
if (backslashes % 2 === 0) quote = null;
|
|
50
|
+
}
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
if (c === '"' || c === "'") quote = c;
|
|
54
|
+
else if (c === "[" || c === "(") depth++;
|
|
55
|
+
else if (c === "]" || c === ")") depth = Math.max(0, depth - 1);
|
|
56
|
+
else if (c === "=" && depth === 0)
|
|
57
|
+
return { selector: arg.slice(0, i), value: arg.slice(i + 1) };
|
|
58
|
+
}
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Parse a `--step <type>:<arg>` shorthand into a typed step.
|
|
64
|
+
*
|
|
65
|
+
* Examples:
|
|
66
|
+
* "settle" → settle
|
|
67
|
+
* "goto:/checkout" → goto
|
|
68
|
+
* "wait:networkidle" → wait loadState
|
|
69
|
+
* "wait:2000" → wait ms
|
|
70
|
+
* "wait:#cta" → wait selector
|
|
71
|
+
* "wait:text=Submit" → wait text
|
|
72
|
+
* "click:button:has-text(\"Submit\")" → click (note: selector may contain `:`)
|
|
73
|
+
* "fill:input[name=email]=user@example.com" → fill (first `=` after selector splits value)
|
|
74
|
+
* "screenshot:after-submit" → screenshot
|
|
75
|
+
* "screenshot" → screenshot anonymous
|
|
76
|
+
*/
|
|
77
|
+
export function parseStep(raw: string): Step {
|
|
78
|
+
const trimmed = raw.trim();
|
|
79
|
+
if (!trimmed) throw new Error("empty --step");
|
|
80
|
+
|
|
81
|
+
const colonAt = trimmed.indexOf(":");
|
|
82
|
+
const type = colonAt === -1 ? trimmed : trimmed.slice(0, colonAt);
|
|
83
|
+
const arg = colonAt === -1 ? "" : trimmed.slice(colonAt + 1);
|
|
84
|
+
|
|
85
|
+
switch (type) {
|
|
86
|
+
case "settle": {
|
|
87
|
+
if (!arg) return { kind: "settle" };
|
|
88
|
+
const ms = Number(arg);
|
|
89
|
+
if (!Number.isFinite(ms) || ms <= 0)
|
|
90
|
+
throw new Error("`settle:<ms>` needs a positive integer");
|
|
91
|
+
return { kind: "settle", timeoutMs: ms };
|
|
92
|
+
}
|
|
93
|
+
case "reload":
|
|
94
|
+
return { kind: "reload" };
|
|
95
|
+
case "goto":
|
|
96
|
+
if (!arg) throw new Error("`goto` needs a URL");
|
|
97
|
+
return { kind: "goto", url: arg };
|
|
98
|
+
case "click":
|
|
99
|
+
if (!arg) throw new Error("`click` needs a selector");
|
|
100
|
+
return { kind: "click", selector: arg };
|
|
101
|
+
case "hover":
|
|
102
|
+
if (!arg) throw new Error("`hover` needs a selector");
|
|
103
|
+
return { kind: "hover", selector: arg };
|
|
104
|
+
case "fill": {
|
|
105
|
+
const parts = splitSelectorValue(arg);
|
|
106
|
+
if (!parts) throw new Error("`fill` needs `<selector>=<value>`");
|
|
107
|
+
return { kind: "fill", selector: parts.selector, value: parts.value };
|
|
108
|
+
}
|
|
109
|
+
case "react-fill": {
|
|
110
|
+
const parts = splitSelectorValue(arg);
|
|
111
|
+
if (!parts) throw new Error("`react-fill` needs `<selector>=<value>`");
|
|
112
|
+
return { kind: "reactFill", selector: parts.selector, value: parts.value };
|
|
113
|
+
}
|
|
114
|
+
case "press": {
|
|
115
|
+
const parts = splitSelectorValue(arg);
|
|
116
|
+
if (!parts) throw new Error("`press` needs `<selector>=<key>`");
|
|
117
|
+
return { kind: "press", selector: parts.selector, key: parts.value };
|
|
118
|
+
}
|
|
119
|
+
case "select": {
|
|
120
|
+
const parts = splitSelectorValue(arg);
|
|
121
|
+
if (!parts) throw new Error("`select` needs `<selector>=<value>`");
|
|
122
|
+
return { kind: "select", selector: parts.selector, value: parts.value };
|
|
123
|
+
}
|
|
124
|
+
case "scroll":
|
|
125
|
+
if (arg === "top") return { kind: "scroll", target: "top" };
|
|
126
|
+
if (arg === "bottom") return { kind: "scroll", target: "bottom" };
|
|
127
|
+
if (/^\d+$/.test(arg))
|
|
128
|
+
return { kind: "scroll", target: "px", px: Number(arg) };
|
|
129
|
+
throw new Error(`unknown scroll target: ${arg}`);
|
|
130
|
+
case "wait": {
|
|
131
|
+
if (!arg) throw new Error("`wait` needs a target");
|
|
132
|
+
if (LOAD_STATES.has(arg))
|
|
133
|
+
return {
|
|
134
|
+
kind: "wait",
|
|
135
|
+
target: {
|
|
136
|
+
kind: "loadState",
|
|
137
|
+
state: arg as "load" | "domcontentloaded" | "networkidle"
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
if (/^\d+$/.test(arg))
|
|
141
|
+
return { kind: "wait", target: { kind: "ms", ms: Number(arg) } };
|
|
142
|
+
if (arg.startsWith("text="))
|
|
143
|
+
return {
|
|
144
|
+
kind: "wait",
|
|
145
|
+
target: { kind: "text", text: arg.slice("text=".length) }
|
|
146
|
+
};
|
|
147
|
+
if (arg === "url-stable")
|
|
148
|
+
return { kind: "wait", target: { kind: "urlStable", quietMs: 250 } };
|
|
149
|
+
if (arg.startsWith("url-stable=")) {
|
|
150
|
+
const ms = Number(arg.slice("url-stable=".length));
|
|
151
|
+
if (!Number.isFinite(ms) || ms <= 0)
|
|
152
|
+
throw new Error("`wait:url-stable=<ms>` needs a positive integer");
|
|
153
|
+
return { kind: "wait", target: { kind: "urlStable", quietMs: ms } };
|
|
154
|
+
}
|
|
155
|
+
if (arg.startsWith("url-contains:")) {
|
|
156
|
+
const rest = arg.slice("url-contains:".length);
|
|
157
|
+
// Optional trailing `@<timeoutMs>` overrides the 10s default. The
|
|
158
|
+
// separator is `@` (not `=`) so the substring can itself contain `=`
|
|
159
|
+
// — e.g. `wait:url-contains:tab=details@30000`.
|
|
160
|
+
const at = rest.lastIndexOf("@");
|
|
161
|
+
if (at === -1 || !/^\d+$/.test(rest.slice(at + 1)))
|
|
162
|
+
return {
|
|
163
|
+
kind: "wait",
|
|
164
|
+
target: { kind: "urlContains", substring: rest, timeoutMs: 10_000 }
|
|
165
|
+
};
|
|
166
|
+
const timeoutMs = Number(rest.slice(at + 1));
|
|
167
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0)
|
|
168
|
+
throw new Error(
|
|
169
|
+
"`wait:url-contains:<sub>@<ms>` timeout must be a positive integer"
|
|
170
|
+
);
|
|
171
|
+
return {
|
|
172
|
+
kind: "wait",
|
|
173
|
+
target: {
|
|
174
|
+
kind: "urlContains",
|
|
175
|
+
substring: rest.slice(0, at),
|
|
176
|
+
timeoutMs
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
return { kind: "wait", target: { kind: "selector", selector: arg } };
|
|
181
|
+
}
|
|
182
|
+
case "screenshot":
|
|
183
|
+
return { kind: "screenshot", name: arg || undefined, fullPage: false };
|
|
184
|
+
case "screenshot-full":
|
|
185
|
+
return { kind: "screenshot", name: arg || undefined, fullPage: true };
|
|
186
|
+
case "eval":
|
|
187
|
+
if (!arg) throw new Error("`eval` needs a JS expression");
|
|
188
|
+
return { kind: "eval", script: arg };
|
|
189
|
+
default:
|
|
190
|
+
throw new Error(
|
|
191
|
+
`unknown step type "${type}". See \`pnpm web-tester help\`.`
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const DEFAULT_TIMEOUT_MS = Number(process.env.STEP_TIMEOUT_MS ?? 15_000);
|
|
197
|
+
const DEFAULT_SETTLE_MS = Number(process.env.SETTLE_TIMEOUT_MS ?? 30_000);
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Execute a single step against the page. Returns a short label describing
|
|
201
|
+
* what happened and, optionally, an `evalResult` for `eval` steps.
|
|
202
|
+
*/
|
|
203
|
+
export async function executeStep(
|
|
204
|
+
step: Step,
|
|
205
|
+
page: Page
|
|
206
|
+
): Promise<{ label: string; evalResult?: unknown }> {
|
|
207
|
+
switch (step.kind) {
|
|
208
|
+
case "goto": {
|
|
209
|
+
const baseUrl = new URL(page.url()).origin;
|
|
210
|
+
const target = step.url.startsWith("http")
|
|
211
|
+
? step.url
|
|
212
|
+
: new URL(step.url, baseUrl).toString();
|
|
213
|
+
const response = await page.goto(target, {
|
|
214
|
+
waitUntil: "domcontentloaded",
|
|
215
|
+
timeout: DEFAULT_TIMEOUT_MS
|
|
216
|
+
});
|
|
217
|
+
return { label: `goto ${target} (${response?.status() ?? "?"})` };
|
|
218
|
+
}
|
|
219
|
+
case "reload":
|
|
220
|
+
await page.reload({ waitUntil: "domcontentloaded" });
|
|
221
|
+
return { label: "reload" };
|
|
222
|
+
case "click":
|
|
223
|
+
await page.locator(step.selector).first().click({ timeout: DEFAULT_TIMEOUT_MS });
|
|
224
|
+
return { label: `click ${step.selector}` };
|
|
225
|
+
case "hover":
|
|
226
|
+
await page.locator(step.selector).first().hover({ timeout: DEFAULT_TIMEOUT_MS });
|
|
227
|
+
return { label: `hover ${step.selector}` };
|
|
228
|
+
case "fill":
|
|
229
|
+
await page
|
|
230
|
+
.locator(step.selector)
|
|
231
|
+
.first()
|
|
232
|
+
.fill(step.value, { timeout: DEFAULT_TIMEOUT_MS });
|
|
233
|
+
return { label: `fill ${step.selector} = ${step.value}` };
|
|
234
|
+
case "reactFill": {
|
|
235
|
+
// React controlled inputs reset to their state value when you mutate
|
|
236
|
+
// the DOM value directly, so Playwright's `fill` doesn't stick. Call
|
|
237
|
+
// the native value setter on the prototype, then dispatch input/change
|
|
238
|
+
// so React's synthetic event system picks up the change.
|
|
239
|
+
const result = await page.evaluate(
|
|
240
|
+
({ selector, value }) => {
|
|
241
|
+
const el = document.querySelector(
|
|
242
|
+
selector
|
|
243
|
+
) as HTMLInputElement | HTMLTextAreaElement | null;
|
|
244
|
+
if (!el) return { ok: false, reason: `selector not found: ${selector}` };
|
|
245
|
+
const proto =
|
|
246
|
+
el.tagName === "TEXTAREA"
|
|
247
|
+
? window.HTMLTextAreaElement.prototype
|
|
248
|
+
: window.HTMLInputElement.prototype;
|
|
249
|
+
const desc = Object.getOwnPropertyDescriptor(proto, "value");
|
|
250
|
+
if (!desc?.set)
|
|
251
|
+
return { ok: false, reason: "no value setter on prototype" };
|
|
252
|
+
desc.set.call(el, value);
|
|
253
|
+
el.dispatchEvent(new Event("input", { bubbles: true }));
|
|
254
|
+
el.dispatchEvent(new Event("change", { bubbles: true }));
|
|
255
|
+
el.blur();
|
|
256
|
+
return { ok: true, finalDomValue: el.value };
|
|
257
|
+
},
|
|
258
|
+
{ selector: step.selector, value: step.value }
|
|
259
|
+
);
|
|
260
|
+
if (!result.ok)
|
|
261
|
+
throw new Error(`react-fill failed: ${result.reason ?? "unknown"}`);
|
|
262
|
+
return {
|
|
263
|
+
label: `react-fill ${step.selector} = ${step.value}`,
|
|
264
|
+
evalResult: result
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
case "press":
|
|
268
|
+
await page
|
|
269
|
+
.locator(step.selector)
|
|
270
|
+
.first()
|
|
271
|
+
.press(step.key, { timeout: DEFAULT_TIMEOUT_MS });
|
|
272
|
+
return { label: `press ${step.key} on ${step.selector}` };
|
|
273
|
+
case "select":
|
|
274
|
+
await page
|
|
275
|
+
.locator(step.selector)
|
|
276
|
+
.first()
|
|
277
|
+
.selectOption(step.value, { timeout: DEFAULT_TIMEOUT_MS });
|
|
278
|
+
return { label: `select ${step.value} in ${step.selector}` };
|
|
279
|
+
case "scroll":
|
|
280
|
+
if (step.target === "top") {
|
|
281
|
+
await page.evaluate(() => window.scrollTo(0, 0));
|
|
282
|
+
return { label: "scroll top" };
|
|
283
|
+
}
|
|
284
|
+
if (step.target === "bottom") {
|
|
285
|
+
await page.evaluate(() =>
|
|
286
|
+
window.scrollTo(0, document.body.scrollHeight)
|
|
287
|
+
);
|
|
288
|
+
return { label: "scroll bottom" };
|
|
289
|
+
}
|
|
290
|
+
await page.evaluate((px) => window.scrollTo(0, px), step.px ?? 0);
|
|
291
|
+
return { label: `scroll ${step.px}px` };
|
|
292
|
+
case "wait": {
|
|
293
|
+
const t = step.target;
|
|
294
|
+
if (t.kind === "loadState") {
|
|
295
|
+
await page.waitForLoadState(t.state, { timeout: DEFAULT_TIMEOUT_MS });
|
|
296
|
+
return { label: `wait load=${t.state}` };
|
|
297
|
+
}
|
|
298
|
+
if (t.kind === "ms") {
|
|
299
|
+
await page.waitForTimeout(t.ms);
|
|
300
|
+
return { label: `wait ${t.ms}ms` };
|
|
301
|
+
}
|
|
302
|
+
if (t.kind === "selector") {
|
|
303
|
+
await page.locator(t.selector).first().waitFor({ timeout: DEFAULT_TIMEOUT_MS });
|
|
304
|
+
return { label: `wait selector ${t.selector}` };
|
|
305
|
+
}
|
|
306
|
+
if (t.kind === "urlStable") {
|
|
307
|
+
// Wait for the URL to change at least once, then hold steady for
|
|
308
|
+
// `quietMs`. Requiring an observed change (not just "stable from the
|
|
309
|
+
// start") avoids a false pass when the action under test hasn't
|
|
310
|
+
// written to the URL yet. Useful after a debounced router.replace.
|
|
311
|
+
const POLL_MS = 50;
|
|
312
|
+
const DEADLINE = Date.now() + DEFAULT_TIMEOUT_MS;
|
|
313
|
+
const initial = page.url();
|
|
314
|
+
let last = initial;
|
|
315
|
+
let stableSince = Date.now();
|
|
316
|
+
let hasChanged = false;
|
|
317
|
+
while (Date.now() < DEADLINE) {
|
|
318
|
+
await page.waitForTimeout(POLL_MS);
|
|
319
|
+
const current = page.url();
|
|
320
|
+
if (current !== last) {
|
|
321
|
+
last = current;
|
|
322
|
+
stableSince = Date.now();
|
|
323
|
+
if (current !== initial) hasChanged = true;
|
|
324
|
+
} else if (
|
|
325
|
+
hasChanged &&
|
|
326
|
+
Date.now() - stableSince >= t.quietMs
|
|
327
|
+
) {
|
|
328
|
+
return { label: `wait url-stable (${t.quietMs}ms quiet)` };
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
throw new Error(
|
|
332
|
+
hasChanged
|
|
333
|
+
? `wait:url-stable timed out — URL kept changing past ${DEFAULT_TIMEOUT_MS}ms`
|
|
334
|
+
: `wait:url-stable timed out — URL never changed from "${initial}" within ${DEFAULT_TIMEOUT_MS}ms. If the action under test doesn't write to the URL, use \`wait:<ms>\` or \`wait:url-contains:<sub>\` instead.`
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
if (t.kind === "urlContains") {
|
|
338
|
+
// Deterministic alternative to url-stable: wait until the URL contains
|
|
339
|
+
// a known substring. Use when an action pushes a specific param or
|
|
340
|
+
// navigates to a known path.
|
|
341
|
+
const POLL_MS = 100;
|
|
342
|
+
const DEADLINE = Date.now() + t.timeoutMs;
|
|
343
|
+
while (Date.now() < DEADLINE) {
|
|
344
|
+
if (page.url().includes(t.substring))
|
|
345
|
+
return {
|
|
346
|
+
label: `wait url-contains "${t.substring}" (${Date.now() - (DEADLINE - t.timeoutMs)}ms)`
|
|
347
|
+
};
|
|
348
|
+
await page.waitForTimeout(POLL_MS);
|
|
349
|
+
}
|
|
350
|
+
throw new Error(
|
|
351
|
+
`wait:url-contains "${t.substring}" timed out after ${t.timeoutMs}ms (final URL: ${page.url()})`
|
|
352
|
+
);
|
|
353
|
+
}
|
|
354
|
+
await page.getByText(t.text).first().waitFor({ timeout: DEFAULT_TIMEOUT_MS });
|
|
355
|
+
return { label: `wait text="${t.text}"` };
|
|
356
|
+
}
|
|
357
|
+
case "settle":
|
|
358
|
+
await waitForAttrsReady(page, step.timeoutMs ?? DEFAULT_SETTLE_MS);
|
|
359
|
+
return {
|
|
360
|
+
label:
|
|
361
|
+
step.timeoutMs !== undefined
|
|
362
|
+
? `settle (attrs ready, ${step.timeoutMs}ms cap)`
|
|
363
|
+
: "settle (attrs ready)"
|
|
364
|
+
};
|
|
365
|
+
case "screenshot":
|
|
366
|
+
// Screenshot is captured by the runner, which also names it. The runner
|
|
367
|
+
// looks at the step kind directly — we just pass through.
|
|
368
|
+
return {
|
|
369
|
+
label: step.fullPage
|
|
370
|
+
? `screenshot-full ${step.name ?? ""}`
|
|
371
|
+
: `screenshot ${step.name ?? ""}`
|
|
372
|
+
};
|
|
373
|
+
case "eval": {
|
|
374
|
+
// Pass as a raw expression string so we don't go through esbuild's
|
|
375
|
+
// function compilation (which would inject `__name` helpers).
|
|
376
|
+
const value = await page.evaluate(step.script);
|
|
377
|
+
return { label: `eval ${step.script.slice(0, 40)}`, evalResult: value };
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { resolve } from "node:path";
|
|
5
|
+
import { log } from "../util/log";
|
|
6
|
+
import type { InspectResult } from "./run";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Locate the `claude` binary. Order:
|
|
10
|
+
* 1. `which claude` (anything on PATH)
|
|
11
|
+
* 2. VSCode extension bundled binary (anthropic.claude-code-*)
|
|
12
|
+
* 3. Anthropic desktop app (macOS)
|
|
13
|
+
* Returns the absolute path, or null if not found.
|
|
14
|
+
*/
|
|
15
|
+
function findClaudeBinary(): string | null {
|
|
16
|
+
const which = spawnSync("which", ["claude"], { encoding: "utf-8" });
|
|
17
|
+
if (which.status === 0 && which.stdout.trim()) return which.stdout.trim();
|
|
18
|
+
|
|
19
|
+
const extRoot = resolve(homedir(), ".vscode/extensions");
|
|
20
|
+
if (existsSync(extRoot)) {
|
|
21
|
+
const matches = readdirSync(extRoot)
|
|
22
|
+
.filter((d) => d.startsWith("anthropic.claude-code-"))
|
|
23
|
+
.sort()
|
|
24
|
+
.reverse();
|
|
25
|
+
for (const dir of matches) {
|
|
26
|
+
const candidate = resolve(extRoot, dir, "resources/native-binary/claude");
|
|
27
|
+
if (existsSync(candidate)) return candidate;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const desktopCandidate = "/Applications/Claude.app/Contents/Resources/bin/claude";
|
|
32
|
+
if (existsSync(desktopCandidate)) return desktopCandidate;
|
|
33
|
+
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function summariseConsole(result: InspectResult): string {
|
|
38
|
+
const errs = result.console.entries
|
|
39
|
+
.filter((e) => e.type === "error")
|
|
40
|
+
.slice(0, 6)
|
|
41
|
+
.map((e) => ` - ${e.text.split("\n")[0]?.slice(0, 200)}`)
|
|
42
|
+
.join("\n");
|
|
43
|
+
return errs || " (no console errors)";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function summariseNetworkFailures(result: InspectResult): string {
|
|
47
|
+
const failed = result.network.entries
|
|
48
|
+
.filter(
|
|
49
|
+
(e) => e.failureText !== null || (e.status !== null && e.status >= 400)
|
|
50
|
+
)
|
|
51
|
+
.slice(0, 6);
|
|
52
|
+
if (!failed.length) return " (no failed/4xx requests)";
|
|
53
|
+
return failed
|
|
54
|
+
.map((e) =>
|
|
55
|
+
e.failureText
|
|
56
|
+
? ` - ${e.method} ${e.url} :: ${e.failureText}`
|
|
57
|
+
: ` - ${e.status} ${e.method} ${e.url}`
|
|
58
|
+
)
|
|
59
|
+
.join("\n");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function summarisePageErrors(result: InspectResult): string {
|
|
63
|
+
if (!result.pageErrors.length) return " (no uncaught JS errors)";
|
|
64
|
+
const grouped = new Map<string, number>();
|
|
65
|
+
for (const e of result.pageErrors)
|
|
66
|
+
grouped.set(e.message.split("\n")[0] ?? e.message, (grouped.get(e.message.split("\n")[0] ?? e.message) ?? 0) + 1);
|
|
67
|
+
return Array.from(grouped.entries())
|
|
68
|
+
.slice(0, 6)
|
|
69
|
+
.map(([msg, count]) => ` - ${msg}${count > 1 ? ` (×${count})` : ""}`)
|
|
70
|
+
.join("\n");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function summariseSteps(result: InspectResult): string {
|
|
74
|
+
if (!result.steps.length) return " (no steps — single-page snapshot)";
|
|
75
|
+
return result.steps
|
|
76
|
+
.map((s) => {
|
|
77
|
+
const tag = s.ok ? "✓" : "✗";
|
|
78
|
+
const evalStr =
|
|
79
|
+
s.evalResult !== undefined
|
|
80
|
+
? ` -> ${JSON.stringify(s.evalResult).slice(0, 120)}`
|
|
81
|
+
: "";
|
|
82
|
+
return ` ${tag} ${s.index}. ${s.label} (${s.durationMs}ms)${evalStr}`;
|
|
83
|
+
})
|
|
84
|
+
.join("\n");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function summariseAttrs(
|
|
88
|
+
attrs: { name: string; value: string; label: string }[]
|
|
89
|
+
): string {
|
|
90
|
+
if (!attrs.length) return "(none captured)";
|
|
91
|
+
return attrs
|
|
92
|
+
.slice(0, 20)
|
|
93
|
+
.map((a) => `${a.name}=${a.label || a.value}`)
|
|
94
|
+
.join(", ");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function buildPrompt(result: InspectResult): string {
|
|
98
|
+
return `You are summarising the result of a web-tester run against the developer's web app for a developer who is about to open the HTML report. Be concise and useful.
|
|
99
|
+
|
|
100
|
+
# Run context
|
|
101
|
+
- URL: ${result.requestedUrl}
|
|
102
|
+
- Final URL: ${result.finalUrl}
|
|
103
|
+
- Page title: ${result.title || "(unknown)"}
|
|
104
|
+
- Duration: ${result.durationMs}ms
|
|
105
|
+
- Verdict: ${result.ok ? "all steps executed" : `${result.failedSteps} step(s) failed`}
|
|
106
|
+
|
|
107
|
+
# Steps (✓=ok, ✗=error during step)
|
|
108
|
+
${summariseSteps(result)}
|
|
109
|
+
|
|
110
|
+
# Page errors (uncaught JS)
|
|
111
|
+
${summarisePageErrors(result)}
|
|
112
|
+
|
|
113
|
+
# Console errors
|
|
114
|
+
${summariseConsole(result)}
|
|
115
|
+
|
|
116
|
+
# Failed / 4xx network requests
|
|
117
|
+
${summariseNetworkFailures(result)}
|
|
118
|
+
|
|
119
|
+
# Final on-page attrs
|
|
120
|
+
${summariseAttrs(result.final.attrs)}
|
|
121
|
+
|
|
122
|
+
---
|
|
123
|
+
|
|
124
|
+
Write a short summary aimed at a developer scanning the report. Format:
|
|
125
|
+
|
|
126
|
+
**TL;DR:** one sentence — what the run did and whether it looks healthy.
|
|
127
|
+
|
|
128
|
+
**Notable findings:** 2–4 bullets, each one short line. Focus on signal: real failures, surprising state, unexpected URLs, missing attrs, suspicious network calls. Skip hydration warnings unless they are the only issue. Skip generic noise.
|
|
129
|
+
|
|
130
|
+
**Suggested next look:** 1 bullet if there is anything specific in the report worth zooming into; omit otherwise.
|
|
131
|
+
|
|
132
|
+
Output only the summary in markdown — no preamble, no closing remarks, no headers other than the three bolded labels above.`;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const SUMMARY_TIMEOUT_MS = 60_000;
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Generate a short Sonnet-written summary of the run. Returns null if the
|
|
139
|
+
* claude CLI isn't available, errors out, or times out — callers should treat
|
|
140
|
+
* a null as "no summary, render the report without one".
|
|
141
|
+
*/
|
|
142
|
+
export async function summariseRun(
|
|
143
|
+
result: InspectResult,
|
|
144
|
+
opts: { enabled: boolean }
|
|
145
|
+
): Promise<string | null> {
|
|
146
|
+
if (!opts.enabled) return null;
|
|
147
|
+
const bin = findClaudeBinary();
|
|
148
|
+
if (!bin) {
|
|
149
|
+
log.dim(" summary: claude CLI not found, skipping");
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
log.dim(" summary: asking sonnet to summarise…");
|
|
154
|
+
const started = Date.now();
|
|
155
|
+
const prompt = buildPrompt(result);
|
|
156
|
+
const proc = spawnSync(
|
|
157
|
+
bin,
|
|
158
|
+
["-p", "--model", "claude-sonnet-4-6", "--output-format", "text"],
|
|
159
|
+
{
|
|
160
|
+
input: prompt,
|
|
161
|
+
encoding: "utf-8",
|
|
162
|
+
timeout: SUMMARY_TIMEOUT_MS,
|
|
163
|
+
maxBuffer: 4 * 1024 * 1024
|
|
164
|
+
}
|
|
165
|
+
);
|
|
166
|
+
if (proc.error) {
|
|
167
|
+
log.dim(` summary: ${proc.error.message}, skipping`);
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
if (proc.status !== 0) {
|
|
171
|
+
log.dim(` summary: claude exited ${proc.status}, skipping`);
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
const text = proc.stdout.trim();
|
|
175
|
+
if (!text) return null;
|
|
176
|
+
log.dim(` summary: done in ${Date.now() - started}ms`);
|
|
177
|
+
return text;
|
|
178
|
+
}
|