debutly 0.2.0 → 0.3.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "debutly",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Rehearse your launch: AI agents use your web app like your first customers and report what breaks.",
5
5
  "type": "module",
6
6
  "license": "AGPL-3.0-only",
@@ -73,19 +73,25 @@ export function describeEffect(action: Action, before: Observation, after: Obser
73
73
 
74
74
  const parts: string[] = [];
75
75
  if (result.enterIgnored) parts.push("Enter did nothing on it, so Space was pressed instead");
76
+ if (result.invalid?.length) parts.push(`the browser stopped the form and said: ${result.invalid.join("; ")}`);
76
77
  const changed = whatChanged(before, after, (e) => controlLabel(e, view.screenReader));
77
78
  if (pathOf(after.url) !== pathOf(before.url)) parts.push(`now on ${pathOf(after.url)}`);
78
79
  else if (changed) parts.push(changed);
79
80
  else if (after.text !== before.text) parts.push("the page changed");
80
- else if (action.type !== "fill" && !signals.some((s) => s.kind === "download")) parts.push("nothing visibly changed");
81
+ else if (action.type !== "fill" && !result.invalid?.length && !signals.some((s) => s.kind === "download")) parts.push("nothing visibly changed");
81
82
 
82
83
  for (const s of signals) if (s.kind === "download") parts.push(`a file downloaded: ${s.filename}`);
83
84
 
84
85
  for (const s of signals) if (s.kind === "dialog") parts.push(`a ${s.dialogType} dialog said "${s.message}" (answered OK)`);
85
86
  if (signals.some((s) => s.kind === "page_error")) parts.push("a JavaScript error occurred");
87
+ // Only the server failing: a person never sees a status code, and apps answer 404 or 401 as part
88
+ // of working (a setting never saved yet). Told "a request failed (HTTP 404)" after each click, an
89
+ // agent decided onboarding was broken (2026-10-25). The report still lists those answers.
86
90
  const failed = [
87
91
  ...new Set(
88
- signals.filter((s) => s.kind === "http_error" && pathOf(s.requestUrl) !== "/favicon.ico").map((s) => (s.kind === "http_error" ? s.status : 0)),
92
+ signals
93
+ .filter((s) => s.kind === "http_error" && s.status >= 500 && pathOf(s.requestUrl) !== "/favicon.ico")
94
+ .map((s) => (s.kind === "http_error" ? s.status : 0)),
89
95
  ),
90
96
  ];
91
97
  for (const status of failed) parts.push(`a request failed (HTTP ${status})`);
@@ -0,0 +1,51 @@
1
+ import type { Action, InteractiveElement, Observation } from "../types";
2
+
3
+ const norm = (s: string) => s.toLowerCase().replace(/\s+/g, " ").trim();
4
+
5
+ /**
6
+ * How a control is named in an agent's words: whole when short, by its first words when its name
7
+ * runs on into a description ("Email alerts Get notified of hiring changes" is "email alerts").
8
+ */
9
+ function mentionOf(name: string): string {
10
+ const n = norm(name);
11
+ if (n.length <= 30) return n;
12
+ const words = n.split(" ");
13
+ const two = words.slice(0, 2).join(" ");
14
+ return two.length >= 8 ? two : words.slice(0, 3).join(" ");
15
+ }
16
+
17
+ const escape = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
18
+
19
+ /**
20
+ * Whether the agent's words name the control. A one-word name counts only as written on the
21
+ * control (its capitals), so "go back" in passing isn't the "Back" button.
22
+ */
23
+ function said(reasoning: string, name: string): boolean {
24
+ const m = mentionOf(name);
25
+ if (m.length < 3) return false;
26
+ if (m.includes(" ")) return norm(reasoning).includes(m);
27
+ return new RegExp(`(^|[^\\p{L}\\p{N}])${escape(name.trim())}($|[^\\p{L}\\p{N}])`, "u").test(reasoning);
28
+ }
29
+
30
+ const nameOf = (e: InteractiveElement, screenReader: boolean) => (screenReader && e.ax ? e.ax.name : e.name || e.ax?.name || "");
31
+
32
+ /**
33
+ * When an agent's reasoning names controls on the page but its action points at another one: the
34
+ * controls it named. An agent in a product's onboarding wrote "I will click the button for email
35
+ * alerts" and then "select 'Competitor'", and both times sent the id of the logo, which restarted
36
+ * onboarding; it gave up blaming the app (2026-10-25). Undefined when the action matches what it
37
+ * said, or when there is nothing to compare (no id, or a control without a name).
38
+ */
39
+ export function saidOtherControl(reasoning: string, action: Action, obs: Observation, screenReader = false): string[] | undefined {
40
+ if (action.type !== "click" && action.type !== "fill") return undefined;
41
+ const chosen = obs.elements.find((e) => e.id === action.id);
42
+ if (!chosen) return undefined;
43
+ const chosenName = nameOf(chosen, screenReader);
44
+ if (!chosenName.trim() || said(reasoning, chosenName)) return undefined;
45
+ // A field filled with text it names ("type Agency into Industry") is fine either way.
46
+ if (action.type === "fill" && norm(reasoning).includes(norm(action.text))) return undefined;
47
+ const meant = obs.elements
48
+ .filter((e) => e.id !== chosen.id && nameOf(e, screenReader).trim().length >= 4 && said(reasoning, nameOf(e, screenReader)))
49
+ .map((e) => nameOf(e, screenReader).trim());
50
+ return meant.length ? [...new Set(meant)].slice(0, 3) : undefined;
51
+ }
package/src/agent/loop.ts CHANGED
@@ -12,6 +12,8 @@ import type { ActionResult, Persona, Session, Step } from "../types";
12
12
  import { describeAction } from "./describe";
13
13
  import { describeEffect } from "./effect";
14
14
  import { nothingToPressYet, untriedWaysOn } from "./give-up";
15
+ import { saidOtherControl } from "./intent";
16
+ import { controlLabel } from "./label";
15
17
  import { NoActionError, parseAgentReply } from "./parse-reply";
16
18
  import { buildStepMessage, buildSystemPrompt, type AgentIdentity } from "./prompt";
17
19
 
@@ -55,12 +57,35 @@ export interface RunSessionOptions {
55
57
  firstLoad?: { timeoutMs?: number; slowMs?: number };
56
58
  }
57
59
 
58
- function describeInbox(messages: EmailMessage[]): string {
60
+ /** The inbox as the agent reads it, each link numbered ("email link 2") so it can be opened without retyping it. */
61
+ export function describeInbox(messages: EmailMessage[], numberOf: (link: string) => number): string {
59
62
  if (!messages.length) return "checked email → no emails yet";
60
- const items = messages.map((m) => `"${m.subject}"${m.links.length ? ` links: ${m.links.slice(0, LINKS_PER_EMAIL).join(" ")}` : ""}`);
63
+ const items = messages.map((m) => {
64
+ const links = m.links.slice(0, LINKS_PER_EMAIL).map((l) => `[email link ${numberOf(l)}] ${l}`);
65
+ return `"${m.subject}"${links.length ? ` links: ${links.join(" ")}` : ""}`;
66
+ });
61
67
  return `checked email → ${messages.length} email${messages.length === 1 ? "" : "s"}: ${items.join("; ")}`;
62
68
  }
63
69
 
70
+ /**
71
+ * The emailed link a goto means: "email link 2" by its number, or a mistyped copy of one, the same
72
+ * path and query under a garbled host. An agent retyped a sign-in link as http://127.0.0.0.0.1:…,
73
+ * got "Invalid URL" and gave up (2026-10-25). Undefined when it means no emailed link.
74
+ */
75
+ export function emailedLinkMeant(path: string, links: string[]): string | undefined {
76
+ const numbered = /^email ?link ?(\d+)$/i.exec(path.trim());
77
+ if (numbered) return links[Number(numbered[1]) - 1];
78
+ const tail = (u: string) => {
79
+ const start = u.indexOf("//");
80
+ const slash = u.indexOf("/", start < 0 ? 0 : start + 2);
81
+ return slash < 0 ? "" : u.slice(slash);
82
+ };
83
+ const typed = tail(path.trim());
84
+ // Long enough to be a link's own path and token, not a common page like "/login".
85
+ if (typed.length < 20) return undefined;
86
+ return links.find((l) => tail(l) === typed && l !== path.trim());
87
+ }
88
+
64
89
  /**
65
90
  * Open the app the way a person does: wait for the page, but never fail the session because it
66
91
  * is slow. Waiting for every image and font ("load") within Playwright's 30 s ended the
@@ -209,6 +234,22 @@ async function takeSteps(o: RunSessionOptions, { signals, system, steps, history
209
234
  if (!reply) {
210
235
  return end("error", `${problem} | last reply: ${raw.slice(0, 300) || "(empty)"}`);
211
236
  }
237
+ // One more question about the reply just given; an unreadable answer leaves the reply standing.
238
+ const askAgain = async (question: string) => {
239
+ const again = await o.llm.complete({
240
+ system,
241
+ messages: [...messages, { role: "assistant", content: raw }, { role: "user", content: question }],
242
+ maxTokens: 4096,
243
+ onUsage: meter.add,
244
+ });
245
+ try {
246
+ const answer = parseAgentReply(again);
247
+ raw = again;
248
+ return answer;
249
+ } catch {
250
+ return undefined;
251
+ }
252
+ };
212
253
  // Giving up with a way on in plain sight that it never pressed here, or on a page with nothing
213
254
  // to press yet: asked once per page. Still giving up, it gives up.
214
255
  if (reply.action.type === "done" && !reply.action.success && !askedBeforeGivingUp.has(observation.url)) {
@@ -220,26 +261,16 @@ async function takeSteps(o: RunSessionOptions, { signals, system, steps, history
220
261
  : "";
221
262
  if (ask) {
222
263
  askedBeforeGivingUp.add(observation.url);
223
- const again = await o.llm.complete({
224
- system,
225
- messages: [
226
- ...messages,
227
- { role: "assistant", content: raw },
228
- {
229
- role: "user",
230
- content: `${ask} Reply again with one JSON object: go on, or give up if you are sure.`,
231
- },
232
- ],
233
- maxTokens: 4096,
234
- onUsage: meter.add,
235
- });
236
- try {
237
- reply = parseAgentReply(again);
238
- } catch {
239
- // an unreadable second answer leaves the first one standing
240
- }
264
+ reply = (await askAgain(`${ask} Reply again with one JSON object: go on, or give up if you are sure.`)) ?? reply;
241
265
  }
242
266
  }
267
+ // Its words name one control and its action points at another: asked once to check the id.
268
+ const meant = saidOtherControl(reply.reasoning, reply.action, observation, screenReader);
269
+ const chosen = "id" in reply.action ? observation.elements.find((e) => e.id === (reply!.action as { id: number }).id) : undefined;
270
+ if (meant && chosen) {
271
+ const question = `You wrote about ${meant.map((n) => `"${n}"`).join(" or ")}, but your action points at [${chosen.id}] ${controlLabel(chosen, screenReader)}. Check the id: reply again with one JSON object.`;
272
+ reply = (await askAgain(question)) ?? reply;
273
+ }
243
274
 
244
275
  let result: ActionResult;
245
276
  let historyLine = describeAction(reply.action, observation);
@@ -250,14 +281,22 @@ async function takeSteps(o: RunSessionOptions, { signals, system, steps, history
250
281
  try {
251
282
  const mail = await o.inbox.latest(o.identity.email);
252
283
  for (const m of mail) for (const link of m.links) emailedLinks.add(link);
253
- historyLine = describeInbox(mail);
284
+ const numbered = [...emailedLinks];
285
+ historyLine = describeInbox(mail, (link) => numbered.indexOf(link) + 1);
254
286
  result = {};
255
287
  } catch (err) {
256
288
  result = { error: err instanceof Error ? err.message : String(err) };
257
289
  }
258
290
  }
259
291
  } else {
260
- result = await execute(o.page, reply.action, observation, o.origin, (href) => emailedLinks.has(href), { keyboard: screenReader });
292
+ // An emailed link, by its number or as a mistyped copy, is opened as it was sent.
293
+ const link = reply.action.type === "goto" ? emailedLinkMeant(reply.action.path, [...emailedLinks]) : undefined;
294
+ if (reply.action.type === "goto" && /^email ?link ?\d+$/i.test(reply.action.path.trim()) && !link) {
295
+ result = { error: `there is no ${reply.action.path.trim()}: check your email to see the links` };
296
+ } else {
297
+ const action = link ? { type: "goto" as const, path: link } : reply.action;
298
+ result = await execute(o.page, action, observation, o.origin, (href) => emailedLinks.has(href), { keyboard: screenReader });
299
+ }
261
300
  }
262
301
 
263
302
  let screenshotPath: string | undefined;
@@ -14,7 +14,7 @@ export interface AgentIdentity {
14
14
  export function buildSystemPrompt(persona: Persona, goal: string, identity?: AgentIdentity, brief?: string): string {
15
15
  const email = identity
16
16
  ? `Your email address is ${identity.email}. Use it whenever the site asks for your email.
17
- To read your inbox (for example a login link or a confirmation), reply {"type":"check_email"}. To open a link from an email, use {"type":"goto","path":"<the full link>"}.`
17
+ To read your inbox (for example a login link or a confirmation), reply {"type":"check_email"}. To open a link from an email, use its number: {"type":"goto","path":"email link 1"}.`
18
18
  : `Use emails at example.com.`;
19
19
  const about = [
20
20
  `You are ${persona.name}. ${persona.background}`,
@@ -47,8 +47,10 @@ Action is one of:
47
47
  }
48
48
 
49
49
  /** A control's state, as a person sees it (and a screen reader announces it). */
50
- function stateOf(e: InteractiveElement): string {
50
+ function stateOf(e: InteractiveElement, behindOpenLayer = false): string {
51
51
  const states = [
52
+ // Hidden from assistive technology while something is open: what a modal does to the page behind it.
53
+ behindOpenLayer && e.ax?.hidden ? "behind what is open" : "",
52
54
  e.disabled ? "disabled" : "",
53
55
  e.readOnly ? "read-only" : "",
54
56
  e.checked === undefined ? "" : e.checked ? "checked" : "not checked",
@@ -59,19 +61,20 @@ function stateOf(e: InteractiveElement): string {
59
61
  return states.length ? ` (${states.join(", ")})` : "";
60
62
  }
61
63
 
62
- function describeElement(e: InteractiveElement, screenReader = false): string {
63
- const line = `[${e.id}] ${controlLabel(e, screenReader)}${stateOf(e)}`;
64
+ function describeElement(e: InteractiveElement, screenReader = false, behindOpenLayer = false): string {
65
+ const line = `[${e.id}] ${controlLabel(e, screenReader)}${stateOf(e, behindOpenLayer)}`;
64
66
  if (!e.options) return line;
65
67
  return `${line} set to "${e.value ?? ""}", options: ${e.options.map((o) => `"${o}"`).join(" | ")}`;
66
68
  }
67
69
 
68
70
  export function buildStepMessage(obs: Observation, history: string[], view: StepView = {}): string {
69
71
  const shown = view.screenReader ? obs.elements.filter((e) => !e.ax?.hidden) : obs.elements;
70
- const elements = shown.map((e) => describeElement(e, view.screenReader)).join("\n");
72
+ const elements = shown.map((e) => describeElement(e, view.screenReader, !!obs.openLayer)).join("\n");
71
73
  const past = history.length ? history.map((h, i) => `${i + 1}. ${h}`).join("\n") : "(none yet)";
74
+ const open = obs.openLayer ? `\nOpen now: ${obs.openLayer}. It takes clicks outside it: finish it, choose from it or close it (Escape, or its close button) before using what is behind it.\n` : "";
72
75
  return `URL: ${obs.url}
73
76
  Title: ${obs.title}
74
-
77
+ ${open}
75
78
  Interactive elements:
76
79
  ${elements}
77
80
 
package/src/browser/ax.ts CHANGED
@@ -67,6 +67,7 @@ export async function accessibleNames(page: Page): Promise<Map<number, Accessibl
67
67
  role: node.role?.value ?? "",
68
68
  hidden: node.ignored,
69
69
  ...(description ? { description } : {}),
70
+ ...(node.ignoredReasons?.some((r) => r.name === "ariaHiddenElement" || r.name === "ariaHiddenSubtree") ? { ariaHidden: true as const } : {}),
70
71
  });
71
72
  }
72
73
  return out;
@@ -1,5 +1,6 @@
1
1
  import type { Locator, Page } from "playwright";
2
2
  import type { Action, ActionResult, InteractiveElement, Observation } from "../types";
3
+ import { OPEN_LAYER_SCRIPT } from "./observe";
3
4
  import { sampleFiles, sampleFor, type SampleKind } from "./samples";
4
5
 
5
6
  const DESTRUCTIVE = /\b(delete|remove|destroy|deactivate|close account|cancel (my )?(subscription|account))\b/i;
@@ -85,6 +86,8 @@ const FOCUS_ONLY = /^(input:(text|email|password|number|search|tel|url|date|time
85
86
  const focusCoveredScript = (id: number) => `(() => {
86
87
  const el = document.querySelector('[data-debutly-id="${id}"]');
87
88
  if (!el) return null;
89
+ // A dropdown's own open list may be drawn over it, by design (2026-11-01).
90
+ if (el.getAttribute('aria-expanded') === 'true') return null;
88
91
  const r = el.getBoundingClientRect();
89
92
  const points = [[0.5, 0.5], [0.1, 0.1], [0.9, 0.1], [0.1, 0.9], [0.9, 0.9]].map(([fx, fy]) => [r.left + r.width * fx, r.top + r.height * fy]);
90
93
  let cover = null;
@@ -94,7 +97,7 @@ const focusCoveredScript = (id: number) => `(() => {
94
97
  if (!hit || hit === el || el.contains(hit) || hit.contains(el)) return null;
95
98
  cover = cover || hit;
96
99
  }
97
- if (!cover) return 'the edge of the window';
100
+ if (!cover) return '${EDGE_OF_WINDOW}';
98
101
  const classes = typeof cover.className === 'string' ? cover.className.trim().split(/\\s+/).filter(Boolean).slice(0, 2) : [];
99
102
  const text = (cover.innerText || '').replace(/\\s+/g, ' ').trim().slice(0, 50);
100
103
  return cover.tagName.toLowerCase() + (cover.id ? '#' + cover.id : '') + classes.map((c) => '.' + c).join('') + (text ? ' ("' + text + '")' : '');
@@ -121,9 +124,65 @@ async function focusByKeyboard(page: Page, locator: Locator, id: number): Promis
121
124
  if (reach === "disabled") throw new Unusable(UNUSABLE.disabled);
122
125
  if (reach === "untabbable") throw new NotByKeyboard(`${NOT_BY_KEYBOARD}: it isn't in the tab order`);
123
126
  await locator.focus({ timeout: 5_000 });
127
+ const hidden = (await page.evaluate(focusCoveredScript(id))) as string | null;
128
+ // A page that scrolls smoothly brings the focused control into view a moment later: measured at
129
+ // once, a Continue button in plain sight was reported hidden by the edge of the window.
130
+ if (hidden !== EDGE_OF_WINDOW) return hidden ?? undefined;
131
+ await page.waitForTimeout(SMOOTH_SCROLL_MS);
124
132
  return ((await page.evaluate(focusCoveredScript(id))) as string | null) ?? undefined;
125
133
  }
126
134
 
135
+ const EDGE_OF_WINDOW = "the edge of the window";
136
+
137
+ // What the browser itself says when a submit finds a field invalid ("Please lengthen this text to
138
+ // 8 characters or more"): a person sees that bubble; it isn't in the page's text, so an agent with
139
+ // a 7-letter password saw nothing happen, and gave up saying sign-up was broken (2026-10-25).
140
+ // Listened for during each action; a string, like SCAN_SCRIPT.
141
+ const LISTEN_INVALID = `(() => {
142
+ if (window.__debutlyInvalid) return;
143
+ window.__debutlyInvalid = [];
144
+ document.addEventListener('invalid', (e) => {
145
+ const el = e.target;
146
+ const label = (el.labels && el.labels[0] && el.labels[0].textContent) || el.getAttribute('aria-label') || el.getAttribute('placeholder') || el.name || 'a field';
147
+ window.__debutlyInvalid.push('"' + label.replace(/\\s+/g, ' ').trim().slice(0, 60) + '": ' + el.validationMessage);
148
+ }, true);
149
+ })()`;
150
+ const READ_INVALID = `(() => {
151
+ const said = window.__debutlyInvalid || [];
152
+ window.__debutlyInvalid = [];
153
+ return [...new Set(said)].slice(0, 3);
154
+ })()`;
155
+ /** Long enough for a smooth scroll to finish. */
156
+ const SMOOTH_SCROLL_MS = 600;
157
+
158
+ /**
159
+ * Whether a click on the control was taken by something open over the page, which a person uses
160
+ * or closes first: a dialog, tour step, menu or dropdown list, or the backdrop that dims the page
161
+ * behind one (an open dropdown can also make the page itself take every click outside it). Says
162
+ * what is open, or "" when the cover is part of the page: a fixed bar, or an invisible layer over
163
+ * everything, which a person can't see to close and so stays a finding.
164
+ */
165
+ const OPEN_OVER_SCRIPT = (id: number) => `(() => {
166
+ const el = document.querySelector('[data-debutly-id="${id}"]');
167
+ if (!el) return '';
168
+ const r = el.getBoundingClientRect();
169
+ const hit = document.elementFromPoint(Math.min(Math.max(r.left + r.width / 2, 0), innerWidth - 1), Math.min(Math.max(r.top + r.height / 2, 0), innerHeight - 1));
170
+ const open = ${OPEN_LAYER_SCRIPT};
171
+ if (!hit || hit === document.documentElement || hit === document.body) return open;
172
+ if (hit.closest('[role="dialog"], [role="alertdialog"], dialog, [aria-modal="true"], [role="listbox"], [role="menu"]')) return open || 'something';
173
+ const b = hit.getBoundingClientRect();
174
+ const s = getComputedStyle(hit);
175
+ const fills = (s.position === 'fixed' || s.position === 'absolute') && b.width >= innerWidth * 0.9 && b.height >= innerHeight * 0.9;
176
+ const bg = s.backgroundColor;
177
+ const alpha = bg === 'transparent' ? 0 : bg.startsWith('rgba') ? parseFloat(bg.slice(bg.lastIndexOf(',') + 1)) : 1;
178
+ const dims = alpha > 0 || (s.backdropFilter && s.backdropFilter !== 'none');
179
+ return fills && dims ? open || 'something (its backdrop dims the page)' : '';
180
+ })()`;
181
+
182
+ async function openOver(page: Page, id: number): Promise<string> {
183
+ return ((await page.evaluate(OPEN_OVER_SCRIPT(id)).catch(() => "")) as string) || "";
184
+ }
185
+
127
186
  // Playwright's timeout message hides the cause in its call log; say it the way a person would notice it.
128
187
  export function describeFailure(err: unknown): string {
129
188
  const message = err instanceof Error ? err.message : String(err);
@@ -331,6 +390,7 @@ export async function execute(
331
390
  opts: ExecuteOptions = {},
332
391
  ): Promise<ActionResult> {
333
392
  const result: ActionResult = {};
393
+ await page.evaluate(LISTEN_INVALID).catch(() => {});
334
394
  try {
335
395
  switch (action.type) {
336
396
  case "click":
@@ -351,7 +411,13 @@ export async function execute(
351
411
  try {
352
412
  await locator.click({ timeout: 5_000 });
353
413
  } catch (err) {
354
- if (!describeFailure(err).startsWith(COVERED) || !(await clickVisiblePart(page, action.id))) throw err;
414
+ if (!describeFailure(err).startsWith(COVERED) || !(await clickVisiblePart(page, action.id))) {
415
+ // Something open over the page took the click: the agent is told what to do, as a
416
+ // person sees it, and it is no defect of the page behind.
417
+ const open = describeFailure(err).startsWith(COVERED) ? await openOver(page, action.id) : "";
418
+ if (open) throw new Unusable(`${open} is open over the page and takes the click: finish it, choose from it or close it (Escape, or its close button) first`);
419
+ throw err;
420
+ }
355
421
  }
356
422
  } else if (el.tag === "select") await choose(locator, action.text);
357
423
  else if (customDropdown) await pick(page, locator, action.text);
@@ -391,7 +457,9 @@ export async function execute(
391
457
  await page.waitForLoadState("domcontentloaded");
392
458
  // Let async handlers (fetch after submit, deferred errors) surface their signals.
393
459
  await page.waitForTimeout(300);
394
- return result.focusObscured || result.enterIgnored ? result : {};
460
+ const invalid = ((await page.evaluate(READ_INVALID).catch(() => [])) as string[]) ?? [];
461
+ if (invalid.length) result.invalid = invalid;
462
+ return result;
395
463
  } catch (err) {
396
464
  if (err instanceof Refused) return { blocked: err.message };
397
465
  if (err instanceof NotByKeyboard || err instanceof Unusable) return { error: err.message };
@@ -43,6 +43,36 @@ const LANGUAGE_SCRIPT = `(() => {
43
43
  };
44
44
  })()`;
45
45
 
46
+ /**
47
+ * What is open over the page and takes clicks outside it: a dialog (or a tour step, a bottom sheet),
48
+ * or a dropdown list or menu. Agents clicked behind a welcome dialog, an open menu and an open
49
+ * dropdown on three apps in one week (2026-10-25, 2026-11-01), and each was reported as
50
+ * "something covers it". "" when nothing is open.
51
+ */
52
+ export const OPEN_LAYER_SCRIPT = `(() => {
53
+ const shown = (e) => {
54
+ const b = e.getBoundingClientRect();
55
+ const s = getComputedStyle(e);
56
+ return b.width > 0 && b.height > 0 && s.visibility !== 'hidden' && s.display !== 'none' && Number(s.opacity) !== 0;
57
+ };
58
+ const nameOf = (e) => {
59
+ const label = e.getAttribute('aria-label');
60
+ if (label) return label;
61
+ const by = (e.getAttribute('aria-labelledby') || '').split(/\\s+/)[0];
62
+ const titled = by && document.getElementById(by);
63
+ if (titled) return titled.textContent;
64
+ const heading = e.querySelector('h1, h2, h3, h4, [role="heading"]');
65
+ return heading ? heading.textContent : '';
66
+ };
67
+ const dialog = [...document.querySelectorAll('[role="dialog"], [role="alertdialog"], dialog[open], [aria-modal="true"]')].find(shown);
68
+ const list = [...document.querySelectorAll('[role="listbox"], [role="menu"]')].find((e) => shown(e) && document.querySelector('[aria-expanded="true"]'));
69
+ const layer = dialog || list;
70
+ if (!layer) return '';
71
+ const kind = layer === dialog ? 'a dialog' : layer.getAttribute('role') === 'menu' ? 'a menu' : 'a dropdown list';
72
+ const name = (nameOf(layer) || '').replace(/\\s+/g, ' ').trim().slice(0, 60);
73
+ return kind + (name ? ' ("' + name + '")' : '');
74
+ })()`;
75
+
46
76
  export async function observe(page: Page): Promise<Observation> {
47
77
  const elements = (await page.evaluate(SCAN_SCRIPT)) as InteractiveElement[];
48
78
  const names = await accessibleNames(page);
@@ -58,5 +88,6 @@ export async function observe(page: Page): Promise<Observation> {
58
88
  .join("\n")
59
89
  .slice(0, 2000);
60
90
  const language = (await page.evaluate(LANGUAGE_SCRIPT)) as Pick<Observation, "lang" | "dir" | "arabicShare" | "zoomBlocked">;
61
- return { url: page.url(), title: await page.title(), elements, text, ...language };
91
+ const openLayer = (await page.evaluate(OPEN_LAYER_SCRIPT)) as string;
92
+ return { url: page.url(), title: await page.title(), elements, text, ...language, ...(openLayer ? { openLayer } : {}) };
62
93
  }
@@ -17,9 +17,10 @@ const BASE_SEVERITY: Record<FindingKind, Severity> = {
17
17
  reflow: "minor",
18
18
  language: "minor",
19
19
  zoom: "major",
20
+ hidden_focus: "major",
20
21
  };
21
22
  // 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
+ export const MEASURED: ReadonlySet<FindingKind> = new Set(["a11y_unlabeled", "covered", "keyboard", "focus_obscured", "reflow", "language", "zoom", "hidden_focus"]);
23
24
  /** Sideways overflow this small is reported, but never raised above minor. */
24
25
  const SLIGHT_OVERFLOW_PX = 20;
25
26
  /** A page whose letters are at least this share Arabic is an Arabic page. */
@@ -34,6 +35,8 @@ const VERY_SLOW_MS = 30_000;
34
35
  // "no" and "API key", and the variable it wants.
35
36
  const MISSING_KEY =
36
37
  /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;
38
+ // How an auth server refuses a sign-in provider it has no keys for.
39
+ const PROVIDER_OFF = /provider is not enabled|unsupported provider|provider (?:is )?(?:disabled|not configured)/i;
37
40
  // A web address typed in on its own, as an agent gives its company's site ("https://www.pulsehr.io/").
38
41
  const TYPED_SITE = /^(?:https?:\/\/)?(?:www\.)?((?:[a-z0-9-]+\.)+[a-z]{2,})(?:[/?#]\S*)?$/i;
39
42
  // How an app says it couldn't get at a site: "Could not read that website", "DNS lookup failed".
@@ -50,6 +53,14 @@ const SANDBOX_SLACK_MS = 2000;
50
53
  // /app/accounts/<their account>/onboarding, which listed the same unlabeled button once per agent.
51
54
  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
55
 
56
+ const originOf = (url: string) => {
57
+ try {
58
+ return new URL(url).origin;
59
+ } catch {
60
+ return "";
61
+ }
62
+ };
63
+
53
64
  const pathOf = (url: string) => {
54
65
  try {
55
66
  return new URL(url).pathname
@@ -211,6 +222,39 @@ function placeOf(elements: InteractiveElement[], i: number): string {
211
222
  return after ? `before ${q(after)}` : "";
212
223
  }
213
224
 
225
+ /**
226
+ * Controls hidden from screen readers (aria-hidden) that Tab still reaches: a closed mobile menu
227
+ * left in the page, its ~15 links aria-hidden but focusable (2026-10-25). Keyboard focus goes into
228
+ * them unseen, and a screen reader says nothing or something misleading. Not while something is
229
+ * open over the page: a modal hides the page behind it on purpose, and keeps focus inside itself.
230
+ */
231
+ function hiddenButReachable(obs: Observation, pagePath: string): Candidate | undefined {
232
+ if (obs.openLayer) return undefined;
233
+ const reached = obs.elements.filter((e) => e.ax?.ariaHidden && !e.disabled && e.tabbable !== false && e.role !== "input:file");
234
+ if (!reached.length) return undefined;
235
+ const names = [...new Set(reached.map((e) => e.name.trim()).filter(Boolean))].slice(0, 3).map((n) => `"${n.slice(0, 40)}"`);
236
+ const count = reached.length === 1 ? "A control" : `${reached.length} controls`;
237
+ return {
238
+ signature: `hidden-focus:${pagePath}`,
239
+ kind: "hidden_focus",
240
+ title: `${count} on ${pagePath} ${reached.length === 1 ? "is" : "are"} hidden from screen readers but reachable with Tab`,
241
+ detail: `${count}${names.length ? ` (${names.join(", ")}${reached.length > names.length ? ", …" : ""})` : ""} ${reached.length === 1 ? "is" : "are"} marked aria-hidden, often a closed menu or panel left in the page, yet Tab still lands on ${reached.length === 1 ? "it" : "them"}: keyboard focus disappears from view, and a screen reader says nothing or something misleading.`,
242
+ pagePath,
243
+ severity: severityFor("hidden_focus", [pagePath]),
244
+ wcag: ["4.1.2", "2.4.3"],
245
+ };
246
+ }
247
+
248
+ /** Whether the agent pressed the control without trouble later on the same page. */
249
+ function pressedLater(steps: Step[], after: number, pagePath: string, control: string): boolean {
250
+ return steps.some((st) => {
251
+ if (st.index <= after || st.error || st.blocked || st.action.type !== "click" || pathOf(st.observation.url) !== pagePath) return false;
252
+ const id = st.action.id;
253
+ const el = st.observation.elements.find((e) => e.id === id);
254
+ return !!el && `${el.role} "${el.name}"` === control;
255
+ });
256
+ }
257
+
214
258
  /** One finding per page, kind of control and kind of problem, naming up to three places. */
215
259
  function unnamedControls(elements: InteractiveElement[], pagePath: string): Candidate[] {
216
260
  const groups = new Map<string, { control: string; role: string; problem: Unnamed; places: string[]; said: string; count: number }>();
@@ -282,12 +326,12 @@ function unnamedControls(elements: InteractiveElement[], pagePath: string): Cand
282
326
 
283
327
  const capitalize = (s: string) => s.charAt(0).toUpperCase() + s.slice(1);
284
328
 
285
- /** What a server error said, for quoting: its JSON `error` or `message` if it has one. */
329
+ /** What a server error said, for quoting: its JSON `error`, `message` or `msg` (Supabase's auth) if it has one. */
286
330
  function said(body: string): string {
287
331
  let text = body;
288
332
  try {
289
- const parsed = JSON.parse(body) as { error?: unknown; message?: unknown };
290
- const field = parsed.error ?? parsed.message;
333
+ const parsed = JSON.parse(body) as { error?: unknown; message?: unknown; msg?: unknown };
334
+ const field = typeof parsed.error === "string" ? parsed.error : (parsed.message ?? parsed.msg);
291
335
  if (typeof field === "string") text = field;
292
336
  } catch {
293
337
  // not JSON: quote it as it is
@@ -298,8 +342,12 @@ function said(body: string): string {
298
342
 
299
343
  /** In a test environment, the app's own words when a server error says a key is missing. */
300
344
  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);
345
+ if (!sandbox || signal.kind !== "http_error" || !signal.body) return undefined;
346
+ if (signal.status >= 500 && MISSING_KEY.test(signal.body)) return said(signal.body);
347
+ // A sign-in provider the test environment's auth server has no keys for (Google, Microsoft…)
348
+ // refuses with a 400: "Unsupported provider: provider is not enabled" (2026-10-25).
349
+ if (signal.status >= 400 && PROVIDER_OFF.test(signal.body)) return said(signal.body);
350
+ return undefined;
303
351
  }
304
352
 
305
353
  /** The site an agent typed in for its company, which is made up like the company. */
@@ -455,6 +503,8 @@ export function detectFindings(sessions: Session[], opts: DetectOptions = {}): F
455
503
  };
456
504
 
457
505
  for (const s of sessions) {
506
+ // The app's own pages are where it started; a sign-in server's raw error page is not one of them.
507
+ const appOrigin = originOf(s.steps[0]?.observation.url ?? "");
458
508
  let repeatKey = "";
459
509
  let repeats = 0;
460
510
  // Per control: how often a click on it was refused because another element lay on top.
@@ -506,8 +556,10 @@ export function detectFindings(sessions: Session[], opts: DetectOptions = {}): F
506
556
  }
507
557
 
508
558
  for (const c of unnamedControls(step.observation.elements, pagePath)) record(c, s.agentId, step.index);
559
+ const hiddenFocus = hiddenButReachable(step.observation, pagePath);
560
+ if (hiddenFocus) record(hiddenFocus, s.agentId, step.index);
509
561
 
510
- for (const c of languageFindings(step.observation, pagePath)) record(c, s.agentId, step.index);
562
+ if (originOf(step.observation.url) === appOrigin) for (const c of languageFindings(step.observation, pagePath)) record(c, s.agentId, step.index);
511
563
 
512
564
  // Keyboard only (the screen-reader agent): a control Tab never reaches, and focus that
513
565
  // something entirely hides.
@@ -565,12 +617,14 @@ export function detectFindings(sessions: Session[], opts: DetectOptions = {}): F
565
617
 
566
618
  // A control something else keeps covering: the person can't press it either. The agent
567
619
  // works round it in other ways (keys, closing things), so this is not always "stuck".
620
+ // Unless the agent presses it later on the same page: then what lay over it went away once
621
+ // the agent dealt with it (a dropdown list chosen from, 2026-11-04), and it is no defect.
568
622
  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
623
  if (target) {
570
624
  const control = `${target.role} "${target.name}"`;
571
625
  const times = (covered.get(control) ?? 0) + 1;
572
626
  covered.set(control, times);
573
- if (times === COVERED_REPEATS) {
627
+ if (times === COVERED_REPEATS && !pressedLater(s.steps, step.index, pagePath, control)) {
574
628
  const by = step.error!.slice(COVERED.length).replace(/^:\s*/, "");
575
629
  record(
576
630
  {
@@ -51,6 +51,8 @@ export function nextStep(f: Finding): string {
51
51
  if (f.signature.startsWith("dir:")) return 'Set dir="rtl" on Arabic pages (on <html>, or on the part that holds the Arabic).';
52
52
  if (f.signature === "lang:missing") return 'Add a lang attribute to the <html> element, e.g. lang="en".';
53
53
  return 'Set lang="ar" on Arabic pages.';
54
+ case "hidden_focus":
55
+ return 'Make the hidden part unreachable while hidden: add inert to it (or tabindex="-1" to each control), or stop hiding it from screen readers.';
54
56
  case "zoom":
55
57
  return "Remove user-scalable=no and maximum-scale from the page's viewport meta tag.";
56
58
  }
@@ -69,6 +71,8 @@ const quote = (s: string) => `"${s.length > MAX_QUOTE ? `${s.slice(0, MAX_QUOTE)
69
71
  /** A screenshot as the report's reader finds it: next to the report, not on the machine that ran it. */
70
72
  export function shotPath(path: string): string {
71
73
  if (/^https?:/.test(path)) return path;
74
+ // Kept in the run's database too (db:<run>/<file>), and under the same name beside the report.
75
+ if (path.startsWith("db:")) return `screenshots/${path.split("/").pop()}`;
72
76
  const at = path.replace(/\\/g, "/").lastIndexOf("/screenshots/");
73
77
  return at >= 0 ? path.replace(/\\/g, "/").slice(at + 1) : path;
74
78
  }
package/src/types.ts CHANGED
@@ -67,6 +67,8 @@ export interface AccessibleName {
67
67
  hidden: boolean;
68
68
  /** What a screen reader reads after the role (aria-describedby, a tooltip); absent when there's none. */
69
69
  description?: string;
70
+ /** Hidden by aria-hidden on it or around it, rather than for another reason. */
71
+ ariaHidden?: true;
70
72
  }
71
73
 
72
74
  export interface InteractiveElement {
@@ -109,6 +111,8 @@ export interface Observation {
109
111
  arabicShare?: number;
110
112
  /** The page's viewport setting when it stops pinch-zoom (user-scalable=no, maximum-scale under 2), else "". */
111
113
  zoomBlocked?: string;
114
+ /** What is open over the page and takes clicks outside it, e.g. 'a dialog ("Welcome")' or "a dropdown list". */
115
+ openLayer?: string;
112
116
  }
113
117
 
114
118
  export interface ActionResult {
@@ -118,6 +122,8 @@ export interface ActionResult {
118
122
  focusObscured?: string;
119
123
  /** Keyboard only: Enter did nothing on the button, and Space pressed it. */
120
124
  enterIgnored?: true;
125
+ /** What the browser's own bubble said about fields a submit found invalid: '"Password": Please lengthen…'. */
126
+ invalid?: string[];
121
127
  }
122
128
 
123
129
  export interface Step extends ActionResult {
@@ -174,7 +180,8 @@ export type FindingKind =
174
180
  | "focus_obscured"
175
181
  | "reflow"
176
182
  | "language"
177
- | "zoom";
183
+ | "zoom"
184
+ | "hidden_focus";
178
185
 
179
186
  export interface Finding {
180
187
  signature: string;