debutly 0.1.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.
@@ -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;
@@ -37,6 +38,47 @@ export interface ExecuteOptions {
37
38
  const ARROW_KEY_ITEMS = new Set(["option", "menuitem", "menuitemcheckbox", "menuitemradio", "tab", "treeitem", "radio", "input:radio"]);
38
39
  // What Space presses; Enter presses the rest. Text fields and selects are only focused.
39
40
  const SPACE_KEY = new Set(["checkbox", "input:checkbox", "switch"]);
41
+ // Buttons take Enter and Space alike, so a keyboard user who gets nothing from Enter tries Space.
42
+ const BUTTON_ROLE = /^(button|input:(submit|button|reset|image))$/;
43
+ /** How long to watch for Enter's effect before trying Space. */
44
+ const ENTER_SETTLE_MS = 1_000;
45
+
46
+ /**
47
+ * Presses Enter on the focused button; if nothing happened that a person would notice (the page
48
+ * sent nothing, kept its address and looks exactly the same), presses Space, as a keyboard user
49
+ * would. True when Space then did what Enter didn't: Twenty's set-up "Continue" ignores Enter and
50
+ * goes on with Space, and a keyboard-only agent that knew only Enter gave up there twice
51
+ * (2026-10-24). It goes by how the page looks, not its text, so a toggle whose change is only
52
+ * visual is never pressed twice.
53
+ */
54
+ async function pressEnterThenSpace(page: Page, id: number): Promise<boolean> {
55
+ const look = () => page.screenshot({ animations: "disabled", timeout: 5_000 }).catch(() => undefined);
56
+ const url = page.url();
57
+ // The first key press on a page draws the focus ring; Shift, which presses nothing, draws it
58
+ // first, or Enter would always seem to have changed the page.
59
+ await page.keyboard.press("Shift");
60
+ const before = await look();
61
+ let sent = 0;
62
+ const count = () => sent++;
63
+ page.on("request", count);
64
+ try {
65
+ await page.keyboard.press("Enter");
66
+ await page.waitForTimeout(ENTER_SETTLE_MS);
67
+ } finally {
68
+ page.off("request", count);
69
+ }
70
+ const afterEnter = sent || page.url() !== url || !before ? undefined : await look();
71
+ if (!before || !afterEnter || !afterEnter.equals(before)) return false;
72
+ // Space goes to whatever has focus: only to the button that was checked before Enter, never to
73
+ // something Enter moved focus to unseen.
74
+ const stillFocused = await page.evaluate((target) => document.activeElement?.getAttribute("data-debutly-id") === target, String(id));
75
+ if (!stillFocused) return false;
76
+ await page.keyboard.press("Space");
77
+ await page.waitForTimeout(ENTER_SETTLE_MS);
78
+ if (page.url() !== url) return true;
79
+ const afterSpace = await look();
80
+ return !!afterSpace && !afterSpace.equals(before);
81
+ }
40
82
  const FOCUS_ONLY = /^(input:(text|email|password|number|search|tel|url|date|time|datetime-local|month|week)|textarea|select|combobox)$/;
41
83
 
42
84
  // Is the focused element entirely hidden behind something else? Checks its middle and four points
@@ -44,6 +86,8 @@ const FOCUS_ONLY = /^(input:(text|email|password|number|search|tel|url|date|time
44
86
  const focusCoveredScript = (id: number) => `(() => {
45
87
  const el = document.querySelector('[data-debutly-id="${id}"]');
46
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;
47
91
  const r = el.getBoundingClientRect();
48
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]);
49
93
  let cover = null;
@@ -53,22 +97,92 @@ const focusCoveredScript = (id: number) => `(() => {
53
97
  if (!hit || hit === el || el.contains(hit) || hit.contains(el)) return null;
54
98
  cover = cover || hit;
55
99
  }
56
- if (!cover) return 'the edge of the window';
100
+ if (!cover) return '${EDGE_OF_WINDOW}';
57
101
  const classes = typeof cover.className === 'string' ? cover.className.trim().split(/\\s+/).filter(Boolean).slice(0, 2) : [];
58
102
  const text = (cover.innerText || '').replace(/\\s+/g, ' ').trim().slice(0, 50);
59
103
  return cover.tagName.toLowerCase() + (cover.id ? '#' + cover.id : '') + classes.map((c) => '.' + c).join('') + (text ? ' ("' + text + '")' : '');
60
104
  })()`;
61
105
 
62
106
  class NotByKeyboard extends Error {}
107
+ /** A control a person can't use at all right now, whatever they use: said as it is. */
108
+ class Unusable extends Error {}
63
109
 
64
110
  /** Moves focus to the control as Tab would; refuses one that isn't in the tab order. Returns what hides it, if anything. */
65
111
  async function focusByKeyboard(page: Page, locator: Locator, id: number): Promise<string | undefined> {
66
- const tabbable = await locator.evaluate((node) => (node as HTMLElement).tabIndex >= 0 && !node.closest("[inert]"), undefined, { timeout: 5_000 });
67
- if (!tabbable) throw new NotByKeyboard(`${NOT_BY_KEYBOARD}: it isn't in the tab order`);
112
+ const reach = await locator.evaluate(
113
+ (node) => {
114
+ const el = node as HTMLElement & { disabled?: boolean };
115
+ if (el.disabled === true || el.getAttribute("aria-disabled") === "true") return "disabled";
116
+ return el.tabIndex >= 0 && !el.closest("[inert]") ? "tabbable" : "untabbable";
117
+ },
118
+ undefined,
119
+ { timeout: 5_000 },
120
+ );
121
+ // A disabled button can't take focus, so Enter went to whatever had it, and the agent was told
122
+ // only that nothing changed; it pressed "Create account" again and gave up (2026-10-24). A mouse
123
+ // click on it is told it is disabled; so is a key press.
124
+ if (reach === "disabled") throw new Unusable(UNUSABLE.disabled);
125
+ if (reach === "untabbable") throw new NotByKeyboard(`${NOT_BY_KEYBOARD}: it isn't in the tab order`);
68
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);
69
132
  return ((await page.evaluate(focusCoveredScript(id))) as string | null) ?? undefined;
70
133
  }
