@melra/browser-runtime 0.3.0-alpha.3
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 +202 -0
- package/dist/browser-connection.d.ts +17 -0
- package/dist/browser-connection.d.ts.map +1 -0
- package/dist/browser-connection.js +95 -0
- package/dist/browser-connection.js.map +1 -0
- package/dist/browser-connection.test.d.ts +2 -0
- package/dist/browser-connection.test.d.ts.map +1 -0
- package/dist/browser-connection.test.js +123 -0
- package/dist/browser-connection.test.js.map +1 -0
- package/dist/dialogs.test.d.ts +2 -0
- package/dist/dialogs.test.d.ts.map +1 -0
- package/dist/dialogs.test.js +165 -0
- package/dist/dialogs.test.js.map +1 -0
- package/dist/frames.test.d.ts +2 -0
- package/dist/frames.test.d.ts.map +1 -0
- package/dist/frames.test.js +157 -0
- package/dist/frames.test.js.map +1 -0
- package/dist/history-tabs.test.d.ts +2 -0
- package/dist/history-tabs.test.d.ts.map +1 -0
- package/dist/history-tabs.test.js +171 -0
- package/dist/history-tabs.test.js.map +1 -0
- package/dist/index.d.ts +124 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +822 -0
- package/dist/index.js.map +1 -0
- package/dist/input-fidelity.test.d.ts +2 -0
- package/dist/input-fidelity.test.d.ts.map +1 -0
- package/dist/input-fidelity.test.js +260 -0
- package/dist/input-fidelity.test.js.map +1 -0
- package/dist/network-policy.d.ts +7 -0
- package/dist/network-policy.d.ts.map +1 -0
- package/dist/network-policy.js +112 -0
- package/dist/network-policy.js.map +1 -0
- package/dist/network-policy.test.d.ts +2 -0
- package/dist/network-policy.test.d.ts.map +1 -0
- package/dist/network-policy.test.js +21 -0
- package/dist/network-policy.test.js.map +1 -0
- package/dist/see-act.test.d.ts +2 -0
- package/dist/see-act.test.d.ts.map +1 -0
- package/dist/see-act.test.js +152 -0
- package/dist/see-act.test.js.map +1 -0
- package/dist/selector.d.ts +30 -0
- package/dist/selector.d.ts.map +1 -0
- package/dist/selector.js +39 -0
- package/dist/selector.js.map +1 -0
- package/dist/selector.test.d.ts +2 -0
- package/dist/selector.test.d.ts.map +1 -0
- package/dist/selector.test.js +34 -0
- package/dist/selector.test.js.map +1 -0
- package/dist/stable-dom.d.ts +21 -0
- package/dist/stable-dom.d.ts.map +1 -0
- package/dist/stable-dom.js +76 -0
- package/dist/stable-dom.js.map +1 -0
- package/dist/stable-dom.test.d.ts +2 -0
- package/dist/stable-dom.test.d.ts.map +1 -0
- package/dist/stable-dom.test.js +22 -0
- package/dist/stable-dom.test.js.map +1 -0
- package/package.json +33 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,822 @@
|
|
|
1
|
+
// Copyright 2026 XAGI Labs Private Limited
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
4
|
+
import { mkdir, readFile, realpath, stat } from "node:fs/promises";
|
|
5
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
6
|
+
import { connectBrowser, } from "./browser-connection.js";
|
|
7
|
+
import { assertSafeUrl, } from "./network-policy.js";
|
|
8
|
+
import { buildSelector } from "./selector.js";
|
|
9
|
+
import { waitForStableDom } from "./stable-dom.js";
|
|
10
|
+
/**
|
|
11
|
+
* How many frames one page is searched across. Ad and analytics stacks attach
|
|
12
|
+
* dozens; the interactive ones a caller cares about are always near the front.
|
|
13
|
+
*/
|
|
14
|
+
const MAX_FRAMES = 20;
|
|
15
|
+
/**
|
|
16
|
+
* Total elements one `inspect` reports. Each frame contributes at most 250
|
|
17
|
+
* (the cap inside `collectFrame`), so the main document can never consume the
|
|
18
|
+
* whole budget and leave a consent iframe unlisted.
|
|
19
|
+
*/
|
|
20
|
+
const MAX_ELEMENTS = 400;
|
|
21
|
+
/**
|
|
22
|
+
* Dialogs recorded per action. A page can raise them in a loop, and the result
|
|
23
|
+
* is persisted, so the report is bounded even though answering is not.
|
|
24
|
+
*/
|
|
25
|
+
const MAX_DIALOGS = 20;
|
|
26
|
+
/** How often `waitForTarget` re-resolves the target across the frame list. */
|
|
27
|
+
const WAIT_POLL_MS = 100;
|
|
28
|
+
/**
|
|
29
|
+
* Frame URLs of the widgets that gate a page behind a human check.
|
|
30
|
+
*
|
|
31
|
+
* These are reported, never solved. Detecting one turns an opaque "click timed
|
|
32
|
+
* out" into a caller-actionable "a captcha is in the way", which is the honest
|
|
33
|
+
* outcome and the only one we will produce.
|
|
34
|
+
*/
|
|
35
|
+
const CAPTCHA_FRAMES = [
|
|
36
|
+
[/^https?:\/\/(?:www\.)?(?:google\.com|recaptcha\.net)\/recaptcha\//, "recaptcha"],
|
|
37
|
+
[/^https?:\/\/(?:[^/]+\.)?hcaptcha\.com\//, "hcaptcha"],
|
|
38
|
+
[/^https?:\/\/challenges\.cloudflare\.com\//, "turnstile"],
|
|
39
|
+
[/^https?:\/\/(?:[^/]+\.)?arkoselabs\.com\//, "arkose"],
|
|
40
|
+
];
|
|
41
|
+
/**
|
|
42
|
+
* Name any human-verification widget embedded in the page.
|
|
43
|
+
*
|
|
44
|
+
* Returns nothing when the page is clear, so a normal snapshot is unchanged.
|
|
45
|
+
*/
|
|
46
|
+
export function captchaReport(frameUrls) {
|
|
47
|
+
const vendors = new Set();
|
|
48
|
+
for (const url of frameUrls) {
|
|
49
|
+
for (const [pattern, vendor] of CAPTCHA_FRAMES) {
|
|
50
|
+
if (pattern.test(url))
|
|
51
|
+
vendors.add(vendor);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (vendors.size === 0)
|
|
55
|
+
return {};
|
|
56
|
+
return {
|
|
57
|
+
captcha: {
|
|
58
|
+
present: true,
|
|
59
|
+
vendors: [...vendors].sort(),
|
|
60
|
+
// Stated in the payload rather than only in docs, because the caller
|
|
61
|
+
// reading this is usually a model deciding whether to keep retrying.
|
|
62
|
+
note: "A human-verification challenge is present. MELRA does not solve or bypass captchas; the page needs a human, a pre-authenticated session, or a different route.",
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Collect the readable text and addressable elements of one frame.
|
|
68
|
+
*
|
|
69
|
+
* Module level rather than inline because it is now evaluated once per frame
|
|
70
|
+
* instead of once per page. It runs inside the browser, so it may not close
|
|
71
|
+
* over anything here.
|
|
72
|
+
*/
|
|
73
|
+
function collectFrame(limit) {
|
|
74
|
+
const text = document.body?.innerText ?? "";
|
|
75
|
+
/**
|
|
76
|
+
* Walk up from the element recording the position of each ancestor, and
|
|
77
|
+
* stop at the first one the page has labelled. The selector string is
|
|
78
|
+
* assembled by the caller so the interesting part stays testable.
|
|
79
|
+
*/
|
|
80
|
+
const describe = (element) => {
|
|
81
|
+
const chain = [];
|
|
82
|
+
for (let node = element; node !== null && chain.length < 12; node = node.parentElement) {
|
|
83
|
+
const parent = node.parentElement;
|
|
84
|
+
const id = node.id === "" ? undefined : node.id;
|
|
85
|
+
const testId = node.getAttribute("data-testid") ?? undefined;
|
|
86
|
+
chain.unshift({
|
|
87
|
+
tag: node.tagName.toLowerCase(),
|
|
88
|
+
nth: parent === null
|
|
89
|
+
? 1
|
|
90
|
+
: Array.prototype.indexOf.call(parent.children, node) + 1,
|
|
91
|
+
...(id === undefined ? {} : { id }),
|
|
92
|
+
...(testId === undefined ? {} : { testId }),
|
|
93
|
+
});
|
|
94
|
+
if (id !== undefined || testId !== undefined)
|
|
95
|
+
break;
|
|
96
|
+
}
|
|
97
|
+
return chain;
|
|
98
|
+
};
|
|
99
|
+
const elements = Array.from(document.querySelectorAll("a,button,input,select,textarea,[role],[tabindex]"))
|
|
100
|
+
.filter((element) => {
|
|
101
|
+
const style = window.getComputedStyle(element);
|
|
102
|
+
const box = element.getBoundingClientRect();
|
|
103
|
+
return style.visibility !== "hidden" && style.display !== "none" && box.width > 0;
|
|
104
|
+
})
|
|
105
|
+
.slice(0, 250)
|
|
106
|
+
.map((element) => ({
|
|
107
|
+
tag: element.tagName.toLowerCase(),
|
|
108
|
+
role: element.getAttribute("role"),
|
|
109
|
+
name: element.getAttribute("aria-label") ??
|
|
110
|
+
element.getAttribute("alt") ??
|
|
111
|
+
element.getAttribute("title") ??
|
|
112
|
+
element.innerText?.trim().slice(0, 200) ??
|
|
113
|
+
null,
|
|
114
|
+
type: element.getAttribute("type"),
|
|
115
|
+
// Everything below is what a caller needs to address the element it is
|
|
116
|
+
// looking at. Without it a snapshot could be read but not acted on.
|
|
117
|
+
chain: describe(element),
|
|
118
|
+
id: element.id === "" ? null : element.id,
|
|
119
|
+
testId: element.getAttribute("data-testid"),
|
|
120
|
+
attributeName: element.getAttribute("name"),
|
|
121
|
+
placeholder: element.getAttribute("placeholder"),
|
|
122
|
+
href: element.getAttribute("href"),
|
|
123
|
+
value: element instanceof HTMLInputElement ||
|
|
124
|
+
element instanceof HTMLTextAreaElement ||
|
|
125
|
+
element instanceof HTMLSelectElement
|
|
126
|
+
? element.value.slice(0, 200)
|
|
127
|
+
: null,
|
|
128
|
+
disabled: element instanceof HTMLInputElement ||
|
|
129
|
+
element instanceof HTMLButtonElement ||
|
|
130
|
+
element instanceof HTMLTextAreaElement ||
|
|
131
|
+
element instanceof HTMLSelectElement
|
|
132
|
+
? element.disabled
|
|
133
|
+
: null,
|
|
134
|
+
checked: element instanceof HTMLInputElement ? element.checked : null,
|
|
135
|
+
}));
|
|
136
|
+
return {
|
|
137
|
+
text: text.slice(0, limit),
|
|
138
|
+
truncated: text.length > limit,
|
|
139
|
+
elements,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
export class BrowserRuntime {
|
|
143
|
+
options;
|
|
144
|
+
browser;
|
|
145
|
+
context;
|
|
146
|
+
activePage;
|
|
147
|
+
connection;
|
|
148
|
+
routeHandler;
|
|
149
|
+
/**
|
|
150
|
+
* Dialogs raised by the action currently running, cleared by `execute`.
|
|
151
|
+
*
|
|
152
|
+
* One buffer on the runtime rather than one per page because the handler is
|
|
153
|
+
* registered on the context, and because operations execute one at a time —
|
|
154
|
+
* a task owns its runtime for the length of one action.
|
|
155
|
+
*/
|
|
156
|
+
dialogs = [];
|
|
157
|
+
constructor(options) {
|
|
158
|
+
this.options = options;
|
|
159
|
+
}
|
|
160
|
+
async uploadPaths(paths) {
|
|
161
|
+
const root = await realpath(this.options.workspaceRoot);
|
|
162
|
+
const resolved = [];
|
|
163
|
+
for (const input of paths) {
|
|
164
|
+
const candidate = resolve(root, input);
|
|
165
|
+
const rel = relative(root, candidate);
|
|
166
|
+
if (rel === ".." ||
|
|
167
|
+
rel.startsWith(`..${sep}`) ||
|
|
168
|
+
isAbsolute(rel)) {
|
|
169
|
+
throw new Error("browser_upload_outside_workspace");
|
|
170
|
+
}
|
|
171
|
+
const actual = await realpath(candidate);
|
|
172
|
+
const actualRelative = relative(root, actual);
|
|
173
|
+
if (actualRelative === ".." ||
|
|
174
|
+
actualRelative.startsWith(`..${sep}`) ||
|
|
175
|
+
isAbsolute(actualRelative)) {
|
|
176
|
+
throw new Error("browser_upload_outside_workspace");
|
|
177
|
+
}
|
|
178
|
+
if (!(await stat(actual)).isFile()) {
|
|
179
|
+
throw new Error("browser_upload_requires_regular_file");
|
|
180
|
+
}
|
|
181
|
+
resolved.push(actual);
|
|
182
|
+
}
|
|
183
|
+
return resolved;
|
|
184
|
+
}
|
|
185
|
+
async ensureContext() {
|
|
186
|
+
if (this.context !== undefined)
|
|
187
|
+
return this.context;
|
|
188
|
+
await mkdir(this.options.artifactDirectory, { recursive: true });
|
|
189
|
+
if (this.options.recordHarPath !== undefined) {
|
|
190
|
+
await mkdir(dirname(this.options.recordHarPath), { recursive: true });
|
|
191
|
+
}
|
|
192
|
+
const connection = await connectBrowser({
|
|
193
|
+
...(this.options.executablePath === undefined
|
|
194
|
+
? {}
|
|
195
|
+
: { executablePath: this.options.executablePath }),
|
|
196
|
+
headless: this.options.headless ?? true,
|
|
197
|
+
...(this.options.cdpEndpoint === undefined
|
|
198
|
+
? {}
|
|
199
|
+
: { cdpEndpoint: this.options.cdpEndpoint }),
|
|
200
|
+
...(this.options.cdpContextIndex === undefined
|
|
201
|
+
? {}
|
|
202
|
+
: { cdpContextIndex: this.options.cdpContextIndex }),
|
|
203
|
+
...(this.options.recordHarPath === undefined
|
|
204
|
+
? {}
|
|
205
|
+
: { recordHarPath: this.options.recordHarPath }),
|
|
206
|
+
});
|
|
207
|
+
this.connection = connection;
|
|
208
|
+
this.browser = connection.browser;
|
|
209
|
+
this.context = connection.context;
|
|
210
|
+
this.routeHandler = async (route) => {
|
|
211
|
+
const requestUrl = route.request().url();
|
|
212
|
+
if (requestUrl.startsWith("data:") ||
|
|
213
|
+
requestUrl.startsWith("blob:") ||
|
|
214
|
+
requestUrl.startsWith("about:")) {
|
|
215
|
+
await route.continue();
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
try {
|
|
219
|
+
await assertSafeUrl(requestUrl, this.options);
|
|
220
|
+
await route.continue();
|
|
221
|
+
}
|
|
222
|
+
catch {
|
|
223
|
+
await route.abort("blockedbyclient");
|
|
224
|
+
}
|
|
225
|
+
};
|
|
226
|
+
await this.context.route("**/*", this.routeHandler);
|
|
227
|
+
/**
|
|
228
|
+
* Answer dialogs instead of letting Playwright discard them.
|
|
229
|
+
*
|
|
230
|
+
* With no handler registered Playwright dismisses every dialog, so a button
|
|
231
|
+
* guarded by `confirm()` reported a successful click while the guarded work
|
|
232
|
+
* never ran — a false success the verifier cannot catch, because the click
|
|
233
|
+
* genuinely did succeed. `beforeunload` is the same defect pointed at
|
|
234
|
+
* navigation and `prompt()` the same pointed at input.
|
|
235
|
+
*
|
|
236
|
+
* Accepting is the honest answer: the caller already approved the action
|
|
237
|
+
* that raised the dialog, and the confirmation is part of that action, not
|
|
238
|
+
* a second one. What makes it safe is that every dialog is reported back —
|
|
239
|
+
* a caller is never told a page was changed without also being told what it
|
|
240
|
+
* was asked. `prompt` accepts its own default rather than inventing a
|
|
241
|
+
* value, since MELRA has nothing to say on the page's behalf.
|
|
242
|
+
*
|
|
243
|
+
* Registered on the context so tabs opened later are covered too.
|
|
244
|
+
*/
|
|
245
|
+
this.context.on("dialog", (dialog) => {
|
|
246
|
+
// Bounded because a page can raise dialogs in a loop; recording stops but
|
|
247
|
+
// answering does not, since an unanswered dialog blocks the page forever.
|
|
248
|
+
if (this.dialogs.length < MAX_DIALOGS) {
|
|
249
|
+
this.dialogs.push({
|
|
250
|
+
type: dialog.type(),
|
|
251
|
+
message: dialog.message(),
|
|
252
|
+
accepted: true,
|
|
253
|
+
...(dialog.type() === "prompt"
|
|
254
|
+
? { defaultValue: dialog.defaultValue() }
|
|
255
|
+
: {}),
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
// The page stays blocked until this settles, and a dialog belonging to a
|
|
259
|
+
// page that closed first can no longer be answered — a rejection here
|
|
260
|
+
// must not become the action's error.
|
|
261
|
+
void dialog.accept().catch(() => undefined);
|
|
262
|
+
});
|
|
263
|
+
this.context.on("close", () => {
|
|
264
|
+
this.context = undefined;
|
|
265
|
+
this.activePage = undefined;
|
|
266
|
+
});
|
|
267
|
+
return this.context;
|
|
268
|
+
}
|
|
269
|
+
async page() {
|
|
270
|
+
const context = await this.ensureContext();
|
|
271
|
+
if (this.activePage !== undefined && !this.activePage.isClosed()) {
|
|
272
|
+
return this.activePage;
|
|
273
|
+
}
|
|
274
|
+
this.activePage = context.pages()[0] ?? (await context.newPage());
|
|
275
|
+
return this.activePage;
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* The tab list, with the index each entry can be addressed by.
|
|
279
|
+
*
|
|
280
|
+
* Returned by every tab action rather than only by `tabs`, because opening,
|
|
281
|
+
* switching, and closing all renumber the list — a caller that had to issue a
|
|
282
|
+
* separate `tabs` call after each one would be acting on a stale index.
|
|
283
|
+
*/
|
|
284
|
+
async describeTabs(context) {
|
|
285
|
+
return await Promise.all(context.pages().map(async (item, index) => ({
|
|
286
|
+
index,
|
|
287
|
+
url: item.url(),
|
|
288
|
+
title: await item.title(),
|
|
289
|
+
active: item === this.activePage,
|
|
290
|
+
})));
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* The frames a target may live in, main frame first.
|
|
294
|
+
*
|
|
295
|
+
* Consent banners, cookie walls, payment fields, and captcha widgets are
|
|
296
|
+
* almost always in an iframe, and `page.locator` only ever searched the main
|
|
297
|
+
* document — so the element an agent could plainly see in a screenshot was
|
|
298
|
+
* unaddressable, and the run died on an action timeout. Searching every frame
|
|
299
|
+
* with the main one first keeps precise callers unaffected and makes the
|
|
300
|
+
* embedded case work without a new field to pass.
|
|
301
|
+
*
|
|
302
|
+
* Ad and analytics pages can attach dozens of frames, so the list is bounded.
|
|
303
|
+
*/
|
|
304
|
+
frames(page) {
|
|
305
|
+
return page.frames().slice(0, MAX_FRAMES);
|
|
306
|
+
}
|
|
307
|
+
/**
|
|
308
|
+
* Every way the caller gave us to find the element, in the order we trust
|
|
309
|
+
* them. Split out from `locator` because waiting and acting need the same
|
|
310
|
+
* list but disagree about what to do when nothing matches yet.
|
|
311
|
+
*/
|
|
312
|
+
candidates(page, target) {
|
|
313
|
+
return this.frames(page).flatMap((frame) => {
|
|
314
|
+
const found = [];
|
|
315
|
+
if (target.role !== undefined) {
|
|
316
|
+
found.push(frame.getByRole(target.role, {
|
|
317
|
+
...(target.name === undefined ? {} : { name: target.name }),
|
|
318
|
+
}));
|
|
319
|
+
}
|
|
320
|
+
if (target.selector !== undefined)
|
|
321
|
+
found.push(frame.locator(target.selector));
|
|
322
|
+
if (target.text !== undefined) {
|
|
323
|
+
found.push(frame.getByText(target.text, { exact: true }), frame.getByText(target.text, { exact: false }));
|
|
324
|
+
}
|
|
325
|
+
if (found.length === 0) {
|
|
326
|
+
throw new Error("browser_target_requires_role_selector_or_text");
|
|
327
|
+
}
|
|
328
|
+
return found;
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
/**
|
|
332
|
+
* Resolve a target to a locator that actually matches something.
|
|
333
|
+
*
|
|
334
|
+
* Text matching used to be `exact: true` only, which fails on the whitespace,
|
|
335
|
+
* casing, and nested-markup differences that real pages are full of — a button
|
|
336
|
+
* rendered as `<button> Sign in </button>` never matched `"Sign in"`. Exact is
|
|
337
|
+
* still tried first so a precise caller keeps precise behaviour; the substring
|
|
338
|
+
* form is only consulted when exact found nothing.
|
|
339
|
+
*
|
|
340
|
+
* A target that matches nothing is reported as such instead of being handed to
|
|
341
|
+
* Playwright to fail as an opaque action timeout thirty seconds later.
|
|
342
|
+
*/
|
|
343
|
+
async locator(page, target) {
|
|
344
|
+
if (target === undefined)
|
|
345
|
+
throw new Error("browser_action_requires_target");
|
|
346
|
+
for (const candidate of this.candidates(page, target)) {
|
|
347
|
+
if ((await candidate.count()) > 0)
|
|
348
|
+
return candidate;
|
|
349
|
+
}
|
|
350
|
+
throw new Error(`browser_target_not_found:${JSON.stringify(target)}`);
|
|
351
|
+
}
|
|
352
|
+
/**
|
|
353
|
+
* Wait for a target to reach a state, re-resolving it on every poll.
|
|
354
|
+
*
|
|
355
|
+
* Playwright's own `waitFor` is bound to one frame decided up front, which is
|
|
356
|
+
* exactly wrong here: the consent iframe or captcha widget being waited for
|
|
357
|
+
* usually does not exist yet when the wait starts. Re-reading `page.frames()`
|
|
358
|
+
* each round costs a poll interval of latency and covers the frame that
|
|
359
|
+
* appears halfway through.
|
|
360
|
+
*
|
|
361
|
+
* `visible` and `attached` are satisfied by any one candidate; `hidden` and
|
|
362
|
+
* `detached` must hold for all of them. Fanning out across frames makes that
|
|
363
|
+
* distinction load-bearing — most frames never contain the target, so "some
|
|
364
|
+
* candidate is absent" is true from the first poll and would report a banner
|
|
365
|
+
* as dismissed while it is still on screen.
|
|
366
|
+
*/
|
|
367
|
+
async waitForTarget(page, target, state, timeoutMs) {
|
|
368
|
+
const negated = state === "hidden" || state === "detached";
|
|
369
|
+
const deadline = Date.now() + timeoutMs;
|
|
370
|
+
for (;;) {
|
|
371
|
+
let reached = negated;
|
|
372
|
+
for (const candidate of this.candidates(page, target)) {
|
|
373
|
+
const element = candidate.first();
|
|
374
|
+
// `isVisible`/`count` report on a missing element rather than throwing,
|
|
375
|
+
// so absence is an answer here: it satisfies `hidden` and `detached`.
|
|
376
|
+
const present = state === "attached" || state === "detached"
|
|
377
|
+
? (await element.count()) > 0
|
|
378
|
+
: await element.isVisible();
|
|
379
|
+
if (negated) {
|
|
380
|
+
if (present) {
|
|
381
|
+
reached = false;
|
|
382
|
+
break;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
else if (present) {
|
|
386
|
+
reached = true;
|
|
387
|
+
break;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
if (reached)
|
|
391
|
+
return;
|
|
392
|
+
if (Date.now() >= deadline) {
|
|
393
|
+
throw new Error(`browser_wait_timeout:${state}:${JSON.stringify(target)}`);
|
|
394
|
+
}
|
|
395
|
+
await page.waitForTimeout(WAIT_POLL_MS);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
/**
|
|
399
|
+
* Type by pressing keys rather than by assigning the value.
|
|
400
|
+
*
|
|
401
|
+
* `.fill()` sets the field and fires a single `input` event. Anything that
|
|
402
|
+
* listens for keystrokes never sees the text: React inputs with key handlers,
|
|
403
|
+
* autocomplete dropdowns, comboboxes that filter as you type, and fields that
|
|
404
|
+
* enable a submit button only after a `keyup`. The old server used
|
|
405
|
+
* `page.type`, and losing that is most of why typing "stopped working".
|
|
406
|
+
* `.fill("")` is still how the field gets cleared, because it empties one
|
|
407
|
+
* reliably no matter what is in it.
|
|
408
|
+
*/
|
|
409
|
+
async typeInto(locator, value, operation) {
|
|
410
|
+
const field = locator.first();
|
|
411
|
+
if (operation.clearFirst) {
|
|
412
|
+
await field.fill("", { timeout: operation.timeoutMs });
|
|
413
|
+
}
|
|
414
|
+
await field.pressSequentially(value, {
|
|
415
|
+
delay: operation.delayMs,
|
|
416
|
+
timeout: operation.timeoutMs,
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
async snapshot(page, maxChars) {
|
|
420
|
+
const frames = this.frames(page);
|
|
421
|
+
const collected = await Promise.all(frames.map(async (frame) => {
|
|
422
|
+
try {
|
|
423
|
+
return await frame.evaluate(collectFrame, maxChars);
|
|
424
|
+
}
|
|
425
|
+
catch {
|
|
426
|
+
// A frame that navigated or detached mid-evaluate is not a failed
|
|
427
|
+
// inspect. Losing an ad frame should not lose the page.
|
|
428
|
+
return undefined;
|
|
429
|
+
}
|
|
430
|
+
}));
|
|
431
|
+
const main = collected[0];
|
|
432
|
+
const elements = collected
|
|
433
|
+
.flatMap((data, frameIndex) => (data?.elements ?? []).map(({ chain, ...element }) => ({
|
|
434
|
+
...element,
|
|
435
|
+
selector: buildSelector(chain),
|
|
436
|
+
// Which document the element lives in. `null` is the main one; a
|
|
437
|
+
// caller does not need this to act (targets are resolved across every
|
|
438
|
+
// frame) but it explains why an element is not in the page text.
|
|
439
|
+
frame: frameIndex === 0 ? null : (frames[frameIndex]?.url() ?? null),
|
|
440
|
+
})))
|
|
441
|
+
.slice(0, MAX_ELEMENTS)
|
|
442
|
+
// Numbered after flattening: a per-frame index would repeat across
|
|
443
|
+
// frames, and this one is only ever a handle on the returned list.
|
|
444
|
+
.map((element, index) => ({ index, ...element }));
|
|
445
|
+
return {
|
|
446
|
+
url: page.url(),
|
|
447
|
+
title: await page.title(),
|
|
448
|
+
text: main?.text ?? "",
|
|
449
|
+
truncated: main?.truncated ?? false,
|
|
450
|
+
elements,
|
|
451
|
+
...captchaReport(frames.map((frame) => frame.url())),
|
|
452
|
+
untrustedContent: true,
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
/**
|
|
456
|
+
* Read one element rather than the whole page.
|
|
457
|
+
*
|
|
458
|
+
* The old server had `extract_text` and `extract_html`; both were dropped, so
|
|
459
|
+
* a caller wanting one table out of a large document had to take the entire
|
|
460
|
+
* page text and cut it up itself. `inspect` already accepts a target, so
|
|
461
|
+
* scoping it needs no new action.
|
|
462
|
+
*/
|
|
463
|
+
async extract(page, operation) {
|
|
464
|
+
const locator = (await this.locator(page, operation.target)).first();
|
|
465
|
+
const [text, html] = await Promise.all([
|
|
466
|
+
locator.innerText({ timeout: operation.timeoutMs }),
|
|
467
|
+
locator.innerHTML({ timeout: operation.timeoutMs }),
|
|
468
|
+
]);
|
|
469
|
+
return {
|
|
470
|
+
url: page.url(),
|
|
471
|
+
title: await page.title(),
|
|
472
|
+
target: operation.target,
|
|
473
|
+
text: text.slice(0, operation.maxChars),
|
|
474
|
+
html: html.slice(0, operation.maxChars),
|
|
475
|
+
truncated: text.length > operation.maxChars || html.length > operation.maxChars,
|
|
476
|
+
untrustedContent: true,
|
|
477
|
+
};
|
|
478
|
+
}
|
|
479
|
+
async settledSnapshot(page, operation) {
|
|
480
|
+
const settle = await waitForStableDom(page, {
|
|
481
|
+
quietWindowMs: operation.settleQuietMs,
|
|
482
|
+
timeoutMs: operation.settleTimeoutMs,
|
|
483
|
+
});
|
|
484
|
+
return {
|
|
485
|
+
settle,
|
|
486
|
+
...(await this.snapshot(page, operation.maxChars)),
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
async execute(operation) {
|
|
490
|
+
// Every branch reports `success: true` on the way out, because a caller who
|
|
491
|
+
// declared no evidence is held to exactly that field: policy derives
|
|
492
|
+
// `result_equals success true` for browser mutations, and a result without
|
|
493
|
+
// it verified as `partial` no matter how well the action went. Failures
|
|
494
|
+
// throw, so reaching a return is what success means here.
|
|
495
|
+
this.dialogs = [];
|
|
496
|
+
const result = { success: true, ...(await this.run(operation)) };
|
|
497
|
+
// Only present when the page actually asked something, so a caller can test
|
|
498
|
+
// for the field rather than for an empty array.
|
|
499
|
+
return this.dialogs.length === 0
|
|
500
|
+
? result
|
|
501
|
+
: { ...result, dialogs: this.dialogs };
|
|
502
|
+
}
|
|
503
|
+
async run(operation) {
|
|
504
|
+
const page = await this.page();
|
|
505
|
+
switch (operation.action) {
|
|
506
|
+
case "navigate": {
|
|
507
|
+
if (operation.url === undefined)
|
|
508
|
+
throw new Error("browser_navigate_requires_url");
|
|
509
|
+
await assertSafeUrl(operation.url, this.options);
|
|
510
|
+
const response = await page.goto(operation.url, {
|
|
511
|
+
waitUntil: "domcontentloaded",
|
|
512
|
+
timeout: operation.timeoutMs,
|
|
513
|
+
});
|
|
514
|
+
return {
|
|
515
|
+
status: response?.status() ?? null,
|
|
516
|
+
...(await this.settledSnapshot(page, operation)),
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
case "back":
|
|
520
|
+
case "forward": {
|
|
521
|
+
const before = page.url();
|
|
522
|
+
const moved = operation.action === "back"
|
|
523
|
+
? await page.goBack({
|
|
524
|
+
waitUntil: "domcontentloaded",
|
|
525
|
+
timeout: operation.timeoutMs,
|
|
526
|
+
})
|
|
527
|
+
: await page.goForward({
|
|
528
|
+
waitUntil: "domcontentloaded",
|
|
529
|
+
timeout: operation.timeoutMs,
|
|
530
|
+
});
|
|
531
|
+
return {
|
|
532
|
+
// Playwright returns null both when there is nowhere to go *and* when
|
|
533
|
+
// the entry it landed on produced no HTTP response — `about:blank`, a
|
|
534
|
+
// hash change, a `data:` URL. Only the URL tells those apart, and the
|
|
535
|
+
// difference matters: a caller probing "go back unless we are at the
|
|
536
|
+
// start" would otherwise be told it had not moved when it had.
|
|
537
|
+
moved: moved !== null || page.url() !== before,
|
|
538
|
+
status: moved?.status() ?? null,
|
|
539
|
+
...(await this.settledSnapshot(page, operation)),
|
|
540
|
+
};
|
|
541
|
+
}
|
|
542
|
+
case "reload": {
|
|
543
|
+
// `page.reload()` re-issues the request that produced the current page,
|
|
544
|
+
// which for a POST result means re-submitting it. That is the documented
|
|
545
|
+
// meaning of reload and matches `navigate`, whose target URL may have any
|
|
546
|
+
// server-side effect; the domain allowlist, not the read/mutate split, is
|
|
547
|
+
// what bounds what a navigation may reach.
|
|
548
|
+
const response = await page.reload({
|
|
549
|
+
waitUntil: "domcontentloaded",
|
|
550
|
+
timeout: operation.timeoutMs,
|
|
551
|
+
});
|
|
552
|
+
return {
|
|
553
|
+
status: response?.status() ?? null,
|
|
554
|
+
...(await this.settledSnapshot(page, operation)),
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
case "inspect":
|
|
558
|
+
return operation.target === undefined
|
|
559
|
+
? await this.snapshot(page, operation.maxChars)
|
|
560
|
+
: await this.extract(page, operation);
|
|
561
|
+
/**
|
|
562
|
+
* Block until the page reaches a state, instead of guessing a sleep.
|
|
563
|
+
*
|
|
564
|
+
* Without this the only way to handle a slow login redirect or a modal
|
|
565
|
+
* that animates in was to retry the next action and hope, which is why
|
|
566
|
+
* `settleTimeoutMs` kept getting raised as a substitute. Waiting for the
|
|
567
|
+
* thing you are actually waiting for is both faster and honest about what
|
|
568
|
+
* failed when it times out.
|
|
569
|
+
*/
|
|
570
|
+
case "wait": {
|
|
571
|
+
const deadline = operation.timeoutMs;
|
|
572
|
+
if (operation.urlContains !== undefined) {
|
|
573
|
+
const needle = operation.urlContains;
|
|
574
|
+
await page.waitForURL((url) => url.href.includes(needle), {
|
|
575
|
+
timeout: deadline,
|
|
576
|
+
});
|
|
577
|
+
}
|
|
578
|
+
else if (operation.target !== undefined) {
|
|
579
|
+
await this.waitForTarget(page, operation.target, operation.state ?? "visible", deadline);
|
|
580
|
+
}
|
|
581
|
+
else if (operation.value !== undefined) {
|
|
582
|
+
const needle = operation.value;
|
|
583
|
+
await page.waitForFunction((text) => document.body?.innerText.includes(text) === true, needle, { timeout: deadline });
|
|
584
|
+
}
|
|
585
|
+
else {
|
|
586
|
+
throw new Error("browser_wait_requires_target_url_contains_or_value");
|
|
587
|
+
}
|
|
588
|
+
return {
|
|
589
|
+
waited: true,
|
|
590
|
+
...(await this.settledSnapshot(page, operation)),
|
|
591
|
+
};
|
|
592
|
+
}
|
|
593
|
+
case "click": {
|
|
594
|
+
await (await this.locator(page, operation.target)).first().click({
|
|
595
|
+
timeout: operation.timeoutMs,
|
|
596
|
+
});
|
|
597
|
+
return {
|
|
598
|
+
clicked: true,
|
|
599
|
+
...(await this.settledSnapshot(page, operation)),
|
|
600
|
+
};
|
|
601
|
+
}
|
|
602
|
+
case "type": {
|
|
603
|
+
if (operation.value === undefined)
|
|
604
|
+
throw new Error("browser_type_requires_value");
|
|
605
|
+
await this.typeInto(await this.locator(page, operation.target), operation.value, operation);
|
|
606
|
+
return {
|
|
607
|
+
typed: true,
|
|
608
|
+
...(await this.settledSnapshot(page, operation)),
|
|
609
|
+
};
|
|
610
|
+
}
|
|
611
|
+
/**
|
|
612
|
+
* Fill several fields and optionally submit, as one governed action.
|
|
613
|
+
*
|
|
614
|
+
* Each field is its own mutation under the per-action model, so a
|
|
615
|
+
* six-field checkout form cost six typed approval phrases and six DOM
|
|
616
|
+
* settles. The approval covers the whole form because the whole form is
|
|
617
|
+
* what the caller planned, and it is digested into the challenge like any
|
|
618
|
+
* other operation.
|
|
619
|
+
*/
|
|
620
|
+
case "fill_form": {
|
|
621
|
+
if (operation.fields === undefined) {
|
|
622
|
+
throw new Error("browser_fill_form_requires_fields");
|
|
623
|
+
}
|
|
624
|
+
for (const field of operation.fields) {
|
|
625
|
+
await this.typeInto(await this.locator(page, field.target), field.value, operation);
|
|
626
|
+
}
|
|
627
|
+
const submitted = operation.target !== undefined;
|
|
628
|
+
if (submitted) {
|
|
629
|
+
await (await this.locator(page, operation.target)).first().click({
|
|
630
|
+
timeout: operation.timeoutMs,
|
|
631
|
+
});
|
|
632
|
+
}
|
|
633
|
+
return {
|
|
634
|
+
filled: operation.fields.length,
|
|
635
|
+
submitted,
|
|
636
|
+
...(await this.settledSnapshot(page, operation)),
|
|
637
|
+
};
|
|
638
|
+
}
|
|
639
|
+
case "select": {
|
|
640
|
+
if (operation.values === undefined)
|
|
641
|
+
throw new Error("browser_select_requires_values");
|
|
642
|
+
const selected = await (await this.locator(page, operation.target))
|
|
643
|
+
.first()
|
|
644
|
+
.selectOption(operation.values, { timeout: operation.timeoutMs });
|
|
645
|
+
return {
|
|
646
|
+
selected,
|
|
647
|
+
...(await this.settledSnapshot(page, operation)),
|
|
648
|
+
};
|
|
649
|
+
}
|
|
650
|
+
case "press": {
|
|
651
|
+
if (operation.key === undefined)
|
|
652
|
+
throw new Error("browser_press_requires_key");
|
|
653
|
+
const locator = operation.target === undefined
|
|
654
|
+
? page.locator("body")
|
|
655
|
+
: await this.locator(page, operation.target);
|
|
656
|
+
await locator.first().press(operation.key, { timeout: operation.timeoutMs });
|
|
657
|
+
return {
|
|
658
|
+
pressed: operation.key,
|
|
659
|
+
...(await this.settledSnapshot(page, operation)),
|
|
660
|
+
};
|
|
661
|
+
}
|
|
662
|
+
case "scroll": {
|
|
663
|
+
const direction = operation.direction ?? "down";
|
|
664
|
+
if (direction === "into_view") {
|
|
665
|
+
await (await this.locator(page, operation.target)).first().scrollIntoViewIfNeeded({
|
|
666
|
+
timeout: operation.timeoutMs,
|
|
667
|
+
});
|
|
668
|
+
}
|
|
669
|
+
else {
|
|
670
|
+
// A fixed ±600 was too small for a long article and too large for a
|
|
671
|
+
// short scroll container, and nothing could change it.
|
|
672
|
+
await page.evaluate(({ value, pixels }) => {
|
|
673
|
+
if (value === "top")
|
|
674
|
+
window.scrollTo(0, 0);
|
|
675
|
+
else if (value === "bottom")
|
|
676
|
+
window.scrollTo(0, document.body.scrollHeight);
|
|
677
|
+
else
|
|
678
|
+
window.scrollBy(0, value === "up" ? -pixels : pixels);
|
|
679
|
+
}, { value: direction, pixels: operation.pixels });
|
|
680
|
+
}
|
|
681
|
+
return {
|
|
682
|
+
scrolled: direction,
|
|
683
|
+
// Where we ended up, so a caller paging through a document can tell
|
|
684
|
+
// it has hit the bottom rather than scrolling forever.
|
|
685
|
+
scrollY: await page.evaluate(() => window.scrollY),
|
|
686
|
+
...(await this.settledSnapshot(page, operation)),
|
|
687
|
+
};
|
|
688
|
+
}
|
|
689
|
+
case "screenshot": {
|
|
690
|
+
const path = join(this.options.artifactDirectory, `browser-${randomUUID()}.png`);
|
|
691
|
+
await page.screenshot({ path, fullPage: operation.fullPage, type: "png" });
|
|
692
|
+
const bytes = await readFile(path);
|
|
693
|
+
return {
|
|
694
|
+
captured: true,
|
|
695
|
+
path,
|
|
696
|
+
size: bytes.byteLength,
|
|
697
|
+
sha256: createHash("sha256").update(bytes).digest("hex"),
|
|
698
|
+
url: page.url(),
|
|
699
|
+
title: await page.title(),
|
|
700
|
+
};
|
|
701
|
+
}
|
|
702
|
+
case "upload": {
|
|
703
|
+
if (operation.filePaths === undefined) {
|
|
704
|
+
throw new Error("browser_upload_requires_file_paths");
|
|
705
|
+
}
|
|
706
|
+
const files = await this.uploadPaths(operation.filePaths);
|
|
707
|
+
await (await this.locator(page, operation.target)).first().setInputFiles(files, {
|
|
708
|
+
timeout: operation.timeoutMs,
|
|
709
|
+
});
|
|
710
|
+
return {
|
|
711
|
+
uploaded: files.length,
|
|
712
|
+
...(await this.settledSnapshot(page, operation)),
|
|
713
|
+
};
|
|
714
|
+
}
|
|
715
|
+
case "download": {
|
|
716
|
+
const downloadPromise = page.waitForEvent("download", {
|
|
717
|
+
timeout: operation.timeoutMs,
|
|
718
|
+
});
|
|
719
|
+
await (await this.locator(page, operation.target)).first().click({
|
|
720
|
+
timeout: operation.timeoutMs,
|
|
721
|
+
});
|
|
722
|
+
const download = await downloadPromise;
|
|
723
|
+
const suggested = download.suggestedFilename().replaceAll(/[^A-Za-z0-9._-]/g, "_");
|
|
724
|
+
const path = join(this.options.artifactDirectory, `${randomUUID()}-${suggested || "download"}`);
|
|
725
|
+
await download.saveAs(path);
|
|
726
|
+
const bytes = await readFile(path);
|
|
727
|
+
return {
|
|
728
|
+
downloaded: true,
|
|
729
|
+
path,
|
|
730
|
+
suggestedFilename: download.suggestedFilename(),
|
|
731
|
+
size: bytes.byteLength,
|
|
732
|
+
sha256: createHash("sha256").update(bytes).digest("hex"),
|
|
733
|
+
url: page.url(),
|
|
734
|
+
settle: await waitForStableDom(page, {
|
|
735
|
+
quietWindowMs: operation.settleQuietMs,
|
|
736
|
+
timeoutMs: operation.settleTimeoutMs,
|
|
737
|
+
}),
|
|
738
|
+
};
|
|
739
|
+
}
|
|
740
|
+
case "tabs": {
|
|
741
|
+
const context = await this.ensureContext();
|
|
742
|
+
return { tabs: await this.describeTabs(context) };
|
|
743
|
+
}
|
|
744
|
+
case "tab_new": {
|
|
745
|
+
const context = await this.ensureContext();
|
|
746
|
+
const opened = await context.newPage();
|
|
747
|
+
this.activePage = opened;
|
|
748
|
+
if (operation.url !== undefined) {
|
|
749
|
+
await assertSafeUrl(operation.url, this.options);
|
|
750
|
+
await opened.goto(operation.url, {
|
|
751
|
+
waitUntil: "domcontentloaded",
|
|
752
|
+
timeout: operation.timeoutMs,
|
|
753
|
+
});
|
|
754
|
+
}
|
|
755
|
+
return {
|
|
756
|
+
opened: true,
|
|
757
|
+
index: context.pages().indexOf(opened),
|
|
758
|
+
tabs: await this.describeTabs(context),
|
|
759
|
+
};
|
|
760
|
+
}
|
|
761
|
+
case "tab_switch": {
|
|
762
|
+
const context = await this.ensureContext();
|
|
763
|
+
if (operation.tabIndex === undefined) {
|
|
764
|
+
throw new Error("browser_tab_switch_requires_tab_index");
|
|
765
|
+
}
|
|
766
|
+
const target = context.pages()[operation.tabIndex];
|
|
767
|
+
if (target === undefined)
|
|
768
|
+
throw new Error("browser_tab_not_found");
|
|
769
|
+
// Bring the tab to the front so a screenshot of it is what the user
|
|
770
|
+
// would actually see; a background page still renders, but modal and
|
|
771
|
+
// focus behaviour differ.
|
|
772
|
+
await target.bringToFront();
|
|
773
|
+
this.activePage = target;
|
|
774
|
+
return {
|
|
775
|
+
switched: true,
|
|
776
|
+
index: operation.tabIndex,
|
|
777
|
+
url: target.url(),
|
|
778
|
+
tabs: await this.describeTabs(context),
|
|
779
|
+
};
|
|
780
|
+
}
|
|
781
|
+
case "close": {
|
|
782
|
+
const context = await this.ensureContext();
|
|
783
|
+
const pages = context.pages();
|
|
784
|
+
const target = operation.tabIndex === undefined
|
|
785
|
+
? page
|
|
786
|
+
: pages[operation.tabIndex];
|
|
787
|
+
if (target === undefined)
|
|
788
|
+
throw new Error("browser_tab_not_found");
|
|
789
|
+
const url = target.url();
|
|
790
|
+
await target.close();
|
|
791
|
+
this.activePage = context.pages().at(-1);
|
|
792
|
+
return { closed: true, url, tabs: await this.describeTabs(context) };
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
async close() {
|
|
797
|
+
const connection = this.connection;
|
|
798
|
+
if (connection !== undefined &&
|
|
799
|
+
!connection.ownsContext &&
|
|
800
|
+
this.routeHandler !== undefined) {
|
|
801
|
+
await connection.context
|
|
802
|
+
.unroute("**/*", this.routeHandler)
|
|
803
|
+
.catch(() => undefined);
|
|
804
|
+
}
|
|
805
|
+
if (connection?.ownsContext === true) {
|
|
806
|
+
await connection.context.close().catch(() => undefined);
|
|
807
|
+
}
|
|
808
|
+
if (connection?.ownsBrowser === true) {
|
|
809
|
+
await connection.browser.close().catch(() => undefined);
|
|
810
|
+
}
|
|
811
|
+
this.activePage = undefined;
|
|
812
|
+
this.context = undefined;
|
|
813
|
+
this.browser = undefined;
|
|
814
|
+
this.connection = undefined;
|
|
815
|
+
this.routeHandler = undefined;
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
export * from "./network-policy.js";
|
|
819
|
+
export * from "./selector.js";
|
|
820
|
+
export * from "./stable-dom.js";
|
|
821
|
+
export * from "./browser-connection.js";
|
|
822
|
+
//# sourceMappingURL=index.js.map
|