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,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
|
+
}
|