debutly 0.1.0 → 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.
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/agent/effect.ts +59 -2
- package/src/agent/give-up.ts +42 -0
- package/src/agent/label.ts +19 -0
- package/src/agent/loop.ts +74 -4
- package/src/agent/parse-reply.ts +52 -1
- package/src/agent/prompt.ts +18 -9
- package/src/browser/execute.ts +68 -5
- package/src/browser/observe.ts +7 -1
- package/src/browser/scan-script.ts +46 -0
- package/src/cli.ts +25 -9
- package/src/detect/findings.ts +34 -2
- package/src/init.ts +6 -5
- package/src/persona/people.ts +32 -0
- package/src/report/fix-first.ts +121 -0
- package/src/report/markdown.ts +3 -1
- package/src/run.ts +4 -2
- package/src/types.ts +17 -3
package/README.md
CHANGED
|
@@ -24,7 +24,7 @@ Website and docs: [debutly.dev](https://debutly.dev) ([quick start](https://debu
|
|
|
24
24
|
|
|
25
25
|
## Quick start
|
|
26
26
|
|
|
27
|
-
Needs Node.js 20.12 or later.
|
|
27
|
+
Needs Node.js 20.12 or later. In your app's folder:
|
|
28
28
|
|
|
29
29
|
```bash
|
|
30
30
|
npx debutly init # a few questions, then a debutly.json
|
package/package.json
CHANGED
package/src/agent/effect.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import type { Action, ActionResult, Observation, Signal } from "../types";
|
|
1
|
+
import type { Action, ActionResult, InteractiveElement, Observation, Signal } from "../types";
|
|
2
|
+
import { controlLabel, type StepView } from "./label";
|
|
2
3
|
|
|
3
4
|
const pathOf = (url: string) => {
|
|
4
5
|
try {
|
|
@@ -9,16 +10,72 @@ const pathOf = (url: string) => {
|
|
|
9
10
|
}
|
|
10
11
|
};
|
|
11
12
|
|
|
13
|
+
const MAX_LISTED = 4;
|
|
14
|
+
const MAX_LINE = 80;
|
|
15
|
+
|
|
16
|
+
type Label = (e: InteractiveElement) => string;
|
|
17
|
+
|
|
18
|
+
/** Controls in `a` beyond those in `b`, counting copies: a second "Untitled" row is new. */
|
|
19
|
+
function beyond(a: InteractiveElement[], b: InteractiveElement[], controlOf: Label): InteractiveElement[] {
|
|
20
|
+
const left = new Map<string, number>();
|
|
21
|
+
for (const e of b) left.set(controlOf(e), (left.get(controlOf(e)) ?? 0) + 1);
|
|
22
|
+
return a.filter((e) => {
|
|
23
|
+
const n = left.get(controlOf(e)) ?? 0;
|
|
24
|
+
if (n > 0) left.set(controlOf(e), n - 1);
|
|
25
|
+
return n === 0;
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Controls still there whose state changed: a Save button now enabled, a box now checked. */
|
|
30
|
+
function stateChanges(before: InteractiveElement[], after: InteractiveElement[], controlOf: Label): string[] {
|
|
31
|
+
const out: string[] = [];
|
|
32
|
+
for (const a of after) {
|
|
33
|
+
const b = before.find((e) => controlOf(e) === controlOf(a));
|
|
34
|
+
if (!b) continue;
|
|
35
|
+
if (!!b.disabled !== !!a.disabled) out.push(`now ${a.disabled ? "disabled" : "enabled"} ${controlOf(a)}`);
|
|
36
|
+
if (b.checked !== undefined && a.checked !== undefined && b.checked !== a.checked) out.push(`now ${a.checked ? "checked" : "unchecked"} ${controlOf(a)}`);
|
|
37
|
+
if (b.expanded !== undefined && a.expanded !== undefined && b.expanded !== a.expanded) out.push(`now ${a.expanded ? "open" : "closed"} ${controlOf(a)}`);
|
|
38
|
+
}
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const listed = (items: string[]) => items.slice(0, MAX_LISTED).join(", ") + (items.length > MAX_LISTED ? `, and ${items.length - MAX_LISTED} more` : "");
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* What changed on the page, as a person would notice it: controls that appeared or went, state
|
|
46
|
+
* that changed, and new lines of text. Told only "the page changed", agents missed a password
|
|
47
|
+
* field that had appeared, pressed "Create company" seven times without seeing seven new rows,
|
|
48
|
+
* and kept retyping values while waiting for a Save button to wake up.
|
|
49
|
+
*/
|
|
50
|
+
function whatChanged(before: Observation, after: Observation, controlOf: Label): string {
|
|
51
|
+
const appeared = beyond(after.elements, before.elements, controlOf);
|
|
52
|
+
const gone = beyond(before.elements, after.elements, controlOf);
|
|
53
|
+
const names = new Set(after.elements.map((e) => e.name));
|
|
54
|
+
const had = new Set(before.text.split("\n").map((l) => l.trim()));
|
|
55
|
+
const newText = [...new Set(after.text.split("\n").map((l) => l.trim()))].filter((l) => l && !had.has(l) && !names.has(l)).map((l) => `"${l.slice(0, MAX_LINE)}"`);
|
|
56
|
+
const parts = [
|
|
57
|
+
appeared.length ? `new ${listed(appeared.map(controlOf))}` : "",
|
|
58
|
+
gone.length ? `gone ${listed(gone.map(controlOf))}` : "",
|
|
59
|
+
...stateChanges(before.elements, after.elements, controlOf).slice(0, MAX_LISTED),
|
|
60
|
+
newText.length ? `new text ${listed(newText)}` : "",
|
|
61
|
+
].filter(Boolean);
|
|
62
|
+
return parts.length ? `the page changed: ${parts.join("; ")}` : "";
|
|
63
|
+
}
|
|
64
|
+
|
|
12
65
|
// What a person would notice after acting: where they are now, whether anything changed,
|
|
13
66
|
// and whether something visibly broke. Without this, agents retry dead buttons forever.
|
|
14
|
-
|
|
67
|
+
// Controls are named as the agent meets them, as in its step message.
|
|
68
|
+
export function describeEffect(action: Action, before: Observation, after: Observation, signals: Signal[], result: ActionResult, view: StepView = {}): string {
|
|
15
69
|
if (result.blocked) return `blocked: ${result.blocked}`;
|
|
16
70
|
if (result.error) return `failed: ${result.error}`;
|
|
17
71
|
// check_email reports its own result; done ends the session.
|
|
18
72
|
if (action.type === "done" || action.type === "check_email") return "";
|
|
19
73
|
|
|
20
74
|
const parts: string[] = [];
|
|
75
|
+
if (result.enterIgnored) parts.push("Enter did nothing on it, so Space was pressed instead");
|
|
76
|
+
const changed = whatChanged(before, after, (e) => controlLabel(e, view.screenReader));
|
|
21
77
|
if (pathOf(after.url) !== pathOf(before.url)) parts.push(`now on ${pathOf(after.url)}`);
|
|
78
|
+
else if (changed) parts.push(changed);
|
|
22
79
|
else if (after.text !== before.text) parts.push("the page changed");
|
|
23
80
|
else if (action.type !== "fill" && !signals.some((s) => s.kind === "download")) parts.push("nothing visibly changed");
|
|
24
81
|
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { Observation, Step } from "../types";
|
|
2
|
+
|
|
3
|
+
// What a person presses before giving up: to go on, join, sign in or start, or to add or create
|
|
4
|
+
// what they came to add (agents said adding a person was "missing" beside "Add person").
|
|
5
|
+
// Arabic too, as agents often use Arabic interfaces.
|
|
6
|
+
const WAY_ON =
|
|
7
|
+
/^(continue|next|proceed|join\b.*|accept\b.*|sign ?in|log ?in|sign ?up|create (an )?account|get started|start|confirm|verify|submit|(add|create|new)\b.*|استمر|متابعة|التالي|انضم.*|قبول.*|تسجيل الدخول|ابدأ.*|تأكيد|إرسال|(إنشاء|أضف|إضافة).*)$/i;
|
|
8
|
+
// Buttons only: a page's own way on. A "Sign up" link in the site's menu is not one from where it stands.
|
|
9
|
+
const PRESSABLE = /^(button|input:(submit|button))$/;
|
|
10
|
+
|
|
11
|
+
const pathOf = (url: string) => {
|
|
12
|
+
try {
|
|
13
|
+
return new URL(url).pathname;
|
|
14
|
+
} catch {
|
|
15
|
+
return url;
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The ways on that an agent about to give up hasn't tried on the page it is on: enabled buttons
|
|
21
|
+
* named like Continue, Join or Sign in that it hasn't pressed since it came to this page.
|
|
22
|
+
* A teammate agent gave up on Twenty's invite page saying there was "no way into the workspace",
|
|
23
|
+
* with a Continue button under its eyes (2026-10-25), and the report blamed the app.
|
|
24
|
+
*/
|
|
25
|
+
export function untriedWaysOn(obs: Observation, steps: Step[], screenReader = false): string[] {
|
|
26
|
+
const path = pathOf(obs.url);
|
|
27
|
+
const pressed = new Set<string>();
|
|
28
|
+
for (let i = steps.length - 1; i >= 0 && pathOf(steps[i].observation.url) === path; i--) {
|
|
29
|
+
const { action, observation } = steps[i];
|
|
30
|
+
if (action.type !== "click") continue;
|
|
31
|
+
const el = observation.elements.find((e) => e.id === action.id);
|
|
32
|
+
if (el) pressed.add((el.ax?.name || el.name).trim().toLowerCase());
|
|
33
|
+
}
|
|
34
|
+
const names = obs.elements
|
|
35
|
+
.filter((e) => PRESSABLE.test(e.role) && !e.disabled && !(screenReader && e.ax?.hidden))
|
|
36
|
+
.map((e) => (screenReader && e.ax ? e.ax.name : e.name || e.ax?.name || "").trim())
|
|
37
|
+
.filter((name) => WAY_ON.test(name) && !pressed.has(name.toLowerCase()));
|
|
38
|
+
return [...new Set(names)].slice(0, 3);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** A page with nothing to press is often still arriving: one agent on a slow connection gave up the moment an invite page opened. */
|
|
42
|
+
export const nothingToPressYet = (obs: Observation) => obs.elements.length === 0;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { InteractiveElement } from "../types";
|
|
2
|
+
|
|
3
|
+
// Apart from prompt.ts, which needs the browser (Playwright) through the inclusion briefs: the
|
|
4
|
+
// dashboard imports effect.ts, and its build has no Playwright.
|
|
5
|
+
|
|
6
|
+
export interface StepView {
|
|
7
|
+
/** As a screen reader presents the page: the browser's names and roles, without what is hidden from it. */
|
|
8
|
+
screenReader?: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* A control as the agent meets it. A screen reader announces the role and name the browser gives
|
|
13
|
+
* it; a sighted person looking at a control with no text sees its icon.
|
|
14
|
+
*/
|
|
15
|
+
export function controlLabel(e: InteractiveElement, screenReader = false): string {
|
|
16
|
+
const heard = screenReader && e.ax ? e.ax : e;
|
|
17
|
+
const icon = !screenReader && !e.name && e.icon ? ` (${e.icon} icon)` : "";
|
|
18
|
+
return `${heard.role} "${heard.name}"${icon}`;
|
|
19
|
+
}
|
package/src/agent/loop.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Page } from "playwright";
|
|
1
|
+
import type { Page, Request } from "playwright";
|
|
2
2
|
import type { ArtifactStore } from "../artifacts/store";
|
|
3
3
|
import { execute } from "../browser/execute";
|
|
4
4
|
import { observe } from "../browser/observe";
|
|
@@ -11,15 +11,20 @@ import type { LlmClient, LlmMessage } from "../llm/types";
|
|
|
11
11
|
import type { ActionResult, Persona, Session, Step } from "../types";
|
|
12
12
|
import { describeAction } from "./describe";
|
|
13
13
|
import { describeEffect } from "./effect";
|
|
14
|
-
import {
|
|
14
|
+
import { nothingToPressYet, untriedWaysOn } from "./give-up";
|
|
15
|
+
import { NoActionError, parseAgentReply } from "./parse-reply";
|
|
15
16
|
import { buildStepMessage, buildSystemPrompt, type AgentIdentity } from "./prompt";
|
|
16
17
|
|
|
17
18
|
const MAX_REPLY_ATTEMPTS = 3;
|
|
19
|
+
/** Steps a session may wait for want of an action in the reply, before that is an engine error. */
|
|
20
|
+
const MAX_NO_ACTION_WAITS = 3;
|
|
18
21
|
const LINKS_PER_EMAIL = 3;
|
|
19
22
|
/** How long the first page may take to arrive; a slow-connection agent needs more than 30 s. */
|
|
20
23
|
const FIRST_LOAD_MS = 90_000;
|
|
21
24
|
/** A first page slower than this is reported. */
|
|
22
25
|
const SLOW_LOAD_MS = 10_000;
|
|
26
|
+
/** How long a slow first page's scripts and styles may take to be weighed before it is reported without. */
|
|
27
|
+
const WEIGH_MS = 2_000;
|
|
23
28
|
/** How long a step's screenshot may take before the step goes without one. */
|
|
24
29
|
const SCREENSHOT_MS = 15_000;
|
|
25
30
|
|
|
@@ -73,14 +78,37 @@ async function openFirstPage(
|
|
|
73
78
|
): Promise<void> {
|
|
74
79
|
const startedAt = Date.now();
|
|
75
80
|
let finished = true;
|
|
81
|
+
// What its scripts and styles weigh as sent, and how much came uncompressed: a test copy served
|
|
82
|
+
// straight from its container may skip the compression its real hosting does (Twenty's sent
|
|
83
|
+
// 10 MB uncompressed, 2026-10-24), and then a slow page says more about the copy than the app.
|
|
84
|
+
const assets = { files: 0, bytes: 0, uncompressedBytes: 0 };
|
|
85
|
+
const weighing = new Set<Promise<void>>();
|
|
86
|
+
const weigh = (request: Request) => {
|
|
87
|
+
if (request.resourceType() !== "script" && request.resourceType() !== "stylesheet") return;
|
|
88
|
+
const done = (async () => {
|
|
89
|
+
const [{ responseBodySize }, encoding] = await Promise.all([request.sizes(), request.response().then((r) => r?.headerValue("content-encoding"))]);
|
|
90
|
+
assets.files++;
|
|
91
|
+
assets.bytes += responseBodySize;
|
|
92
|
+
if (!encoding || encoding === "identity") assets.uncompressedBytes += responseBodySize;
|
|
93
|
+
})()
|
|
94
|
+
.catch(() => {}) // the page moved on before it could be weighed
|
|
95
|
+
.finally(() => weighing.delete(done));
|
|
96
|
+
weighing.add(done);
|
|
97
|
+
};
|
|
98
|
+
page.on("requestfinished", weigh);
|
|
76
99
|
try {
|
|
77
100
|
await page.goto(origin, { waitUntil: "domcontentloaded", timeout: timeoutMs });
|
|
78
101
|
} catch (err) {
|
|
79
102
|
if (!(err instanceof Error && err.name === "TimeoutError")) throw err;
|
|
80
103
|
finished = false;
|
|
104
|
+
} finally {
|
|
105
|
+
page.off("requestfinished", weigh);
|
|
81
106
|
}
|
|
82
107
|
const ms = Date.now() - startedAt;
|
|
83
|
-
if (!finished || ms >= slowMs)
|
|
108
|
+
if (!finished || ms >= slowMs) {
|
|
109
|
+
await Promise.race([Promise.allSettled([...weighing]), new Promise((resolve) => setTimeout(resolve, WEIGH_MS))]);
|
|
110
|
+
signals.note({ kind: "slow_load", url: origin, ms, finished, slowNetwork, ...(assets.files ? { assets: { ...assets } } : {}) });
|
|
111
|
+
}
|
|
84
112
|
}
|
|
85
113
|
|
|
86
114
|
export async function runSession(o: RunSessionOptions): Promise<Session> {
|
|
@@ -129,6 +157,8 @@ interface SessionState {
|
|
|
129
157
|
async function takeSteps(o: RunSessionOptions, { signals, system, steps, history, emailedLinks, meter, startedAt, end, reflow }: SessionState): Promise<Session> {
|
|
130
158
|
// The screen-reader agent hears what the browser tells assistive technology and works by keyboard.
|
|
131
159
|
const screenReader = o.persona.inclusion === "screen_reader";
|
|
160
|
+
let noActionWaits = 0;
|
|
161
|
+
const askedBeforeGivingUp = new Set<string>();
|
|
132
162
|
for (let index = 0; index < o.maxSteps; index++) {
|
|
133
163
|
if (o.shouldStop && (await o.shouldStop())) return end("cancelled", "run cancelled");
|
|
134
164
|
const { inputTokens, outputTokens } = meter.total;
|
|
@@ -144,13 +174,14 @@ async function takeSteps(o: RunSessionOptions, { signals, system, steps, history
|
|
|
144
174
|
// Now that we can see the page after the previous action, tell the agent what it did.
|
|
145
175
|
const prev = steps.at(-1);
|
|
146
176
|
if (prev) {
|
|
147
|
-
const effect = describeEffect(prev.action, prev.observation, observation, prev.signals, prev);
|
|
177
|
+
const effect = describeEffect(prev.action, prev.observation, observation, prev.signals, prev, { screenReader });
|
|
148
178
|
if (effect) history[history.length - 1] += ` → ${effect}`;
|
|
149
179
|
}
|
|
150
180
|
const messages: LlmMessage[] = [{ role: "user", content: buildStepMessage(observation, history, { screenReader }) }];
|
|
151
181
|
let reply: ReturnType<typeof parseAgentReply> | undefined;
|
|
152
182
|
let raw = "";
|
|
153
183
|
let problem = "";
|
|
184
|
+
let noAction: NoActionError | undefined;
|
|
154
185
|
// Real models occasionally send prose, an empty reply or malformed JSON; say what was wrong and ask again.
|
|
155
186
|
for (let attempt = 0; attempt < MAX_REPLY_ATTEMPTS && !reply; attempt++) {
|
|
156
187
|
raw = await o.llm.complete({ system, messages: [...messages], maxTokens: 4096, onUsage: meter.add });
|
|
@@ -158,6 +189,7 @@ async function takeSteps(o: RunSessionOptions, { signals, system, steps, history
|
|
|
158
189
|
reply = parseAgentReply(raw);
|
|
159
190
|
} catch (err) {
|
|
160
191
|
problem = err instanceof Error ? err.message : String(err);
|
|
192
|
+
noAction = err instanceof NoActionError ? err : undefined;
|
|
161
193
|
messages.push(
|
|
162
194
|
{ role: "assistant", content: raw || "(empty reply)" },
|
|
163
195
|
{
|
|
@@ -167,9 +199,47 @@ async function takeSteps(o: RunSessionOptions, { signals, system, steps, history
|
|
|
167
199
|
);
|
|
168
200
|
}
|
|
169
201
|
}
|
|
202
|
+
// Asked three times, GLM 5.3 Flash went on sending only its reasoning, often "I will wait", and
|
|
203
|
+
// the agent ended on an engine error (2026-10-24). It waits instead, as someone unsure what to
|
|
204
|
+
// do next would; not for ever.
|
|
205
|
+
if (!reply && noAction && noActionWaits < MAX_NO_ACTION_WAITS) {
|
|
206
|
+
noActionWaits++;
|
|
207
|
+
reply = { reasoning: noAction.reasoning, action: { type: "wait" } };
|
|
208
|
+
}
|
|
170
209
|
if (!reply) {
|
|
171
210
|
return end("error", `${problem} | last reply: ${raw.slice(0, 300) || "(empty)"}`);
|
|
172
211
|
}
|
|
212
|
+
// Giving up with a way on in plain sight that it never pressed here, or on a page with nothing
|
|
213
|
+
// to press yet: asked once per page. Still giving up, it gives up.
|
|
214
|
+
if (reply.action.type === "done" && !reply.action.success && !askedBeforeGivingUp.has(observation.url)) {
|
|
215
|
+
const wayOn = untriedWaysOn(observation, steps, screenReader);
|
|
216
|
+
const ask = wayOn.length
|
|
217
|
+
? `Before you give up: on this page you haven't tried ${wayOn.map((n) => `"${n}"`).join(", ")}. Someone who wanted to get on would try that first.`
|
|
218
|
+
: nothingToPressYet(observation)
|
|
219
|
+
? "Before you give up: nothing on this page can be pressed yet, so it may still be loading. Someone who wanted to get on would wait a little first."
|
|
220
|
+
: "";
|
|
221
|
+
if (ask) {
|
|
222
|
+
askedBeforeGivingUp.add(observation.url);
|
|
223
|
+
const again = await o.llm.complete({
|
|
224
|
+
system,
|
|
225
|
+
messages: [
|
|
226
|
+
...messages,
|
|
227
|
+
{ role: "assistant", content: raw },
|
|
228
|
+
{
|
|
229
|
+
role: "user",
|
|
230
|
+
content: `${ask} Reply again with one JSON object: go on, or give up if you are sure.`,
|
|
231
|
+
},
|
|
232
|
+
],
|
|
233
|
+
maxTokens: 4096,
|
|
234
|
+
onUsage: meter.add,
|
|
235
|
+
});
|
|
236
|
+
try {
|
|
237
|
+
reply = parseAgentReply(again);
|
|
238
|
+
} catch {
|
|
239
|
+
// an unreadable second answer leaves the first one standing
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
173
243
|
|
|
174
244
|
let result: ActionResult;
|
|
175
245
|
let historyLine = describeAction(reply.action, observation);
|
package/src/agent/parse-reply.ts
CHANGED
|
@@ -19,7 +19,56 @@ export function parseAgentReply(raw: string): { reasoning: string; action: Actio
|
|
|
19
19
|
const start = raw.indexOf("{");
|
|
20
20
|
const end = raw.lastIndexOf("}");
|
|
21
21
|
if (start === -1 || end <= start) throw new Error("no JSON object in agent reply");
|
|
22
|
-
|
|
22
|
+
const reply = actionAsObject(parseQuotedAction(raw.slice(start, end + 1)));
|
|
23
|
+
const parsed = ReplySchema.safeParse(reply);
|
|
24
|
+
if (!parsed.success) throw new Error(problemWith(reply, parsed.error));
|
|
25
|
+
return parsed.data;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** A reply with reasoning and no action. It carries the reasoning, so the agent can be left to wait. */
|
|
29
|
+
export class NoActionError extends Error {
|
|
30
|
+
constructor(readonly reasoning: string) {
|
|
31
|
+
super(`the reply has no "action". Add one, for example {"reasoning":"…","action":{"type":"wait"}}`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Said back to the agent when it is asked again, so in words it can act on. Shown the validator's
|
|
36
|
+
// own JSON for a reply with reasoning and no action, one model sent the same reply three times.
|
|
37
|
+
function problemWith(reply: unknown, error: z.ZodError): string {
|
|
38
|
+
if (reply && typeof reply === "object" && !("action" in reply)) {
|
|
39
|
+
const reasoning = (reply as { reasoning?: unknown }).reasoning;
|
|
40
|
+
throw new NoActionError(typeof reasoning === "string" ? reasoning : "");
|
|
41
|
+
}
|
|
42
|
+
return error.issues.map((i) => `${i.path.join(".") || "reply"}: ${i.message}`).join("; ");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The reply split into objects one after another, merged: {"reasoning":"…"} and on the next line
|
|
47
|
+
* {"action":"press","key":"Enter"} (GLM 5.3 Flash, 2026-10-24, twice in one run). When two whole
|
|
48
|
+
* replies come, the first one counts. Undefined when there is only one object or one doesn't parse.
|
|
49
|
+
*/
|
|
50
|
+
function splitReply(text: string): Record<string, unknown> | undefined {
|
|
51
|
+
const objects: string[] = [];
|
|
52
|
+
let depth = 0;
|
|
53
|
+
let from = 0;
|
|
54
|
+
let inString = false;
|
|
55
|
+
for (let i = 0; i < text.length; i++) {
|
|
56
|
+
const c = text[i];
|
|
57
|
+
if (inString) {
|
|
58
|
+
if (c === "\\") i++;
|
|
59
|
+
else if (c === '"') inString = false;
|
|
60
|
+
} else if (c === '"') inString = true;
|
|
61
|
+
else if (c === "{" && depth++ === 0) from = i;
|
|
62
|
+
else if (c === "}" && depth > 0 && --depth === 0) objects.push(text.slice(from, i + 1));
|
|
63
|
+
}
|
|
64
|
+
if (objects.length < 2) return undefined;
|
|
65
|
+
try {
|
|
66
|
+
const parsed = objects.map((o) => JSON.parse(o) as unknown);
|
|
67
|
+
if (!parsed.every((p) => p && typeof p === "object" && !Array.isArray(p))) return undefined;
|
|
68
|
+
return Object.assign({}, ...(parsed as Record<string, unknown>[]).reverse());
|
|
69
|
+
} catch {
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
23
72
|
}
|
|
24
73
|
|
|
25
74
|
// Some models (GLM 5.3 Flash) send the action as a string. Unescaped,
|
|
@@ -31,6 +80,8 @@ function parseQuotedAction(text: string): unknown {
|
|
|
31
80
|
try {
|
|
32
81
|
return JSON.parse(text);
|
|
33
82
|
} catch (err) {
|
|
83
|
+
const split = splitReply(text);
|
|
84
|
+
if (split) return split;
|
|
34
85
|
const unquoted = text.replace(/("action"\s*:\s*)"(\{[^{}]*\})"/, "$1$2");
|
|
35
86
|
if (unquoted !== text) {
|
|
36
87
|
try {
|
package/src/agent/prompt.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { inclusionBrief } from "../persona/inclusion";
|
|
2
2
|
import { languageBrief } from "../persona/language";
|
|
3
3
|
import type { InteractiveElement, Observation, Persona } from "../types";
|
|
4
|
+
import { controlLabel, type StepView } from "./label";
|
|
5
|
+
|
|
6
|
+
export type { StepView } from "./label";
|
|
4
7
|
|
|
5
8
|
// Stays constant for the whole session so providers can cache it; everything that changes goes in the user message.
|
|
6
9
|
export interface AgentIdentity {
|
|
@@ -38,24 +41,30 @@ Action is one of:
|
|
|
38
41
|
{"type":"fill","id":number,"text":string} (on a select or dropdown, text is the option to choose)
|
|
39
42
|
{"type":"upload","id":number,"file":"photo"|"document"} (attach a file through an upload button or file field: you have a sample photo and a sample CV/document)
|
|
40
43
|
{"type":"goto","path":string}
|
|
41
|
-
{"type":"press","key":"Escape"|"Enter"|"Tab"|"ArrowDown"|"ArrowUp"} (e.g. Escape closes a popup that blocks the page)
|
|
44
|
+
{"type":"press","key":"Escape"|"Enter"|"Space"|"Tab"|"ArrowDown"|"ArrowUp"} (e.g. Escape closes a popup that blocks the page; Space presses the focused button or ticks a box)
|
|
42
45
|
{"type":"wait"} (the page is still loading, e.g. a spinner or a form that has not appeared yet: wait a few seconds rather than leave)${identity ? `\n{"type":"check_email"}` : ""}
|
|
43
46
|
{"type":"done","success":boolean,"note":string}`;
|
|
44
47
|
}
|
|
45
48
|
|
|
49
|
+
/** A control's state, as a person sees it (and a screen reader announces it). */
|
|
50
|
+
function stateOf(e: InteractiveElement): string {
|
|
51
|
+
const states = [
|
|
52
|
+
e.disabled ? "disabled" : "",
|
|
53
|
+
e.readOnly ? "read-only" : "",
|
|
54
|
+
e.checked === undefined ? "" : e.checked ? "checked" : "not checked",
|
|
55
|
+
e.expanded === undefined ? "" : e.expanded ? "open" : "closed",
|
|
56
|
+
e.filled ? "filled in" : "",
|
|
57
|
+
!e.options && e.value ? `holds "${e.value}"` : "",
|
|
58
|
+
].filter(Boolean);
|
|
59
|
+
return states.length ? ` (${states.join(", ")})` : "";
|
|
60
|
+
}
|
|
61
|
+
|
|
46
62
|
function describeElement(e: InteractiveElement, screenReader = false): string {
|
|
47
|
-
|
|
48
|
-
const heard = screenReader && e.ax ? e.ax : e;
|
|
49
|
-
const line = `[${e.id}] ${heard.role} "${heard.name}"`;
|
|
63
|
+
const line = `[${e.id}] ${controlLabel(e, screenReader)}${stateOf(e)}`;
|
|
50
64
|
if (!e.options) return line;
|
|
51
65
|
return `${line} set to "${e.value ?? ""}", options: ${e.options.map((o) => `"${o}"`).join(" | ")}`;
|
|
52
66
|
}
|
|
53
67
|
|
|
54
|
-
export interface StepView {
|
|
55
|
-
/** As a screen reader presents the page: the browser's names and roles, without what is hidden from it. */
|
|
56
|
-
screenReader?: boolean;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
68
|
export function buildStepMessage(obs: Observation, history: string[], view: StepView = {}): string {
|
|
60
69
|
const shown = view.screenReader ? obs.elements.filter((e) => !e.ax?.hidden) : obs.elements;
|
|
61
70
|
const elements = shown.map((e) => describeElement(e, view.screenReader)).join("\n");
|
package/src/browser/execute.ts
CHANGED
|
@@ -37,6 +37,47 @@ export interface ExecuteOptions {
|
|
|
37
37
|
const ARROW_KEY_ITEMS = new Set(["option", "menuitem", "menuitemcheckbox", "menuitemradio", "tab", "treeitem", "radio", "input:radio"]);
|
|
38
38
|
// What Space presses; Enter presses the rest. Text fields and selects are only focused.
|
|
39
39
|
const SPACE_KEY = new Set(["checkbox", "input:checkbox", "switch"]);
|
|
40
|
+
// Buttons take Enter and Space alike, so a keyboard user who gets nothing from Enter tries Space.
|
|
41
|
+
const BUTTON_ROLE = /^(button|input:(submit|button|reset|image))$/;
|
|
42
|
+
/** How long to watch for Enter's effect before trying Space. */
|
|
43
|
+
const ENTER_SETTLE_MS = 1_000;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Presses Enter on the focused button; if nothing happened that a person would notice (the page
|
|
47
|
+
* sent nothing, kept its address and looks exactly the same), presses Space, as a keyboard user
|
|
48
|
+
* would. True when Space then did what Enter didn't: Twenty's set-up "Continue" ignores Enter and
|
|
49
|
+
* goes on with Space, and a keyboard-only agent that knew only Enter gave up there twice
|
|
50
|
+
* (2026-10-24). It goes by how the page looks, not its text, so a toggle whose change is only
|
|
51
|
+
* visual is never pressed twice.
|
|
52
|
+
*/
|
|
53
|
+
async function pressEnterThenSpace(page: Page, id: number): Promise<boolean> {
|
|
54
|
+
const look = () => page.screenshot({ animations: "disabled", timeout: 5_000 }).catch(() => undefined);
|
|
55
|
+
const url = page.url();
|
|
56
|
+
// The first key press on a page draws the focus ring; Shift, which presses nothing, draws it
|
|
57
|
+
// first, or Enter would always seem to have changed the page.
|
|
58
|
+
await page.keyboard.press("Shift");
|
|
59
|
+
const before = await look();
|
|
60
|
+
let sent = 0;
|
|
61
|
+
const count = () => sent++;
|
|
62
|
+
page.on("request", count);
|
|
63
|
+
try {
|
|
64
|
+
await page.keyboard.press("Enter");
|
|
65
|
+
await page.waitForTimeout(ENTER_SETTLE_MS);
|
|
66
|
+
} finally {
|
|
67
|
+
page.off("request", count);
|
|
68
|
+
}
|
|
69
|
+
const afterEnter = sent || page.url() !== url || !before ? undefined : await look();
|
|
70
|
+
if (!before || !afterEnter || !afterEnter.equals(before)) return false;
|
|
71
|
+
// Space goes to whatever has focus: only to the button that was checked before Enter, never to
|
|
72
|
+
// something Enter moved focus to unseen.
|
|
73
|
+
const stillFocused = await page.evaluate((target) => document.activeElement?.getAttribute("data-debutly-id") === target, String(id));
|
|
74
|
+
if (!stillFocused) return false;
|
|
75
|
+
await page.keyboard.press("Space");
|
|
76
|
+
await page.waitForTimeout(ENTER_SETTLE_MS);
|
|
77
|
+
if (page.url() !== url) return true;
|
|
78
|
+
const afterSpace = await look();
|
|
79
|
+
return !!afterSpace && !afterSpace.equals(before);
|
|
80
|
+
}
|
|
40
81
|
const FOCUS_ONLY = /^(input:(text|email|password|number|search|tel|url|date|time|datetime-local|month|week)|textarea|select|combobox)$/;
|
|
41
82
|
|
|
42
83
|
// Is the focused element entirely hidden behind something else? Checks its middle and four points
|
|
@@ -60,11 +101,25 @@ const focusCoveredScript = (id: number) => `(() => {
|
|
|
60
101
|
})()`;
|
|
61
102
|
|
|
62
103
|
class NotByKeyboard extends Error {}
|
|
104
|
+
/** A control a person can't use at all right now, whatever they use: said as it is. */
|
|
105
|
+
class Unusable extends Error {}
|
|
63
106
|
|
|
64
107
|
/** Moves focus to the control as Tab would; refuses one that isn't in the tab order. Returns what hides it, if anything. */
|
|
65
108
|
async function focusByKeyboard(page: Page, locator: Locator, id: number): Promise<string | undefined> {
|
|
66
|
-
const
|
|
67
|
-
|
|
109
|
+
const reach = await locator.evaluate(
|
|
110
|
+
(node) => {
|
|
111
|
+
const el = node as HTMLElement & { disabled?: boolean };
|
|
112
|
+
if (el.disabled === true || el.getAttribute("aria-disabled") === "true") return "disabled";
|
|
113
|
+
return el.tabIndex >= 0 && !el.closest("[inert]") ? "tabbable" : "untabbable";
|
|
114
|
+
},
|
|
115
|
+
undefined,
|
|
116
|
+
{ timeout: 5_000 },
|
|
117
|
+
);
|
|
118
|
+
// A disabled button can't take focus, so Enter went to whatever had it, and the agent was told
|
|
119
|
+
// only that nothing changed; it pressed "Create account" again and gave up (2026-10-24). A mouse
|
|
120
|
+
// click on it is told it is disabled; so is a key press.
|
|
121
|
+
if (reach === "disabled") throw new Unusable(UNUSABLE.disabled);
|
|
122
|
+
if (reach === "untabbable") throw new NotByKeyboard(`${NOT_BY_KEYBOARD}: it isn't in the tab order`);
|
|
68
123
|
await locator.focus({ timeout: 5_000 });
|
|
69
124
|
return ((await page.evaluate(focusCoveredScript(id))) as string | null) ?? undefined;
|
|
70
125
|
}
|
|
@@ -198,6 +253,7 @@ async function pick(page: Page, trigger: Locator, text: string): Promise<void> {
|
|
|
198
253
|
// (aria-activedescendant);
|
|
199
254
|
// - Enter in a form field submits its form through the form's default button, which may sit
|
|
200
255
|
// outside the form (form="…"), or with no button through the form itself;
|
|
256
|
+
// - Space presses the focused button, or ticks the focused box;
|
|
201
257
|
// - an arrow key moves a focused native select to the next option.
|
|
202
258
|
// Each thing the key would set off is listed with every name it goes by (aria-label, the elements
|
|
203
259
|
// aria-labelledby points at, labels, text, value, title) and its data-debutly-id, so the check also
|
|
@@ -217,6 +273,7 @@ const keyTargetsScript = (key: string) => `(() => {
|
|
|
217
273
|
}
|
|
218
274
|
if (e.labels) for (const l of e.labels) names.push(l.textContent);
|
|
219
275
|
if (e.tagName === 'INPUT' || e.tagName === 'BUTTON') names.push(e.value);
|
|
276
|
+
if (e.tagName === 'INPUT' && e.type === 'image') names.push(e.getAttribute('alt'));
|
|
220
277
|
if (e.tagName !== 'INPUT') names.push(e.innerText);
|
|
221
278
|
targets.push({ id: e.getAttribute('data-debutly-id'), names: names.filter((n) => n && n.trim()) });
|
|
222
279
|
};
|
|
@@ -233,6 +290,9 @@ const keyTargetsScript = (key: string) => `(() => {
|
|
|
233
290
|
add(el);
|
|
234
291
|
}
|
|
235
292
|
}
|
|
293
|
+
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"]')) {
|
|
294
|
+
add(el);
|
|
295
|
+
}
|
|
236
296
|
if ((key === 'ArrowDown' || key === 'ArrowUp') && el.tagName === 'SELECT') {
|
|
237
297
|
const next = el.options[el.selectedIndex + (key === 'ArrowDown' ? 1 : -1)];
|
|
238
298
|
if (next) targets.push({ id: null, names: [next.label] });
|
|
@@ -283,7 +343,10 @@ export async function execute(
|
|
|
283
343
|
const byKeyboard = opts.keyboard && !ARROW_KEY_ITEMS.has(el.role);
|
|
284
344
|
if (byKeyboard) result.focusObscured = await focusByKeyboard(page, locator, action.id);
|
|
285
345
|
if (action.type === "click" && byKeyboard) {
|
|
286
|
-
if (
|
|
346
|
+
if (SPACE_KEY.has(el.role)) await page.keyboard.press("Space");
|
|
347
|
+
else if (BUTTON_ROLE.test(el.role)) {
|
|
348
|
+
if (await pressEnterThenSpace(page, action.id)) result.enterIgnored = true;
|
|
349
|
+
} else if (!FOCUS_ONLY.test(el.role)) await page.keyboard.press("Enter");
|
|
287
350
|
} else if (action.type === "click") {
|
|
288
351
|
try {
|
|
289
352
|
await locator.click({ timeout: 5_000 });
|
|
@@ -328,10 +391,10 @@ export async function execute(
|
|
|
328
391
|
await page.waitForLoadState("domcontentloaded");
|
|
329
392
|
// Let async handlers (fetch after submit, deferred errors) surface their signals.
|
|
330
393
|
await page.waitForTimeout(300);
|
|
331
|
-
return result.focusObscured ? result : {};
|
|
394
|
+
return result.focusObscured || result.enterIgnored ? result : {};
|
|
332
395
|
} catch (err) {
|
|
333
396
|
if (err instanceof Refused) return { blocked: err.message };
|
|
334
|
-
if (err instanceof NotByKeyboard) return { error: err.message };
|
|
397
|
+
if (err instanceof NotByKeyboard || err instanceof Unusable) return { error: err.message };
|
|
335
398
|
const why = "id" in action ? await explainTimeout(page, action.id, err) : undefined;
|
|
336
399
|
return { error: why ?? describeFailure(err), ...(result.focusObscured ? { focusObscured: result.focusObscured } : {}) };
|
|
337
400
|
}
|
package/src/browser/observe.ts
CHANGED
|
@@ -50,7 +50,13 @@ export async function observe(page: Page): Promise<Observation> {
|
|
|
50
50
|
const ax = names.get(el.id);
|
|
51
51
|
if (ax) el.ax = ax;
|
|
52
52
|
}
|
|
53
|
-
|
|
53
|
+
// Line by line, as it reads on screen, so what changed after an action can be told line by line.
|
|
54
|
+
const text = (await page.locator("body").innerText())
|
|
55
|
+
.split("\n")
|
|
56
|
+
.map((line) => line.replace(/\s+/g, " ").trim())
|
|
57
|
+
.filter(Boolean)
|
|
58
|
+
.join("\n")
|
|
59
|
+
.slice(0, 2000);
|
|
54
60
|
const language = (await page.evaluate(LANGUAGE_SCRIPT)) as Pick<Observation, "lang" | "dir" | "arabicShare" | "zoomBlocked">;
|
|
55
61
|
return { url: page.url(), title: await page.title(), elements, text, ...language };
|
|
56
62
|
}
|
|
@@ -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
|
@@ -131,7 +131,7 @@ function fromSignal(signal: Signal): Candidate | null {
|
|
|
131
131
|
signature: `slow:${signal.slowNetwork ? "slow" : "normal"}:${pagePath}`,
|
|
132
132
|
kind: "slow_load",
|
|
133
133
|
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
|
|
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.${weightNote(signal.assets)}`,
|
|
135
135
|
pagePath,
|
|
136
136
|
severity: !signal.finished || signal.ms >= VERY_SLOW_MS ? "major" : BASE_SEVERITY.slow_load,
|
|
137
137
|
};
|
|
@@ -161,6 +161,20 @@ const NAMED_CONTROLS: Record<string, string> = {
|
|
|
161
161
|
"input:url": "web address field",
|
|
162
162
|
};
|
|
163
163
|
|
|
164
|
+
/** Said of a slow page whose scripts and styles came mostly uncompressed; the report's next step looks for it. */
|
|
165
|
+
export const UNCOMPRESSED = "came uncompressed";
|
|
166
|
+
const kb = (bytes: number) => (bytes >= 1024 * 1024 ? `${(bytes / 1024 / 1024).toFixed(1)} MB` : `${Math.round(bytes / 1024)} KB`);
|
|
167
|
+
|
|
168
|
+
// What the scripts and styles that had arrived weighed. Mostly uncompressed, it may be the test
|
|
169
|
+
// copy's doing (served straight from a container) rather than the real site's.
|
|
170
|
+
function weightNote(assets: Extract<Signal, { kind: "slow_load" }>["assets"]): string {
|
|
171
|
+
if (!assets || assets.bytes < 100 * 1024) return "";
|
|
172
|
+
if (assets.uncompressedBytes >= assets.bytes / 2) {
|
|
173
|
+
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.`;
|
|
174
|
+
}
|
|
175
|
+
return ` By then it had received ${kb(assets.bytes)} of scripts and styles, compressed.`;
|
|
176
|
+
}
|
|
177
|
+
|
|
164
178
|
/** What to call a control that has no name, or undefined when it doesn't need one. */
|
|
165
179
|
function unlabeledControl(role: string): string | undefined {
|
|
166
180
|
return NAMED_CONTROLS[role];
|
|
@@ -208,7 +222,8 @@ function unnamedControls(elements: InteractiveElement[], pagePath: string): Cand
|
|
|
208
222
|
const key = `${problem}:${el.role}`;
|
|
209
223
|
const g = groups.get(key) ?? { control, role: el.role, problem, places: [] as string[], said: problem === "placeholder" ? name : (el.ax?.description ?? ""), count: 0 };
|
|
210
224
|
g.count++;
|
|
211
|
-
|
|
225
|
+
// The icon it shows, when its markup names one, is how a founder spots it on the page.
|
|
226
|
+
const place = [el.icon ? `the ${el.icon} icon` : "", placeOf(elements, i)].filter(Boolean).join(" ");
|
|
212
227
|
if (place && g.places.length < MAX_PLACES) g.places.push(place);
|
|
213
228
|
groups.set(key, g);
|
|
214
229
|
});
|
|
@@ -514,6 +529,23 @@ export function detectFindings(sessions: Session[], opts: DetectOptions = {}): F
|
|
|
514
529
|
step.index,
|
|
515
530
|
);
|
|
516
531
|
}
|
|
532
|
+
// A button that takes Space but not Enter: most keyboard users press Enter, and stop there.
|
|
533
|
+
if (acted && step.enterIgnored) {
|
|
534
|
+
const control = `${acted.role} "${acted.ax?.name || acted.name}"`;
|
|
535
|
+
record(
|
|
536
|
+
{
|
|
537
|
+
signature: `enter:${pagePath}:${control}`,
|
|
538
|
+
kind: "keyboard",
|
|
539
|
+
title: `Enter doesn't press ${control} on ${pagePath}`,
|
|
540
|
+
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.`,
|
|
541
|
+
pagePath,
|
|
542
|
+
severity: severityFor("keyboard", [pagePath]),
|
|
543
|
+
wcag: ["2.1.1"],
|
|
544
|
+
},
|
|
545
|
+
s.agentId,
|
|
546
|
+
step.index,
|
|
547
|
+
);
|
|
548
|
+
}
|
|
517
549
|
if (acted && step.focusObscured) {
|
|
518
550
|
const control = `${acted.role} "${acted.name}"`;
|
|
519
551
|
record(
|
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,121 @@
|
|
|
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 "zoom":
|
|
55
|
+
return "Remove user-scalable=no and maximum-scale from the page's viewport meta tag.";
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const lastPath = (s: Session) => {
|
|
60
|
+
try {
|
|
61
|
+
return new URL(s.steps.at(-1)?.observation.url ?? "").pathname;
|
|
62
|
+
} catch {
|
|
63
|
+
return "";
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const quote = (s: string) => `"${s.length > MAX_QUOTE ? `${s.slice(0, MAX_QUOTE)}…` : s}"`;
|
|
68
|
+
|
|
69
|
+
/** A screenshot as the report's reader finds it: next to the report, not on the machine that ran it. */
|
|
70
|
+
export function shotPath(path: string): string {
|
|
71
|
+
if (/^https?:/.test(path)) return path;
|
|
72
|
+
const at = path.replace(/\\/g, "/").lastIndexOf("/screenshots/");
|
|
73
|
+
return at >= 0 ? path.replace(/\\/g, "/").slice(at + 1) : path;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function stoppedItem(s: Session, needs: string, environment: boolean): string {
|
|
77
|
+
const who = [s.role, needs !== "—" ? needs : ""].filter(Boolean).join("; ");
|
|
78
|
+
const where = lastPath(s) ? ` on ${lastPath(s)}` : "";
|
|
79
|
+
const shot = [...s.steps].reverse().find((st) => st.screenshotPath)?.screenshotPath;
|
|
80
|
+
// Out of steps, the note only says so; the agent's last thought says where it was.
|
|
81
|
+
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)}`;
|
|
82
|
+
return [
|
|
83
|
+
`**${s.personaName}${who ? ` (${who})` : ""} ${STOPPED[s.outcome]}${where}.** ${said}`,
|
|
84
|
+
environment ? "Part of it may be the test environment: see the end of the report." : "",
|
|
85
|
+
shot ? `Start with the last screenshot, ${shotPath(shot)}.` : "",
|
|
86
|
+
]
|
|
87
|
+
.filter(Boolean)
|
|
88
|
+
.join(" ");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The few things to look at first: agents the app seemingly stopped, in their own words, then the
|
|
93
|
+
* blocker and major findings, those that stopped an agent first, each with what to do.
|
|
94
|
+
*/
|
|
95
|
+
export function renderFixFirst(sessions: Session[], findings: Finding[], needLabel: (s: Session) => string): string[] {
|
|
96
|
+
const stopped = sessions.filter((s) => STOPPED[s.outcome]);
|
|
97
|
+
const failed = new Set(stopped.map((s) => s.agentId));
|
|
98
|
+
const caused = findings.filter((f) => f.sandboxCause);
|
|
99
|
+
const serious = findings
|
|
100
|
+
.filter((f) => !f.sandboxCause && f.confidence !== "low" && f.severity !== "minor")
|
|
101
|
+
.map((f, order) => ({ f, order, stoppedSome: f.hitBy.some((id) => failed.has(id)) }))
|
|
102
|
+
// Most severe first; within that, one an agent didn't get past.
|
|
103
|
+
.sort((a, b) => RANK[a.f.severity] - RANK[b.f.severity] || Number(b.stoppedSome) - Number(a.stoppedSome) || a.order - b.order)
|
|
104
|
+
.map(({ f }) => f);
|
|
105
|
+
const reached = sessions.filter((s) => s.outcome === "success").length;
|
|
106
|
+
const stops = stopped.slice(0, MAX_ITEMS).map((s) => stoppedItem(s, needLabel(s), caused.some((f) => f.hitBy.includes(s.agentId))));
|
|
107
|
+
const fixes = serious.slice(0, MAX_ITEMS - stops.length).map((f) => `**${f.title}** (${f.severity}). ${nextStep(f)}`);
|
|
108
|
+
const shown = [...stops, ...fixes];
|
|
109
|
+
const rest = findings.filter((f) => !f.sandboxCause).length - fixes.length;
|
|
110
|
+
return [
|
|
111
|
+
`## Fix these first`,
|
|
112
|
+
"",
|
|
113
|
+
`${reached} of ${sessions.length} agents reached the goal.`,
|
|
114
|
+
"",
|
|
115
|
+
...(shown.length
|
|
116
|
+
? shown.map((item, i) => `${i + 1}. ${item}`)
|
|
117
|
+
: ["Nothing stopped an agent, and nothing serious was found."]),
|
|
118
|
+
...(rest > 0 ? ["", `${rest === 1 ? "One" : rest}${fixes.length ? " more" : ""} ${rest === 1 ? "finding is" : "findings are"} below.`] : []),
|
|
119
|
+
"",
|
|
120
|
+
];
|
|
121
|
+
}
|
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
|
|
|
@@ -73,9 +74,18 @@ export interface InteractiveElement {
|
|
|
73
74
|
role: string;
|
|
74
75
|
name: string;
|
|
75
76
|
tag: string;
|
|
76
|
-
/**
|
|
77
|
+
/** What a field holds: a native select's chosen option, or a text field's text (never a password's). */
|
|
77
78
|
value?: string;
|
|
79
|
+
/** A native select's options, by label. */
|
|
78
80
|
options?: string[];
|
|
81
|
+
/** A password field with something typed in it. */
|
|
82
|
+
filled?: true;
|
|
83
|
+
/** Shown but not editable. */
|
|
84
|
+
readOnly?: true;
|
|
85
|
+
/** A checkbox, radio button or switch: on or off. */
|
|
86
|
+
checked?: boolean;
|
|
87
|
+
/** A control that opens something (a menu, a dropdown): open or closed. */
|
|
88
|
+
expanded?: boolean;
|
|
79
89
|
/** The browser's own accessible name and role (Chromium), as a screen reader would announce them. */
|
|
80
90
|
ax?: AccessibleName;
|
|
81
91
|
/** Named only by its placeholder: no label, aria-label or aria-labelledby. */
|
|
@@ -84,6 +94,8 @@ export interface InteractiveElement {
|
|
|
84
94
|
disabled?: true;
|
|
85
95
|
/** Out of the tab order (tabindex -1, or inside an inert part of the page). */
|
|
86
96
|
tabbable?: false;
|
|
97
|
+
/** A control with no text: the icon it shows, as its markup names it ("menu", "trash"). What a sighted person sees. */
|
|
98
|
+
icon?: string;
|
|
87
99
|
}
|
|
88
100
|
|
|
89
101
|
export interface Observation {
|
|
@@ -104,6 +116,8 @@ export interface ActionResult {
|
|
|
104
116
|
error?: string;
|
|
105
117
|
/** Keyboard only: what entirely hid the control once it had focus. */
|
|
106
118
|
focusObscured?: string;
|
|
119
|
+
/** Keyboard only: Enter did nothing on the button, and Space pressed it. */
|
|
120
|
+
enterIgnored?: true;
|
|
107
121
|
}
|
|
108
122
|
|
|
109
123
|
export interface Step extends ActionResult {
|