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.
@@ -0,0 +1,293 @@
1
+ import type { BrowserContext, Page, Request, Response } from "playwright";
2
+
3
+ export type ConsoleEntry = {
4
+ type: string;
5
+ text: string;
6
+ location?: string;
7
+ timestamp: number;
8
+ };
9
+
10
+ export type NetworkEntry = {
11
+ method: string;
12
+ url: string;
13
+ resourceType: string;
14
+ status: number | null;
15
+ statusText: string | null;
16
+ durationMs: number | null;
17
+ fromCache: boolean;
18
+ failureText: string | null;
19
+ timestamp: number;
20
+ /** Request payload (truncated). Only captured under `--deep`. */
21
+ requestBody?: string | null;
22
+ /** Response payload, textual content only (truncated). `--deep` only. */
23
+ responseBody?: string | null;
24
+ };
25
+
26
+ export type PageErrorEntry = {
27
+ message: string;
28
+ stack: string | null;
29
+ timestamp: number;
30
+ };
31
+
32
+ export type CaptureBuffers = {
33
+ consoleEntries: ConsoleEntry[];
34
+ networkEntries: NetworkEntry[];
35
+ pageErrors: PageErrorEntry[];
36
+ /** Marks where each step starts so we can slice the buffers per-step. */
37
+ cursor: { console: number; network: number; pageErrors: number };
38
+ /**
39
+ * In-flight `--deep` response-body reads. Each resolves once it has mutated
40
+ * its already-pushed network entry. `flushBodies` awaits these before the
41
+ * context closes (a closed context can't return a body).
42
+ */
43
+ pendingBodies: Promise<void>[];
44
+ };
45
+
46
+ const TRACKED_RESOURCE_TYPES = new Set([
47
+ "xhr",
48
+ "fetch",
49
+ "document",
50
+ "websocket"
51
+ ]);
52
+
53
+ const NOISY_URL_FRAGMENTS = [
54
+ "/__nextjs",
55
+ "_next/static/chunks/",
56
+ "_next/static/css/",
57
+ "_next/static/media/",
58
+ "/_next/data/",
59
+ "/_next/image",
60
+ "favicon.ico",
61
+ "googletagmanager",
62
+ "google-analytics",
63
+ "googleadservices",
64
+ "doubleclick",
65
+ "hotjar",
66
+ "fullstory",
67
+ "intercom",
68
+ "segment.io",
69
+ "cookiebot",
70
+ "consentcdn",
71
+ "hs-scripts.com",
72
+ "hsforms.com",
73
+ "hs-analytics.net",
74
+ "hubapi.com",
75
+ "hubspot.com",
76
+ "px.ads.linkedin",
77
+ "linkedin.com/li/track"
78
+ ];
79
+
80
+ function isNoisyUrl(url: string): boolean {
81
+ return NOISY_URL_FRAGMENTS.some((frag) => url.includes(frag));
82
+ }
83
+
84
+ /**
85
+ * Console messages we drop by default. These are CSP `report-only` violations
86
+ * from third-party tags, Microsoft Clarity beacons, and similar tracker chatter
87
+ * — interesting to no one diagnosing an app bug. Matched against text
88
+ * and location URL. Pass `--all-console` (or `captureAllConsole: true`) to keep
89
+ * everything.
90
+ */
91
+ const NOISY_CONSOLE_FRAGMENTS = [
92
+ "Content Security Policy",
93
+ "Refused to load",
94
+ "Refused to execute",
95
+ "Refused to apply",
96
+ "Refused to connect",
97
+ "googleads",
98
+ "google.com/pagead",
99
+ "googletagmanager",
100
+ "doubleclick",
101
+ "google-analytics",
102
+ "clarity.ms",
103
+ "px.ads.linkedin",
104
+ "facebook.com/tr",
105
+ "hubspot",
106
+ "cookiebot",
107
+ "consentcdn",
108
+ "Tracking Prevention blocked",
109
+ "Loading failed for the <script>",
110
+ "Failed to load resource"
111
+ ];
112
+
113
+ function isNoisyConsole(text: string, location?: string): boolean {
114
+ const haystack = `${text}\n${location ?? ""}`;
115
+ return NOISY_CONSOLE_FRAGMENTS.some((frag) => haystack.includes(frag));
116
+ }
117
+
118
+ export function newBuffers(): CaptureBuffers {
119
+ return {
120
+ consoleEntries: [],
121
+ networkEntries: [],
122
+ pageErrors: [],
123
+ cursor: { console: 0, network: 0, pageErrors: 0 },
124
+ pendingBodies: []
125
+ };
126
+ }
127
+
128
+ /** Await any in-flight `--deep` body reads. Call before closing the context. */
129
+ export async function flushBodies(buffers: CaptureBuffers): Promise<void> {
130
+ if (buffers.pendingBodies.length === 0) return;
131
+ await Promise.all(buffers.pendingBodies);
132
+ }
133
+
134
+ export function snapshotCursor(buffers: CaptureBuffers): CaptureBuffers["cursor"] {
135
+ return {
136
+ console: buffers.consoleEntries.length,
137
+ network: buffers.networkEntries.length,
138
+ pageErrors: buffers.pageErrors.length
139
+ };
140
+ }
141
+
142
+ export function sliceSince(
143
+ buffers: CaptureBuffers,
144
+ from: CaptureBuffers["cursor"]
145
+ ): {
146
+ console: ConsoleEntry[];
147
+ network: NetworkEntry[];
148
+ pageErrors: PageErrorEntry[];
149
+ } {
150
+ return {
151
+ console: buffers.consoleEntries.slice(from.console),
152
+ network: buffers.networkEntries.slice(from.network),
153
+ pageErrors: buffers.pageErrors.slice(from.pageErrors)
154
+ };
155
+ }
156
+
157
+ export type AttachCaptureOptions = {
158
+ /** Keep every network request, not just XHR/fetch/document, and skip noise filter. */
159
+ allNetwork: boolean;
160
+ /** Keep every console line, including CSP / tracker chatter. */
161
+ allConsole: boolean;
162
+ /** Record request + (textual) response bodies, truncated. Off by default. */
163
+ captureBodies?: boolean;
164
+ };
165
+
166
+ /** Cap on captured body size — enough to read a JSON error, not a whole HTML doc. */
167
+ const MAX_BODY_CHARS = 8_192;
168
+
169
+ /** Content types we'll read a response body for. Skips images, fonts, binaries. */
170
+ const TEXTUAL_CONTENT_TYPE =
171
+ /(application\/(json|.*\+json|xml|.*\+xml|javascript|x-www-form-urlencoded)|text\/)/i;
172
+
173
+ function truncateBody(raw: string): string {
174
+ if (raw.length <= MAX_BODY_CHARS) return raw;
175
+ return `${raw.slice(0, MAX_BODY_CHARS)}… (${raw.length - MAX_BODY_CHARS} more chars)`;
176
+ }
177
+
178
+ /**
179
+ * Read a response body when it's textual (JSON/text/xml/js). Returns null for
180
+ * binary responses, empty bodies, or anything Playwright can't hand back (e.g.
181
+ * redirects, served-from-cache). Never throws — body capture is best-effort.
182
+ */
183
+ async function readTextualBody(response: Response | null): Promise<string | null> {
184
+ if (!response) return null;
185
+ const contentType = response.headers()["content-type"] ?? "";
186
+ if (!TEXTUAL_CONTENT_TYPE.test(contentType)) return null;
187
+ try {
188
+ const buf = await response.body();
189
+ if (!buf || buf.length === 0) return null;
190
+ return truncateBody(buf.toString("utf-8"));
191
+ } catch {
192
+ return null;
193
+ }
194
+ }
195
+
196
+ /**
197
+ * Attach console + network + pageerror listeners to a context. The returned
198
+ * buffers grow for the lifetime of the context.
199
+ *
200
+ * By default both streams are filtered: network keeps XHR/fetch/document and
201
+ * drops static asset chunks + known analytics noise; console drops CSP report
202
+ * violations and third-party tracker chatter (Clarity, ads, etc.). The two
203
+ * `all*` flags opt out independently.
204
+ */
205
+ export function attachCapture(
206
+ context: BrowserContext,
207
+ page: Page,
208
+ opts: AttachCaptureOptions
209
+ ): CaptureBuffers {
210
+ const buffers = newBuffers();
211
+ const requestStart = new Map<Request, number>();
212
+
213
+ page.on("console", (msg) => {
214
+ const location = msg.location();
215
+ const locationStr =
216
+ location?.url && location.url.length > 0
217
+ ? `${location.url}:${location.lineNumber}:${location.columnNumber}`
218
+ : undefined;
219
+ const text = msg.text();
220
+ if (!opts.allConsole && isNoisyConsole(text, locationStr)) return;
221
+ buffers.consoleEntries.push({
222
+ type: msg.type(),
223
+ text,
224
+ location: locationStr,
225
+ timestamp: Date.now()
226
+ });
227
+ });
228
+
229
+ page.on("pageerror", (err) => {
230
+ buffers.pageErrors.push({
231
+ message: err.message,
232
+ stack: err.stack ?? null,
233
+ timestamp: Date.now()
234
+ });
235
+ });
236
+
237
+ context.on("request", (req) => {
238
+ requestStart.set(req, Date.now());
239
+ });
240
+
241
+ const finalize = (
242
+ req: Request,
243
+ response: Response | null,
244
+ failureText: string | null
245
+ ): void => {
246
+ if (!opts.allNetwork) {
247
+ if (!TRACKED_RESOURCE_TYPES.has(req.resourceType())) return;
248
+ if (isNoisyUrl(req.url())) return;
249
+ }
250
+ const startedAt = requestStart.get(req) ?? Date.now();
251
+ requestStart.delete(req);
252
+
253
+ const postData = opts.captureBodies ? req.postData() : null;
254
+ const entry: NetworkEntry = {
255
+ method: req.method(),
256
+ url: req.url(),
257
+ resourceType: req.resourceType(),
258
+ status: response?.status() ?? null,
259
+ statusText: response?.statusText() ?? null,
260
+ durationMs: Date.now() - startedAt,
261
+ fromCache: response ? response.fromServiceWorker() : false,
262
+ failureText,
263
+ timestamp: startedAt,
264
+ ...(opts.captureBodies
265
+ ? { requestBody: postData ? truncateBody(postData) : null }
266
+ : {})
267
+ };
268
+ // Push synchronously so per-step `sliceSince` sees the entry immediately;
269
+ // the response body (async) is mutated onto the same object once read, and
270
+ // shows up in the report — which is written after `flushBodies`.
271
+ buffers.networkEntries.push(entry);
272
+ if (opts.captureBodies) {
273
+ buffers.pendingBodies.push(
274
+ readTextualBody(response).then((body) => {
275
+ entry.responseBody = body;
276
+ })
277
+ );
278
+ }
279
+ };
280
+
281
+ context.on("requestfinished", (req) => {
282
+ void (async () => {
283
+ const response = await req.response().catch(() => null);
284
+ finalize(req, response ?? null, null);
285
+ })();
286
+ });
287
+
288
+ context.on("requestfailed", (req) => {
289
+ finalize(req, null, req.failure()?.errorText ?? "request failed");
290
+ });
291
+
292
+ return buffers;
293
+ }
@@ -0,0 +1,147 @@
1
+ import type { CDPSession, Page } from "playwright";
2
+
3
+ /** One scope frame from a paused exception, variable name → rendered value. */
4
+ export type ScopeDump = {
5
+ type: string;
6
+ vars: Record<string, string>;
7
+ };
8
+
9
+ /** An uncaught exception captured with its local scope, via the debugger. */
10
+ export type DeepError = {
11
+ /** First line of the exception (e.g. `TypeError: x is not a function`). */
12
+ reason: string;
13
+ functionName: string;
14
+ /** Script URL + line where it threw, when known. */
15
+ location: string | null;
16
+ /** Local + closure scope at the throw site. Empty when nothing readable. */
17
+ scopes: ScopeDump[];
18
+ timestamp: number;
19
+ };
20
+
21
+ export type DeepBuffers = {
22
+ /** Uncaught exceptions, enriched with scope. */
23
+ errors: DeepError[];
24
+ /** Unhandled promise rejections (message text). Often missed by `pageerror`. */
25
+ rejections: string[];
26
+ };
27
+
28
+ /** Stop pausing once we've collected this many — a throw-loop shouldn't stall the run. */
29
+ const MAX_DEEP_ERRORS = 25;
30
+
31
+ type RemoteObject = {
32
+ value?: unknown;
33
+ description?: string;
34
+ type?: string;
35
+ preview?: { properties?: Array<{ name: string; value?: string }> };
36
+ };
37
+
38
+ /** Render a CDP RemoteObject compactly: primitive value, object preview, or type. */
39
+ function renderRemote(obj: RemoteObject | undefined): string {
40
+ if (!obj) return "undefined";
41
+ if (obj.value !== undefined) return JSON.stringify(obj.value);
42
+ if (obj.preview?.properties?.length) {
43
+ const parts = obj.preview.properties
44
+ .slice(0, 8)
45
+ .map((p) => `${p.name}: ${p.value ?? "…"}`);
46
+ return `{ ${parts.join(", ")} }`;
47
+ }
48
+ return obj.description ?? obj.type ?? "?";
49
+ }
50
+
51
+ /**
52
+ * Attach a CDP debugger to `page` that pauses on every uncaught exception,
53
+ * snapshots the throwing frame's local + closure scope, then resumes
54
+ * immediately — so the page never deadlocks. Also records unhandled promise
55
+ * rejections, which Playwright's `pageerror` event doesn't surface.
56
+ *
57
+ * This is opt-in (`--deep`): pausing on exceptions adds protocol overhead and
58
+ * is wasted on a healthy page. Returns the growing buffers plus a `detach`.
59
+ */
60
+ export async function attachDeepCapture(
61
+ page: Page
62
+ ): Promise<{ buffers: DeepBuffers; detach: () => Promise<void> }> {
63
+ const buffers: DeepBuffers = { errors: [], rejections: [] };
64
+ const cdp: CDPSession = await page.context().newCDPSession(page);
65
+
66
+ await cdp.send("Debugger.enable");
67
+ await cdp.send("Runtime.enable");
68
+ await cdp.send("Debugger.setPauseOnExceptions", { state: "uncaught" });
69
+
70
+ cdp.on("Debugger.paused", async (evt) => {
71
+ // The page's JS thread is frozen here. Whatever happens, resume in the
72
+ // finally so a read error can't strand the page mid-exception.
73
+ try {
74
+ if (buffers.errors.length >= MAX_DEEP_ERRORS) return;
75
+ const frames = (evt as { callFrames?: unknown[] }).callFrames ?? [];
76
+ const top = frames[0] as
77
+ | {
78
+ functionName?: string;
79
+ url?: string;
80
+ location?: { lineNumber?: number };
81
+ scopeChain?: Array<{
82
+ type?: string;
83
+ object?: { objectId?: string };
84
+ }>;
85
+ }
86
+ | undefined;
87
+ const data = (evt as { data?: { description?: string } }).data;
88
+ const reason = (data?.description ?? "(uncaught exception)").split("\n")[0]!;
89
+
90
+ const scopes: ScopeDump[] = [];
91
+ for (const scope of top?.scopeChain ?? []) {
92
+ if (scope.type !== "local" && scope.type !== "closure") continue;
93
+ if (!scope.object?.objectId) continue;
94
+ const props = await cdp
95
+ .send("Runtime.getProperties", {
96
+ objectId: scope.object.objectId,
97
+ ownProperties: true,
98
+ generatePreview: true
99
+ })
100
+ .catch(() => null);
101
+ if (!props) continue;
102
+ const vars: Record<string, string> = {};
103
+ for (const p of (props as { result?: Array<{ name: string; value?: RemoteObject }> }).result ?? []) {
104
+ if (p.name === "this" || !p.value) continue;
105
+ vars[p.name] = renderRemote(p.value);
106
+ }
107
+ if (Object.keys(vars).length > 0) scopes.push({ type: scope.type, vars });
108
+ }
109
+
110
+ // Only record exceptions we could enrich with scope — an exception with
111
+ // no readable variables adds nothing over the message+stack already in
112
+ // `pageErrors`. This keeps `deepErrors` purely the value-add: the throw
113
+ // site's variable state. (Rejections, which rarely have useful scope at
114
+ // the pause point, are covered by `rejections` below.)
115
+ if (scopes.length === 0) return;
116
+ const line = top?.location?.lineNumber;
117
+ buffers.errors.push({
118
+ reason,
119
+ functionName: top?.functionName || "(anonymous)",
120
+ location: top?.url
121
+ ? `${top.url}${line !== undefined ? `:${line + 1}` : ""}`
122
+ : null,
123
+ scopes,
124
+ timestamp: Date.now()
125
+ });
126
+ } finally {
127
+ await cdp.send("Debugger.resume").catch(() => {});
128
+ }
129
+ });
130
+
131
+ cdp.on("Runtime.exceptionThrown", (evt) => {
132
+ const details = (evt as { exceptionDetails?: { text?: string; exception?: { description?: string } } })
133
+ .exceptionDetails;
134
+ const text = details?.exception?.description ?? details?.text;
135
+ // Only rejections land here that the pause path doesn't already cover well.
136
+ if (text && /uncaught \(in promise\)/i.test(details?.text ?? "")) {
137
+ buffers.rejections.push(text.split("\n")[0]!);
138
+ }
139
+ });
140
+
141
+ const detach = async (): Promise<void> => {
142
+ await cdp.send("Debugger.disable").catch(() => {});
143
+ await cdp.detach().catch(() => {});
144
+ };
145
+
146
+ return { buffers, detach };
147
+ }
@@ -0,0 +1,98 @@
1
+ import type { Expectation } from "./verdict";
2
+
3
+ /**
4
+ * Named "expectation packs" — bundles of `--expect` assertions tailored to
5
+ * a page type. A sweep URL can opt into one or more packs via inline
6
+ * `#pack=<name>` annotations in a URL file, and the CLI can also apply a
7
+ * default pack to all URLs via `--pack <name>`. Per-URL expectations are
8
+ * the union of global `--expect`s, global `--pack`s, and the URL's own
9
+ * inline packs.
10
+ *
11
+ * Built-ins below are deliberately generic — invariants that hold for almost
12
+ * any page of that shape. For project-specific assertions, add `--expect`
13
+ * flags per run or per URL, or register a new generally-useful pack here.
14
+ */
15
+ export const BUILT_IN_PACKS: Record<string, Expectation[]> = {
16
+ /**
17
+ * Homepage / landing page — page chrome should render. We deliberately
18
+ * don't assert a heading: many sites lead with a hero or marketing block
19
+ * that uses an <h2> or no heading at all, and the universal signal is
20
+ * header + footer being present.
21
+ */
22
+ homepage: [
23
+ { kind: "selector", selector: "header" },
24
+ { kind: "selector", selector: "footer" }
25
+ ],
26
+ /**
27
+ * Generic content / informational page. Same shape as homepage — header
28
+ * + footer is the universal "the page chrome rendered" signal.
29
+ */
30
+ static: [
31
+ { kind: "selector", selector: "header" },
32
+ { kind: "selector", selector: "footer" }
33
+ ],
34
+ /**
35
+ * Category / index page — header + footer + at least one product/article
36
+ * card link. The card selector matches any `<a>` inside `<main>` that
37
+ * points to an internal route and contains an `<img>` — a near-universal
38
+ * shape for catalog cards.
39
+ */
40
+ category: [
41
+ { kind: "selector", selector: "header" },
42
+ { kind: "selector", selector: "footer" },
43
+ { kind: "selector", selector: "main a[href^='/']:has(img)" }
44
+ ],
45
+ /**
46
+ * Any "main content area present" assertion — useful as a baseline when
47
+ * you don't want to overspecify the page shape.
48
+ */
49
+ "has-main": [{ kind: "selector", selector: "main" }],
50
+ /**
51
+ * Any page with an `<h1>`. Common safety net for content pages where the
52
+ * heading is the primary "we rendered the right thing" signal.
53
+ */
54
+ "has-h1": [{ kind: "selector", selector: "h1" }]
55
+ };
56
+
57
+ export function getBuiltInPack(name: string): Expectation[] {
58
+ const pack = BUILT_IN_PACKS[name];
59
+ if (!pack) {
60
+ const known = Object.keys(BUILT_IN_PACKS).join(", ");
61
+ throw new Error(
62
+ `unknown pack "${name}". Built-in packs: ${known}. Add new packs in src/inspector/packs.ts.`
63
+ );
64
+ }
65
+ return pack;
66
+ }
67
+
68
+ export function listBuiltInPackNames(): string[] {
69
+ return Object.keys(BUILT_IN_PACKS);
70
+ }
71
+
72
+ /**
73
+ * Parse a URL-file line of the form `<path> [#pack=<name>] [#pack=<name>]…`.
74
+ * Returns `{ path, packs }`. Lines without an annotation get `packs: []`.
75
+ * Inline pack syntax is tab- or space-separated from the URL.
76
+ */
77
+ export function parseUrlLine(line: string): {
78
+ path: string;
79
+ packs: string[];
80
+ } {
81
+ const trimmed = line.trim();
82
+ // Split on whitespace into tokens. First token is the path. Each
83
+ // subsequent token must match `#pack=<name>`; anything else is an error
84
+ // (so typos don't get silently ignored).
85
+ const parts = trimmed.split(/\s+/);
86
+ const path = parts[0] ?? "";
87
+ const packs: string[] = [];
88
+ for (let i = 1; i < parts.length; i++) {
89
+ const part = parts[i] ?? "";
90
+ const match = part.match(/^#pack=(.+)$/);
91
+ if (!match || !match[1])
92
+ throw new Error(
93
+ `invalid URL-file annotation "${part}" on line "${line}". Expected "#pack=<name>".`
94
+ );
95
+ packs.push(match[1]);
96
+ }
97
+ return { path, packs };
98
+ }