@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.
Files changed (58) hide show
  1. package/LICENSE +202 -0
  2. package/dist/browser-connection.d.ts +17 -0
  3. package/dist/browser-connection.d.ts.map +1 -0
  4. package/dist/browser-connection.js +95 -0
  5. package/dist/browser-connection.js.map +1 -0
  6. package/dist/browser-connection.test.d.ts +2 -0
  7. package/dist/browser-connection.test.d.ts.map +1 -0
  8. package/dist/browser-connection.test.js +123 -0
  9. package/dist/browser-connection.test.js.map +1 -0
  10. package/dist/dialogs.test.d.ts +2 -0
  11. package/dist/dialogs.test.d.ts.map +1 -0
  12. package/dist/dialogs.test.js +165 -0
  13. package/dist/dialogs.test.js.map +1 -0
  14. package/dist/frames.test.d.ts +2 -0
  15. package/dist/frames.test.d.ts.map +1 -0
  16. package/dist/frames.test.js +157 -0
  17. package/dist/frames.test.js.map +1 -0
  18. package/dist/history-tabs.test.d.ts +2 -0
  19. package/dist/history-tabs.test.d.ts.map +1 -0
  20. package/dist/history-tabs.test.js +171 -0
  21. package/dist/history-tabs.test.js.map +1 -0
  22. package/dist/index.d.ts +124 -0
  23. package/dist/index.d.ts.map +1 -0
  24. package/dist/index.js +822 -0
  25. package/dist/index.js.map +1 -0
  26. package/dist/input-fidelity.test.d.ts +2 -0
  27. package/dist/input-fidelity.test.d.ts.map +1 -0
  28. package/dist/input-fidelity.test.js +260 -0
  29. package/dist/input-fidelity.test.js.map +1 -0
  30. package/dist/network-policy.d.ts +7 -0
  31. package/dist/network-policy.d.ts.map +1 -0
  32. package/dist/network-policy.js +112 -0
  33. package/dist/network-policy.js.map +1 -0
  34. package/dist/network-policy.test.d.ts +2 -0
  35. package/dist/network-policy.test.d.ts.map +1 -0
  36. package/dist/network-policy.test.js +21 -0
  37. package/dist/network-policy.test.js.map +1 -0
  38. package/dist/see-act.test.d.ts +2 -0
  39. package/dist/see-act.test.d.ts.map +1 -0
  40. package/dist/see-act.test.js +152 -0
  41. package/dist/see-act.test.js.map +1 -0
  42. package/dist/selector.d.ts +30 -0
  43. package/dist/selector.d.ts.map +1 -0
  44. package/dist/selector.js +39 -0
  45. package/dist/selector.js.map +1 -0
  46. package/dist/selector.test.d.ts +2 -0
  47. package/dist/selector.test.d.ts.map +1 -0
  48. package/dist/selector.test.js +34 -0
  49. package/dist/selector.test.js.map +1 -0
  50. package/dist/stable-dom.d.ts +21 -0
  51. package/dist/stable-dom.d.ts.map +1 -0
  52. package/dist/stable-dom.js +76 -0
  53. package/dist/stable-dom.js.map +1 -0
  54. package/dist/stable-dom.test.d.ts +2 -0
  55. package/dist/stable-dom.test.d.ts.map +1 -0
  56. package/dist/stable-dom.test.js +22 -0
  57. package/dist/stable-dom.test.js.map +1 -0
  58. 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