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.
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/agent/effect.ts +67 -4
- package/src/agent/give-up.ts +42 -0
- package/src/agent/intent.ts +51 -0
- package/src/agent/label.ts +19 -0
- package/src/agent/loop.ts +117 -8
- package/src/agent/parse-reply.ts +52 -1
- package/src/agent/prompt.ts +25 -13
- package/src/browser/ax.ts +1 -0
- package/src/browser/execute.ts +138 -7
- package/src/browser/observe.ts +39 -2
- package/src/browser/scan-script.ts +46 -0
- package/src/cli.ts +25 -9
- package/src/detect/findings.ts +96 -10
- package/src/init.ts +6 -5
- package/src/persona/people.ts +32 -0
- package/src/report/fix-first.ts +125 -0
- package/src/report/markdown.ts +3 -1
- package/src/run.ts +4 -2
- package/src/types.ts +25 -4
package/src/browser/execute.ts
CHANGED
|
@@ -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 '
|
|
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
|
|
67
|
-
|
|
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 (
|
|
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)))
|
|
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
|
-
|
|
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
|
}
|
package/src/browser/observe.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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}`;
|
package/src/detect/findings.ts
CHANGED
|
@@ -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
|
-
|
|
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 `
|
|
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.
|
|
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" ||
|
|
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
|
{
|