71
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
+
72
186
  // Playwright's timeout message hides the cause in its call log; say it the way a person would notice it.
73
187
  export function describeFailure(err: unknown): string {
74
188
  const message = err instanceof Error ? err.message : String(err);
@@ -198,6 +312,7 @@ async function pick(page: Page, trigger: Locator, text: string): Promise<void> {
198
312
  // (aria-activedescendant);
199
313
  // - Enter in a form field submits its form through the form's default button, which may sit
200
314
  // outside the form (form="…"), or with no button through the form itself;
315
+ // - Space presses the focused button, or ticks the focused box;
201
316
  // - an arrow key moves a focused native select to the next option.
202
317
  // Each thing the key would set off is listed with every name it goes by (aria-label, the elements
203
318
  // aria-labelledby points at, labels, text, value, title) and its data-debutly-id, so the check also
@@ -217,6 +332,7 @@ const keyTargetsScript = (key: string) => `(() => {
217
332
  }
218
333
  if (e.labels) for (const l of e.labels) names.push(l.textContent);
219
334
  if (e.tagName === 'INPUT' || e.tagName === 'BUTTON') names.push(e.value);
335
+ if (e.tagName === 'INPUT' && e.type === 'image') names.push(e.getAttribute('alt'));
220
336
  if (e.tagName !== 'INPUT') names.push(e.innerText);
221
337
  targets.push({ id: e.getAttribute('data-debutly-id'), names: names.filter((n) => n && n.trim()) });
222
338
  };
@@ -233,6 +349,9 @@ const keyTargetsScript = (key: string) => `(() => {
233
349
  add(el);
234
350
  }
235
351
  }
352
+ if (key === 'Space' && el.matches('button, summary, input[type="checkbox"], input[type="radio"], input[type="submit"], input[type="button"], input[type="reset"], input[type="image"], [role="button"], [role="checkbox"], [role="switch"], [role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"], [role="option"], [role="tab"]')) {
353
+ add(el);
354
+ }
236
355
  if ((key === 'ArrowDown' || key === 'ArrowUp') && el.tagName === 'SELECT') {
237
356
  const next = el.options[el.selectedIndex + (key === 'ArrowDown' ? 1 : -1)];
238
357
  if (next) targets.push({ id: null, names: [next.label] });
@@ -271,6 +390,7 @@ export async function execute(
271
390
  opts: ExecuteOptions = {},
272
391
  ): Promise<ActionResult> {
273
392
  const result: ActionResult = {};
393
+ await page.evaluate(LISTEN_INVALID).catch(() => {});
274
394
  try {
275
395
  switch (action.type) {
276
396
  case "click":
@@ -283,12 +403,21 @@ export async function execute(
283
403
  const byKeyboard = opts.keyboard && !ARROW_KEY_ITEMS.has(el.role);
284
404
  if (byKeyboard) result.focusObscured = await focusByKeyboard(page, locator, action.id);
285
405
  if (action.type === "click" && byKeyboard) {
286
- if (!FOCUS_ONLY.test(el.role)) await page.keyboard.press(SPACE_KEY.has(el.role) ? "Space" : "Enter");
406
+ if (SPACE_KEY.has(el.role)) await page.keyboard.press("Space");
407
+ else if (BUTTON_ROLE.test(el.role)) {
408
+ if (await pressEnterThenSpace(page, action.id)) result.enterIgnored = true;
409
+ } else if (!FOCUS_ONLY.test(el.role)) await page.keyboard.press("Enter");
287
410
  } else if (action.type === "click") {
288
411
  try {
289
412
  await locator.click({ timeout: 5_000 });
290
413
  } catch (err) {
291
- 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
+ }
292
421
  }
293
422
  } else if (el.tag === "select") await choose(locator, action.text);
294
423
  else if (customDropdown) await pick(page, locator, action.text);
@@ -328,10 +457,12 @@ export async function execute(
328
457
  await page.waitForLoadState("domcontentloaded");
329
458
  // Let async handlers (fetch after submit, deferred errors) surface their signals.
330
459
  await page.waitForTimeout(300);
331
- return result.focusObscured ? result : {};
460
+ const invalid = ((await page.evaluate(READ_INVALID).catch(() => [])) as string[]) ?? [];
461
+ if (invalid.length) result.invalid = invalid;
462
+ return result;
332
463
  } catch (err) {
333
464
  if (err instanceof Refused) return { blocked: err.message };
334
- if (err instanceof NotByKeyboard) return { error: err.message };
465
+ if (err instanceof NotByKeyboard || err instanceof Unusable) return { error: err.message };
335
466
  const why = "id" in action ? await explainTimeout(page, action.id, err) : undefined;
336
467
  return { error: why ?? describeFailure(err), ...(result.focusObscured ? { focusObscured: result.focusObscured } : {}) };
337
468
  }
@@ -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);
@@ -50,7 +80,14 @@ export async function observe(page: Page): Promise<Observation> {
50
80
  const ax = names.get(el.id);
51
81
  if (ax) el.ax = ax;
52
82
  }
53
- const text = (await page.locator("body").innerText()).replace(/\s+/g, " ").trim().slice(0, 2000);
83
+ // Line by line, as it reads on screen, so what changed after an action can be told line by line.
84
+ const text = (await page.locator("body").innerText())
85
+ .split("\n")
86
+ .map((line) => line.replace(/\s+/g, " ").trim())
87
+ .filter(Boolean)
88
+ .join("\n")
89
+ .slice(0, 2000);
54
90
  const language = (await page.evaluate(LANGUAGE_SCRIPT)) as Pick<Observation, "lang" | "dir" | "arabicShare" | "zoomBlocked">;
55
- 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 } : {}) };
56
93
  }
@@ -12,6 +12,35 @@ export const SCAN_SCRIPT = `(() => {
12
12
  ? el.checkVisibility({ visibilityProperty: true })
13
13
  : (getComputedStyle(el).display !== 'none' && getComputedStyle(el).visibility !== 'hidden');
14
14
  const textOf = (el) => (el && (el.innerText || el.textContent)) || '';
15
+ // What a sighted person sees on a control with no text: its icon. Icon sets name their icons in
16
+ // class names (lucide-menu, tabler-icon-dots, fa-bars, i-ph-gear), data attributes, sprite
17
+ // references (<use href="#menu">) or an image's file name. Without it, agents met
18
+ // a row of buttons named "" and guessed.
19
+ const ICON_CLASS = /^(?:i-[a-z0-9]+-|lucide-|tabler-icon-|icon-tabler-|fa-|bi-|ti-|mdi-|ph-|ri-|feather-|heroicons?-|bx-|bxs-|octicon-|codicon-|icon-)([a-z][a-z0-9-]*)$/;
20
+ const NOT_ICON = /^(?:solid|regular|light|thin|duotone|brands|sharp|fw|xs|sm|md|lg|xl|spin|pulse|button|btn|wrapper|container|only|small|large|size-.*)$/;
21
+ const iconWord = (s) => {
22
+ const word = s.replace(/([a-z])([A-Z])/g, '$1 $2').toLowerCase().replace(/[-_]+/g, ' ')
23
+ .replace(/^icon /, '').replace(/( (?:icon|outline|outlined|filled|fill|line|solid|bold|regular|light|thin|duotone|round|rounded|sharp|[0-9]+))+$/, '').trim();
24
+ return word || s.toLowerCase();
25
+ };
26
+ const iconOf = (el) => {
27
+ for (const n of [el, ...el.querySelectorAll('svg, i, span, img, use')].slice(0, 15)) {
28
+ const tag = n.tagName.toLowerCase();
29
+ const href = tag === 'use' ? n.getAttribute('href') || n.getAttribute('xlink:href') || '' : '';
30
+ const testId = (n.getAttribute('data-testid') || '').match(/^(.+)Icon$/);
31
+ const named = n.getAttribute('data-icon') || n.getAttribute('data-lucide') || (href.includes('#') && href.split('#').pop()) || (testId && testId[1]);
32
+ if (named) return iconWord(named);
33
+ for (const token of n.classList || []) {
34
+ const m = token.match(ICON_CLASS);
35
+ if (m && !NOT_ICON.test(m[1])) return iconWord(m[1]);
36
+ }
37
+ if (tag === 'img') {
38
+ const file = (n.getAttribute('src') || '').split(/[?#]/)[0].split('/').pop().replace(/[.][a-z0-9]+$/i, '');
39
+ if (/^[a-z][a-z_-]{1,30}$/i.test(file)) return iconWord(file);
40
+ }
41
+ }
42
+ return '';
43
+ };
15
44
  const out = [];
16
45
  let next = 0;
17
46
  // Ids from the last look stay on elements that have since gone out of sight; the same number
@@ -48,6 +77,10 @@ export const SCAN_SCRIPT = `(() => {
48
77
  // A placeholder is an example, not a label: screen readers read it as the field's name (a
49
78
  // password's dots as "13 bullet"), and it's gone once someone types.
50
79
  if (!labelled && placeholder) item.nameFrom = 'placeholder';
80
+ if (!item.name) {
81
+ const icon = iconOf(el);
82
+ if (icon) item.icon = icon;
83
+ }
51
84
  // What a keyboard user can reach: a disabled control, or one out of the tab order, isn't.
52
85
  if (el.disabled === true || el.getAttribute('aria-disabled') === 'true') item.disabled = true;
53
86
  else if (el.tabIndex < 0 || el.closest('[inert]')) item.tabbable = false;
@@ -56,6 +89,19 @@ export const SCAN_SCRIPT = `(() => {
56
89
  item.value = chosen ? chosen.label.slice(0, 80) : '';
57
90
  item.options = Array.from(el.options).slice(0, 30).map((o) => o.label.slice(0, 60));
58
91
  }
92
+ // What a person sees of a control's state: what a field holds (a password only as filled),
93
+ // whether it can be edited, whether a box is ticked and whether a menu is open.
94
+ const textField = tag === 'textarea' || (tag === 'input' && !['checkbox', 'radio', 'file', 'submit', 'button', 'reset', 'image', 'range', 'color'].includes(el.type));
95
+ if (textField && el.value) {
96
+ if (el.type === 'password') item.filled = true;
97
+ else item.value = el.value.slice(0, 60);
98
+ }
99
+ if (textField && el.readOnly) item.readOnly = true;
100
+ const ariaChecked = el.getAttribute('aria-checked');
101
+ if (el.type === 'checkbox' || el.type === 'radio') item.checked = el.checked;
102
+ else if (ariaChecked === 'true' || ariaChecked === 'false') item.checked = ariaChecked === 'true';
103
+ const ariaExpanded = el.getAttribute('aria-expanded');
104
+ if (ariaExpanded === 'true' || ariaExpanded === 'false') item.expanded = ariaExpanded === 'true';
59
105
  out.push(item);
60
106
  }
61
107
  return out;
package/src/cli.ts CHANGED
@@ -17,6 +17,7 @@ import { OUT_OF_CREDITS } from "./llm/types";
17
17
  import { generateUnits } from "./persona/generate";
18
18
  import { withInclusion } from "./persona/inclusion";
19
19
  import { languageName, parseLanguages, withLanguages } from "./persona/language";
20
+ import { peopleCount, readPeople } from "./persona/people";
20
21
  import { exhibitsFromSessions } from "./perception/exhibits";
21
22
  import { runPerception } from "./perception/run-perception";
22
23
  import { renderLaunchReadiness } from "./report/launch-readiness";
@@ -26,7 +27,7 @@ import { HttpSandboxOutbox } from "./sandbox/outbox";
26
27
  import { runSimulation } from "./run";
27
28
  import { openDb } from "./store/db";
28
29
  import { PgRunStore } from "./store/pg-run-store";
29
- import type { Persona } from "./types";
30
+ import type { CustomerUnit, Persona } from "./types";
30
31
  import { runOrigin } from "./util/run-origin";
31
32
 
32
33
  const MAX_EMPLOYEES = 4;
@@ -77,13 +78,15 @@ const { values } = parseArgs({
77
78
  evaluators: { type: "string" },
78
79
  // Customers' first languages, as lang codes (ar,en): agents take them in turn.
79
80
  languages: { type: "string" },
81
+ // An earlier run's people.json: the same agents again, e.g. to see whether a fix worked.
82
+ people: { type: "string" },
80
83
  },
81
84
  });
82
85
 
83
86
  const unitKind = values.unit;
84
87
  if (!values.url || !values.goal || !values.icp || (unitKind !== "individual" && unitKind !== "business")) {
85
88
  console.error(
86
- 'Usage: npx debutly (settings in debutly.json; npx debutly init writes one) or npx debutly --url <url> --goal "<goal>" --icp "<ideal customer profile>" [--unit individual|business] [--tier quick|standard|deep] [--agents N] [--employees N] [--max-cost USD] [--inclusion on|off] [--max-steps 30] [--concurrency 3] [--out runs/x] [--mailpit http://127.0.0.1:54324] [--email-domain agents.test] [--sandbox-outbox http://localhost:3000/api/sandbox/outbox] [--store postgres://…] [--app name] [--trigger nightly|pr|manual] [--ci-url url] [--screenshot-days 7] [--browser-state dir] [--queue redis://…] [--agent-token-cap N] [--agent-minutes N] [--evaluators N]',
89
+ 'Usage: npx debutly (settings in debutly.json; npx debutly init writes one) or npx debutly --url <url> --goal "<goal>" --icp "<ideal customer profile>" [--unit individual|business] [--tier quick|standard|deep] [--agents N] [--employees N] [--max-cost USD] [--inclusion on|off] [--max-steps 30] [--concurrency 3] [--out runs/x] [--mailpit http://127.0.0.1:54324] [--email-domain agents.test] [--sandbox-outbox http://localhost:3000/api/sandbox/outbox] [--store postgres://…] [--app name] [--trigger nightly|pr|manual] [--ci-url url] [--screenshot-days 7] [--browser-state dir] [--queue redis://…] [--agent-token-cap N] [--agent-minutes N] [--evaluators N] [--people runs/x/people.json]',
87
90
  );
88
91
  process.exit(1);
89
92
  }
@@ -103,13 +106,20 @@ const size = resolveRunSize({
103
106
  const employees = Math.min(Math.max(size.employees || 0, 0), MAX_EMPLOYEES);
104
107
  const count = size.units;
105
108
  const maxSteps = Number(values["max-steps"]);
109
+ let reused: CustomerUnit[] | undefined;
110
+ try {
111
+ reused = values.people ? await readPeople(values.people) : undefined;
112
+ } catch (err) {
113
+ console.error(err instanceof Error ? err.message : err);
114
+ process.exit(1);
115
+ }
106
116
  if (unitKind === "business" && employees > 0 && !values.mailpit) {
107
117
  console.warn("Business crews need --mailpit for invites; employees will be recorded as not invited.");
108
118
  }
109
119
 
110
120
  // Before anything is spent: what could this run cost?
111
121
  const navModel = process.env.DEBUTLY_NAV_MODEL ?? (process.env.DEBUTLY_PROVIDER === "openai-compatible" ? "" : "claude-haiku-4-5");
112
- const agents = agentCount(unitKind, { ...size, employees });
122
+ const agents = reused ? peopleCount(reused) : agentCount(unitKind, { ...size, employees });
113
123
  const price = await fetchModelPrice(navModel);
114
124
  const evaluatorCount = values.evaluators !== undefined ? Number(values.evaluators) : 0;
115
125
  const depthEstimate = estimateRunCost({ agents, maxSteps, price });
@@ -141,13 +151,19 @@ if (credits.refuse) {
141
151
  if (credits.warn) console.warn(`Warning: ${credits.warn}`);
142
152
 
143
153
  const { nav, judge } = createLlms(process.env);
144
- console.log(`Generating ${count} ${unitKind === "business" ? `business crew(s) with ${employees} employees each` : "individual(s)"}...`);
145
- let units = await generateUnits(judge, values.icp, { kind: unitKind, count, employees, languages });
146
- if (values.inclusion !== "off") {
147
- const dayOfYear = Math.floor((Date.now() - Date.UTC(new Date().getUTCFullYear(), 0, 1)) / 86_400_000);
148
- units = withInclusion(units, dayOfYear);
154
+ let units: CustomerUnit[];
155
+ if (reused) {
156
+ console.log(`The same ${agents} agents as before, from ${values.people}:`);
157
+ units = reused;
158
+ } else {
159
+ console.log(`Generating ${count} ${unitKind === "business" ? `business crew(s) with ${employees} employees each` : "individual(s)"}...`);
160
+ units = await generateUnits(judge, values.icp, { kind: unitKind, count, employees, languages });
161
+ if (values.inclusion !== "off") {
162
+ const dayOfYear = Math.floor((Date.now() - Date.UTC(new Date().getUTCFullYear(), 0, 1)) / 86_400_000);
163
+ units = withInclusion(units, dayOfYear);
164
+ }
165
+ units = withLanguages(units, languages);
149
166
  }
150
- units = withLanguages(units, languages);
151
167
 
152
168
  const describe = (p: Persona) =>
153
169
  `${p.id} ${p.name}${p.role ? ` (${p.role})` : ""}${p.inclusion ? ` [${p.inclusion.replace(/_/g, " ")}]` : ""}${p.language && p.language !== "en" ? ` [${languageName(p.language)}]` : ""}: ${p.background}`;
@@ -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
@@ -131,7 +142,7 @@ function fromSignal(signal: Signal): Candidate | null {
131
142
  signature: `slow:${signal.slowNetwork ? "slow" : "normal"}:${pagePath}`,
132
143
  kind: "slow_load",
133
144
  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.`,
145
+ 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
146
  pagePath,
136
147
  severity: !signal.finished || signal.ms >= VERY_SLOW_MS ? "major" : BASE_SEVERITY.slow_load,
137
148
  };
@@ -161,6 +172,20 @@ const NAMED_CONTROLS: Record<string, string> = {
161
172
  "input:url": "web address field",
162
173
  };
163
174
 
175
+ /** Said of a slow page whose scripts and styles came mostly uncompressed; the report's next step looks for it. */
176
+ export const UNCOMPRESSED = "came uncompressed";
177
+ const kb = (bytes: number) => (bytes >= 1024 * 1024 ? `${(bytes / 1024 / 1024).toFixed(1)} MB` : `${Math.round(bytes / 1024)} KB`);
178
+
179
+ // What the scripts and styles that had arrived weighed. Mostly uncompressed, it may be the test
180
+ // copy's doing (served straight from a container) rather than the real site's.
181
+ function weightNote(assets: Extract<Signal, { kind: "slow_load" }>["assets"]): string {
182
+ if (!assets || assets.bytes < 100 * 1024) return "";
183
+ if (assets.uncompressedBytes >= assets.bytes / 2) {
184
+ 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.`;
185
+ }
186
+ return ` By then it had received ${kb(assets.bytes)} of scripts and styles, compressed.`;
187
+ }
188
+
164
189
  /** What to call a control that has no name, or undefined when it doesn't need one. */
165
190
  function unlabeledControl(role: string): string | undefined {
166
191
  return NAMED_CONTROLS[role];
@@ -197,6 +222,39 @@ function placeOf(elements: InteractiveElement[], i: number): string {
197
222
  return after ? `before ${q(after)}` : "";
198
223
  }
199
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
+
200
258
  /** One finding per page, kind of control and kind of problem, naming up to three places. */
201
259
  function unnamedControls(elements: InteractiveElement[], pagePath: string): Candidate[] {
202
260
  const groups = new Map<string, { control: string; role: string; problem: Unnamed; places: string[]; said: string; count: number }>();
@@ -208,7 +266,8 @@ function unnamedControls(elements: InteractiveElement[], pagePath: string): Cand
208
266
  const key = `${problem}:${el.role}`;
209
267
  const g = groups.get(key) ?? { control, role: el.role, problem, places: [] as string[], said: problem === "placeholder" ? name : (el.ax?.description ?? ""), count: 0 };
210
268
  g.count++;
211
- const place = placeOf(elements, i);
269
+ // The icon it shows, when its markup names one, is how a founder spots it on the page.
270
+ const place = [el.icon ? `the ${el.icon} icon` : "", placeOf(elements, i)].filter(Boolean).join(" ");
212
271
  if (place && g.places.length < MAX_PLACES) g.places.push(place);
213
272
  groups.set(key, g);
214
273
  });
@@ -267,12 +326,12 @@ function unnamedControls(elements: InteractiveElement[], pagePath: string): Cand
267
326
 
268
327
  const capitalize = (s: string) => s.charAt(0).toUpperCase() + s.slice(1);
269
328
 
270
- /** 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. */
271
330
  function said(body: string): string {
272
331
  let text = body;
273
332
  try {
274
- const parsed = JSON.parse(body) as { error?: unknown; message?: unknown };
275
- 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);
276
335
  if (typeof field === "string") text = field;
277
336
  } catch {
278
337
  // not JSON: quote it as it is
@@ -283,8 +342,12 @@ function said(body: string): string {
283
342
 
284
343
  /** In a test environment, the app's own words when a server error says a key is missing. */
285
344
  function missingKey(signal: Signal, sandbox: boolean): string | undefined {
286
- if (!sandbox || signal.kind !== "http_error" || signal.status < 500 || !signal.body || !MISSING_KEY.test(signal.body)) return undefined;
287
- 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;
288
351
  }
289
352
 
290
353
  /** The site an agent typed in for its company, which is made up like the company. */
@@ -440,6 +503,8 @@ export function detectFindings(sessions: Session[], opts: DetectOptions = {}): F
440
503
  };
441
504
 
442
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 ?? "");
443
508
  let repeatKey = "";
444
509
  let repeats = 0;
445
510
  // Per control: how often a click on it was refused because another element lay on top.
@@ -491,8 +556,10 @@ export function detectFindings(sessions: Session[], opts: DetectOptions = {}): F
491
556
  }
492
557
 
493
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);
494
561
 
495
- 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);
496
563
 
497
564
  // Keyboard only (the screen-reader agent): a control Tab never reaches, and focus that
498
565
  // something entirely hides.
@@ -514,6 +581,23 @@ export function detectFindings(sessions: Session[], opts: DetectOptions = {}): F
514
581
  step.index,
515
582
  );
516
583
  }
584
+ // A button that takes Space but not Enter: most keyboard users press Enter, and stop there.
585
+ if (acted && step.enterIgnored) {
586
+ const control = `${acted.role} "${acted.ax?.name || acted.name}"`;
587
+ record(
588
+ {
589
+ signature: `enter:${pagePath}:${control}`,
590
+ kind: "keyboard",
591
+ title: `Enter doesn't press ${control} on ${pagePath}`,
592
+ 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.`,
593
+ pagePath,
594
+ severity: severityFor("keyboard", [pagePath]),
595
+ wcag: ["2.1.1"],
596
+ },
597
+ s.agentId,
598
+ step.index,
599
+ );
600
+ }
517
601
  if (acted && step.focusObscured) {
518
602
  const control = `${acted.role} "${acted.name}"`;
519
603
  record(
@@ -533,12 +617,14 @@ export function detectFindings(sessions: Session[], opts: DetectOptions = {}): F
533
617
 
534
618
  // A control something else keeps covering: the person can't press it either. The agent
535
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.
536
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;
537
623
  if (target) {
538
624
  const control = `${target.role} "${target.name}"`;
539
625
  const times = (covered.get(control) ?? 0) + 1;
540
626
  covered.set(control, times);
541
- if (times === COVERED_REPEATS) {
627
+ if (times === COVERED_REPEATS && !pressedLater(s.steps, step.index, pagePath, control)) {
542
628
  const by = step.error!.slice(COVERED.length).replace(/^:\s*/, "");
543
629
  record(
544
630
  {