debutly 0.0.0-stage → 0.1.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/LICENSE +662 -0
- package/README.md +192 -2
- package/bin/debutly.mjs +8 -0
- package/drizzle/0000_init.sql +107 -0
- package/drizzle/0001_predictions.sql +14 -0
- package/drizzle/0002_dashboard.sql +15 -0
- package/drizzle/meta/0000_snapshot.json +722 -0
- package/drizzle/meta/0001_snapshot.json +821 -0
- package/drizzle/meta/0002_snapshot.json +942 -0
- package/drizzle/meta/_journal.json +27 -0
- package/package.json +83 -4
- package/src/agent/describe.ts +29 -0
- package/src/agent/effect.ts +37 -0
- package/src/agent/loop.ts +232 -0
- package/src/agent/parse-reply.ts +76 -0
- package/src/agent/prompt.ts +76 -0
- package/src/agent/run-agent.ts +68 -0
- package/src/artifacts/db.ts +55 -0
- package/src/artifacts/s3.ts +36 -0
- package/src/artifacts/store.ts +26 -0
- package/src/bin.ts +140 -0
- package/src/browser/ax.ts +77 -0
- package/src/browser/execute.ts +338 -0
- package/src/browser/observe.ts +56 -0
- package/src/browser/reflow.ts +73 -0
- package/src/browser/samples.ts +119 -0
- package/src/browser/scan-script.ts +62 -0
- package/src/browser/settle.ts +32 -0
- package/src/browser/signals.ts +73 -0
- package/src/cancel.ts +20 -0
- package/src/cli.ts +265 -0
- package/src/config.ts +62 -0
- package/src/cost/estimate.ts +32 -0
- package/src/cost/prices.ts +32 -0
- package/src/cost/tiers.ts +27 -0
- package/src/costs.ts +96 -0
- package/src/crew/run-unit.ts +62 -0
- package/src/detect/findings.ts +617 -0
- package/src/detect/wcag.ts +38 -0
- package/src/doctor.ts +145 -0
- package/src/hear-cli.ts +137 -0
- package/src/hear.ts +309 -0
- package/src/import.ts +108 -0
- package/src/inbox/mailpit.ts +46 -0
- package/src/inbox/types.ts +10 -0
- package/src/init.ts +183 -0
- package/src/llm/anthropic.ts +41 -0
- package/src/llm/credits.ts +35 -0
- package/src/llm/factory.ts +65 -0
- package/src/llm/meter.ts +39 -0
- package/src/llm/openai-compatible.ts +90 -0
- package/src/llm/openrouter.ts +9 -0
- package/src/llm/scripted.ts +18 -0
- package/src/llm/types.ts +36 -0
- package/src/perceive.ts +150 -0
- package/src/perception/aggregate.ts +111 -0
- package/src/perception/compare.ts +77 -0
- package/src/perception/evaluate.ts +83 -0
- package/src/perception/exhibits.ts +146 -0
- package/src/perception/market-context.ts +50 -0
- package/src/perception/run-perception.ts +178 -0
- package/src/persona/generate.ts +92 -0
- package/src/persona/inclusion.ts +53 -0
- package/src/persona/language.ts +59 -0
- package/src/predictions.ts +33 -0
- package/src/queue/dispatch.ts +18 -0
- package/src/queue/jobs.ts +28 -0
- package/src/queue/processor.ts +42 -0
- package/src/report/launch-readiness.ts +106 -0
- package/src/report/markdown.ts +200 -0
- package/src/run.ts +139 -0
- package/src/sandbox/outbox.ts +65 -0
- package/src/store/db.ts +40 -0
- package/src/store/import-run.ts +116 -0
- package/src/store/pg-run-store.ts +163 -0
- package/src/store/predictions.ts +70 -0
- package/src/store/run-store.ts +78 -0
- package/src/store/schema.ts +193 -0
- package/src/store/url.ts +15 -0
- package/src/types.ts +187 -0
- package/src/util/chromium.ts +20 -0
- package/src/util/json.ts +7 -0
- package/src/util/key-defaults.ts +34 -0
- package/src/util/legacy-env.ts +14 -0
- package/src/util/load-env.ts +28 -0
- package/src/util/pool.ts +12 -0
- package/src/util/run-origin.ts +27 -0
- package/src/worker.ts +53 -0
- package/tsconfig.json +13 -0
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { and, eq, lt } from "drizzle-orm";
|
|
2
|
+
import type { Db } from "../store/db";
|
|
3
|
+
import { screenshots } from "../store/schema";
|
|
4
|
+
import type { ArtifactStore, ScreenshotFormat } from "./store";
|
|
5
|
+
|
|
6
|
+
/** What a step's screenshot reference looks like when the image is in the database. */
|
|
7
|
+
export const DB_SCREENSHOT_PREFIX = "db:";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Screenshots in the run's own Postgres, for deployments with no object storage: a free
|
|
11
|
+
* Postgres (Neon's 0.5 GB) is all the dashboard needs. Compact JPEGs, and prune() drops old ones.
|
|
12
|
+
*/
|
|
13
|
+
export class DbArtifactStore implements ArtifactStore {
|
|
14
|
+
readonly screenshotFormat: ScreenshotFormat = { type: "jpeg", quality: 50 };
|
|
15
|
+
|
|
16
|
+
constructor(
|
|
17
|
+
private readonly db: Db,
|
|
18
|
+
private readonly runId: string,
|
|
19
|
+
) {}
|
|
20
|
+
|
|
21
|
+
async put(key: string, body: Buffer, contentType: string): Promise<string> {
|
|
22
|
+
await this.db.insert(screenshots).values({ runId: this.runId, key, contentType, data: body }).onConflictDoNothing();
|
|
23
|
+
return `${DB_SCREENSHOT_PREFIX}${this.runId}/${key}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Delete screenshots older than `days`: steps keep their text, the pictures go. */
|
|
27
|
+
static async prune(db: Db, days: number, now = new Date()): Promise<number> {
|
|
28
|
+
const cutoff = new Date(now.getTime() - days * 86_400_000);
|
|
29
|
+
const deleted = await db.delete(screenshots).where(lt(screenshots.createdAt, cutoff)).returning({ key: screenshots.key });
|
|
30
|
+
return deleted.length;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
static async get(db: Db, runId: string, key: string): Promise<{ contentType: string; data: Buffer } | null> {
|
|
34
|
+
const [row] = await db
|
|
35
|
+
.select({ contentType: screenshots.contentType, data: screenshots.data })
|
|
36
|
+
.from(screenshots)
|
|
37
|
+
.where(and(eq(screenshots.runId, runId), eq(screenshots.key, key)));
|
|
38
|
+
return row ?? null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Every screenshot to each store (e.g. the CI artifact folder and the database); the last one's reference is kept. */
|
|
43
|
+
export class TeeArtifactStore implements ArtifactStore {
|
|
44
|
+
readonly screenshotFormat?: ScreenshotFormat;
|
|
45
|
+
|
|
46
|
+
constructor(private readonly stores: ArtifactStore[]) {
|
|
47
|
+
this.screenshotFormat = stores.find((s) => s.screenshotFormat)?.screenshotFormat;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async put(key: string, body: Buffer, contentType: string): Promise<string> {
|
|
51
|
+
let ref = "";
|
|
52
|
+
for (const store of this.stores) ref = await store.put(key, body, contentType);
|
|
53
|
+
return ref;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
|
|
2
|
+
import type { ArtifactStore } from "./store";
|
|
3
|
+
|
|
4
|
+
export interface S3ArtifactOptions {
|
|
5
|
+
bucket: string;
|
|
6
|
+
/** Usually the run id, so each run's artifacts sit together. */
|
|
7
|
+
prefix: string;
|
|
8
|
+
/** Base URL the bucket is served at, for links in reports (e.g. MinIO's http://localhost:9000/<bucket>). */
|
|
9
|
+
publicBaseUrl: string;
|
|
10
|
+
client: Pick<S3Client, "send">;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Any S3-compatible store: MinIO in the self-hosted stack, S3/R2/etc. in production. */
|
|
14
|
+
export class S3ArtifactStore implements ArtifactStore {
|
|
15
|
+
constructor(private readonly o: S3ArtifactOptions) {}
|
|
16
|
+
|
|
17
|
+
/** From DEBUTLY_S3_ENDPOINT, DEBUTLY_S3_BUCKET, DEBUTLY_S3_ACCESS_KEY, DEBUTLY_S3_SECRET_KEY (and optional DEBUTLY_S3_REGION, DEBUTLY_S3_PUBLIC_URL); undefined if unset. */
|
|
18
|
+
static fromEnv(env: Record<string, string | undefined>, prefix: string): S3ArtifactStore | undefined {
|
|
19
|
+
const endpoint = env.DEBUTLY_S3_ENDPOINT;
|
|
20
|
+
if (!endpoint) return undefined;
|
|
21
|
+
const bucket = env.DEBUTLY_S3_BUCKET ?? "debutly";
|
|
22
|
+
const client = new S3Client({
|
|
23
|
+
endpoint,
|
|
24
|
+
region: env.DEBUTLY_S3_REGION ?? "us-east-1",
|
|
25
|
+
forcePathStyle: true,
|
|
26
|
+
credentials: { accessKeyId: env.DEBUTLY_S3_ACCESS_KEY ?? "", secretAccessKey: env.DEBUTLY_S3_SECRET_KEY ?? "" },
|
|
27
|
+
});
|
|
28
|
+
return new S3ArtifactStore({ bucket, prefix, publicBaseUrl: env.DEBUTLY_S3_PUBLIC_URL ?? `${endpoint.replace(/\/+$/, "")}/${bucket}`, client });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async put(key: string, body: Buffer, contentType: string): Promise<string> {
|
|
32
|
+
const fullKey = `${this.o.prefix}/${key}`;
|
|
33
|
+
await this.o.client.send(new PutObjectCommand({ Bucket: this.o.bucket, Key: fullKey, Body: body, ContentType: contentType }));
|
|
34
|
+
return `${this.o.publicBaseUrl.replace(/\/+$/, "")}/${fullKey}`;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
|
|
4
|
+
/** How a store wants screenshots taken; PNG when it doesn't say. */
|
|
5
|
+
export interface ScreenshotFormat {
|
|
6
|
+
type: "jpeg";
|
|
7
|
+
quality: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Where step artifacts (screenshots) go. Returns a path or URL the report can show. */
|
|
11
|
+
export interface ArtifactStore {
|
|
12
|
+
put(key: string, body: Buffer, contentType: string): Promise<string>;
|
|
13
|
+
readonly screenshotFormat?: ScreenshotFormat;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** A local folder: what file-only CLI runs (and CI) have always used. */
|
|
17
|
+
export class LocalArtifactStore implements ArtifactStore {
|
|
18
|
+
constructor(private readonly dir: string) {}
|
|
19
|
+
|
|
20
|
+
async put(key: string, body: Buffer, _contentType?: string): Promise<string> {
|
|
21
|
+
const path = join(this.dir, key);
|
|
22
|
+
await mkdir(dirname(path), { recursive: true });
|
|
23
|
+
await writeFile(path, body);
|
|
24
|
+
return path;
|
|
25
|
+
}
|
|
26
|
+
}
|
package/src/bin.ts
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// The `debutly` command: `npx debutly init` once, then `npx debutly` runs the agents.
|
|
2
|
+
import "./util/load-env";
|
|
3
|
+
import "./util/legacy-env";
|
|
4
|
+
import "./util/key-defaults";
|
|
5
|
+
import { createRequire } from "node:module";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import { CONFIG_FILE, configArgs, readConfig } from "./config";
|
|
9
|
+
import { OutOfCreditsError } from "./llm/types";
|
|
10
|
+
|
|
11
|
+
const HELP = `Debutly: AI agents use a test copy of your app like your first customers, and report what breaks.
|
|
12
|
+
|
|
13
|
+
Usage: npx debutly [command] [flags]
|
|
14
|
+
|
|
15
|
+
init a few questions, then a ${CONFIG_FILE} (and, if you want, a weekly GitHub workflow)
|
|
16
|
+
run the agents, with the settings in ${CONFIG_FILE} (the default command)
|
|
17
|
+
doctor checks the key, the browser, the config and that the app answers
|
|
18
|
+
perceive skeptical-buyer evaluators judge your pages, variants or an idea
|
|
19
|
+
hear checks a run's screen-reader findings with NVDA (Windows) or VoiceOver (macOS)
|
|
20
|
+
worker an agent worker for queued runs
|
|
21
|
+
cancel stops a run: cancel <run id> --store <url>
|
|
22
|
+
import loads earlier runs from GitHub Actions report artifacts
|
|
23
|
+
predictions records real outcomes and shows prediction accuracy
|
|
24
|
+
costs your OpenRouter key's spend and each app's latest run
|
|
25
|
+
|
|
26
|
+
Flags override ${CONFIG_FILE}, e.g. npx debutly --tier standard --max-cost 3
|
|
27
|
+
The model key is read from DEBUTLY_API_KEY (or OPENROUTER_API_KEY, ANTHROPIC_API_KEY), and from a .env file here.
|
|
28
|
+
Docs: https://debutly.dev/docs`;
|
|
29
|
+
|
|
30
|
+
const [first, ...rest] = process.argv.slice(2);
|
|
31
|
+
const flagsOnly = !first || (first.startsWith("-") && !["--help", "-h", "--version", "-v"].includes(first));
|
|
32
|
+
const command = flagsOnly ? "run" : first;
|
|
33
|
+
const args = flagsOnly ? process.argv.slice(2) : rest;
|
|
34
|
+
|
|
35
|
+
// The other entry points read their own flags from process.argv, as if run directly.
|
|
36
|
+
const handOver = (entry: string, argv: string[]) => {
|
|
37
|
+
process.argv = [process.argv[0], fileURLToPath(new URL(entry, import.meta.url)), ...argv];
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
async function run(): Promise<void> {
|
|
41
|
+
if (args.includes("--help") || args.includes("-h")) {
|
|
42
|
+
console.log(HELP);
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
let config;
|
|
46
|
+
try {
|
|
47
|
+
config = readConfig();
|
|
48
|
+
} catch (err) {
|
|
49
|
+
console.error(err instanceof Error ? err.message : err);
|
|
50
|
+
process.exit(1);
|
|
51
|
+
}
|
|
52
|
+
if (!config && !args.includes("--url")) {
|
|
53
|
+
console.error(`No ${CONFIG_FILE} here. Set one up with: npx debutly init\n(or pass --url, --goal and --icp)`);
|
|
54
|
+
process.exit(1);
|
|
55
|
+
}
|
|
56
|
+
let argv: string[];
|
|
57
|
+
try {
|
|
58
|
+
argv = [...(config ? configArgs(config) : []), ...args];
|
|
59
|
+
} catch (err) {
|
|
60
|
+
console.error(err instanceof Error ? err.message : err);
|
|
61
|
+
process.exit(1);
|
|
62
|
+
}
|
|
63
|
+
if (process.env.DEBUTLY_STORE_URL && !argv.includes("--store")) argv.push("--store", process.env.DEBUTLY_STORE_URL);
|
|
64
|
+
if (!argv.includes("--out")) argv.push("--out", join(".debutly", "runs", new Date().toISOString().replace(/[:.]/g, "-")));
|
|
65
|
+
|
|
66
|
+
const { chromiumInstalled, installChromium } = await import("./util/chromium");
|
|
67
|
+
if (!chromiumInstalled()) {
|
|
68
|
+
console.log("Downloading Chromium for the agents (once per machine, about 150 MB)...");
|
|
69
|
+
if (!installChromium()) {
|
|
70
|
+
console.error("Couldn't download Chromium. Try: npx playwright install chromium");
|
|
71
|
+
process.exit(1);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
handOver("./cli.ts", argv);
|
|
75
|
+
await import("./cli");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
try {
|
|
79
|
+
switch (command) {
|
|
80
|
+
case "run":
|
|
81
|
+
await run();
|
|
82
|
+
break;
|
|
83
|
+
case "init": {
|
|
84
|
+
const { init } = await import("./init");
|
|
85
|
+
process.exitCode = await init();
|
|
86
|
+
break;
|
|
87
|
+
}
|
|
88
|
+
case "doctor": {
|
|
89
|
+
const { doctor } = await import("./doctor");
|
|
90
|
+
process.exitCode = await doctor();
|
|
91
|
+
break;
|
|
92
|
+
}
|
|
93
|
+
case "perceive":
|
|
94
|
+
handOver("./perceive.ts", args);
|
|
95
|
+
await import("./perceive");
|
|
96
|
+
break;
|
|
97
|
+
case "hear":
|
|
98
|
+
handOver("./hear-cli.ts", args);
|
|
99
|
+
await import("./hear-cli");
|
|
100
|
+
break;
|
|
101
|
+
case "worker":
|
|
102
|
+
handOver("./worker.ts", args);
|
|
103
|
+
await import("./worker");
|
|
104
|
+
break;
|
|
105
|
+
case "cancel":
|
|
106
|
+
handOver("./cancel.ts", args);
|
|
107
|
+
await import("./cancel");
|
|
108
|
+
break;
|
|
109
|
+
case "import":
|
|
110
|
+
handOver("./import.ts", args);
|
|
111
|
+
await import("./import");
|
|
112
|
+
break;
|
|
113
|
+
case "predictions":
|
|
114
|
+
handOver("./predictions.ts", args);
|
|
115
|
+
await import("./predictions");
|
|
116
|
+
break;
|
|
117
|
+
case "costs":
|
|
118
|
+
handOver("./costs.ts", args);
|
|
119
|
+
await import("./costs");
|
|
120
|
+
break;
|
|
121
|
+
case "help":
|
|
122
|
+
case "--help":
|
|
123
|
+
case "-h":
|
|
124
|
+
console.log(HELP);
|
|
125
|
+
break;
|
|
126
|
+
case "version":
|
|
127
|
+
case "--version":
|
|
128
|
+
case "-v":
|
|
129
|
+
console.log(createRequire(import.meta.url)("../package.json").version);
|
|
130
|
+
break;
|
|
131
|
+
default:
|
|
132
|
+
console.error(`Unknown command "${command}".\n\n${HELP}`);
|
|
133
|
+
process.exitCode = 1;
|
|
134
|
+
}
|
|
135
|
+
} catch (err) {
|
|
136
|
+
// A known dead end gets its fix, not a stack trace.
|
|
137
|
+
if (!(err instanceof OutOfCreditsError)) throw err;
|
|
138
|
+
console.error(`${err.message}\n\nTop up at https://openrouter.ai/settings/credits, or raise the key's limit, then run again.`);
|
|
139
|
+
process.exitCode = 1;
|
|
140
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { CDPSession, Page } from "playwright";
|
|
2
|
+
import type { AccessibleName } from "../types";
|
|
3
|
+
|
|
4
|
+
// The name and role Chromium gives assistive technology for each element Debutly tagged, read
|
|
5
|
+
// from its accessibility tree. Debutly's own reading of names (scan-script) is close but not the
|
|
6
|
+
// same: an icon button named by its SVG <title> has a name here and none there, and a control
|
|
7
|
+
// hidden with aria-hidden is listed there but hidden here. Chromium only; undefined elsewhere.
|
|
8
|
+
|
|
9
|
+
interface AXNode {
|
|
10
|
+
ignored: boolean;
|
|
11
|
+
ignoredReasons?: { name: string }[];
|
|
12
|
+
role?: { value?: string };
|
|
13
|
+
name?: { value?: string };
|
|
14
|
+
description?: { value?: string };
|
|
15
|
+
backendDOMNodeId?: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface DOMNode {
|
|
19
|
+
backendNodeId: number;
|
|
20
|
+
attributes?: string[];
|
|
21
|
+
children?: DOMNode[];
|
|
22
|
+
shadowRoots?: DOMNode[];
|
|
23
|
+
contentDocument?: DOMNode;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const sessions = new WeakMap<Page, Promise<CDPSession | undefined>>();
|
|
27
|
+
|
|
28
|
+
function cdpFor(page: Page): Promise<CDPSession | undefined> {
|
|
29
|
+
let session = sessions.get(page);
|
|
30
|
+
if (!session) {
|
|
31
|
+
session = page
|
|
32
|
+
.context()
|
|
33
|
+
.newCDPSession(page)
|
|
34
|
+
.then(async (s) => {
|
|
35
|
+
await s.send("Accessibility.enable");
|
|
36
|
+
return s;
|
|
37
|
+
})
|
|
38
|
+
.catch(() => undefined);
|
|
39
|
+
sessions.set(page, session);
|
|
40
|
+
}
|
|
41
|
+
return session;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Debutly id → what assistive technology is told about that element. */
|
|
45
|
+
export async function accessibleNames(page: Page): Promise<Map<number, AccessibleName> | undefined> {
|
|
46
|
+
const cdp = await cdpFor(page);
|
|
47
|
+
if (!cdp) return undefined;
|
|
48
|
+
try {
|
|
49
|
+
const [{ nodes }, { root }] = await Promise.all([
|
|
50
|
+
cdp.send("Accessibility.getFullAXTree") as Promise<{ nodes: AXNode[] }>,
|
|
51
|
+
cdp.send("DOM.getDocument", { depth: -1, pierce: true }) as Promise<{ root: DOMNode }>,
|
|
52
|
+
]);
|
|
53
|
+
const tagged = new Map<number, number>();
|
|
54
|
+
const walk = (n: DOMNode) => {
|
|
55
|
+
const attrs = n.attributes ?? [];
|
|
56
|
+
for (let i = 0; i < attrs.length; i += 2) if (attrs[i] === "data-debutly-id") tagged.set(n.backendNodeId, Number(attrs[i + 1]));
|
|
57
|
+
for (const child of [...(n.children ?? []), ...(n.shadowRoots ?? []), ...(n.contentDocument ? [n.contentDocument] : [])]) walk(child);
|
|
58
|
+
};
|
|
59
|
+
walk(root);
|
|
60
|
+
const out = new Map<number, AccessibleName>();
|
|
61
|
+
for (const node of nodes) {
|
|
62
|
+
const id = node.backendDOMNodeId === undefined ? undefined : tagged.get(node.backendDOMNodeId);
|
|
63
|
+
if (id === undefined) continue;
|
|
64
|
+
const description = (node.description?.value ?? "").replace(/\s+/g, " ").trim();
|
|
65
|
+
out.set(id, {
|
|
66
|
+
name: (node.name?.value ?? "").replace(/\s+/g, " ").trim(),
|
|
67
|
+
role: node.role?.value ?? "",
|
|
68
|
+
hidden: node.ignored,
|
|
69
|
+
...(description ? { description } : {}),
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
return out;
|
|
73
|
+
} catch {
|
|
74
|
+
// A page navigating away mid-read, or a detached frame: this look goes without them.
|
|
75
|
+
return undefined;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
import type { Locator, Page } from "playwright";
|
|
2
|
+
import type { Action, ActionResult, InteractiveElement, Observation } from "../types";
|
|
3
|
+
import { sampleFiles, sampleFor, type SampleKind } from "./samples";
|
|
4
|
+
|
|
5
|
+
const DESTRUCTIVE = /\b(delete|remove|destroy|deactivate|close account|cancel (my )?(subscription|account))\b/i;
|
|
6
|
+
|
|
7
|
+
// Agents never click anything named like this, whichever action would click it: a plain click,
|
|
8
|
+
// the button behind an upload, or a custom dropdown and the option chosen in it.
|
|
9
|
+
class Refused extends Error {}
|
|
10
|
+
function refuseDestructive(name: string): void {
|
|
11
|
+
if (DESTRUCTIVE.test(name)) throw new Refused(`destructive action refused: "${name}"`);
|
|
12
|
+
}
|
|
13
|
+
// Every name the control goes by: Debutly's reading, and the one the browser gives assistive
|
|
14
|
+
// technology, which is what the screen-reader agent sees (a button holding only an image whose alt
|
|
15
|
+
// says "Delete workspace" has no name in the first and that one in the second).
|
|
16
|
+
function refuseDestructiveControl(el: InteractiveElement): void {
|
|
17
|
+
refuseDestructive(el.name);
|
|
18
|
+
if (el.ax) refuseDestructive(el.ax.name);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const COVERED = "another element covers it";
|
|
22
|
+
export const NOT_BY_KEYBOARD = "can't be reached with the keyboard";
|
|
23
|
+
const WAIT_MS = 3_000;
|
|
24
|
+
const OPTION = '[role="option"]';
|
|
25
|
+
|
|
26
|
+
export interface ExecuteOptions {
|
|
27
|
+
/**
|
|
28
|
+
* Keyboard only, as a screen-reader user works: a control is used by moving focus to it (as
|
|
29
|
+
* Tab would) and pressing Enter or Space, so one that isn't in the tab order can't be used.
|
|
30
|
+
* Items inside lists, menus, tab lists and radio groups are reached with arrow keys, not Tab;
|
|
31
|
+
* those are still chosen as a mouse would.
|
|
32
|
+
*/
|
|
33
|
+
keyboard?: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Roles reached with arrow keys inside their list, menu, tab list or group, not with Tab.
|
|
37
|
+
const ARROW_KEY_ITEMS = new Set(["option", "menuitem", "menuitemcheckbox", "menuitemradio", "tab", "treeitem", "radio", "input:radio"]);
|
|
38
|
+
// What Space presses; Enter presses the rest. Text fields and selects are only focused.
|
|
39
|
+
const SPACE_KEY = new Set(["checkbox", "input:checkbox", "switch"]);
|
|
40
|
+
const FOCUS_ONLY = /^(input:(text|email|password|number|search|tel|url|date|time|datetime-local|month|week)|textarea|select|combobox)$/;
|
|
41
|
+
|
|
42
|
+
// Is the focused element entirely hidden behind something else? Checks its middle and four points
|
|
43
|
+
// just inside its corners: WCAG 2.4.11 asks that focus is not entirely hidden. Returns what covers it.
|
|
44
|
+
const focusCoveredScript = (id: number) => `(() => {
|
|
45
|
+
const el = document.querySelector('[data-debutly-id="${id}"]');
|
|
46
|
+
if (!el) return null;
|
|
47
|
+
const r = el.getBoundingClientRect();
|
|
48
|
+
const points = [[0.5, 0.5], [0.1, 0.1], [0.9, 0.1], [0.1, 0.9], [0.9, 0.9]].map(([fx, fy]) => [r.left + r.width * fx, r.top + r.height * fy]);
|
|
49
|
+
let cover = null;
|
|
50
|
+
for (const [x, y] of points) {
|
|
51
|
+
if (x < 0 || y < 0 || x >= innerWidth || y >= innerHeight) continue;
|
|
52
|
+
const hit = document.elementFromPoint(x, y);
|
|
53
|
+
if (!hit || hit === el || el.contains(hit) || hit.contains(el)) return null;
|
|
54
|
+
cover = cover || hit;
|
|
55
|
+
}
|
|
56
|
+
if (!cover) return 'the edge of the window';
|
|
57
|
+
const classes = typeof cover.className === 'string' ? cover.className.trim().split(/\\s+/).filter(Boolean).slice(0, 2) : [];
|
|
58
|
+
const text = (cover.innerText || '').replace(/\\s+/g, ' ').trim().slice(0, 50);
|
|
59
|
+
return cover.tagName.toLowerCase() + (cover.id ? '#' + cover.id : '') + classes.map((c) => '.' + c).join('') + (text ? ' ("' + text + '")' : '');
|
|
60
|
+
})()`;
|
|
61
|
+
|
|
62
|
+
class NotByKeyboard extends Error {}
|
|
63
|
+
|
|
64
|
+
/** Moves focus to the control as Tab would; refuses one that isn't in the tab order. Returns what hides it, if anything. */
|
|
65
|
+
async function focusByKeyboard(page: Page, locator: Locator, id: number): Promise<string | undefined> {
|
|
66
|
+
const tabbable = await locator.evaluate((node) => (node as HTMLElement).tabIndex >= 0 && !node.closest("[inert]"), undefined, { timeout: 5_000 });
|
|
67
|
+
if (!tabbable) throw new NotByKeyboard(`${NOT_BY_KEYBOARD}: it isn't in the tab order`);
|
|
68
|
+
await locator.focus({ timeout: 5_000 });
|
|
69
|
+
return ((await page.evaluate(focusCoveredScript(id))) as string | null) ?? undefined;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Playwright's timeout message hides the cause in its call log; say it the way a person would notice it.
|
|
73
|
+
export function describeFailure(err: unknown): string {
|
|
74
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
75
|
+
const covered = message.match(/(<[^\n]*?>)[^\n]*?(?: from [^\n]*? subtree)? intercepts pointer events/);
|
|
76
|
+
if (covered) return `${COVERED}: ${covered[1].slice(0, 160)}`;
|
|
77
|
+
if (/element is not enabled|element is disabled/.test(message)) return "the element is disabled";
|
|
78
|
+
if (/element is not visible/.test(message)) return "the element is not visible";
|
|
79
|
+
return message.split("\n")[0];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// When Playwright gives up waiting on an element it says only "Timeout exceeded", and an agent
|
|
83
|
+
// told that tries the same thing again. A person can see why: the field only opens for typing
|
|
84
|
+
// after its edit button, or the link is in a side menu that is shut. A string, like SCAN_SCRIPT.
|
|
85
|
+
const WHY_UNUSABLE_SCRIPT = (id: number) => `(() => {
|
|
86
|
+
const el = document.querySelector('[data-debutly-id="${id}"]');
|
|
87
|
+
if (!el) return 'gone';
|
|
88
|
+
if (el.disabled || el.getAttribute('aria-disabled') === 'true') return 'disabled';
|
|
89
|
+
if ((el.tagName === 'INPUT' || el.tagName === 'TEXTAREA') && el.readOnly) return 'readonly';
|
|
90
|
+
const style = getComputedStyle(el);
|
|
91
|
+
el.scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
|
92
|
+
const r = el.getBoundingClientRect();
|
|
93
|
+
if (r.width === 0 || r.height === 0 || style.visibility === 'hidden') return 'hidden';
|
|
94
|
+
if (r.right <= 0 || r.bottom <= 0 || r.left >= innerWidth || r.top >= innerHeight) return 'offscreen';
|
|
95
|
+
return '';
|
|
96
|
+
})()`;
|
|
97
|
+
|
|
98
|
+
const UNUSABLE: Record<string, string> = {
|
|
99
|
+
gone: "the element is no longer on the page",
|
|
100
|
+
disabled: "the element is disabled",
|
|
101
|
+
readonly: "the field is read-only, so it can't be typed in yet; there may be a button to edit it",
|
|
102
|
+
hidden: "the element is not visible",
|
|
103
|
+
offscreen: "it is off the screen, perhaps in a menu or panel that is closed; open that first",
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
async function explainTimeout(page: Page, id: number, err: unknown): Promise<string | undefined> {
|
|
107
|
+
if (!/Timeout \d+ms exceeded/.test(err instanceof Error ? err.message : String(err))) return undefined;
|
|
108
|
+
const why = (await page.evaluate(WHY_UNUSABLE_SCRIPT(id)).catch(() => "")) as string;
|
|
109
|
+
return UNUSABLE[why];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Playwright clicks an element's centre; a person clicks whatever part of it they can see.
|
|
113
|
+
async function clickVisiblePart(page: Page, id: number): Promise<boolean> {
|
|
114
|
+
const locator = page.locator(`[data-debutly-id="${id}"]`);
|
|
115
|
+
await locator.scrollIntoViewIfNeeded({ timeout: 2_000 }).catch(() => {});
|
|
116
|
+
const box = await locator.boundingBox();
|
|
117
|
+
if (!box) return false;
|
|
118
|
+
for (const fy of [0.5, 0.25, 0.75]) {
|
|
119
|
+
for (const fx of [0.1, 0.3, 0.5, 0.7, 0.9]) {
|
|
120
|
+
const x = box.x + box.width * fx;
|
|
121
|
+
const y = box.y + box.height * fy;
|
|
122
|
+
const reachable = await page.evaluate(
|
|
123
|
+
([px, py, target]) => {
|
|
124
|
+
const hit = document.elementFromPoint(px, py);
|
|
125
|
+
const el = document.querySelector(`[data-debutly-id="${target}"]`);
|
|
126
|
+
return !!(hit && el && (hit === el || el.contains(hit)));
|
|
127
|
+
},
|
|
128
|
+
[x, y, id] as const,
|
|
129
|
+
);
|
|
130
|
+
if (reachable) {
|
|
131
|
+
await page.mouse.click(x, y);
|
|
132
|
+
return true;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return false;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// A native select's list is drawn outside the page, where arrow keys move a highlight the agent
|
|
140
|
+
// can't see: choose the option by its label, as a person picks what they read.
|
|
141
|
+
async function choose(select: Locator, text: string): Promise<void> {
|
|
142
|
+
const labels = await select.evaluate((el) => Array.from((el as HTMLSelectElement).options, (o) => o.label));
|
|
143
|
+
const label = labels[matchOption(labels, text)];
|
|
144
|
+
refuseDestructive(label);
|
|
145
|
+
await select.selectOption({ label }, { timeout: 5_000 });
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** The option a person would pick for `text`: the one it names, else the first containing it. */
|
|
149
|
+
function matchOption(labels: string[], text: string): number {
|
|
150
|
+
const want = text.trim().toLowerCase();
|
|
151
|
+
let i = labels.findIndex((l) => l.trim().toLowerCase() === want);
|
|
152
|
+
if (i === -1) i = labels.findIndex((l) => l.toLowerCase().includes(want));
|
|
153
|
+
if (i === -1) throw new Error(`no option "${text}"; the options are ${labels.map((l) => `"${l}"`).join(", ")}`);
|
|
154
|
+
return i;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** The options of the dropdown that is open now: each visible role="option", with its position. */
|
|
158
|
+
async function openOptions(page: Page): Promise<{ label: string; nth: number }[]> {
|
|
159
|
+
const all = await page.locator(OPTION).evaluateAll((els) =>
|
|
160
|
+
els.map((el) => ({
|
|
161
|
+
label: (el.textContent ?? "").replace(/\s+/g, " ").trim(),
|
|
162
|
+
visible: typeof el.checkVisibility === "function" ? el.checkVisibility() : (el as HTMLElement).offsetParent !== null,
|
|
163
|
+
})),
|
|
164
|
+
);
|
|
165
|
+
return all.flatMap((o, nth) => (o.visible ? [{ label: o.label, nth }] : []));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// A custom dropdown (a button or div with role="combobox", as Radix and shadcn build them) takes
|
|
169
|
+
// no typing: a person opens it and clicks the option they read. Agents typed into them and got
|
|
170
|
+
// "Element is not an <input>" again and again, until the report called the page stuck.
|
|
171
|
+
async function pick(page: Page, trigger: Locator, text: string): Promise<void> {
|
|
172
|
+
const appear = () => page.locator(OPTION).first().waitFor({ state: "visible", timeout: 2_000 }).catch(() => {});
|
|
173
|
+
if (!(await openOptions(page)).length) {
|
|
174
|
+
await trigger.click({ timeout: 5_000 });
|
|
175
|
+
await appear();
|
|
176
|
+
}
|
|
177
|
+
// Some open on a key rather than a click.
|
|
178
|
+
if (!(await openOptions(page)).length) {
|
|
179
|
+
await trigger.focus();
|
|
180
|
+
await page.keyboard.press("ArrowDown");
|
|
181
|
+
await appear();
|
|
182
|
+
}
|
|
183
|
+
const options = await openOptions(page);
|
|
184
|
+
if (!options.length) throw new Error("the dropdown shows no options");
|
|
185
|
+
let chosen: { label: string; nth: number };
|
|
186
|
+
try {
|
|
187
|
+
chosen = options[matchOption(options.map((o) => o.label), text)];
|
|
188
|
+
refuseDestructive(chosen.label);
|
|
189
|
+
} catch (err) {
|
|
190
|
+
await page.keyboard.press("Escape"); // leave it closed, as a person who found nothing would
|
|
191
|
+
throw err;
|
|
192
|
+
}
|
|
193
|
+
await page.locator(OPTION).nth(chosen.nth).click({ timeout: 5_000 });
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// A key can click too:
|
|
197
|
+
// - Enter activates the focused button or link, or the option a combobox highlights
|
|
198
|
+
// (aria-activedescendant);
|
|
199
|
+
// - Enter in a form field submits its form through the form's default button, which may sit
|
|
200
|
+
// outside the form (form="…"), or with no button through the form itself;
|
|
201
|
+
// - an arrow key moves a focused native select to the next option.
|
|
202
|
+
// Each thing the key would set off is listed with every name it goes by (aria-label, the elements
|
|
203
|
+
// aria-labelledby points at, labels, text, value, title) and its data-debutly-id, so the check also
|
|
204
|
+
// sees the name agents see. With nothing interactive focused there is nothing: the page's own text
|
|
205
|
+
// is not a button. A string, like SCAN_SCRIPT: tsx injects helpers into functions the page lacks.
|
|
206
|
+
const keyTargetsScript = (key: string) => `(() => {
|
|
207
|
+
const key = ${JSON.stringify(key)};
|
|
208
|
+
const el = document.activeElement;
|
|
209
|
+
if (!el || el === document.body || el === document.documentElement) return [];
|
|
210
|
+
const targets = [];
|
|
211
|
+
const add = (e) => {
|
|
212
|
+
if (!e) return;
|
|
213
|
+
const names = [e.getAttribute('aria-label'), e.getAttribute('title')];
|
|
214
|
+
for (const ref of (e.getAttribute('aria-labelledby') || '').split(/\\s+/).filter(Boolean)) {
|
|
215
|
+
const r = document.getElementById(ref);
|
|
216
|
+
if (r) names.push(r.textContent);
|
|
217
|
+
}
|
|
218
|
+
if (e.labels) for (const l of e.labels) names.push(l.textContent);
|
|
219
|
+
if (e.tagName === 'INPUT' || e.tagName === 'BUTTON') names.push(e.value);
|
|
220
|
+
if (e.tagName !== 'INPUT') names.push(e.innerText);
|
|
221
|
+
targets.push({ id: e.getAttribute('data-debutly-id'), names: names.filter((n) => n && n.trim()) });
|
|
222
|
+
};
|
|
223
|
+
if (key === 'Enter') {
|
|
224
|
+
const active = el.getAttribute('aria-activedescendant');
|
|
225
|
+
if (active) add(document.getElementById(active));
|
|
226
|
+
const field = el.tagName === 'INPUT' && !['submit', 'button', 'reset', 'image'].includes(el.type);
|
|
227
|
+
if (field && el.form) {
|
|
228
|
+
const button = Array.from(el.form.elements).find((e) =>
|
|
229
|
+
(e.tagName === 'BUTTON' && e.type === 'submit') || (e.tagName === 'INPUT' && (e.type === 'submit' || e.type === 'image')));
|
|
230
|
+
if (button) add(button);
|
|
231
|
+
else targets.push({ id: null, names: [el.form.getAttribute('aria-label'), el.form.getAttribute('name'), el.form.getAttribute('action')].filter((n) => n && n.trim()) });
|
|
232
|
+
} else if (!field && el.matches('a, button, input, summary, [role="button"], [role="link"], [role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"], [role="option"], [role="tab"]')) {
|
|
233
|
+
add(el);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
if ((key === 'ArrowDown' || key === 'ArrowUp') && el.tagName === 'SELECT') {
|
|
237
|
+
const next = el.options[el.selectedIndex + (key === 'ArrowDown' ? 1 : -1)];
|
|
238
|
+
if (next) targets.push({ id: null, names: [next.label] });
|
|
239
|
+
}
|
|
240
|
+
return targets;
|
|
241
|
+
})()`;
|
|
242
|
+
|
|
243
|
+
async function namesAKeyWouldActivate(page: Page, key: string, obs: Observation): Promise<string[]> {
|
|
244
|
+
const targets = (await page.evaluate(keyTargetsScript(key))) as { id: string | null; names: string[] }[];
|
|
245
|
+
return targets.flatMap((t) => [...t.names, ...obs.elements.filter((e) => t.id !== null && String(e.id) === t.id).flatMap((e) => [e.name, e.ax?.name ?? ""])]);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Attach a sample file, as a person picks one from their computer. A visible file field takes it
|
|
249
|
+
// directly; an upload button, label or drop zone opens the file picker, which is answered.
|
|
250
|
+
// Without this an agent asked for a photo (an Instagram post needs one) could only give up.
|
|
251
|
+
async function upload(page: Page, target: Locator, isFileField: boolean, wanted?: SampleKind): Promise<void> {
|
|
252
|
+
const files = await sampleFiles();
|
|
253
|
+
if (isFileField) {
|
|
254
|
+
await target.setInputFiles(files[wanted ?? sampleFor(await target.getAttribute("accept"))], { timeout: 5_000 });
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
const chooser = page.waitForEvent("filechooser", { timeout: 5_000 }).catch(() => null);
|
|
258
|
+
await target.click({ timeout: 5_000 });
|
|
259
|
+
const picker = await chooser;
|
|
260
|
+
if (!picker) throw new Error("clicking it opened no file picker");
|
|
261
|
+
await picker.setFiles(files[wanted ?? sampleFor(await picker.element().getAttribute("accept"))]);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export async function execute(
|
|
265
|
+
page: Page,
|
|
266
|
+
action: Action,
|
|
267
|
+
obs: Observation,
|
|
268
|
+
origin: string,
|
|
269
|
+
// Links the agent received by email (e.g. a magic link to the auth server) may leave the app's origin.
|
|
270
|
+
allowUrl: (href: string) => boolean = () => false,
|
|
271
|
+
opts: ExecuteOptions = {},
|
|
272
|
+
): Promise<ActionResult> {
|
|
273
|
+
const result: ActionResult = {};
|
|
274
|
+
try {
|
|
275
|
+
switch (action.type) {
|
|
276
|
+
case "click":
|
|
277
|
+
case "fill": {
|
|
278
|
+
const el = obs.elements.find((e) => e.id === action.id);
|
|
279
|
+
if (!el) return { error: `no element with id ${action.id}` };
|
|
280
|
+
const customDropdown = el.tag !== "select" && el.role === "combobox" && el.tag !== "input" && el.tag !== "textarea";
|
|
281
|
+
if (action.type === "click" || customDropdown) refuseDestructiveControl(el);
|
|
282
|
+
const locator = page.locator(`[data-debutly-id="${action.id}"]`);
|
|
283
|
+
const byKeyboard = opts.keyboard && !ARROW_KEY_ITEMS.has(el.role);
|
|
284
|
+
if (byKeyboard) result.focusObscured = await focusByKeyboard(page, locator, action.id);
|
|
285
|
+
if (action.type === "click" && byKeyboard) {
|
|
286
|
+
if (!FOCUS_ONLY.test(el.role)) await page.keyboard.press(SPACE_KEY.has(el.role) ? "Space" : "Enter");
|
|
287
|
+
} else if (action.type === "click") {
|
|
288
|
+
try {
|
|
289
|
+
await locator.click({ timeout: 5_000 });
|
|
290
|
+
} catch (err) {
|
|
291
|
+
if (!describeFailure(err).startsWith(COVERED) || !(await clickVisiblePart(page, action.id))) throw err;
|
|
292
|
+
}
|
|
293
|
+
} else if (el.tag === "select") await choose(locator, action.text);
|
|
294
|
+
else if (customDropdown) await pick(page, locator, action.text);
|
|
295
|
+
else await locator.fill(action.text, { timeout: 5_000 });
|
|
296
|
+
break;
|
|
297
|
+
}
|
|
298
|
+
case "upload": {
|
|
299
|
+
const el = obs.elements.find((e) => e.id === action.id);
|
|
300
|
+
if (!el) return { error: `no element with id ${action.id}` };
|
|
301
|
+
if (el.role !== "input:file") refuseDestructiveControl(el);
|
|
302
|
+
const target = page.locator(`[data-debutly-id="${action.id}"]`);
|
|
303
|
+
// A drop zone or label a keyboard can't reach can't open the file picker for a keyboard user.
|
|
304
|
+
if (opts.keyboard && el.role !== "input:file") result.focusObscured = await focusByKeyboard(page, target, action.id);
|
|
305
|
+
await upload(page, target, el.role === "input:file", action.file);
|
|
306
|
+
break;
|
|
307
|
+
}
|
|
308
|
+
case "goto": {
|
|
309
|
+
const target = new URL(action.path, origin);
|
|
310
|
+
if (target.origin !== new URL(origin).origin && !allowUrl(target.href)) {
|
|
311
|
+
return { blocked: `off-origin navigation refused: ${target.href}` };
|
|
312
|
+
}
|
|
313
|
+
await page.goto(target.href);
|
|
314
|
+
break;
|
|
315
|
+
}
|
|
316
|
+
case "press":
|
|
317
|
+
for (const name of await namesAKeyWouldActivate(page, action.key, obs)) refuseDestructive(name);
|
|
318
|
+
await page.keyboard.press(action.key);
|
|
319
|
+
break;
|
|
320
|
+
case "wait":
|
|
321
|
+
// A few seconds more than the settle before every look (runSession) already gives.
|
|
322
|
+
await page.waitForTimeout(WAIT_MS);
|
|
323
|
+
break;
|
|
324
|
+
case "done":
|
|
325
|
+
case "check_email":
|
|
326
|
+
return {};
|
|
327
|
+
}
|
|
328
|
+
await page.waitForLoadState("domcontentloaded");
|
|
329
|
+
// Let async handlers (fetch after submit, deferred errors) surface their signals.
|
|
330
|
+
await page.waitForTimeout(300);
|
|
331
|
+
return result.focusObscured ? result : {};
|
|
332
|
+
} catch (err) {
|
|
333
|
+
if (err instanceof Refused) return { blocked: err.message };
|
|
334
|
+
if (err instanceof NotByKeyboard) return { error: err.message };
|
|
335
|
+
const why = "id" in action ? await explainTimeout(page, action.id, err) : undefined;
|
|
336
|
+
return { error: why ?? describeFailure(err), ...(result.focusObscured ? { focusObscured: result.focusObscured } : {}) };
|
|
337
|
+
}
|
|
338
|
+
}
|