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/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
|
|
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 || !
|
|
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
|
-
|
|
130
|
-
const
|
|
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
|
+
}
|
package/src/report/markdown.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
/**
|
|
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;
|