debutly 0.0.0-stage → 0.2.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.
Files changed (93) hide show
  1. package/LICENSE +662 -0
  2. package/README.md +192 -2
  3. package/bin/debutly.mjs +8 -0
  4. package/drizzle/0000_init.sql +107 -0
  5. package/drizzle/0001_predictions.sql +14 -0
  6. package/drizzle/0002_dashboard.sql +15 -0
  7. package/drizzle/meta/0000_snapshot.json +722 -0
  8. package/drizzle/meta/0001_snapshot.json +821 -0
  9. package/drizzle/meta/0002_snapshot.json +942 -0
  10. package/drizzle/meta/_journal.json +27 -0
  11. package/package.json +83 -4
  12. package/src/agent/describe.ts +29 -0
  13. package/src/agent/effect.ts +94 -0
  14. package/src/agent/give-up.ts +42 -0
  15. package/src/agent/label.ts +19 -0
  16. package/src/agent/loop.ts +302 -0
  17. package/src/agent/parse-reply.ts +127 -0
  18. package/src/agent/prompt.ts +85 -0
  19. package/src/agent/run-agent.ts +68 -0
  20. package/src/artifacts/db.ts +55 -0
  21. package/src/artifacts/s3.ts +36 -0
  22. package/src/artifacts/store.ts +26 -0
  23. package/src/bin.ts +140 -0
  24. package/src/browser/ax.ts +77 -0
  25. package/src/browser/execute.ts +401 -0
  26. package/src/browser/observe.ts +62 -0
  27. package/src/browser/reflow.ts +73 -0
  28. package/src/browser/samples.ts +119 -0
  29. package/src/browser/scan-script.ts +108 -0
  30. package/src/browser/settle.ts +32 -0
  31. package/src/browser/signals.ts +73 -0
  32. package/src/cancel.ts +20 -0
  33. package/src/cli.ts +281 -0
  34. package/src/config.ts +62 -0
  35. package/src/cost/estimate.ts +32 -0
  36. package/src/cost/prices.ts +32 -0
  37. package/src/cost/tiers.ts +27 -0
  38. package/src/costs.ts +96 -0
  39. package/src/crew/run-unit.ts +62 -0
  40. package/src/detect/findings.ts +649 -0
  41. package/src/detect/wcag.ts +38 -0
  42. package/src/doctor.ts +145 -0
  43. package/src/hear-cli.ts +137 -0
  44. package/src/hear.ts +309 -0
  45. package/src/import.ts +108 -0
  46. package/src/inbox/mailpit.ts +46 -0
  47. package/src/inbox/types.ts +10 -0
  48. package/src/init.ts +184 -0
  49. package/src/llm/anthropic.ts +41 -0
  50. package/src/llm/credits.ts +35 -0
  51. package/src/llm/factory.ts +65 -0
  52. package/src/llm/meter.ts +39 -0
  53. package/src/llm/openai-compatible.ts +90 -0
  54. package/src/llm/openrouter.ts +9 -0
  55. package/src/llm/scripted.ts +18 -0
  56. package/src/llm/types.ts +36 -0
  57. package/src/perceive.ts +150 -0
  58. package/src/perception/aggregate.ts +111 -0
  59. package/src/perception/compare.ts +77 -0
  60. package/src/perception/evaluate.ts +83 -0
  61. package/src/perception/exhibits.ts +146 -0
  62. package/src/perception/market-context.ts +50 -0
  63. package/src/perception/run-perception.ts +178 -0
  64. package/src/persona/generate.ts +92 -0
  65. package/src/persona/inclusion.ts +53 -0
  66. package/src/persona/language.ts +59 -0
  67. package/src/persona/people.ts +32 -0
  68. package/src/predictions.ts +33 -0
  69. package/src/queue/dispatch.ts +18 -0
  70. package/src/queue/jobs.ts +28 -0
  71. package/src/queue/processor.ts +42 -0
  72. package/src/report/fix-first.ts +121 -0
  73. package/src/report/launch-readiness.ts +106 -0
  74. package/src/report/markdown.ts +202 -0
  75. package/src/run.ts +141 -0
  76. package/src/sandbox/outbox.ts +65 -0
  77. package/src/store/db.ts +40 -0
  78. package/src/store/import-run.ts +116 -0
  79. package/src/store/pg-run-store.ts +163 -0
  80. package/src/store/predictions.ts +70 -0
  81. package/src/store/run-store.ts +78 -0
  82. package/src/store/schema.ts +193 -0
  83. package/src/store/url.ts +15 -0
  84. package/src/types.ts +201 -0
  85. package/src/util/chromium.ts +20 -0
  86. package/src/util/json.ts +7 -0
  87. package/src/util/key-defaults.ts +34 -0
  88. package/src/util/legacy-env.ts +14 -0
  89. package/src/util/load-env.ts +28 -0
  90. package/src/util/pool.ts +12 -0
  91. package/src/util/run-origin.ts +27 -0
  92. package/src/worker.ts +53 -0
  93. package/tsconfig.json +13 -0
