@melra/browser-runtime 0.3.0-alpha.10

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