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/src/init.ts CHANGED
@@ -103,15 +103,15 @@ async function guessCustomers(url: string): Promise<string | undefined> {
103
103
  }
104
104
  }
105
105
 
106
- export async function init(cwd: string = process.cwd()): Promise<number> {
107
- const rl = createInterface({ input: process.stdin, output: process.stdout });
106
+ export async function init(cwd: string = process.cwd(), input: NodeJS.ReadableStream & { isTTY?: boolean } = process.stdin): Promise<number> {
107
+ const rl = createInterface({ input, output: process.stdout });
108
108
  // Lines are buffered, so answers piped in by a script aren't lost between questions; at the end
109
109
  // of the input every question takes its default.
110
110
  const lines = rl[Symbol.asyncIterator]();
111
111
  const ask = async (question: string, fallback = "") => {
112
112
  process.stdout.write(fallback ? `${question} [${fallback}] ` : `${question} `);
113
113
  const { value, done } = await lines.next();
114
- if (done || !process.stdin.isTTY) process.stdout.write("\n");
114
+ if (done || !input.isTTY) process.stdout.write("\n");
115
115
  return (done ? "" : String(value).trim()) || fallback;
116
116
  };
117
117
  try {
@@ -126,8 +126,9 @@ export async function init(cwd: string = process.cwd()): Promise<number> {
126
126
  console.log("\nWhat should the agents try to do?");
127
127
  GOALS.forEach((g, i) => console.log(` ${i + 1}. ${g}`));
128
128
  console.log(" 4. Something else");
129
- const pick = await ask("Choose 1-4:", "1");
130
- const goal = GOALS[Number(pick) - 1] ?? (await ask("Describe it in a sentence:"));
129
+ // A goal typed here instead of a number is the goal.
130
+ const pick = await ask("Choose 1-4, or type the goal:", "1");
131
+ const goal = GOALS[Number(pick) - 1] ?? (/^\d*$/.test(pick) ? await ask("Describe it in a sentence:") : pick);
131
132
 
132
133
  const guess = await guessCustomers(url);
133
134
  const icp = await ask("\nWho are your customers? One sentence, e.g. who they are and how big.", guess);
@@ -0,0 +1,32 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import type { CustomerUnit } from "../types";
3
+
4
+ /** Each run writes its people here, next to the report, so a later run can meet the same ones. */
5
+ export const PEOPLE_FILE = "people.json";
6
+
7
+ /**
8
+ * The people of an earlier run, to run the same agents again: after a fix, whether it worked is
9
+ * only clear with the same people, needs and languages. Generated afresh, a run had a different
10
+ * crew every day (needs rotate daily), and two runs of one app could not be compared.
11
+ */
12
+ export async function readPeople(path: string): Promise<CustomerUnit[]> {
13
+ let parsed: unknown;
14
+ try {
15
+ parsed = JSON.parse(await readFile(path, "utf8"));
16
+ } catch (err) {
17
+ throw new Error(`${path} can't be read: ${err instanceof Error ? err.message : err}`);
18
+ }
19
+ const ok = (u: unknown) => {
20
+ const unit = u as Partial<CustomerUnit> | null;
21
+ if (!unit || typeof unit !== "object" || typeof unit.id !== "string") return false;
22
+ if (unit.kind === "individual") return !!unit.person?.id;
23
+ return unit.kind === "business" && !!unit.owner?.id && Array.isArray(unit.employees);
24
+ };
25
+ if (!Array.isArray(parsed) || !parsed.length || !parsed.every(ok)) {
26
+ throw new Error(`${path} doesn't hold a run's people: use the ${PEOPLE_FILE} from an earlier run's report folder.`);
27
+ }
28
+ return parsed as CustomerUnit[];
29
+ }
30
+
31
+ /** How many agents the units make: everyone in a crew is one. */
32
+ export const peopleCount = (units: CustomerUnit[]) => units.reduce((n, u) => n + (u.kind === "individual" ? 1 : 1 + u.employees.length), 0);
@@ -0,0 +1,125 @@
1
+ import { UNCOMPRESSED } from "../detect/findings";
2
+ import type { Finding, Session } from "../types";
3
+
4
+ /** At most this many items, so the section reads in a couple of minutes. */
5
+ const MAX_ITEMS = 5;
6
+ const MAX_QUOTE = 200;
7
+ const RANK: Record<Finding["severity"], number> = { blocker: 0, major: 1, minor: 2 };
8
+
9
+ // Agents that didn't reach the goal because of the app, or seemingly so. An engine error or a
10
+ // cancelled run says nothing about the app; a run cut short for credit has its own section.
11
+ const STOPPED: Partial<Record<Session["outcome"], string>> = {
12
+ gave_up: "gave up",
13
+ budget_exhausted: "ran out of steps",
14
+ not_invited: "never got an invite",
15
+ };
16
+
17
+ /** What to do about a finding, in a line. */
18
+ export function nextStep(f: Finding): string {
19
+ switch (f.kind) {
20
+ case "http_error": {
21
+ const status = Number(/^HTTP (\d{3})/.exec(f.title)?.[1] ?? 0);
22
+ return status >= 500
23
+ ? "The server failed: its log for this request says why."
24
+ : "The server refused the request: compare what the page sent with what the server expects.";
25
+ }
26
+ case "page_error":
27
+ return "Fix the JavaScript error; the reproduction below shows the step that sets it off.";
28
+ case "console_error":
29
+ return "Read the error in the browser console on that page.";
30
+ case "a11y_unlabeled":
31
+ if (f.control?.problem === "placeholder") return "Give the field a visible <label>, and keep the placeholder as an example.";
32
+ if (f.control?.problem === "described") return "Name it: visible text or an aria-label, with the description kept as a hint.";
33
+ if (f.control && !/^(button|link|menuitem|tab|summary)/.test(f.control.role)) return "Give each one a <label> (or an aria-label) that says what goes in it.";
34
+ return 'Give each one visible text, or an aria-label that says what it does (aria-label="Open menu" on a menu icon).';
35
+ case "stuck":
36
+ return "Watch the reproduction below: the agent repeated an action that seemed to do nothing. Make the page show that it worked, or why it didn't.";
37
+ case "slow_load":
38
+ return f.detail.includes(UNCOMPRESSED)
39
+ ? "Turn on compression (gzip or Brotli) for scripts and styles, or check that the real site has it."
40
+ : "Find what holds the page up: the agents sat waiting for it.";
41
+ case "covered":
42
+ return "Move or close whatever lies over the control, so it can be clicked.";
43
+ case "keyboard":
44
+ if (f.signature.startsWith("enter:")) return "Let Enter press it too: look for a key handler that catches Enter and stops it (a page-wide shortcut, a form's own Enter handling).";
45
+ return 'Use a real <button> or <a>, or add tabindex="0" and handle Enter and Space.';
46
+ case "focus_obscured":
47
+ return "Keep the control with keyboard focus in view: move or shrink what covers it.";
48
+ case "reflow":
49
+ return "Let the page wrap at narrow widths instead of scrolling sideways: look for fixed widths.";
50
+ case "language":
51
+ if (f.signature.startsWith("dir:")) return 'Set dir="rtl" on Arabic pages (on <html>, or on the part that holds the Arabic).';
52
+ if (f.signature === "lang:missing") return 'Add a lang attribute to the <html> element, e.g. lang="en".';
53
+ return 'Set lang="ar" on Arabic pages.';
54
+ case "hidden_focus":
55
+ return 'Make the hidden part unreachable while hidden: add inert to it (or tabindex="-1" to each control), or stop hiding it from screen readers.';
56
+ case "zoom":
57
+ return "Remove user-scalable=no and maximum-scale from the page's viewport meta tag.";
58
+ }
59
+ }
60
+
61
+ const lastPath = (s: Session) => {
62
+ try {
63
+ return new URL(s.steps.at(-1)?.observation.url ?? "").pathname;
64
+ } catch {
65
+ return "";
66
+ }
67
+ };
68
+
69
+ const quote = (s: string) => `"${s.length > MAX_QUOTE ? `${s.slice(0, MAX_QUOTE)}…` : s}"`;
70
+
71
+ /** A screenshot as the report's reader finds it: next to the report, not on the machine that ran it. */
72
+ export function shotPath(path: string): string {
73
+ if (/^https?:/.test(path)) return path;
74
+ // Kept in the run's database too (db:<run>/<file>), and under the same name beside the report.
75
+ if (path.startsWith("db:")) return `screenshots/${path.split("/").pop()}`;
76
+ const at = path.replace(/\\/g, "/").lastIndexOf("/screenshots/");
77
+ return at >= 0 ? path.replace(/\\/g, "/").slice(at + 1) : path;
78
+ }
79
+
80
+ function stoppedItem(s: Session, needs: string, environment: boolean): string {
81
+ const who = [s.role, needs !== "—" ? needs : ""].filter(Boolean).join("; ");
82
+ const where = lastPath(s) ? ` on ${lastPath(s)}` : "";
83
+ const shot = [...s.steps].reverse().find((st) => st.screenshotPath)?.screenshotPath;
84
+ // Out of steps, the note only says so; the agent's last thought says where it was.
85
+ const said = s.outcome === "budget_exhausted" && s.steps.at(-1)?.reasoning ? `Its last thought: ${quote(s.steps.at(-1)!.reasoning)}` : `In the agent's words: ${quote(s.outcomeNote)}`;
86
+ return [
87
+ `**${s.personaName}${who ? ` (${who})` : ""} ${STOPPED[s.outcome]}${where}.** ${said}`,
88
+ environment ? "Part of it may be the test environment: see the end of the report." : "",
89
+ shot ? `Start with the last screenshot, ${shotPath(shot)}.` : "",
90
+ ]
91
+ .filter(Boolean)
92
+ .join(" ");
93
+ }
94
+
95
+ /**
96
+ * The few things to look at first: agents the app seemingly stopped, in their own words, then the
97
+ * blocker and major findings, those that stopped an agent first, each with what to do.
98
+ */
99
+ export function renderFixFirst(sessions: Session[], findings: Finding[], needLabel: (s: Session) => string): string[] {
100
+ const stopped = sessions.filter((s) => STOPPED[s.outcome]);
101
+ const failed = new Set(stopped.map((s) => s.agentId));
102
+ const caused = findings.filter((f) => f.sandboxCause);
103
+ const serious = findings
104
+ .filter((f) => !f.sandboxCause && f.confidence !== "low" && f.severity !== "minor")
105
+ .map((f, order) => ({ f, order, stoppedSome: f.hitBy.some((id) => failed.has(id)) }))
106
+ // Most severe first; within that, one an agent didn't get past.
107
+ .sort((a, b) => RANK[a.f.severity] - RANK[b.f.severity] || Number(b.stoppedSome) - Number(a.stoppedSome) || a.order - b.order)
108
+ .map(({ f }) => f);
109
+ const reached = sessions.filter((s) => s.outcome === "success").length;
110
+ const stops = stopped.slice(0, MAX_ITEMS).map((s) => stoppedItem(s, needLabel(s), caused.some((f) => f.hitBy.includes(s.agentId))));
111
+ const fixes = serious.slice(0, MAX_ITEMS - stops.length).map((f) => `**${f.title}** (${f.severity}). ${nextStep(f)}`);
112
+ const shown = [...stops, ...fixes];
113
+ const rest = findings.filter((f) => !f.sandboxCause).length - fixes.length;
114
+ return [
115
+ `## Fix these first`,
116
+ "",
117
+ `${reached} of ${sessions.length} agents reached the goal.`,
118
+ "",
119
+ ...(shown.length
120
+ ? shown.map((item, i) => `${i + 1}. ${item}`)
121
+ : ["Nothing stopped an agent, and nothing serious was found."]),
122
+ ...(rest > 0 ? ["", `${rest === 1 ? "One" : rest}${fixes.length ? " more" : ""} ${rest === 1 ? "finding is" : "findings are"} below.`] : []),
123
+ "",
124
+ ];
125
+ }
@@ -4,6 +4,7 @@ import { describeCriterion } from "../detect/wcag";
4
4
  import { OUT_OF_CREDITS } from "../llm/types";
5
5
  import { languageName } from "../persona/language";
6
6
  import type { CustomerUnit, Finding, Session } from "../types";
7
+ import { renderFixFirst, shotPath } from "./fix-first";
7
8
 
8
9
  export interface ReportInput {
9
10
  targetUrl: string;
@@ -40,7 +41,7 @@ function renderFinding(f: Finding, sessions: Session[]): string {
40
41
  lines.push(`${step.index + 1}. ${describeAction(step.action, step.observation)} — "${step.reasoning}"`);
41
42
  }
42
43
  const shot = session.steps[ev.stepIndex]?.screenshotPath;
43
- if (shot) lines.push("", `Screenshot: ${shot}`);
44
+ if (shot) lines.push("", `Screenshot: ${shotPath(shot)}`);
44
45
  }
45
46
  return lines.join("\n");
46
47
  }
@@ -164,6 +165,7 @@ export function renderReport(input: ReportInput): string {
164
165
  `Findings: ${count("blocker")} blocker, ${count("major")} major, ${count("minor")} minor (${low.length} low-confidence${caused.length ? `, ${caused.length} caused by the test environment` : ""})`,
165
166
  "",
166
167
  ...renderOutOfCredits(input.sessions),
168
+ ...renderFixFirst(input.sessions, input.findings, needLabel),
167
169
  ...renderUnits(input.units ?? [], input.sessions),
168
170
  `## Agent outcomes`,
169
171
  "",
package/src/run.ts CHANGED
@@ -7,6 +7,7 @@ import { runUnit, type RunAgent } from "./crew/run-unit";
7
7
  import { detectFindings } from "./detect/findings";
8
8
  import type { Inbox } from "./inbox/types";
9
9
  import type { LlmClient } from "./llm/types";
10
+ import { PEOPLE_FILE } from "./persona/people";
10
11
  import { renderReport } from "./report/markdown";
11
12
  import type { ModelPrice } from "./llm/meter";
12
13
  import { blockedCalls, type SandboxOutbox } from "./sandbox/outbox";
@@ -43,8 +44,8 @@ export interface SimulationOptions {
43
44
  price?: ModelPrice | null;
44
45
  /** In-memory kill switch when there is no store (e.g. Ctrl+C). */
45
46
  shouldStop?: () => boolean | Promise<boolean>;
46
- /** Where to keep each agent's browser state (cookies, storage) at the end, for `debutly hear`. */
47
- browserStateDir?: string;
47
+ /** Where to keep each agent's browser state (cookies, storage) at the end, for `debutly hear`. */
48
+ browserStateDir?: string;
48
49
  /** Sends each agent somewhere else to run (e.g. a queue of workers) instead of this process. */
49
50
  dispatch?: (ctx: { runId?: string; runTag: string }) => RunAgent;
50
51
  }
@@ -120,6 +121,7 @@ export async function runSimulation(o: SimulationOptions): Promise<SimulationRes
120
121
  const reportPath = join(o.outDir, "report.md");
121
122
  await writeFile(join(o.outDir, "sessions.json"), JSON.stringify(sessions, null, 2));
122
123
  await writeFile(join(o.outDir, "findings.json"), JSON.stringify(findings, null, 2));
124
+ await writeFile(join(o.outDir, PEOPLE_FILE), JSON.stringify(o.units, null, 2));
123
125
  await writeFile(reportPath, reportMd);
124
126
  if (store && runId) {
125
127
  const status = sessions.some((s) => s.outcome === "cancelled") ? "cancelled" : "finished";
package/src/types.ts CHANGED
@@ -33,7 +33,7 @@ export type CustomerUnit =
33
33
  | { kind: "business"; id: string; business: Business; owner: Persona; employees: Persona[] };
34
34
 
35
35
  /** Keys a person uses to drive popups and forms; nothing that could close tabs or run shortcuts. */
36
- export const PRESS_KEYS = ["Escape", "Enter", "Tab", "ArrowDown", "ArrowUp"] as const;
36
+ export const PRESS_KEYS = ["Escape", "Enter", "Space", "Tab", "ArrowDown", "ArrowUp"] as const;
37
37
  export type PressKey = (typeof PRESS_KEYS)[number];
38
38
 
39
39
  export type Action =
@@ -55,7 +55,8 @@ export type Signal =
55
55
  | { kind: "http_error"; status: number; requestUrl: string; url: string; startedAt?: number; at?: number; body?: string }
56
56
  | { kind: "dialog"; dialogType: string; message: string; url: string }
57
57
  // A page that took long to arrive. finished: false when the agent's wait ran out first.
58
- | { kind: "slow_load"; url: string; ms: number; finished: boolean; slowNetwork: boolean }
58
+ // assets: the scripts and styles received by then, as sent, and how much of that came uncompressed.
59
+ | { kind: "slow_load"; url: string; ms: number; finished: boolean; slowNetwork: boolean; assets?: { files: number; bytes: number; uncompressedBytes: number } }
59
60
  // A file the page handed over (a report, an export): the page itself often doesn't change.
60
61
  | { kind: "download"; filename: string; url: string };
61
62
 
@@ -66,6 +67,8 @@ export interface AccessibleName {
66
67
  hidden: boolean;
67
68
  /** What a screen reader reads after the role (aria-describedby, a tooltip); absent when there's none. */
68
69
  description?: string;
70
+ /** Hidden by aria-hidden on it or around it, rather than for another reason. */
71
+ ariaHidden?: true;
69
72
  }
70
73
 
71
74
  export interface InteractiveElement {
@@ -73,9 +76,18 @@ export interface InteractiveElement {
73
76
  role: string;
74
77
  name: string;
75
78
  tag: string;
76
- /** A native select's chosen option and its options, by label. */
79
+ /** What a field holds: a native select's chosen option, or a text field's text (never a password's). */
77
80
  value?: string;
81
+ /** A native select's options, by label. */
78
82
  options?: string[];
83
+ /** A password field with something typed in it. */
84
+ filled?: true;
85
+ /** Shown but not editable. */
86
+ readOnly?: true;
87
+ /** A checkbox, radio button or switch: on or off. */
88
+ checked?: boolean;
89
+ /** A control that opens something (a menu, a dropdown): open or closed. */
90
+ expanded?: boolean;
79
91
  /** The browser's own accessible name and role (Chromium), as a screen reader would announce them. */
80
92
  ax?: AccessibleName;
81
93
  /** Named only by its placeholder: no label, aria-label or aria-labelledby. */
@@ -84,6 +96,8 @@ export interface InteractiveElement {
84
96
  disabled?: true;
85
97
  /** Out of the tab order (tabindex -1, or inside an inert part of the page). */
86
98
  tabbable?: false;
99
+ /** A control with no text: the icon it shows, as its markup names it ("menu", "trash"). What a sighted person sees. */
100
+ icon?: string;
87
101
  }
88
102
 
89
103
  export interface Observation {
@@ -97,6 +111,8 @@ export interface Observation {
97
111
  arabicShare?: number;
98
112
  /** The page's viewport setting when it stops pinch-zoom (user-scalable=no, maximum-scale under 2), else "". */
99
113
  zoomBlocked?: string;
114
+ /** What is open over the page and takes clicks outside it, e.g. 'a dialog ("Welcome")' or "a dropdown list". */
115
+ openLayer?: string;
100
116
  }
101
117
 
102
118
  export interface ActionResult {
@@ -104,6 +120,10 @@ export interface ActionResult {
104
120
  error?: string;
105
121
  /** Keyboard only: what entirely hid the control once it had focus. */
106
122
  focusObscured?: string;
123
+ /** Keyboard only: Enter did nothing on the button, and Space pressed it. */
124
+ enterIgnored?: true;
125
+ /** What the browser's own bubble said about fields a submit found invalid: '"Password": Please lengthen…'. */
126
+ invalid?: string[];
107
127
  }
108
128
 
109
129
  export interface Step extends ActionResult {
@@ -160,7 +180,8 @@ export type FindingKind =
160
180
  | "focus_obscured"
161
181
  | "reflow"
162
182
  | "language"
163
- | "zoom";
183
+ | "zoom"
184
+ | "hidden_focus";
164
185
 
165
186
  export interface Finding {
166
187
  signature: string;