debutly 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/LICENSE +662 -0
  2. package/README.md +192 -2
  3. package/bin/debutly.mjs +8 -0
  4. package/drizzle/0000_init.sql +107 -0
  5. package/drizzle/0001_predictions.sql +14 -0
  6. package/drizzle/0002_dashboard.sql +15 -0
  7. package/drizzle/meta/0000_snapshot.json +722 -0
  8. package/drizzle/meta/0001_snapshot.json +821 -0
  9. package/drizzle/meta/0002_snapshot.json +942 -0
  10. package/drizzle/meta/_journal.json +27 -0
  11. package/package.json +83 -4
  12. package/src/agent/describe.ts +29 -0
  13. package/src/agent/effect.ts +94 -0
  14. package/src/agent/give-up.ts +42 -0
  15. package/src/agent/label.ts +19 -0
  16. package/src/agent/loop.ts +302 -0
  17. package/src/agent/parse-reply.ts +127 -0
  18. package/src/agent/prompt.ts +85 -0
  19. package/src/agent/run-agent.ts +68 -0
  20. package/src/artifacts/db.ts +55 -0
  21. package/src/artifacts/s3.ts +36 -0
  22. package/src/artifacts/store.ts +26 -0
  23. package/src/bin.ts +140 -0
  24. package/src/browser/ax.ts +77 -0
  25. package/src/browser/execute.ts +401 -0
  26. package/src/browser/observe.ts +62 -0
  27. package/src/browser/reflow.ts +73 -0
  28. package/src/browser/samples.ts +119 -0
  29. package/src/browser/scan-script.ts +108 -0
  30. package/src/browser/settle.ts +32 -0
  31. package/src/browser/signals.ts +73 -0
  32. package/src/cancel.ts +20 -0
  33. package/src/cli.ts +281 -0
  34. package/src/config.ts +62 -0
  35. package/src/cost/estimate.ts +32 -0
  36. package/src/cost/prices.ts +32 -0
  37. package/src/cost/tiers.ts +27 -0
  38. package/src/costs.ts +96 -0
  39. package/src/crew/run-unit.ts +62 -0
  40. package/src/detect/findings.ts +649 -0
  41. package/src/detect/wcag.ts +38 -0
  42. package/src/doctor.ts +145 -0
  43. package/src/hear-cli.ts +137 -0
  44. package/src/hear.ts +309 -0
  45. package/src/import.ts +108 -0
  46. package/src/inbox/mailpit.ts +46 -0
  47. package/src/inbox/types.ts +10 -0
  48. package/src/init.ts +184 -0
  49. package/src/llm/anthropic.ts +41 -0
  50. package/src/llm/credits.ts +35 -0
  51. package/src/llm/factory.ts +65 -0
  52. package/src/llm/meter.ts +39 -0
  53. package/src/llm/openai-compatible.ts +90 -0
  54. package/src/llm/openrouter.ts +9 -0
  55. package/src/llm/scripted.ts +18 -0
  56. package/src/llm/types.ts +36 -0
  57. package/src/perceive.ts +150 -0
  58. package/src/perception/aggregate.ts +111 -0
  59. package/src/perception/compare.ts +77 -0
  60. package/src/perception/evaluate.ts +83 -0
  61. package/src/perception/exhibits.ts +146 -0
  62. package/src/perception/market-context.ts +50 -0
  63. package/src/perception/run-perception.ts +178 -0
  64. package/src/persona/generate.ts +92 -0
  65. package/src/persona/inclusion.ts +53 -0
  66. package/src/persona/language.ts +59 -0
  67. package/src/persona/people.ts +32 -0
  68. package/src/predictions.ts +33 -0
  69. package/src/queue/dispatch.ts +18 -0
  70. package/src/queue/jobs.ts +28 -0
  71. package/src/queue/processor.ts +42 -0
  72. package/src/report/fix-first.ts +121 -0
  73. package/src/report/launch-readiness.ts +106 -0
  74. package/src/report/markdown.ts +202 -0
  75. package/src/run.ts +141 -0
  76. package/src/sandbox/outbox.ts +65 -0
  77. package/src/store/db.ts +40 -0
  78. package/src/store/import-run.ts +116 -0
  79. package/src/store/pg-run-store.ts +163 -0
  80. package/src/store/predictions.ts +70 -0
  81. package/src/store/run-store.ts +78 -0
  82. package/src/store/schema.ts +193 -0
  83. package/src/store/url.ts +15 -0
  84. package/src/types.ts +201 -0
  85. package/src/util/chromium.ts +20 -0
  86. package/src/util/json.ts +7 -0
  87. package/src/util/key-defaults.ts +34 -0
  88. package/src/util/legacy-env.ts +14 -0
  89. package/src/util/load-env.ts +28 -0
  90. package/src/util/pool.ts +12 -0
  91. package/src/util/run-origin.ts +27 -0
  92. package/src/worker.ts +53 -0
  93. package/tsconfig.json +13 -0