@@ -0,0 +1,649 @@
1
+ import { COVERED, NOT_BY_KEYBOARD } from "../browser/execute";
2
+ import type { BlockedCall } from "../sandbox/outbox";
3
+ import type { Confidence, Finding, FindingKind, InclusionNeed, InteractiveElement, Observation, Session, Severity, Signal, Step } from "../types";
4
+ import { unlabeledCriteria } from "./wcag";
5
+
6
+ const CRITICAL_PATH = /(sign-?up|sign-?in|log-?in|register|onboarding|auth|pricing|checkout|billing|payment|invite)/i;
7
+ const BASE_SEVERITY: Record<FindingKind, Severity> = {
8
+ http_error: "minor",
9
+ page_error: "major",
10
+ console_error: "minor",
11
+ a11y_unlabeled: "minor",
12
+ stuck: "major",
13
+ slow_load: "minor",
14
+ covered: "major",
15
+ keyboard: "major",
16
+ focus_obscured: "minor",
17
+ reflow: "minor",
18
+ language: "minor",
19
+ zoom: "major",
20
+ };
21
+ // What the browser measures, not what an agent made of the page: one measurement is enough.
22
+ export const MEASURED: ReadonlySet<FindingKind> = new Set(["a11y_unlabeled", "covered", "keyboard", "focus_obscured", "reflow", "language", "zoom"]);
23
+ /** Sideways overflow this small is reported, but never raised above minor. */
24
+ const SLIGHT_OVERFLOW_PX = 20;
25
+ /** A page whose letters are at least this share Arabic is an Arabic page. */
26
+ const ARABIC_PAGE = 0.5;
27
+ /** Clicks on one control that something else covered, in one session, before it is a finding. */
28
+ const COVERED_REPEATS = 2;
29
+ /** A page this slow, or one still not in when the agent stopped waiting, is a major finding. */
30
+ const VERY_SLOW_MS = 30_000;
31
+ // How an app says it lacks a key or setting. A test environment leaves every secret out on
32
+ // purpose, so there a server error saying this is the environment's doing, not a bug.
33
+ // "No Gemini API key configured. Set GEMINI_API_KEY…" (2026-10-20) names the provider between
34
+ // "no" and "API key", and the variable it wants.
35
+ const MISSING_KEY =
36
+ /no (?:[\w-]+ ){0,2}api[ _-]?key|api[ _-]?key (?:is )?(?:not set|missing|not configured|required)|no ai api configured|not configured|configure [a-z0-9_]*key\b|\bset [a-z0-9]+_[a-z0-9_]*key\b|\b[a-z0-9]+_api_key (?:is )?(?:not set|missing|undefined)/i;
37
+ // A web address typed in on its own, as an agent gives its company's site ("https://www.pulsehr.io/").
38
+ const TYPED_SITE = /^(?:https?:\/\/)?(?:www\.)?((?:[a-z0-9-]+\.)+[a-z]{2,})(?:[/?#]\S*)?$/i;
39
+ // How an app says it couldn't get at a site: "Could not read that website", "DNS lookup failed".
40
+ const UNREACHABLE =
41
+ /\b(?:could ?n[o']t|cannot|can't|unable to|failed to) (?:read|reach|fetch|load|access|scrape|crawl|connect|resolve)\b|\bdns\b|enotfound|getaddrinfo|econnrefused|unreachable|timed? ?out\b/i;
42
+ const QUOTE_CHARS = 160;
43
+ const SEVERITY_RANK: Record<Severity, number> = { blocker: 0, major: 1, minor: 2 };
44
+ const CONFIDENCE_RANK: Record<Confidence, number> = { high: 0, medium: 1, low: 2 };
45
+ const STUCK_REPEATS = 3;
46
+ // Server and browser share a clock in a throwaway environment; allow for background work finishing late.
47
+ const SANDBOX_SLACK_MS = 2000;
48
+
49
+ // Pages that differ only by a record's id are one page: every new customer of one app landed on
50
+ // /app/accounts/<their account>/onboarding, which listed the same unlabeled button once per agent.
51
+ const ID_SEGMENT = /^(?:\d+|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|[0-9a-f]{16,}|(?=[a-z0-9]*\d)[a-z0-9]{20,})$/i;
52
+
53
+ const pathOf = (url: string) => {
54
+ try {
55
+ return new URL(url).pathname
56
+ .split("/")
57
+ .map((segment) => (ID_SEGMENT.test(segment) ? ":id" : segment))
58
+ .join("/");
59
+ } catch {
60
+ return url;
61
+ }
62
+ };
63
+
64
+ function severityFor(kind: FindingKind, paths: string[], status?: number): Severity {
65
+ let severity: Severity = kind === "http_error" && status !== undefined && status >= 500 ? "major" : BASE_SEVERITY[kind];
66
+ if (paths.some((p) => CRITICAL_PATH.test(p))) severity = severity === "minor" ? "major" : "blocker";
67
+ return severity;
68
+ }
69
+
70
+ // Hits as a share of the agents that reached that page: 1 of 1 visitors is solid; 1 of 10 is likely agent error.
71
+ function confidenceFor(ratio: number): Confidence {
72
+ if (ratio >= 0.5) return "high";
73
+ if (ratio >= 0.25) return "medium";
74
+ return "low";
75
+ }
76
+
77
+ interface Candidate {
78
+ signature: string;
79
+ kind: FindingKind;
80
+ title: string;
81
+ detail: string;
82
+ pagePath: string;
83
+ severity: Severity;
84
+ wcag?: string[];
85
+ control?: Finding["control"];
86
+ }
87
+
88
+ function fromSignal(signal: Signal): Candidate | null {
89
+ const pagePath = pathOf(signal.url);
90
+ switch (signal.kind) {
91
+ case "http_error": {
92
+ const target = pathOf(signal.requestUrl);
93
+ if (target === "/favicon.ico") return null;
94
+ return {
95
+ signature: `http:${signal.status}:${target}`,
96
+ kind: "http_error",
97
+ title: `HTTP ${signal.status} from ${target}`,
98
+ detail: `Request ${signal.requestUrl} returned ${signal.status} while on ${pagePath}.`,
99
+ pagePath,
100
+ severity: severityFor("http_error", [target, pagePath], signal.status),
101
+ };
102
+ }
103
+ case "page_error":
104
+ return {
105
+ signature: `page:${pagePath}:${signal.message}`,
106
+ kind: "page_error",
107
+ title: `JavaScript error on ${pagePath}`,
108
+ detail: signal.message,
109
+ pagePath,
110
+ severity: severityFor("page_error", [pagePath]),
111
+ };
112
+ case "console_error":
113
+ // The browser echoes every failed request to the console; the http_error already covers it.
114
+ if (signal.message.startsWith("Failed to load resource")) return null;
115
+ return {
116
+ signature: `console:${pagePath}:${signal.message.slice(0, 120)}`,
117
+ kind: "console_error",
118
+ title: `Console error on ${pagePath}`,
119
+ detail: signal.message,
120
+ pagePath,
121
+ severity: severityFor("console_error", [pagePath]),
122
+ };
123
+ case "dialog":
124
+ case "download":
125
+ // An app asking a question, or handing over a file, is not a defect; the agent is told instead.
126
+ return null;
127
+ case "slow_load": {
128
+ const seconds = Math.round(signal.ms / 1000);
129
+ const connection = signal.slowNetwork ? " on a slow connection" : "";
130
+ return {
131
+ signature: `slow:${signal.slowNetwork ? "slow" : "normal"}:${pagePath}`,
132
+ kind: "slow_load",
133
+ title: signal.finished ? `${pagePath} took ${seconds} s to load${connection}` : `${pagePath} still hadn't loaded after ${seconds} s${connection}`,
134
+ detail: `${signal.slowNetwork ? "On a slow connection (500 kbit/s, 400 ms round trips, like weak mobile data)" : "On a normal connection"}, ${pagePath} ${signal.finished ? `took ${seconds} s before the page arrived` : `had not arrived after ${seconds} s, so the agent went on with whatever was on screen`}. Until then a visitor sees a blank or half-drawn page, and many leave.${weightNote(signal.assets)}`,
135
+ pagePath,
136
+ severity: !signal.finished || signal.ms >= VERY_SLOW_MS ? "major" : BASE_SEVERITY.slow_load,
137
+ };
138
+ }
139
+ }
140
+ }
141
+
142
+ // Controls that need a name, as people call them. Unlabeled radio buttons in one app's
143
+ // questionnaire went unreported: agents clicked them by position, again and again.
144
+ const NAMED_CONTROLS: Record<string, string> = {
145
+ button: "button",
146
+ link: "link",
147
+ select: "dropdown",
148
+ combobox: "dropdown",
149
+ textarea: "text box",
150
+ checkbox: "checkbox",
151
+ radio: "radio button",
152
+ switch: "switch",
153
+ "input:radio": "radio button",
154
+ "input:checkbox": "checkbox",
155
+ "input:text": "text field",
156
+ "input:email": "email field",
157
+ "input:password": "password field",
158
+ "input:number": "number field",
159
+ "input:search": "search field",
160
+ "input:tel": "phone field",
161
+ "input:url": "web address field",
162
+ };
163
+
164
+ /** Said of a slow page whose scripts and styles came mostly uncompressed; the report's next step looks for it. */
165
+ export const UNCOMPRESSED = "came uncompressed";
166
+ const kb = (bytes: number) => (bytes >= 1024 * 1024 ? `${(bytes / 1024 / 1024).toFixed(1)} MB` : `${Math.round(bytes / 1024)} KB`);
167
+
168
+ // What the scripts and styles that had arrived weighed. Mostly uncompressed, it may be the test
169
+ // copy's doing (served straight from a container) rather than the real site's.
170
+ function weightNote(assets: Extract<Signal, { kind: "slow_load" }>["assets"]): string {
171
+ if (!assets || assets.bytes < 100 * 1024) return "";
172
+ if (assets.uncompressedBytes >= assets.bytes / 2) {
173
+ return ` By then it had received ${kb(assets.bytes)} of scripts and styles, and ${kb(assets.uncompressedBytes)} of that ${UNCOMPRESSED}. Compressed (gzip or Brotli, which most hosting turns on), scripts are often a quarter of the size. If the real site compresses them, it is lighter than this test copy.`;
174
+ }
175
+ return ` By then it had received ${kb(assets.bytes)} of scripts and styles, compressed.`;
176
+ }
177
+
178
+ /** What to call a control that has no name, or undefined when it doesn't need one. */
179
+ function unlabeledControl(role: string): string | undefined {
180
+ return NAMED_CONTROLS[role];
181
+ }
182
+
183
+ // Checked against what NVDA itself said on three open-source apps: an unnamed control Tab never
184
+ // reaches, or one a screen reader reads a description for, is a smaller problem than one a
185
+ // keyboard user lands on and hears only "button"; and a field named only by its placeholder
186
+ // (a password read as "13 bullet") was missed, because the browser counts a placeholder as a name.
187
+ type Unnamed = "unnamed" | "described" | "unreached" | "placeholder";
188
+ const MAX_PLACES = 3;
189
+
190
+ /** What's wrong with a control's name, as a screen reader meets it; undefined when nothing is. */
191
+ export function nameProblem(el: InteractiveElement): Unnamed | undefined {
192
+ // The browser's own name when there is one (an icon named by its SVG title has a name
193
+ // there); a control hidden from assistive technology isn't announced at all.
194
+ if (el.ax?.hidden || !unlabeledControl(el.role)) return undefined;
195
+ const name = el.ax ? el.ax.name : el.name;
196
+ if (name === "") return el.ax?.description ? "described" : el.disabled || el.tabbable === false ? "unreached" : "unnamed";
197
+ if (el.nameFrom === "placeholder" && el.role !== "input:search") return "placeholder";
198
+ return undefined;
199
+ }
200
+
201
+ /** Where a control sits, by the named controls around it: 'after "Search..." and before "My Inbox"'. */
202
+ function placeOf(elements: InteractiveElement[], i: number): string {
203
+ const nameAt = (j: number) => (elements[j].ax ? elements[j].ax!.name : elements[j].name);
204
+ let before = "";
205
+ for (let j = i - 1; j >= 0 && !before; j--) before = nameAt(j);
206
+ let after = "";
207
+ for (let j = i + 1; j < elements.length && !after; j++) after = nameAt(j);
208
+ const q = (s: string) => `"${s.slice(0, 40)}"`;
209
+ if (before && after) return `after ${q(before)} and before ${q(after)}`;
210
+ if (before) return `after ${q(before)}`;
211
+ return after ? `before ${q(after)}` : "";
212
+ }
213
+
214
+ /** One finding per page, kind of control and kind of problem, naming up to three places. */
215
+ function unnamedControls(elements: InteractiveElement[], pagePath: string): Candidate[] {
216
+ const groups = new Map<string, { control: string; role: string; problem: Unnamed; places: string[]; said: string; count: number }>();
217
+ elements.forEach((el, i) => {
218
+ const problem = nameProblem(el);
219
+ if (!problem) return;
220
+ const control = unlabeledControl(el.role)!;
221
+ const name = el.ax ? el.ax.name : el.name;
222
+ const key = `${problem}:${el.role}`;
223
+ const g = groups.get(key) ?? { control, role: el.role, problem, places: [] as string[], said: problem === "placeholder" ? name : (el.ax?.description ?? ""), count: 0 };
224
+ g.count++;
225
+ // The icon it shows, when its markup names one, is how a founder spots it on the page.
226
+ const place = [el.icon ? `the ${el.icon} icon` : "", placeOf(elements, i)].filter(Boolean).join(" ");
227
+ if (place && g.places.length < MAX_PLACES) g.places.push(place);
228
+ groups.set(key, g);
229
+ });
230
+ return [...groups.values()].map((g) => {
231
+ const where = g.places.length ? ` ${g.count > 1 ? `${g.count} of them, e.g. ` : "It is "}${g.places.join("; ")}.` : "";
232
+ const does = g.control === "button" || g.control === "link" ? "does" : "is for";
233
+ const an = /^[aeiou]/.test(g.control) ? "An" : "A";
234
+ switch (g.problem) {
235
+ case "unnamed":
236
+ return {
237
+ control: { role: g.role, problem: "unnamed" as const },
238
+ signature: `a11y:${pagePath}:${g.role}`,
239
+ kind: "a11y_unlabeled" as const,
240
+ title: `Unlabeled ${g.control} on ${pagePath}`,
241
+ detail: `${an} ${g.control} has no accessible name; screen-reader users cannot tell what it ${does}.${where}`,
242
+ pagePath,
243
+ severity: severityFor("a11y_unlabeled", [pagePath]),
244
+ wcag: unlabeledCriteria(g.control),
245
+ };
246
+ case "described":
247
+ return {
248
+ control: { role: g.role, problem: "described" as const },
249
+ signature: `a11y:described:${pagePath}:${g.role}`,
250
+ kind: "a11y_unlabeled" as const,
251
+ title: `${capitalize(g.control)} on ${pagePath} has a description but no name`,
252
+ detail: `${an} ${g.control} has no accessible name, only a description ("${g.said.slice(0, 60)}"). A screen reader says "${g.control}" and then the description, so the purpose comes through, but the control itself is unnamed.${where}`,
253
+ pagePath,
254
+ severity: "minor" as const,
255
+ wcag: ["4.1.2"],
256
+ };
257
+ case "unreached":
258
+ return {
259
+ control: { role: g.role, problem: "unreached" as const },
260
+ signature: `a11y:unreached:${pagePath}:${g.role}`,
261
+ kind: "a11y_unlabeled" as const,
262
+ title: `Unlabeled ${g.control} on ${pagePath}, which Tab doesn't reach`,
263
+ detail: `${an} ${g.control} has no accessible name, but it is disabled or outside the tab order, so a keyboard user never lands on it. A screen-reader user reading the page line by line still meets an unnamed ${g.control}.${where}`,
264
+ pagePath,
265
+ severity: "minor" as const,
266
+ wcag: unlabeledCriteria(g.control),
267
+ };
268
+ case "placeholder":
269
+ return {
270
+ control: { role: g.role, problem: "placeholder" as const },
271
+ signature: `a11y:placeholder:${pagePath}:${g.role}`,
272
+ kind: "a11y_unlabeled" as const,
273
+ title: `${capitalize(g.control)} on ${pagePath} is named only by its placeholder`,
274
+ detail: `${an} ${g.control} has no label: a screen reader reads its placeholder ("${g.said.slice(0, 40)}") as its name${/^[•·*●]+$/.test(g.said) ? ", here as a count of bullets" : ""}, and the placeholder disappears once someone types, leaving nothing to say what the field is.${where}`,
275
+ pagePath,
276
+ severity: severityFor("a11y_unlabeled", [pagePath]),
277
+ wcag: ["1.3.1", "3.3.2"],
278
+ };
279
+ }
280
+ });
281
+ }
282
+
283
+ const capitalize = (s: string) => s.charAt(0).toUpperCase() + s.slice(1);
284
+
285
+ /** What a server error said, for quoting: its JSON `error` or `message` if it has one. */
286
+ function said(body: string): string {
287
+ let text = body;
288
+ try {
289
+ const parsed = JSON.parse(body) as { error?: unknown; message?: unknown };
290
+ const field = parsed.error ?? parsed.message;
291
+ if (typeof field === "string") text = field;
292
+ } catch {
293
+ // not JSON: quote it as it is
294
+ }
295
+ text = text.replace(/\s+/g, " ").trim();
296
+ return text.length > QUOTE_CHARS ? `${text.slice(0, QUOTE_CHARS)}…` : text;
297
+ }
298
+
299
+ /** In a test environment, the app's own words when a server error says a key is missing. */
300
+ function missingKey(signal: Signal, sandbox: boolean): string | undefined {
301
+ if (!sandbox || signal.kind !== "http_error" || signal.status < 500 || !signal.body || !MISSING_KEY.test(signal.body)) return undefined;
302
+ return said(signal.body);
303
+ }
304
+
305
+ /** The site an agent typed in for its company, which is made up like the company. */
306
+ function typedSite(step: Step): string | undefined {
307
+ return step.action.type === "fill" ? step.action.text.trim().match(TYPED_SITE)?.[1]?.toLowerCase() : undefined;
308
+ }
309
+
310
+ /**
311
+ * In a test environment, the made-up site a server error says it couldn't reach. The site doesn't
312
+ * exist, or the environment won't let the app out to it; a real customer's would load.
313
+ */
314
+ function madeUpSite(signal: Signal, sites: Set<string>, sandbox: boolean): string | undefined {
315
+ if (!sandbox || signal.kind !== "http_error" || signal.status < 500 || !signal.body || !UNREACHABLE.test(signal.body)) return undefined;
316
+ const body = signal.body.toLowerCase();
317
+ return [...sites].find((site) => body.includes(site));
318
+ }
319
+
320
+ /**
321
+ * What a page says about its language and direction. A missing lang is site-wide, so it is one
322
+ * finding; Arabic marked as another language, or laid out left to right, is per page. Pages
323
+ * recorded before these checks carry none of it and are skipped.
324
+ */
325
+ function languageFindings(obs: Observation, pagePath: string): Candidate[] {
326
+ if (obs.lang === undefined) return [];
327
+ const out: Candidate[] = [];
328
+ const arabic = (obs.arabicShare ?? 0) >= ARABIC_PAGE;
329
+ if (arabic && obs.dir === "ltr") {
330
+ out.push({
331
+ signature: `dir:ltr:${pagePath}`,
332
+ kind: "language",
333
+ title: `${pagePath} shows Arabic text left to right`,
334
+ detail: `Most of its text is Arabic, but the page is laid out left to right (no dir="rtl"), so alignment, punctuation and numbers come out in the wrong places for Arabic readers.`,
335
+ pagePath,
336
+ severity: "major",
337
+ });
338
+ }
339
+ if (obs.lang === "") {
340
+ out.push({
341
+ signature: "lang:missing",
342
+ kind: "language",
343
+ title: `Pages don't say what language they're in`,
344
+ detail: `The page's <html> element has no lang attribute (first seen on ${pagePath}), so a screen reader may read it with the wrong voice and pronunciation.`,
345
+ pagePath,
346
+ severity: BASE_SEVERITY.language,
347
+ wcag: ["3.1.1"],
348
+ });
349
+ } else if (arabic && !/^ar(-|$)/i.test(obs.lang)) {
350
+ out.push({
351
+ signature: `lang:mismatch:${pagePath}`,
352
+ kind: "language",
353
+ title: `${pagePath} is in Arabic but says it is "${obs.lang}"`,
354
+ detail: `Most of its text is Arabic but its lang attribute says "${obs.lang}", so a screen reader may read Arabic with the wrong voice.`,
355
+ pagePath,
356
+ severity: BASE_SEVERITY.language,
357
+ wcag: ["3.1.1"],
358
+ });
359
+ }
360
+ // Like a missing lang, a viewport setting is usually the same on every page: one finding.
361
+ if (obs.zoomBlocked) {
362
+ out.push({
363
+ signature: "zoom:blocked",
364
+ kind: "zoom",
365
+ title: "Pages stop people zooming in",
366
+ detail: `The page's viewport setting ("${obs.zoomBlocked}") stops pinch-zoom on phones and tablets (first seen on ${pagePath}), so someone with low vision can't enlarge the text. Remove user-scalable=no, and any maximum-scale under 2.`,
367
+ pagePath,
368
+ severity: BASE_SEVERITY.zoom,
369
+ wcag: ["1.4.4"],
370
+ });
371
+ }
372
+ return out;
373
+ }
374
+
375
+ // An address the agent made up that does not exist (e.g. /settings when the app has
376
+ // /dashboard/settings) is the agent's guess, not a broken link. Links from emails still count.
377
+ function guessedMissing(step: Step, signal: Signal): boolean {
378
+ if (!step.guessedUrl || step.action.type !== "goto" || signal.kind !== "http_error" || signal.status !== 404) return false;
379
+ try {
380
+ return pathOf(new URL(step.action.path, signal.requestUrl).href) === pathOf(signal.requestUrl);
381
+ } catch {
382
+ return false;
383
+ }
384
+ }
385
+
386
+ export interface DetectOptions {
387
+ /** Outbound calls the test environment blocked; failures that coincide with them are attributed to it. */
388
+ blocked?: BlockedCall[];
389
+ /**
390
+ * The run is against a test environment, which has none of the app's secret keys: server errors
391
+ * saying a key is missing are attributed to it. Off against a real deployment, where the same
392
+ * error is the app's bug.
393
+ */
394
+ sandbox?: boolean;
395
+ }
396
+
397
+ // Arabic input a form may refuse: digits as an Arabic phone keyboard types them (٠–٩, and the
398
+ // Persian/Urdu ۰–۹), and names in Arabic script. Gulf sign-up forms often reject both.
399
+ const ARABIC_DIGITS = /[٠-٩۰-۹]/;
400
+ const ARABIC_SCRIPT = /[ء-يٱ-ۓ]/;
401
+ const REFUSED_INPUT = new Set([400, 422]);
402
+
403
+ interface ArabicField {
404
+ field: string;
405
+ text: string;
406
+ digits: boolean;
407
+ }
408
+
409
+ function arabicField(field: string, text: string): ArabicField | undefined {
410
+ if (ARABIC_DIGITS.test(text)) return { field, text, digits: true };
411
+ if (ARABIC_SCRIPT.test(text)) return { field, text, digits: false };
412
+ return undefined;
413
+ }
414
+
415
+ /** "1 of the 2 agents who got this error had just typed Arabic-Indic digits into "Phone" ("٠٥٠…")…" */
416
+ function arabicInputNote(fields: ArabicField[], agents: number, refused: number): string {
417
+ const quote = (f: ArabicField) => `"${f.field}" ("${f.text.slice(0, 40)}")`;
418
+ const digits = fields.filter((f) => f.digits).map(quote);
419
+ const script = fields.filter((f) => !f.digits).map(quote);
420
+ const typed = [
421
+ ...(digits.length ? [`Arabic-Indic digits into ${digits.join(" and ")}`] : []),
422
+ ...(script.length ? [`Arabic script into ${script.join(" and ")}`] : []),
423
+ ].join(" and ");
424
+ const who = refused === 1 ? "The agent who got this error" : `${agents} of the ${refused} agents who got this error`;
425
+ return ` ${who} had just typed ${typed}. People type this way on an Arabic keyboard. Check that the form accepts them, and converts the digits to 0–9 where it needs to.`;
426
+ }
427
+
428
+ function blockedDuring(signal: Signal, blocked: BlockedCall[]): string[] {
429
+ if (signal.kind !== "http_error" || signal.startedAt === undefined || signal.at === undefined) return [];
430
+ const from = signal.startedAt - SANDBOX_SLACK_MS;
431
+ const to = signal.at + SANDBOX_SLACK_MS;
432
+ return blocked.filter((b) => b.at >= from && b.at <= to).map((b) => b.host);
433
+ }
434
+
435
+ export function detectFindings(sessions: Session[], opts: DetectOptions = {}): Finding[] {
436
+ const visitors = new Map<string, Set<string>>();
437
+ const found = new Map<string, Finding>();
438
+ // Per HTTP-error signature: how often it happened, and how often the test environment explains
439
+ // it (it was blocking an outbound call at the time, the app said a key was missing, or it
440
+ // couldn't reach the site the agent made up).
441
+ const sandbox = new Map<string, { occurrences: number; explained: number; causes: Set<string> }>();
442
+ // Per refused-request signature: the Arabic each agent had typed into the form just before.
443
+ const arabicInput = new Map<string, Map<string, ArabicField[]>>();
444
+
445
+ const record = (c: Candidate, agentId: string, stepIndex: number) => {
446
+ let f = found.get(c.signature);
447
+ if (!f) {
448
+ f = { ...c, hitBy: [], visitors: 0, confidence: "low", evidence: [] };
449
+ found.set(c.signature, f);
450
+ }
451
+ if (!f.hitBy.includes(agentId)) {
452
+ f.hitBy.push(agentId);
453
+ f.evidence.push({ agentId, stepIndex });
454
+ }
455
+ };
456
+
457
+ for (const s of sessions) {
458
+ let repeatKey = "";
459
+ let repeats = 0;
460
+ // Per control: how often a click on it was refused because another element lay on top.
461
+ const covered = new Map<string, number>();
462
+ // Websites this agent gave for its company so far.
463
+ const sites = new Set<string>();
464
+ // What the agent has typed into each field on the page it is on.
465
+ let typedOn = "";
466
+ const typed = new Map<string, string>();
467
+ for (const step of s.steps) {
468
+ const pagePath = pathOf(step.observation.url);
469
+ const site = typedSite(step);
470
+ if (site) sites.add(site);
471
+ if (pagePath !== typedOn) {
472
+ typed.clear();
473
+ typedOn = pagePath;
474
+ }
475
+ if (step.action.type === "fill") {
476
+ const id = step.action.id;
477
+ const el = step.observation.elements.find((e) => e.id === id);
478
+ typed.set(el?.ax?.name || el?.name || el?.role || `field ${id}`, step.action.text);
479
+ }
480
+ if (!visitors.has(pagePath)) visitors.set(pagePath, new Set());
481
+ visitors.get(pagePath)!.add(s.agentId);
482
+
483
+ for (const signal of step.signals) {
484
+ if (guessedMissing(step, signal)) continue;
485
+ const c = fromSignal(signal);
486
+ if (!c) continue;
487
+ record(c, s.agentId, step.index);
488
+ if (c.kind === "http_error") {
489
+ const tally = sandbox.get(c.signature) ?? { occurrences: 0, explained: 0, causes: new Set<string>() };
490
+ const hosts = blockedDuring(signal, opts.blocked ?? []);
491
+ const missing = missingKey(signal, opts.sandbox ?? false);
492
+ const madeUp = madeUpSite(signal, sites, opts.sandbox ?? false);
493
+ tally.occurrences++;
494
+ if (hosts.length || missing || madeUp) tally.explained++;
495
+ for (const h of hosts) tally.causes.add(h);
496
+ if (missing) tally.causes.add(`missing key: ${missing}`);
497
+ if (madeUp) tally.causes.add(`made-up site: ${madeUp}`);
498
+ sandbox.set(c.signature, tally);
499
+ const arabic = [...typed].map(([field, text]) => arabicField(field, text)).filter((f) => f !== undefined);
500
+ if (signal.kind === "http_error" && REFUSED_INPUT.has(signal.status) && arabic.length) {
501
+ const byAgent = arabicInput.get(c.signature) ?? new Map<string, ArabicField[]>();
502
+ if (!byAgent.has(s.agentId)) byAgent.set(s.agentId, arabic);
503
+ arabicInput.set(c.signature, byAgent);
504
+ }
505
+ }
506
+ }
507
+
508
+ for (const c of unnamedControls(step.observation.elements, pagePath)) record(c, s.agentId, step.index);
509
+
510
+ for (const c of languageFindings(step.observation, pagePath)) record(c, s.agentId, step.index);
511
+
512
+ // Keyboard only (the screen-reader agent): a control Tab never reaches, and focus that
513
+ // something entirely hides.
514
+ const acted = "id" in step.action ? step.observation.elements.find((e) => e.id === (step.action as { id: number }).id) : undefined;
515
+ if (acted && step.error?.startsWith(NOT_BY_KEYBOARD)) {
516
+ const control = `${acted.role} "${acted.name}"`;
517
+ record(
518
+ {
519
+ signature: `keyboard:${pagePath}:${control}`,
520
+ kind: "keyboard",
521
+ title: `Can't reach ${control} on ${pagePath} with the keyboard`,
522
+ control: { role: acted.role, name: acted.name, problem: "keyboard" },
523
+ detail: `It isn't in the tab order, so someone who uses a keyboard or a screen reader can't get to it. A mouse can still click it.`,
524
+ pagePath,
525
+ severity: severityFor("keyboard", [pagePath]),
526
+ wcag: ["2.1.1"],
527
+ },
528
+ s.agentId,
529
+ step.index,
530
+ );
531
+ }
532
+ // A button that takes Space but not Enter: most keyboard users press Enter, and stop there.
533
+ if (acted && step.enterIgnored) {
534
+ const control = `${acted.role} "${acted.ax?.name || acted.name}"`;
535
+ record(
536
+ {
537
+ signature: `enter:${pagePath}:${control}`,
538
+ kind: "keyboard",
539
+ title: `Enter doesn't press ${control} on ${pagePath}`,
540
+ detail: `With keyboard focus on it, Enter did nothing (no request, no change on the page), and Space pressed it. Buttons take both, and most keyboard and screen-reader users press Enter, so they get no further here.`,
541
+ pagePath,
542
+ severity: severityFor("keyboard", [pagePath]),
543
+ wcag: ["2.1.1"],
544
+ },
545
+ s.agentId,
546
+ step.index,
547
+ );
548
+ }
549
+ if (acted && step.focusObscured) {
550
+ const control = `${acted.role} "${acted.name}"`;
551
+ record(
552
+ {
553
+ signature: `focus:${pagePath}:${control}`,
554
+ kind: "focus_obscured",
555
+ title: `Keyboard focus on ${control} on ${pagePath} is hidden`,
556
+ detail: `With keyboard focus on it, it is entirely covered by ${step.focusObscured}, so someone using the keyboard can't see where they are.`,
557
+ pagePath,
558
+ severity: severityFor("focus_obscured", [pagePath]),
559
+ wcag: ["2.4.11"],
560
+ },
561
+ s.agentId,
562
+ step.index,
563
+ );
564
+ }
565
+
566
+ // A control something else keeps covering: the person can't press it either. The agent
567
+ // works round it in other ways (keys, closing things), so this is not always "stuck".
568
+ const target = step.action.type === "click" && step.error?.startsWith(COVERED) ? step.observation.elements.find((e) => e.id === (step.action as { id: number }).id) : undefined;
569
+ if (target) {
570
+ const control = `${target.role} "${target.name}"`;
571
+ const times = (covered.get(control) ?? 0) + 1;
572
+ covered.set(control, times);
573
+ if (times === COVERED_REPEATS) {
574
+ const by = step.error!.slice(COVERED.length).replace(/^:\s*/, "");
575
+ record(
576
+ {
577
+ signature: `covered:${pagePath}:${control}`,
578
+ kind: "covered",
579
+ title: `Can't click ${control} on ${pagePath}: something covers it`,
580
+ detail: `Every click on the ${control} landed on another element${by ? ` (${by})` : ""} lying over it, so a person can't press it either.`,
581
+ pagePath,
582
+ severity: severityFor("covered", [pagePath]),
583
+ },
584
+ s.agentId,
585
+ step.index,
586
+ );
587
+ }
588
+ }
589
+
590
+ const key = `${pagePath}|${JSON.stringify(step.action)}`;
591
+ repeats = key === repeatKey ? repeats + 1 : 1;
592
+ repeatKey = key;
593
+ // Waiting while the app works ("Creating…") is patience, not going round in circles; an
594
+ // agent waiting out an image upload was reported stuck.
595
+ if (repeats === STUCK_REPEATS && step.action.type !== "done" && step.action.type !== "wait") {
596
+ record(
597
+ {
598
+ signature: `stuck:${pagePath}`,
599
+ kind: "stuck",
600
+ title: `Agent stuck on ${pagePath}`,
601
+ detail: `Repeated the same action ${STUCK_REPEATS} times without progress.`,
602
+ pagePath,
603
+ severity: severityFor("stuck", [pagePath]),
604
+ },
605
+ s.agentId,
606
+ step.index,
607
+ );
608
+ }
609
+ }
610
+
611
+ // The low-vision agent's pages, measured again at 320 CSS pixels wide (WCAG 1.4.10).
612
+ for (const check of s.reflow ?? []) {
613
+ if (check.overflowPx <= 2) continue;
614
+ const pagePath = pathOf(check.url);
615
+ const lastVisit = [...s.steps].reverse().find((st) => st.observation.url.split("#")[0] === check.url) ?? s.steps.at(-1);
616
+ record(
617
+ {
618
+ signature: `reflow:${pagePath}`,
619
+ kind: "reflow",
620
+ title: `${pagePath} scrolls sideways at ${check.widthPx} pixels wide`,
621
+ detail: `At ${check.widthPx} CSS pixels wide (a 1280-pixel screen zoomed to 400%), the page is ${check.overflowPx < SLIGHT_OVERFLOW_PX ? `only ${check.overflowPx} px` : `${check.overflowPx} px`} wider than the window, so reading it means scrolling both ways.${check.widest ? ` Widest: ${check.widest}.` : ""}${check.exception ? ` It may be allowed: ${check.exception}.` : ""}`,
622
+ pagePath,
623
+ // A few pixels still scrolls, but hardly hides anything: not raised on critical pages.
624
+ severity: check.overflowPx < SLIGHT_OVERFLOW_PX ? "minor" : severityFor("reflow", [pagePath]),
625
+ wcag: ["1.4.10"],
626
+ },
627
+ s.agentId,
628
+ lastVisit?.index ?? 0,
629
+ );
630
+ }
631
+ }
632
+
633
+ const needOf = new Map<string, InclusionNeed | undefined>(sessions.map((s) => [s.agentId, s.inclusion]));
634
+ const findings = [...found.values()].map((f) => {
635
+ const pageVisitors = Math.max(visitors.get(f.pagePath)?.size ?? 0, f.hitBy.length);
636
+ const tally = sandbox.get(f.signature);
637
+ const sandboxCause = tally && tally.explained === tally.occurrences ? [...tally.causes].sort() : undefined;
638
+ const confidence = MEASURED.has(f.kind) ? "high" : confidenceFor(f.hitBy.length / pageVisitors);
639
+ // Covered only for the agent at 200% zoom: the layout at that size is the problem.
640
+ const wcag = f.kind === "covered" && f.hitBy.every((a) => needOf.get(a) === "low_vision") ? ["1.4.4", "1.4.10"] : f.wcag;
641
+ const arabic = arabicInput.get(f.signature);
642
+ const detail = arabic ? f.detail + arabicInputNote([...arabic.values()][0], arabic.size, f.hitBy.length) : f.detail;
643
+ return { ...f, detail, visitors: pageVisitors, confidence, ...(sandboxCause ? { sandboxCause } : {}), ...(wcag ? { wcag } : {}) };
644
+ });
645
+
646
+ return findings.sort(
647
+ (a, b) => SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity] || CONFIDENCE_RANK[a.confidence] - CONFIDENCE_RANK[b.confidence],
648
+ );
649
+ }