@@ -0,0 +1,38 @@
1
+ // WCAG 2.2 success criteria a finding relates to. They point a person at what to check; Debutly
2
+ // doesn't test conformance, and a run without them says nothing about it.
3
+
4
+ export interface Criterion {
5
+ id: string;
6
+ name: string;
7
+ level: "A" | "AA";
8
+ /** The criterion's page in W3C's "Understanding WCAG 2.2". */
9
+ url: string;
10
+ }
11
+
12
+ const understanding = (slug: string) => `https://www.w3.org/WAI/WCAG22/Understanding/${slug}`;
13
+
14
+ export const CRITERIA: Record<string, Criterion> = {
15
+ "1.3.1": { id: "1.3.1", name: "Info and Relationships", level: "A", url: understanding("info-and-relationships") },
16
+ "1.4.4": { id: "1.4.4", name: "Resize Text", level: "AA", url: understanding("resize-text") },
17
+ "1.4.10": { id: "1.4.10", name: "Reflow", level: "AA", url: understanding("reflow") },
18
+ "2.1.1": { id: "2.1.1", name: "Keyboard", level: "A", url: understanding("keyboard") },
19
+ "2.4.4": { id: "2.4.4", name: "Link Purpose (In Context)", level: "A", url: understanding("link-purpose-in-context") },
20
+ "2.4.11": { id: "2.4.11", name: "Focus Not Obscured (Minimum)", level: "AA", url: understanding("focus-not-obscured-minimum") },
21
+ "3.1.1": { id: "3.1.1", name: "Language of Page", level: "A", url: understanding("language-of-page") },
22
+ "3.3.2": { id: "3.3.2", name: "Labels or Instructions", level: "A", url: understanding("labels-or-instructions") },
23
+ "4.1.2": { id: "4.1.2", name: "Name, Role, Value", level: "A", url: understanding("name-role-value") },
24
+ };
25
+
26
+ /** The criteria an unnamed control relates to, by what kind of control it is. */
27
+ export function unlabeledCriteria(control: string): string[] {
28
+ if (control === "link") return ["2.4.4", "4.1.2"];
29
+ if (control === "button") return ["4.1.2"];
30
+ // Fields: a dropdown, text box, checkbox, radio button…
31
+ return ["1.3.1", "3.3.2", "4.1.2"];
32
+ }
33
+
34
+ /** "4.1.2 Name, Role, Value (A)", linked for Markdown. */
35
+ export const describeCriterion = (id: string) => {
36
+ const c = CRITERIA[id];
37
+ return c ? `[${c.id} ${c.name}](${c.url}) (${c.level})` : id;
38
+ };
package/src/doctor.ts ADDED
@@ -0,0 +1,145 @@
1
+ // `debutly doctor`: is everything in place for a run? Each check says what to do when it fails.
2
+ import { CONFIG_FILE, readConfig, type DebutlyConfig } from "./config";
3
+ import { keyCreditsLeft } from "./llm/credits";
4
+ import { isOpenRouter } from "./llm/openrouter";
5
+
6
+ export type CheckStatus = "ok" | "warn" | "fail";
7
+ export interface Check {
8
+ status: CheckStatus;
9
+ label: string;
10
+ detail?: string;
11
+ }
12
+
13
+ export interface DoctorDeps {
14
+ env: NodeJS.ProcessEnv;
15
+ nodeVersion: string;
16
+ fetchFn: typeof fetch;
17
+ chromiumInstalled: () => boolean;
18
+ readConfig: () => DebutlyConfig | undefined;
19
+ }
20
+
21
+ const MIN_NODE = [20, 12];
22
+ const TIMEOUT_MS = 10_000;
23
+
24
+ const atLeast = (version: string, [major, minor]: number[]) => {
25
+ const [a, b] = version.split(".").map(Number);
26
+ return a > major || (a === major && b >= minor);
27
+ };
28
+
29
+ // Addresses that are plainly a copy on this machine or a network-local test host.
30
+ const LOCAL_HOST = /^(localhost|127\.\d+\.\d+\.\d+|\[::1\]|10\.\d+\.\d+\.\d+|192\.168\.\d+\.\d+|.+\.(localhost|local|test|internal))$/i;
31
+ // A label that marks a hosted test copy: staging.example.com, my-app-git-branch.preview.example.com.
32
+ const TEST_LABEL = /(^|[.-])(staging|stage|preview|dev|test|qa|sandbox|uat)([.-]|\d|$)/i;
33
+
34
+ /** Could this address be the real, production app? Agents sign up, submit forms and pay. */
35
+ export function looksLikeProduction(url: string): boolean {
36
+ try {
37
+ const host = new URL(url).hostname;
38
+ return !LOCAL_HOST.test(host) && !TEST_LABEL.test(host);
39
+ } catch {
40
+ return false;
41
+ }
42
+ }
43
+
44
+ async function keyWorks(env: NodeJS.ProcessEnv, fetchFn: typeof fetch): Promise<Check> {
45
+ const key = env.DEBUTLY_API_KEY ?? "";
46
+ const provider = env.DEBUTLY_PROVIDER ?? "anthropic";
47
+ const signal = AbortSignal.timeout(TIMEOUT_MS);
48
+ try {
49
+ const res =
50
+ provider === "anthropic"
51
+ ? await fetchFn("https://api.anthropic.com/v1/models", { headers: { "x-api-key": key, "anthropic-version": "2023-06-01" }, signal })
52
+ : await fetchFn(`${(env.DEBUTLY_BASE_URL ?? "").replace(/\/+$/, "")}/${isOpenRouter(env.DEBUTLY_BASE_URL) ? "key" : "models"}`, {
53
+ headers: { authorization: `Bearer ${key}` },
54
+ signal,
55
+ });
56
+ if (res.status === 401 || res.status === 403) return { status: "fail", label: "The provider rejects the API key", detail: "Check it was copied in full, or create a new one." };
57
+ if (!res.ok) return { status: "warn", label: `The provider answered HTTP ${res.status} when asked about the key` };
58
+ } catch (err) {
59
+ return { status: "warn", label: "Couldn't reach the model provider", detail: err instanceof Error ? err.message : String(err) };
60
+ }
61
+ const left = await keyCreditsLeft(env.DEBUTLY_BASE_URL ?? "", key, fetchFn);
62
+ return { status: "ok", label: "The provider accepts the API key", detail: left === null ? undefined : `$${left.toFixed(2)} left on the key's limit; the account's own balance isn't visible here` };
63
+ }
64
+
65
+ async function appAnswers(url: string, fetchFn: typeof fetch): Promise<Check> {
66
+ try {
67
+ const res = await fetchFn(url, { redirect: "follow", signal: AbortSignal.timeout(TIMEOUT_MS) });
68
+ if (res.status >= 500) return { status: "warn", label: `${url} answers HTTP ${res.status}`, detail: "Agents will start on an error page." };
69
+ return { status: "ok", label: `${url} answers (HTTP ${res.status})` };
70
+ } catch {
71
+ return { status: "fail", label: `Nothing answers at ${url}`, detail: "Start your app first (a test copy, never production), then check again." };
72
+ }
73
+ }
74
+
75
+ export async function runChecks(d: DoctorDeps): Promise<Check[]> {
76
+ const checks: Check[] = [];
77
+ checks.push(
78
+ atLeast(d.nodeVersion, MIN_NODE)
79
+ ? { status: "ok", label: `Node.js ${d.nodeVersion}` }
80
+ : { status: "fail", label: `Node.js ${d.nodeVersion} is too old`, detail: `Debutly needs ${MIN_NODE.join(".")} or later: https://nodejs.org` },
81
+ );
82
+
83
+ if (!d.env.DEBUTLY_API_KEY) {
84
+ checks.push({
85
+ status: "fail",
86
+ label: "No model API key",
87
+ detail: "Create one at https://openrouter.ai/keys and add DEBUTLY_API_KEY=… to a .env file here (keep .env out of git), or set OPENROUTER_API_KEY.",
88
+ });
89
+ } else {
90
+ const via = d.env.DEBUTLY_PROVIDER === "anthropic" ? "Anthropic" : (d.env.DEBUTLY_BASE_URL ?? "an OpenAI-compatible provider");
91
+ checks.push({ status: "ok", label: `API key found (${via}; agents use ${d.env.DEBUTLY_NAV_MODEL ?? "the default model"})` });
92
+ checks.push(await keyWorks(d.env, d.fetchFn));
93
+ }
94
+
95
+ checks.push(
96
+ d.chromiumInstalled()
97
+ ? { status: "ok", label: "Chromium is installed" }
98
+ : { status: "warn", label: "Chromium isn't downloaded yet", detail: "The first run downloads it (about 150 MB), or run: npx playwright install chromium" },
99
+ );
100
+
101
+ let config: DebutlyConfig | undefined;
102
+ try {
103
+ config = d.readConfig();
104
+ } catch (err) {
105
+ checks.push({ status: "fail", label: err instanceof Error ? err.message : String(err) });
106
+ return checks;
107
+ }
108
+ if (!config) {
109
+ checks.push({ status: "warn", label: `No ${CONFIG_FILE} here`, detail: "Run npx debutly init to create one, or pass --url, --goal and --icp to each run." });
110
+ return checks;
111
+ }
112
+ const missing = (["url", "goal", "icp"] as const).filter((k) => !config[k]);
113
+ checks.push(
114
+ missing.length
115
+ ? { status: "fail", label: `${CONFIG_FILE} has no ${missing.join(", ")}`, detail: "Every run needs the app's address, a goal and who the customers are." }
116
+ : { status: "ok", label: `${CONFIG_FILE}: "${config.goal}"` },
117
+ );
118
+ if (typeof config.url === "string") {
119
+ if (looksLikeProduction(config.url)) {
120
+ checks.push({
121
+ status: "warn",
122
+ label: `${config.url} may be your real app`,
123
+ detail: "Agents create accounts, submit forms and pay in test mode. Point Debutly at a local, staging or throwaway copy.",
124
+ });
125
+ }
126
+ checks.push(await appAnswers(config.url, d.fetchFn));
127
+ }
128
+ return checks;
129
+ }
130
+
131
+ const MARK: Record<CheckStatus, string> = { ok: "✓", warn: "!", fail: "✗" };
132
+
133
+ export function renderChecks(checks: Check[]): string {
134
+ const lines = checks.map((c) => `${MARK[c.status]} ${c.label}${c.detail ? `\n ${c.detail}` : ""}`);
135
+ const failed = checks.filter((c) => c.status === "fail").length;
136
+ lines.push("", failed ? `${failed} thing${failed === 1 ? "" : "s"} to fix before a run.` : "Ready. Start a run with: npx debutly");
137
+ return lines.join("\n");
138
+ }
139
+
140
+ export async function doctor(): Promise<number> {
141
+ const { chromiumInstalled } = await import("./util/chromium");
142
+ const checks = await runChecks({ env: process.env, nodeVersion: process.versions.node, fetchFn: fetch, chromiumInstalled, readConfig: () => readConfig() });
143
+ console.log(renderChecks(checks));
144
+ return checks.some((c) => c.status === "fail") ? 1 : 0;
145
+ }
@@ -0,0 +1,137 @@
1
+ // `debutly hear [report dir] [--browser-state dir] [--tabs 120]`: a run's screen-reader findings,
2
+ // checked with a real screen reader through Guidepup: NVDA on Windows, VoiceOver on macOS. The
3
+ // app must be running where the report's pages point; for pages behind sign-in, run the agents
4
+ // with --browser-state <dir> first.
5
+ import { execFileSync } from "node:child_process";
6
+ import { readdirSync, statSync } from "node:fs";
7
+ import { join } from "node:path";
8
+ import { parseArgs } from "node:util";
9
+ import { chromium, type Page } from "playwright";
10
+ import { hearFindings, nextUtterance, type ScreenReader } from "./hear";
11
+
12
+ const USAGE =
13
+ "Usage: npx debutly hear [report dir] [--browser-state <dir>] [--tabs 120]\nNeeds Windows (NVDA) or macOS (VoiceOver), set up for automation: npm i -D @guidepup/guidepup && npx @guidepup/setup";
14
+
15
+ const { values, positionals } = parseArgs({
16
+ allowPositionals: true,
17
+ options: { "browser-state": { type: "string" }, tabs: { type: "string", default: "120" } },
18
+ });
19
+
20
+ /** The newest run under .debutly/runs, where `debutly run` writes reports by default. */
21
+ function latestRun(): string | undefined {
22
+ try {
23
+ const root = join(".debutly", "runs");
24
+ return readdirSync(root)
25
+ .map((d) => join(root, d))
26
+ .filter((d) => statSync(d).isDirectory())
27
+ .sort()
28
+ .at(-1);
29
+ } catch {
30
+ return undefined;
31
+ }
32
+ }
33
+
34
+ const clean = (s: string) => s.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, "");
35
+ const delay = (ms: number) => new Promise((r) => setTimeout(r, ms));
36
+
37
+ /** The Chrome Playwright launches, as macOS knows it. */
38
+ const CHROME_FOR_TESTING = "com.google.chrome.for.testing";
39
+
40
+ async function loadGuidepup(): Promise<typeof import("@guidepup/guidepup")> {
41
+ try {
42
+ return await import("@guidepup/guidepup");
43
+ } catch {
44
+ throw new Error(`Guidepup isn't installed.\n${USAGE}`);
45
+ }
46
+ }
47
+
48
+ /**
49
+ * NVDA, through Guidepup. Putting it on the page follows Guidepup's own Playwright helper (MIT):
50
+ * make sure the browser window is the one in front, then go to the top of the document. It
51
+ * doesn't click the page to focus it, as that helper does: a click lands on whatever is there.
52
+ */
53
+ async function nvdaReader(): Promise<ScreenReader> {
54
+ const { nvda, NVDAKeyCodeCommands, WindowsKeyCodes, WindowsModifiers } = await loadGuidepup();
55
+ // Guidepup's NVDA build is versioned like "0.2.1-2026.2": the NVDA release is the last part.
56
+ const version = (nvda as unknown as { version?: string }).version?.match(/(\d{4}\.\d+(?:\.\d+)?)$/)?.[1];
57
+ return {
58
+ name: version ? `NVDA ${version}` : "NVDA",
59
+ start: () => nvda.start({ capture: "initial" }),
60
+ stop: () => nvda.stop(),
61
+ async focus(page: Page) {
62
+ await nvda.perform(NVDAKeyCodeCommands.exitFocusMode, { capture: false });
63
+ const title = clean(await page.title());
64
+ for (let attempt = 0; attempt < 10; attempt++) {
65
+ await nvda.perform(NVDAKeyCodeCommands.reportTitle);
66
+ const window = clean(await nvda.lastSpokenPhrase());
67
+ if ((title && window.startsWith(title)) || window.includes("chrome")) break;
68
+ await nvda.perform({ keyCode: [WindowsKeyCodes.Escape], modifiers: [WindowsModifiers.Alt] }, { capture: false });
69
+ await delay(500);
70
+ }
71
+ await page.bringToFront();
72
+ await nvda.perform({ keyCode: [WindowsKeyCodes.Home], modifiers: [WindowsModifiers.Control] }, { capture: false });
73
+ await nvda.clearSpokenPhraseLog();
74
+ },
75
+ press: (key) => nvda.press(key),
76
+ said: () => nextUtterance(nvda),
77
+ };
78
+ }
79
+
80
+ /**
81
+ * VoiceOver, through Guidepup. It follows keyboard focus, so Tab needs only Chrome in front
82
+ * (Guidepup's helper for reading a page with VoiceOver's own cursor goes further, through its
83
+ * item chooser; Tab doesn't need that). Pressing Control first stops anything still being read.
84
+ */
85
+ async function voiceOverReader(): Promise<ScreenReader> {
86
+ const { voiceOver, macOSActivateId, MacOSKeyCodes } = await loadGuidepup();
87
+ let macOS = "";
88
+ try {
89
+ macOS = execFileSync("sw_vers", ["-productVersion"], { encoding: "utf8" }).trim();
90
+ } catch {
91
+ // not worth failing over
92
+ }
93
+ return {
94
+ name: macOS ? `VoiceOver (macOS ${macOS})` : "VoiceOver",
95
+ start: () => voiceOver.start({ capture: "initial" }),
96
+ stop: () => voiceOver.stop(),
97
+ async focus(page: Page) {
98
+ await macOSActivateId(CHROME_FOR_TESTING);
99
+ await voiceOver.perform({ keyCode: MacOSKeyCodes.Control }, { capture: false });
100
+ await page.bringToFront();
101
+ // Bringing Chrome forward has VoiceOver read the window and toolbar: let it finish first.
102
+ await nextUtterance(voiceOver, { startWithinMs: 1500, pauseMs: 800 });
103
+ await voiceOver.clearSpokenPhraseLog();
104
+ },
105
+ press: (key) => voiceOver.press(key),
106
+ said: () => nextUtterance(voiceOver),
107
+ };
108
+ }
109
+
110
+ async function screenReader(): Promise<ScreenReader> {
111
+ if (process.platform === "win32") return nvdaReader();
112
+ if (process.platform === "darwin") return voiceOverReader();
113
+ throw new Error(`debutly hear uses NVDA on Windows or VoiceOver on macOS.\n${USAGE}`);
114
+ }
115
+
116
+ const reportDir = positionals[0] ?? latestRun();
117
+ if (!reportDir) {
118
+ console.error(`No report given, and none under .debutly/runs.\n${USAGE}`);
119
+ process.exit(1);
120
+ }
121
+ try {
122
+ const reader = await screenReader();
123
+ console.log(`Hearing ${reportDir} with ${reader.name}...`);
124
+ const heard = await hearFindings({
125
+ reportDir,
126
+ reader,
127
+ browserStateDir: values["browser-state"],
128
+ tabs: Number(values.tabs),
129
+ launch: () => chromium.launch({ headless: false }),
130
+ });
131
+ if (!heard.length) console.log("No screen-reader findings to hear in this report.");
132
+ for (const h of heard) console.log(`${h.verdict.padEnd(15)} ${h.title}: ${h.note}`);
133
+ console.log(`Added to ${join(reportDir, "report.md")} and ${join(reportDir, "heard.json")}.`);
134
+ } catch (err) {
135
+ console.error(err instanceof Error ? err.message : err);
136
+ process.exit(1);
137
+ }
package/src/hear.ts ADDED
@@ -0,0 +1,309 @@
1
+ import { existsSync } from "node:fs";
2
+ import { appendFile, readFile, writeFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import type { Browser, BrowserContext, Page } from "playwright";
5
+ import { accessibleNames } from "./browser/ax";
6
+ import { SCAN_SCRIPT } from "./browser/scan-script";
7
+ import { nameProblem } from "./detect/findings";
8
+ import type { Finding, InteractiveElement, Session } from "./types";
9
+
10
+ // `debutly hear`: a run's screen-reader findings, checked with a real screen reader. Each page a
11
+ // finding is about is opened again (signed in, with the agent's kept browser state), the screen
12
+ // reader is put on it, and Tab is pressed through it. Debutly knows which control has focus at
13
+ // each press, so it can say what the screen reader said for the very control a finding is about.
14
+ // Checked by hand against NVDA on three open-source apps first, where about a third of
15
+ // unnamed-control findings didn't hold up: this is how a report can tell those apart.
16
+
17
+ /** What Debutly needs from a screen reader: NVDA through Guidepup, or a stand-in in tests. */
18
+ export interface ScreenReader {
19
+ /** e.g. "NVDA 2026.2" */
20
+ name: string;
21
+ start(): Promise<void>;
22
+ stop(): Promise<void>;
23
+ /** Puts the screen reader on the page, ready for Tab. */
24
+ focus(page: Page): Promise<void>;
25
+ press(key: string): Promise<void>;
26
+ /** What it has said since the last call. */
27
+ said(): Promise<string>;
28
+ }
29
+
30
+ export type Verdict = "confirmed" | "not confirmed" | "check by ear" | "not on the page";
31
+
32
+ export interface Heard {
33
+ title: string;
34
+ page: string;
35
+ verdict: Verdict;
36
+ /** What the screen reader said on reaching each such control; empty when Tab never reached one. */
37
+ said: string[];
38
+ note: string;
39
+ }
40
+
41
+ export interface HearOptions {
42
+ reportDir: string;
43
+ reader: ScreenReader;
44
+ /** Where `debutly run --browser-state` kept each agent's browser, to open pages behind sign-in. */
45
+ browserStateDir?: string;
46
+ /** Tab presses per page at most; the walk stops sooner, once focus has come round twice. */
47
+ tabs?: number;
48
+ /** Opens the browser. A real screen reader needs a visible one in front. */
49
+ launch: () => Promise<Browser>;
50
+ }
51
+
52
+ const ACTIVE_ID = `(() => { const el = document.activeElement; return el ? el.getAttribute('data-debutly-id') : null; })()`;
53
+ const BLUR = `(() => { if (document.activeElement && document.activeElement.blur) document.activeElement.blur(); })()`;
54
+ const SETTLE_MS = 2500;
55
+
56
+ // The words a screen reader adds about a control's role and state, NVDA's and VoiceOver's. What's
57
+ // left once they're gone is its name: nothing left means the control was announced without one.
58
+ // Longest first, so "edit text" goes as a whole before "edit" would.
59
+ const ROLE_AND_STATE_WORDS = [
60
+ // NVDA
61
+ "button", "toggle button", "menu button", "link", "visited", "same page", "current page", "combo box", "edit",
62
+ "multi line", "blank", "read only", "protected", "collapsed", "expanded", "has auto complete", "editable",
63
+ "check box", "not checked", "half checked", "checked", "switch", "on", "off", "radio button", "sub ?menu",
64
+ "opens dialog", "opens list", "selected", "clickable", "unavailable", "required", "invalid entry", "pressed",
65
+ "not pressed", "section", "grouping", "region", "landmark", "navigation", "main", "form", "list",
66
+ "with \\d+ items", "out of list", "graphic", "heading", "level \\d+", "\\d+ of \\d+",
67
+ // VoiceOver
68
+ "text field", "secure text field", "text area", "pop[- ]?up button", "dimmed", "edit text", "secure edit text",
69
+ "unchecked", "group", "web content", "has keyboard focus", "image", "invalid data", "you are currently on an?",
70
+ ];
71
+ const ROLE_AND_STATE = new RegExp(`\\b(${[...ROLE_AND_STATE_WORDS].sort((a, b) => b.length - a.length).join("|")})\\b`, "gi");
72
+
73
+ /** True when a role was said with nothing but state words around it: silence isn't "no name". */
74
+ export function saidNoName(said: string): boolean {
75
+ return /[\p{L}\p{N}]/u.test(said) && said.replace(ROLE_AND_STATE, " ").replace(/[^\p{L}\p{N}]+/gu, "") === "";
76
+ }
77
+
78
+ export interface SpeechLog {
79
+ spokenPhraseLog(): Promise<string[]>;
80
+ clearSpokenPhraseLog(): Promise<void>;
81
+ }
82
+
83
+ const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
84
+
85
+ /**
86
+ * What a screen reader says after a key press: it waits for speech to start (VoiceOver can take
87
+ * a second, and what it says late was being credited to the next control), then for a pause.
88
+ */
89
+ export async function nextUtterance(log: SpeechLog, { startWithinMs = 2500, pauseMs = 500, stepMs = 100 } = {}): Promise<string> {
90
+ const parts: string[] = [];
91
+ const take = async () => {
92
+ const said = await log.spokenPhraseLog();
93
+ if (said.length) await log.clearSpokenPhraseLog();
94
+ return said;
95
+ };
96
+ for (let waited = 0; waited < startWithinMs && !parts.length; waited += stepMs) {
97
+ await sleep(stepMs);
98
+ parts.push(...(await take()));
99
+ }
100
+ for (let quiet = 0; parts.length && quiet < pauseMs && parts.length < 30; ) {
101
+ await sleep(stepMs);
102
+ const said = await take();
103
+ parts.push(...said);
104
+ quiet = said.length ? 0 : quiet + stepMs;
105
+ }
106
+ return parts.join(" ").replace(/\s+/g, " ").trim();
107
+ }
108
+
109
+ /** How a screen reader says a placeholder: "john@doe.com" as "john at doe dot com", dots as "13 bullet". */
110
+ export function spoken(text: string): string {
111
+ return text
112
+ .replace(/[•●·*]{2,}/g, (dots) => ` ${dots.length} bullet `)
113
+ .replace(/@/g, " at ")
114
+ .replace(/\./g, " dot ")
115
+ .replace(/[^\p{L}\p{N}]+/gu, " ")
116
+ .trim()
117
+ .toLowerCase();
118
+ }
119
+
120
+ // The same reading of a control as the finding's, so only controls with that very problem count.
121
+ const matches = (control: NonNullable<Finding["control"]>, el: InteractiveElement): boolean =>
122
+ el.role === control.role && (control.problem === "keyboard" ? el.name === control.name : nameProblem(el) === control.problem);
123
+
124
+ interface Stop {
125
+ id: string | null;
126
+ said: string;
127
+ }
128
+
129
+ /**
130
+ * Tab through the page from the top, twice round, noting which control had focus and everything
131
+ * said while it did. Arriving on a page, NVDA can say only where focus went ("form landmark",
132
+ * "Chatwoot, document") and not name the first field; on the way round again it does.
133
+ */
134
+ async function tabThrough(page: Page, reader: ScreenReader, tabs: number): Promise<Stop[]> {
135
+ await page.evaluate(BLUR);
136
+ await reader.focus(page);
137
+ await reader.said();
138
+ const stops: Stop[] = [];
139
+ let first: string | null = null;
140
+ let arrivals = 0;
141
+ let previousId: string | null | undefined;
142
+ for (let i = 0; i < tabs; i++) {
143
+ await reader.press("Tab");
144
+ const said = await reader.said();
145
+ const id = (await page.evaluate(ACTIVE_ID).catch(() => null)) as string | null;
146
+ if (id !== null && id === first && previousId !== first && ++arrivals === 2) break; // round twice
147
+ if (first === null) first = id;
148
+ previousId = id;
149
+ const known = id === null ? undefined : stops.find((s) => s.id === id);
150
+ if (known) {
151
+ if (said && !known.said.includes(said)) known.said = `${known.said} ${said}`.trim();
152
+ }
153
+ else stops.push({ id, said });
154
+ }
155
+ return stops;
156
+ }
157
+
158
+ /** A kind of control, in NVDA's or VoiceOver's words: what an announcement of a control includes. */
159
+ const CONTROL_KIND =
160
+ /\b(button|link|combo box|edit|check ?box|switch|radio button|menu button|toggle button|text field|secure text field|text area|edit text|secure edit text|pop[- ]?up button|slider|tab)\b/i;
161
+
162
+ const FOCUS = (id: string) => `(() => { const el = document.querySelector('[data-debutly-id="${id}"]'); if (el) el.focus(); })()`;
163
+
164
+ /**
165
+ * What the screen reader says about each control a finding is about, heard again on its own: focus
166
+ * moves to another control on the page and back, as Shift+Tab would. On the walk, a field reached
167
+ * from the browser's toolbar was announced only as where focus went ("Chatwoot, document", "form
168
+ * landmark"), and VoiceOver could still be speaking about one control when focus reached the next.
169
+ * Tab still decides what can be reached; this decides what is said about it.
170
+ */
171
+ async function hearAgain(page: Page, reader: ScreenReader, stops: Stop[], ids: Set<string>): Promise<void> {
172
+ for (const [i, stop] of stops.entries()) {
173
+ // Only where the walk heard no kind of control at all; and only a hearing that names one is
174
+ // kept, as VoiceOver on a CI Mac sometimes spoke about something else entirely.
175
+ if (stop.id === null || !ids.has(stop.id) || CONTROL_KIND.test(stop.said)) continue;
176
+ const neighbour = [...stops.slice(i + 1), ...stops.slice(0, i).reverse()].find((s) => s.id !== null && s.id !== stop.id);
177
+ if (!neighbour) continue;
178
+ await page.evaluate(FOCUS(neighbour.id!));
179
+ await reader.said();
180
+ await page.evaluate(FOCUS(stop.id));
181
+ const said = await reader.said();
182
+ if (CONTROL_KIND.test(said)) stop.said = said;
183
+ }
184
+ }
185
+
186
+ function judge(f: Finding, page: string, elements: InteractiveElement[], stops: Stop[], readerName: string): Heard {
187
+ const control = f.control!;
188
+ const targets = elements.filter((el) => matches(control, el));
189
+ const base = { title: f.title, page };
190
+ if (!targets.length) return { ...base, verdict: "not on the page", said: [], note: "No such control was on the page when it was opened again." };
191
+ const reached = targets
192
+ .map((t) => ({ t, said: stops.find((s) => s.id === String(t.id))?.said }))
193
+ .filter((r): r is { t: InteractiveElement; said: string } => r.said !== undefined);
194
+ const said = reached.map((r) => r.said);
195
+ // A dropdown announces its current choice ("combo box, English (en)"): that is its value, not a name.
196
+ const nameless = reached.find((r) => saidNoName(r.t.value ? r.said.split(r.t.value).join(" ") : r.said));
197
+ const presses = stops.length;
198
+ switch (control.problem) {
199
+ case "keyboard":
200
+ case "unreached":
201
+ return said.length
202
+ ? { ...base, verdict: "not confirmed", said, note: `Tab did reach it: ${readerName} said "${said[0]}".` }
203
+ : { ...base, verdict: "confirmed", said, note: `Tab never reached it in ${presses} presses.` };
204
+ case "unnamed":
205
+ if (!said.length) return { ...base, verdict: "not confirmed", said, note: `Tab never reached it in ${presses} presses; a screen-reader user meets it only reading line by line.` };
206
+ return nameless
207
+ ? { ...base, verdict: "confirmed", said, note: `${readerName} said "${nameless.said}", with no name.` }
208
+ : { ...base, verdict: "check by ear", said, note: `${readerName} said "${said[0]}".` };
209
+ case "described":
210
+ return said.length
211
+ ? { ...base, verdict: "check by ear", said, note: `${readerName} said "${said[0]}": a description after the role, but no name.` }
212
+ : { ...base, verdict: "not confirmed", said, note: `Tab never reached it in ${presses} presses.` };
213
+ case "placeholder": {
214
+ if (!said.length) return { ...base, verdict: "not confirmed", said, note: `Tab never reached it in ${presses} presses.` };
215
+ const placeholders = targets.map((t) => spoken(t.name)).filter(Boolean);
216
+ const hit = said.find((s) => placeholders.some((p) => spoken(s).includes(p)));
217
+ return hit
218
+ ? { ...base, verdict: "confirmed", said, note: `${readerName} named it by its placeholder: "${hit}".` }
219
+ : { ...base, verdict: "check by ear", said, note: `${readerName} said "${said[0]}".` };
220
+ }
221
+ }
222
+ }
223
+
224
+ /** Hears each screen-reader finding of a run, and adds what was heard to its report. */
225
+ export async function hearFindings(o: HearOptions): Promise<Heard[]> {
226
+ const findings = JSON.parse(await readFile(join(o.reportDir, "findings.json"), "utf8")) as Finding[];
227
+ const sessions = JSON.parse(await readFile(join(o.reportDir, "sessions.json"), "utf8")) as Session[];
228
+ // One visit per page and agent: the agent whose browser state opens it signed in.
229
+ const visits = new Map<string, { agentId: string; url: string; findings: Finding[] }>();
230
+ for (const f of findings) {
231
+ if (!f.control) continue;
232
+ const e = f.evidence[0];
233
+ const step = sessions.find((s) => s.agentId === e?.agentId)?.steps.find((st) => st.index === e.stepIndex);
234
+ if (!step) continue;
235
+ const key = `${e.agentId} ${step.observation.url}`;
236
+ const visit = visits.get(key) ?? { agentId: e.agentId, url: step.observation.url, findings: [] };
237
+ visit.findings.push(f);
238
+ visits.set(key, visit);
239
+ }
240
+ const heard: Heard[] = [];
241
+ if (!visits.size) return heard;
242
+
243
+ const browser = await o.launch();
244
+ await o.reader.start();
245
+ try {
246
+ for (const visit of visits.values()) {
247
+ const path = new URL(visit.url).pathname;
248
+ const stateFile = o.browserStateDir ? join(o.browserStateDir, `${visit.agentId}.json`) : undefined;
249
+ const state = stateFile && existsSync(stateFile) ? stateFile : undefined;
250
+ // Signed in first, as the agent ended; a page from before sign-in (sign-up, log in) sends a
251
+ // signed-in browser on, so it is opened again signed out.
252
+ const tries = state ? [state, undefined] : [undefined];
253
+ const landed: string[] = [];
254
+ let context: BrowserContext | undefined;
255
+ let page: Page | undefined;
256
+ for (const tryState of tries) {
257
+ context = await browser.newContext(tryState ? { storageState: tryState } : {});
258
+ page = await context.newPage();
259
+ await page.goto(visit.url).catch(() => {});
260
+ await page.waitForTimeout(SETTLE_MS);
261
+ if (new URL(page.url()).pathname === path) break;
262
+ landed.push(new URL(page.url()).pathname);
263
+ await context.close();
264
+ context = undefined;
265
+ }
266
+ if (!context || !page) {
267
+ const note = state
268
+ ? `Opening ${path} led to ${landed[0]} signed in and to ${landed[1]} signed out: it may be a step shown only once, such as set-up after sign-up.`
269
+ : `Opening ${path} led to ${landed[0]}; keep the agents' browser state with debutly run --browser-state <dir> to open pages behind sign-in.`;
270
+ for (const f of visit.findings) heard.push({ title: f.title, page: path, verdict: "not on the page", said: [], note });
271
+ continue;
272
+ }
273
+ try {
274
+ const elements = (await page.evaluate(SCAN_SCRIPT)) as InteractiveElement[];
275
+ const names = await accessibleNames(page);
276
+ if (names) for (const el of elements) if (names.get(el.id)) el.ax = names.get(el.id);
277
+ const stops = await tabThrough(page, o.reader, o.tabs ?? 120);
278
+ const ids = new Set(visit.findings.flatMap((f) => elements.filter((el) => matches(f.control!, el)).map((el) => String(el.id))));
279
+ await hearAgain(page, o.reader, stops, ids);
280
+ for (const f of visit.findings) heard.push(judge(f, path, elements, stops, o.reader.name.split(" ")[0]));
281
+ } finally {
282
+ await context.close();
283
+ }
284
+ }
285
+ } finally {
286
+ await o.reader.stop().catch(() => {});
287
+ await browser.close();
288
+ }
289
+
290
+ await writeFile(join(o.reportDir, "heard.json"), JSON.stringify({ reader: o.reader.name, heard }, null, 2));
291
+ await appendFile(join(o.reportDir, "report.md"), renderHeard(o.reader.name, heard));
292
+ return heard;
293
+ }
294
+
295
+ export function renderHeard(readerName: string, heard: Heard[]): string {
296
+ const count = (v: Verdict) => heard.filter((h) => h.verdict === v).length;
297
+ const lines = [
298
+ "",
299
+ "## Heard with a screen reader",
300
+ "",
301
+ `${readerName} went through each page with a screen-reader finding, pressing Tab from the top until it had been round twice. "Confirmed" means it said what the finding says; "check by ear" means a person should judge what it said.`,
302
+ "",
303
+ `${count("confirmed")} confirmed, ${count("not confirmed")} not confirmed, ${count("check by ear")} to check by ear, ${count("not on the page")} not on the page when opened again.`,
304
+ "",
305
+ ...heard.map((h) => `- **${h.verdict}**: ${h.title}. ${h.note}`),
306
+ "",
307
+ ];
308
+ return lines.join("\n");
309
+ }