@melra/browser-runtime 0.3.0-alpha.11 → 0.3.0-alpha.13

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 (59) hide show
  1. package/dist/bounded.d.ts +27 -0
  2. package/dist/bounded.d.ts.map +1 -0
  3. package/dist/bounded.js +50 -0
  4. package/dist/bounded.js.map +1 -0
  5. package/dist/browser-connection.d.ts.map +1 -1
  6. package/dist/browser-connection.js +22 -7
  7. package/dist/browser-connection.js.map +1 -1
  8. package/dist/index.d.ts +106 -4
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/index.js +429 -57
  11. package/dist/index.js.map +1 -1
  12. package/dist/pinning-proxy.d.ts.map +1 -1
  13. package/dist/pinning-proxy.js +7 -8
  14. package/dist/pinning-proxy.js.map +1 -1
  15. package/dist/stable-dom.d.ts +13 -1
  16. package/dist/stable-dom.d.ts.map +1 -1
  17. package/dist/stable-dom.js +22 -3
  18. package/dist/stable-dom.js.map +1 -1
  19. package/package.json +6 -6
  20. package/dist/browser-connection.test.d.ts +0 -2
  21. package/dist/browser-connection.test.d.ts.map +0 -1
  22. package/dist/browser-connection.test.js +0 -210
  23. package/dist/browser-connection.test.js.map +0 -1
  24. package/dist/dialogs.test.d.ts +0 -2
  25. package/dist/dialogs.test.d.ts.map +0 -1
  26. package/dist/dialogs.test.js +0 -165
  27. package/dist/dialogs.test.js.map +0 -1
  28. package/dist/frames.test.d.ts +0 -2
  29. package/dist/frames.test.d.ts.map +0 -1
  30. package/dist/frames.test.js +0 -157
  31. package/dist/frames.test.js.map +0 -1
  32. package/dist/history-tabs.test.d.ts +0 -2
  33. package/dist/history-tabs.test.d.ts.map +0 -1
  34. package/dist/history-tabs.test.js +0 -176
  35. package/dist/history-tabs.test.js.map +0 -1
  36. package/dist/input-fidelity.test.d.ts +0 -2
  37. package/dist/input-fidelity.test.d.ts.map +0 -1
  38. package/dist/input-fidelity.test.js +0 -260
  39. package/dist/input-fidelity.test.js.map +0 -1
  40. package/dist/pinning-proxy.test.d.ts +0 -2
  41. package/dist/pinning-proxy.test.d.ts.map +0 -1
  42. package/dist/pinning-proxy.test.js +0 -80
  43. package/dist/pinning-proxy.test.js.map +0 -1
  44. package/dist/popups-profile.test.d.ts +0 -2
  45. package/dist/popups-profile.test.d.ts.map +0 -1
  46. package/dist/popups-profile.test.js +0 -170
  47. package/dist/popups-profile.test.js.map +0 -1
  48. package/dist/see-act.test.d.ts +0 -2
  49. package/dist/see-act.test.d.ts.map +0 -1
  50. package/dist/see-act.test.js +0 -152
  51. package/dist/see-act.test.js.map +0 -1
  52. package/dist/selector.test.d.ts +0 -2
  53. package/dist/selector.test.d.ts.map +0 -1
  54. package/dist/selector.test.js +0 -34
  55. package/dist/selector.test.js.map +0 -1
  56. package/dist/stable-dom.test.d.ts +0 -2
  57. package/dist/stable-dom.test.d.ts.map +0 -1
  58. package/dist/stable-dom.test.js +0 -22
  59. package/dist/stable-dom.test.js.map +0 -1
package/dist/index.js CHANGED
@@ -4,6 +4,8 @@ import { createHash, randomUUID } from "node:crypto";
4
4
  import { constants } from "node:fs";
5
5
  import { access, mkdir, readFile, realpath, stat } from "node:fs/promises";
6
6
  import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
7
+ import { outcomeUnknown } from "@melra/protocol";
8
+ import { bounded, BrowserCallTimeoutError } from "./bounded.js";
7
9
  import { connectBrowser, } from "./browser-connection.js";
8
10
  import { assertSafeUrl, } from "@melra/policy-core";
9
11
  import { startPinningProxy } from "./pinning-proxy.js";
@@ -33,6 +35,24 @@ const MAX_DIALOGS = 20;
33
35
  const MAX_POPUPS = 20;
34
36
  /** How often `waitForTarget` re-resolves the target across the frame list. */
35
37
  const WAIT_POLL_MS = 100;
38
+ /**
39
+ * How often `awaitOpenedWindows` rechecks while a window it knows about is
40
+ * still unreported. Only ever spent when a window is actually pending.
41
+ */
42
+ const WINDOW_POLL_MS = 25;
43
+ /**
44
+ * Bound on the one-time wiring of a fresh context: the destination guard, HAR
45
+ * replay, and the target-tracking session. Not the action's `timeoutMs`,
46
+ * because loading a large replay archive is legitimately slower than a 100ms
47
+ * action deadline, and this runs once per context rather than per action.
48
+ */
49
+ const SETUP_TIMEOUT_MS = 30_000;
50
+ /**
51
+ * Bound on each teardown call in `close()`. Shutdown has no action to take a
52
+ * deadline from, and a browser that stopped answering must not keep the server
53
+ * from exiting.
54
+ */
55
+ const CLOSE_TIMEOUT_MS = 10_000;
36
56
  /**
37
57
  * Frame URLs of the widgets that gate a page behind a human check.
38
58
  *
@@ -71,6 +91,48 @@ export function captchaReport(frameUrls) {
71
91
  },
72
92
  };
73
93
  }
94
+ /**
95
+ * Whether a Playwright action failed after its input had already been sent.
96
+ *
97
+ * A click's own timeout covers more than waiting for the element: after the
98
+ * mouse events go out, Playwright still asks the page to stop its hit-target
99
+ * check. When the click handler stalls the page, that request is what times
100
+ * out — with the click already delivered. Playwright logs `performing <name>
101
+ * action` immediately before dispatching, and the call log is part of the
102
+ * error, so that line is Playwright's own record that the input left. Keyboard,
103
+ * fill, select, and upload calls log no such line; their timeouts stay plain
104
+ * failures, which for them is overwhelmingly a field that never became ready.
105
+ *
106
+ * Playwright paints the call log dim when colour is on (a TTY, FORCE_COLOR),
107
+ * so each line can end in an escape code; those are stripped first. Without
108
+ * that, a click delivered under colour read as never sent: a plain failure
109
+ * where the outcome was unknown, seen first on the Windows CI runners.
110
+ */
111
+ export function inputSent(error) {
112
+ return error instanceof Error && /^\s*- performing .+ action$/m.test(withoutColour(error.message));
113
+ }
114
+ /**
115
+ * A URL as policy names a browser target, without query or fragment. Chrome
116
+ * escapes two path characters Node's URL can keep raw: `|` on every Node, `^`
117
+ * on Node 22. Both are escaped on both sides. Nothing is decoded: no two paths
118
+ * a server tells apart compare equal, and no escape becomes a raw character in
119
+ * a message. Exported for unit testing.
120
+ */
121
+ export function pageAddress(url) {
122
+ try {
123
+ const parsed = new URL(url);
124
+ parsed.search = "";
125
+ parsed.hash = "";
126
+ return parsed.toString().replaceAll("|", "%7C").replaceAll("^", "%5E");
127
+ }
128
+ catch {
129
+ return url;
130
+ }
131
+ }
132
+ /** Playwright's terminal colour codes, which mean nothing in a stored record. */
133
+ function withoutColour(text) {
134
+ return text.replace(/\u001b\[[0-9;]*m/g, "");
135
+ }
74
136
  /**
75
137
  * Collect the readable text and addressable elements of one frame.
76
138
  *
@@ -156,13 +218,20 @@ export class BrowserRuntime {
156
218
  proxy;
157
219
  routeHandler;
158
220
  /**
159
- * Dialogs raised by the action currently running, cleared by `execute`.
221
+ * Dialogs raised since the last action reported, emptied when one does.
160
222
  *
161
223
  * 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.
224
+ * registered on the context, and because operations execute one at a time:
225
+ * `execute` queues them, so a task owns its runtime for one action. A dialog
226
+ * a page raised between actions is reported by the next one, marked
227
+ * `betweenActions`, rather than dropped: the transfer a late `confirm()`
228
+ * gated still went ahead, and the caller has to hear that it was asked.
164
229
  */
165
230
  dialogs = [];
231
+ /** Whether an action is running, so a dialog or popup can say when it came. */
232
+ acting = false;
233
+ /** The tail of the action queue; see `execute`. */
234
+ queue = Promise.resolve();
166
235
  /**
167
236
  * Windows the page opened during the action currently running, same lifetime
168
237
  * as `dialogs` and reported the same way.
@@ -172,9 +241,47 @@ export class BrowserRuntime {
172
241
  closing = [];
173
242
  /** True only while `tab_new` is opening the tab the caller asked for. */
174
243
  openingTab = false;
244
+ /**
245
+ * Browser-level session that hears about a new window the moment Chromium
246
+ * creates it, which is what lets an action wait for a popup it knows exists
247
+ * instead of guessing how long one might take. Absent if the browser would
248
+ * not grant one; popups are then reported on Playwright's timing alone.
249
+ */
250
+ targets;
251
+ /** Windows Chromium has created since tracking began, by browser context. */
252
+ windowsCreated = new Map();
253
+ /** Windows Playwright has handed to the `page` handler, popup or not. */
254
+ windowsAdopted = 0;
255
+ /** This context's id in CDP, resolved on first use and then fixed. */
256
+ contextId;
257
+ /**
258
+ * The running action's cancel signal and `timeoutMs`, read by `call`. Same
259
+ * per-action lifetime as `dialogs`, set by `execute`.
260
+ */
261
+ signal;
262
+ callMs = 30_000;
263
+ /**
264
+ * Set once the running action's effect call has returned. From then on the
265
+ * page may have acted on it, so a failure is no longer evidence that nothing
266
+ * happened — see `execute`.
267
+ */
268
+ dispatched = false;
175
269
  constructor(options) {
176
270
  this.options = options;
177
271
  }
272
+ /**
273
+ * Bound a Playwright call that takes no timeout of its own by the running
274
+ * action's `timeoutMs` and cancel signal.
275
+ *
276
+ * `title()`, `evaluate()`, `count()`, `isVisible()`, `close()` and CDP
277
+ * `send` all wait for the renderer or the browser to answer, and one that
278
+ * stopped answering held the action until the task budget ran out — or
279
+ * forever, where nothing armed one. Failing with `browser_call_timeout:<what>`
280
+ * instead spends the budget on something that can still answer.
281
+ */
282
+ call(promise, what, ms = this.callMs) {
283
+ return bounded(promise, ms, what, this.signal);
284
+ }
178
285
  async uploadPaths(paths) {
179
286
  const root = await realpath(this.options.workspaceRoot);
180
287
  const resolved = [];
@@ -232,6 +339,10 @@ export class BrowserRuntime {
232
339
  // start proxying, so the rebinding window stays open there and the option is
233
340
  // documented as the weaker path rather than silently equivalent. A replay
234
341
  // opens no sockets at all, so there is nothing to pin.
342
+ // A context that closed (a crash, a closed window) or failed to start left
343
+ // its proxy listening; a relaunch must not start a second one beside it.
344
+ await this.proxy?.close().catch(() => undefined);
345
+ this.proxy = undefined;
235
346
  const proxy = this.options.unhinged === true ||
236
347
  this.options.cdpEndpoint !== undefined ||
237
348
  this.options.replayHarPath !== undefined
@@ -276,16 +387,39 @@ export class BrowserRuntime {
276
387
  await route.abort("blockedbyclient");
277
388
  }
278
389
  };
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
- });
390
+ try {
391
+ await this.call(this.context.route("**/*", this.routeHandler), "route", SETUP_TIMEOUT_MS);
392
+ if (this.options.cdpEndpoint !== undefined && this.options.unhinged !== true) {
393
+ // `route` never sees a WebSocket, so page script could open one to
394
+ // any address. With a pinning proxy the proxy refuses it; an attached
395
+ // browser has none, so the guard runs here.
396
+ await this.call(this.context.routeWebSocket(() => true, async (socket) => {
397
+ try {
398
+ await assertSafeUrl(socket.url().replace(/^ws/i, "http"), this.options);
399
+ socket.connectToServer();
400
+ }
401
+ catch {
402
+ await socket.close({ code: 1008, reason: "destination blocked" });
403
+ }
404
+ }), "route_websocket", SETUP_TIMEOUT_MS);
405
+ }
406
+ if (this.options.replayHarPath !== undefined) {
407
+ // Registered after the destination guard so it runs first: a replayed
408
+ // request is served from the file and never becomes a socket, and
409
+ // `notFound: "abort"` means a request the archive is missing fails
410
+ // visibly rather than falling through to the network the guard would
411
+ // have allowed.
412
+ await this.call(this.context.routeFromHAR(this.options.replayHarPath, {
413
+ notFound: "abort",
414
+ }), "route_from_har", SETUP_TIMEOUT_MS);
415
+ }
416
+ }
417
+ catch (error) {
418
+ // `this.context` is already set, so the next action would skip straight
419
+ // past this method onto a context whose destination guard may never
420
+ // have been installed. Tearing it down makes the next action start over.
421
+ await this.close();
422
+ throw error;
289
423
  }
290
424
  /**
291
425
  * Answer dialogs instead of letting Playwright discard them.
@@ -316,6 +450,7 @@ export class BrowserRuntime {
316
450
  ...(dialog.type() === "prompt"
317
451
  ? { defaultValue: dialog.defaultValue() }
318
452
  : {}),
453
+ ...(this.acting ? {} : { betweenActions: true }),
319
454
  });
320
455
  }
321
456
  // The page stays blocked until this settles, and a dialog belonging to a
@@ -338,6 +473,10 @@ export class BrowserRuntime {
338
473
  * this governs whether it stays.
339
474
  */
340
475
  this.context.on("page", (opened) => {
476
+ // Counted before the early return: every window Chromium creates raises
477
+ // exactly one of these, including tabs MELRA opened and windows that
478
+ // closed before Playwright finished with them.
479
+ this.windowsAdopted += 1;
341
480
  if (opened === this.activePage || this.openingTab)
342
481
  return;
343
482
  // Closing a window the page opened is MELRA's judgement about what the
@@ -345,7 +484,11 @@ export class BrowserRuntime {
345
484
  // not a judgement — that stays on, like every other receipt.
346
485
  const allowed = this.options.popups === "allow" || this.options.unhinged === true;
347
486
  if (this.popups.length < MAX_POPUPS) {
348
- this.popups.push({ url: opened.url(), blocked: !allowed });
487
+ this.popups.push({
488
+ url: opened.url(),
489
+ blocked: !allowed,
490
+ ...(this.acting ? {} : { betweenActions: true }),
491
+ });
349
492
  }
350
493
  if (allowed)
351
494
  return;
@@ -353,8 +496,98 @@ export class BrowserRuntime {
353
496
  // must not turn into the action's error.
354
497
  this.closing.push(opened.close().catch(() => undefined));
355
498
  });
499
+ /**
500
+ * Hear about new windows when Chromium creates them, not when Playwright
501
+ * announces them.
502
+ *
503
+ * Playwright raises `page` for a popup only once the popup's first response
504
+ * has committed, so a click whose window loaded slowly returned with no
505
+ * `popups` field and the report — and, in block mode, the close — landed in
506
+ * whatever ran next. Chromium creates the target synchronously inside
507
+ * `window.open` or the `target=_blank` click, so this count is already
508
+ * right when the action finishes and `awaitOpenedWindows` knows how many
509
+ * reports are still owed. It sees every way of opening a window, and the
510
+ * page cannot see or tamper with it, which an injected `window.open` hook
511
+ * could not promise.
512
+ *
513
+ * Registered after the `page` handler, so any miscount during setup is a
514
+ * window adopted but never counted — at worst one later wait ends early —
515
+ * rather than one counted and never adopted, which no wait could satisfy.
516
+ */
517
+ this.windowsCreated = new Map();
518
+ this.windowsAdopted = 0;
519
+ this.contextId = undefined;
520
+ // A session left from a context that closed under us would count into the
521
+ // fresh map below and make every action wait for windows that never come.
522
+ if (this.targets !== undefined) {
523
+ await this.call(this.targets.detach(), "cdp_detach", SETUP_TIMEOUT_MS).catch(() => undefined);
524
+ }
525
+ try {
526
+ const targets = await this.call(connection.browser.newBrowserCDPSession(), "cdp_session", SETUP_TIMEOUT_MS);
527
+ targets.on("Target.targetCreated", ({ targetInfo }) => {
528
+ // `browser_ui`, workers, and prerenders never become Playwright pages.
529
+ if (targetInfo.type !== "page" || targetInfo.subtype !== undefined)
530
+ return;
531
+ const id = targetInfo.browserContextId ?? "";
532
+ this.windowsCreated.set(id, (this.windowsCreated.get(id) ?? 0) + 1);
533
+ });
534
+ await this.call(targets.send("Target.setDiscoverTargets", { discover: true }), "cdp:Target.setDiscoverTargets", SETUP_TIMEOUT_MS);
535
+ // Chromium announces the windows that already exist before it replies.
536
+ // Playwright had them before the `page` handler did, so they are owed no
537
+ // event here.
538
+ this.windowsCreated.clear();
539
+ this.targets = targets;
540
+ }
541
+ catch {
542
+ this.targets = undefined;
543
+ }
356
544
  return this.context;
357
545
  }
546
+ /**
547
+ * Hold the action's result until Playwright has handed over every window
548
+ * Chromium created, so the action that opened a window is the one that
549
+ * reports it.
550
+ *
551
+ * Costs one round trip when nothing was opened. Waits only while a window is
552
+ * known to exist and is still unreported, bounded by the action's own
553
+ * timeout: the window is part of what the action did. A window still pending
554
+ * after that is reported by the action running when Playwright delivers it.
555
+ */
556
+ async awaitOpenedWindows(timeoutMs) {
557
+ const targets = this.targets;
558
+ const context = this.context;
559
+ if (targets === undefined || context === undefined)
560
+ return;
561
+ try {
562
+ if (this.contextId === undefined) {
563
+ const page = context.pages()[0];
564
+ if (page === undefined)
565
+ return;
566
+ const session = await this.call(context.newCDPSession(page), "cdp_session");
567
+ try {
568
+ const { targetInfo } = await this.call(session.send("Target.getTargetInfo"), "cdp:Target.getTargetInfo");
569
+ this.contextId = targetInfo.browserContextId ?? "";
570
+ }
571
+ finally {
572
+ await this.call(session.detach(), "cdp_detach").catch(() => undefined);
573
+ }
574
+ }
575
+ // Events arrive ahead of the reply on the same session, so after this
576
+ // every window created before now has been counted.
577
+ await this.call(targets.send("Browser.getVersion"), "cdp:Browser.getVersion");
578
+ const deadline = Date.now() + timeoutMs;
579
+ while (this.windowsAdopted < (this.windowsCreated.get(this.contextId) ?? 0) &&
580
+ Date.now() < deadline) {
581
+ await new Promise((done) => setTimeout(done, WINDOW_POLL_MS));
582
+ }
583
+ }
584
+ catch {
585
+ // The browser went away mid-action, or stopped answering within the
586
+ // action's bound; either way nothing more will be delivered in time, and
587
+ // this wait only ever decides which action reports a window, so it is
588
+ // not worth failing an action over.
589
+ }
590
+ }
358
591
  /**
359
592
  * Opens a page MELRA asked for, not one the site opened by itself.
360
593
  *
@@ -365,7 +598,7 @@ export class BrowserRuntime {
365
598
  async openPage(context) {
366
599
  this.openingTab = true;
367
600
  try {
368
- return await context.newPage();
601
+ return await this.call(context.newPage(), "new_page");
369
602
  }
370
603
  finally {
371
604
  this.openingTab = false;
@@ -390,7 +623,7 @@ export class BrowserRuntime {
390
623
  return await Promise.all(context.pages().map(async (item, index) => ({
391
624
  index,
392
625
  url: item.url(),
393
- title: await item.title(),
626
+ title: await this.call(item.title(), "title"),
394
627
  active: item === this.activePage,
395
628
  })));
396
629
  }
@@ -444,12 +677,16 @@ export class BrowserRuntime {
444
677
  *
445
678
  * A target that matches nothing is reported as such instead of being handed to
446
679
  * Playwright to fail as an opaque action timeout thirty seconds later.
680
+ *
681
+ * Resolution only reads the page and always runs before an action's effect,
682
+ * so a `count` that times out here fails a click or a fill cleanly: nothing
683
+ * was sent, and `execute` does not report it as an unknown outcome.
447
684
  */
448
685
  async locator(page, target) {
449
686
  if (target === undefined)
450
687
  throw new Error("browser_action_requires_target");
451
688
  for (const candidate of this.candidates(page, target)) {
452
- if ((await candidate.count()) > 0)
689
+ if ((await this.call(candidate.count(), "count")) > 0)
453
690
  return candidate;
454
691
  }
455
692
  throw new Error(`browser_target_not_found:${JSON.stringify(target)}`);
@@ -479,8 +716,8 @@ export class BrowserRuntime {
479
716
  // `isVisible`/`count` report on a missing element rather than throwing,
480
717
  // so absence is an answer here: it satisfies `hidden` and `detached`.
481
718
  const present = state === "attached" || state === "detached"
482
- ? (await element.count()) > 0
483
- : await element.isVisible();
719
+ ? (await this.call(element.count(), "count")) > 0
720
+ : await this.call(element.isVisible(), "is_visible");
484
721
  if (negated) {
485
722
  if (present) {
486
723
  reached = false;
@@ -515,21 +752,65 @@ export class BrowserRuntime {
515
752
  const field = locator.first();
516
753
  if (operation.clearFirst) {
517
754
  await field.fill("", { timeout: operation.timeoutMs });
755
+ this.dispatched = true;
518
756
  }
757
+ // Found first, then counted as sent before the first key: typing is one
758
+ // key per round trip, so a deadline can land after some keys (an Enter
759
+ // among them) reached the page, and that is not "nothing happened".
760
+ await field.waitFor({ state: "visible", timeout: operation.timeoutMs });
761
+ this.dispatched = true;
519
762
  await field.pressSequentially(value, {
520
763
  delay: operation.delayMs,
521
764
  timeout: operation.timeoutMs,
522
765
  });
766
+ this.dispatched = true;
767
+ }
768
+ /**
769
+ * An attached CDP browser has no pinning proxy, and Chromium follows a
770
+ * redirect without showing the hop to the route guard, so an allowed page
771
+ * that redirects to 169.254.169.254 or a LAN address loads there. The request
772
+ * cannot be stopped from here; what can be is reading it back. A page whose
773
+ * address the guard refuses is blanked and the action fails, and a frame
774
+ * the guard refuses is left out.
775
+ */
776
+ async refusedFrames(page, frames) {
777
+ const refused = new Set();
778
+ if (this.options.cdpEndpoint === undefined || this.options.unhinged === true)
779
+ return refused;
780
+ for (const frame of frames) {
781
+ const url = frame.url();
782
+ if (!/^https?:/i.test(url))
783
+ continue;
784
+ try {
785
+ await assertSafeUrl(url, this.options);
786
+ }
787
+ catch (error) {
788
+ if (frame !== page.mainFrame()) {
789
+ refused.add(frame);
790
+ continue;
791
+ }
792
+ await page.goto("about:blank").catch(() => undefined);
793
+ throw error;
794
+ }
795
+ }
796
+ return refused;
523
797
  }
524
798
  async snapshot(page, maxChars) {
525
- const frames = this.frames(page);
526
- const collected = await Promise.all(frames.map(async (frame) => {
799
+ const all = this.frames(page);
800
+ const refused = await this.refusedFrames(page, all);
801
+ const frames = all.filter((frame) => !refused.has(frame));
802
+ const collected = await Promise.all(frames.map(async (frame, index) => {
527
803
  try {
528
- return await frame.evaluate(collectFrame, maxChars);
804
+ return await this.call(frame.evaluate(collectFrame, maxChars), "evaluate");
529
805
  }
530
- catch {
806
+ catch (error) {
807
+ // The main document not answering is a failed inspect: reporting it
808
+ // as empty would hand back a plausible snapshot of nothing.
809
+ if (index === 0 && error instanceof BrowserCallTimeoutError)
810
+ throw error;
531
811
  // A frame that navigated or detached mid-evaluate is not a failed
532
- // inspect. Losing an ad frame should not lose the page.
812
+ // inspect. Losing an ad frame should not lose the page, and neither
813
+ // should one whose out-of-process renderer stopped answering.
533
814
  return undefined;
534
815
  }
535
816
  }));
@@ -549,7 +830,7 @@ export class BrowserRuntime {
549
830
  .map((element, index) => ({ index, ...element }));
550
831
  return {
551
832
  url: page.url(),
552
- title: await page.title(),
833
+ title: await this.call(page.title(), "title"),
553
834
  text: main?.text ?? "",
554
835
  truncated: main?.truncated ?? false,
555
836
  elements,
@@ -573,7 +854,7 @@ export class BrowserRuntime {
573
854
  ]);
574
855
  return {
575
856
  url: page.url(),
576
- title: await page.title(),
857
+ title: await this.call(page.title(), "title"),
577
858
  target: operation.target,
578
859
  text: text.slice(0, operation.maxChars),
579
860
  html: html.slice(0, operation.maxChars),
@@ -582,39 +863,99 @@ export class BrowserRuntime {
582
863
  };
583
864
  }
584
865
  async settledSnapshot(page, operation) {
866
+ // Bounded by its own `settleTimeoutMs` plus a grace, not by `timeoutMs`:
867
+ // the settle window is a deliberate wait and must not be cut short.
585
868
  const settle = await waitForStableDom(page, {
586
869
  quietWindowMs: operation.settleQuietMs,
587
870
  timeoutMs: operation.settleTimeoutMs,
588
- });
871
+ }, this.signal);
589
872
  return {
590
873
  settle,
591
874
  ...(await this.snapshot(page, operation.maxChars)),
592
875
  };
593
876
  }
594
- async execute(operation) {
877
+ /**
878
+ * One action at a time, in arrival order. A single runtime serves every task,
879
+ * and a parallel workflow node or two concurrent MCP calls used to run here
880
+ * at once: the second reset the first's cancel signal, timeout and
881
+ * `dispatched` flag mid-flight, so a click that landed was reported as a
882
+ * plain failure, and one task's cancel stopped another's action.
883
+ */
884
+ async execute(operation, signal) {
885
+ const turn = this.queue.then(() => this.executeOne(operation, signal));
886
+ this.queue = turn.catch(() => undefined);
887
+ return await turn;
888
+ }
889
+ async executeOne(operation, signal) {
890
+ if (signal?.aborted === true)
891
+ throw new Error("task_cancelled");
595
892
  // Every branch reports `success: true` on the way out, because a caller who
596
893
  // declared no evidence is held to exactly that field: policy derives
597
894
  // `result_equals success true` for browser mutations, and a result without
598
895
  // it verified as `partial` no matter how well the action went. Failures
599
896
  // 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
- };
897
+ this.signal = signal;
898
+ this.callMs = operation.timeoutMs;
899
+ this.dispatched = false;
900
+ this.acting = true;
901
+ try {
902
+ const ran = await this.run(operation);
903
+ await this.awaitOpenedWindows(operation.timeoutMs);
904
+ // A blocked popup is closed from an event handler, which has no way to
905
+ // join the action that provoked it. Waiting here is what stops the next
906
+ // action from seeing a tab this one already reported as closed.
907
+ await this.call(Promise.all(this.closing.splice(0)), "close_popup");
908
+ const result = { success: true, ...ran };
909
+ const dialogs = this.dialogs.splice(0);
910
+ const popups = this.popups.splice(0);
911
+ // Only present when the page actually asked something, so a caller can
912
+ // test for the field rather than for an empty array.
913
+ return {
914
+ ...result,
915
+ ...(dialogs.length === 0 ? {} : { dialogs }),
916
+ ...(popups.length === 0 ? {} : { popups }),
917
+ };
918
+ }
919
+ catch (error) {
920
+ // Only mutations ever set `dispatched`, and only once their effect call
921
+ // has returned. Anything that fails after that — the settle, the
922
+ // snapshot, the popup close, a bound or the task budget running out —
923
+ // fails after the page may have acted, so it is reported as an unknown
924
+ // outcome for the controller to park rather than as a failure it would
925
+ // read as "nothing happened". So is a pointer action that Playwright
926
+ // itself timed out after it had sent the input. A failure before either,
927
+ // including a timeout resolving the target, is an ordinary failure:
928
+ // nothing was sent, and for a read the controller may simply retry.
929
+ // The message is stored on the task record and shown in the console.
930
+ if (error instanceof Error)
931
+ error.message = withoutColour(error.message);
932
+ if (!this.dispatched && !inputSent(error))
933
+ throw error;
934
+ throw outcomeUnknown(error instanceof Error ? error.message : String(error));
935
+ }
936
+ finally {
937
+ this.acting = false;
938
+ }
615
939
  }
616
940
  async run(operation) {
617
941
  const page = await this.page();
942
+ // Anywhere but on the way to it, a url names the page the action acts on:
943
+ // the active page, or for `close` and `tab_switch` the tab they name.
944
+ // Policy shows it to the approver as the target and matches grants against
945
+ // it, so the action is refused, before anything is sent, when that page is
946
+ // elsewhere. Compared as policy shows it, without query or fragment.
947
+ //
948
+ // ponytail: checked when the action starts. A page that navigates itself
949
+ // while the action waits for its target can still receive it; pinning
950
+ // each action to the checked document would close that.
951
+ if (operation.url !== undefined && operation.action !== "navigate" && operation.action !== "tab_new") {
952
+ const acted = (operation.action === "close" || operation.action === "tab_switch") && operation.tabIndex !== undefined
953
+ ? (await this.ensureContext()).pages()[operation.tabIndex]
954
+ : page;
955
+ const at = acted === undefined ? "no such tab" : pageAddress(acted.url());
956
+ if (at !== pageAddress(operation.url))
957
+ throw new Error(`browser_page_not_at_url: the page is at ${at}`);
958
+ }
618
959
  switch (operation.action) {
619
960
  case "navigate": {
620
961
  if (operation.url === undefined)
@@ -707,6 +1048,12 @@ export class BrowserRuntime {
707
1048
  await (await this.locator(page, operation.target)).first().click({
708
1049
  timeout: operation.timeoutMs,
709
1050
  });
1051
+ // Set after the click returns, not before: Playwright's own timeout
1052
+ // mostly fires while it waits for the element to become clickable,
1053
+ // and reporting that as "may have clicked" would park every covered
1054
+ // button for reconciliation. A timeout after the input went out is
1055
+ // caught by `inputSent` instead. See `execute` for what this changes.
1056
+ this.dispatched = true;
710
1057
  return {
711
1058
  clicked: true,
712
1059
  ...(await this.settledSnapshot(page, operation)),
@@ -742,6 +1089,7 @@ export class BrowserRuntime {
742
1089
  await (await this.locator(page, operation.target)).first().click({
743
1090
  timeout: operation.timeoutMs,
744
1091
  });
1092
+ this.dispatched = true;
745
1093
  }
746
1094
  return {
747
1095
  filled: operation.fields.length,
@@ -755,6 +1103,7 @@ export class BrowserRuntime {
755
1103
  const selected = await (await this.locator(page, operation.target))
756
1104
  .first()
757
1105
  .selectOption(operation.values, { timeout: operation.timeoutMs });
1106
+ this.dispatched = true;
758
1107
  return {
759
1108
  selected,
760
1109
  ...(await this.settledSnapshot(page, operation)),
@@ -767,6 +1116,7 @@ export class BrowserRuntime {
767
1116
  ? page.locator("body")
768
1117
  : await this.locator(page, operation.target);
769
1118
  await locator.first().press(operation.key, { timeout: operation.timeoutMs });
1119
+ this.dispatched = true;
770
1120
  return {
771
1121
  pressed: operation.key,
772
1122
  ...(await this.settledSnapshot(page, operation)),
@@ -782,26 +1132,31 @@ export class BrowserRuntime {
782
1132
  else {
783
1133
  // A fixed ±600 was too small for a long article and too large for a
784
1134
  // short scroll container, and nothing could change it.
785
- await page.evaluate(({ value, pixels }) => {
1135
+ await this.call(page.evaluate(({ value, pixels }) => {
786
1136
  if (value === "top")
787
1137
  window.scrollTo(0, 0);
788
1138
  else if (value === "bottom")
789
1139
  window.scrollTo(0, document.body.scrollHeight);
790
1140
  else
791
1141
  window.scrollBy(0, value === "up" ? -pixels : pixels);
792
- }, { value: direction, pixels: operation.pixels });
1142
+ }, { value: direction, pixels: operation.pixels }), "evaluate");
793
1143
  }
794
1144
  return {
795
1145
  scrolled: direction,
796
1146
  // Where we ended up, so a caller paging through a document can tell
797
1147
  // it has hit the bottom rather than scrolling forever.
798
- scrollY: await page.evaluate(() => window.scrollY),
1148
+ scrollY: await this.call(page.evaluate(() => window.scrollY), "evaluate"),
799
1149
  ...(await this.settledSnapshot(page, operation)),
800
1150
  };
801
1151
  }
802
1152
  case "screenshot": {
803
1153
  const path = join(this.options.artifactDirectory, `browser-${randomUUID()}.png`);
804
- await page.screenshot({ path, fullPage: operation.fullPage, type: "png" });
1154
+ await page.screenshot({
1155
+ path,
1156
+ fullPage: operation.fullPage,
1157
+ type: "png",
1158
+ timeout: operation.timeoutMs,
1159
+ });
805
1160
  const bytes = await readFile(path);
806
1161
  return {
807
1162
  captured: true,
@@ -809,7 +1164,7 @@ export class BrowserRuntime {
809
1164
  size: bytes.byteLength,
810
1165
  sha256: createHash("sha256").update(bytes).digest("hex"),
811
1166
  url: page.url(),
812
- title: await page.title(),
1167
+ title: await this.call(page.title(), "title"),
813
1168
  };
814
1169
  }
815
1170
  case "upload": {
@@ -820,6 +1175,7 @@ export class BrowserRuntime {
820
1175
  await (await this.locator(page, operation.target)).first().setInputFiles(files, {
821
1176
  timeout: operation.timeoutMs,
822
1177
  });
1178
+ this.dispatched = true;
823
1179
  return {
824
1180
  uploaded: files.length,
825
1181
  ...(await this.settledSnapshot(page, operation)),
@@ -829,13 +1185,21 @@ export class BrowserRuntime {
829
1185
  const downloadPromise = page.waitForEvent("download", {
830
1186
  timeout: operation.timeoutMs,
831
1187
  });
1188
+ // If resolving or clicking the target throws first, nobody awaits the
1189
+ // download wait, and its own timeout would surface as an unhandled
1190
+ // rejection long after this action failed.
1191
+ downloadPromise.catch(() => undefined);
832
1192
  await (await this.locator(page, operation.target)).first().click({
833
1193
  timeout: operation.timeoutMs,
834
1194
  });
1195
+ this.dispatched = true;
835
1196
  const download = await downloadPromise;
836
1197
  const suggested = download.suggestedFilename().replaceAll(/[^A-Za-z0-9._-]/g, "_");
837
1198
  const path = join(this.options.artifactDirectory, `${randomUUID()}-${suggested || "download"}`);
838
- await download.saveAs(path);
1199
+ // Bounded by the task budget only. Saving waits for the whole file to
1200
+ // arrive, and how long that legitimately takes depends on the file, not
1201
+ // on the per-call `timeoutMs`.
1202
+ await this.call(download.saveAs(path), "save_download", Infinity);
839
1203
  const bytes = await readFile(path);
840
1204
  return {
841
1205
  downloaded: true,
@@ -847,7 +1211,7 @@ export class BrowserRuntime {
847
1211
  settle: await waitForStableDom(page, {
848
1212
  quietWindowMs: operation.settleQuietMs,
849
1213
  timeoutMs: operation.settleTimeoutMs,
850
- }),
1214
+ }, this.signal),
851
1215
  };
852
1216
  }
853
1217
  case "tabs": {
@@ -882,7 +1246,7 @@ export class BrowserRuntime {
882
1246
  // Bring the tab to the front so a screenshot of it is what the user
883
1247
  // would actually see; a background page still renders, but modal and
884
1248
  // focus behaviour differ.
885
- await target.bringToFront();
1249
+ await this.call(target.bringToFront(), "bring_to_front");
886
1250
  this.activePage = target;
887
1251
  return {
888
1252
  switched: true,
@@ -900,7 +1264,11 @@ export class BrowserRuntime {
900
1264
  if (target === undefined)
901
1265
  throw new Error("browser_tab_not_found");
902
1266
  const url = target.url();
903
- await target.close();
1267
+ // Set before the close, unlike the other mutations: this call is the
1268
+ // effect and has no wait-for-ready phase, so once it is in flight a
1269
+ // bound running out cannot say whether the tab went away.
1270
+ this.dispatched = true;
1271
+ await this.call(target.close(), "close");
904
1272
  this.activePage = context.pages().at(-1);
905
1273
  return { closed: true, url, tabs: await this.describeTabs(context) };
906
1274
  }
@@ -908,18 +1276,21 @@ export class BrowserRuntime {
908
1276
  }
909
1277
  async close() {
910
1278
  const connection = this.connection;
1279
+ const quietly = (call, what) => bounded(call, CLOSE_TIMEOUT_MS, what).catch(() => undefined);
1280
+ // An attached browser outlives us; stop asking it to narrate its targets.
1281
+ if (this.targets !== undefined)
1282
+ await quietly(this.targets.detach(), "cdp_detach");
1283
+ this.targets = undefined;
911
1284
  if (connection !== undefined &&
912
1285
  !connection.ownsContext &&
913
1286
  this.routeHandler !== undefined) {
914
- await connection.context
915
- .unroute("**/*", this.routeHandler)
916
- .catch(() => undefined);
1287
+ await quietly(connection.context.unroute("**/*", this.routeHandler), "unroute");
917
1288
  }
918
1289
  if (connection?.ownsContext === true) {
919
- await connection.context.close().catch(() => undefined);
1290
+ await quietly(connection.context.close(), "close_context");
920
1291
  }
921
1292
  if (connection?.ownsBrowser === true) {
922
- await connection.browser.close().catch(() => undefined);
1293
+ await quietly(connection.browser.close(), "close_browser");
923
1294
  }
924
1295
  // After the browser, so a request in flight is not left without a proxy.
925
1296
  await this.proxy?.close().catch(() => undefined);
@@ -939,4 +1310,5 @@ export * from "./pinning-proxy.js";
939
1310
  export * from "./selector.js";
940
1311
  export * from "./stable-dom.js";
941
1312
  export * from "./browser-connection.js";
1313
+ export * from "./bounded.js";
942
1314
  //# sourceMappingURL=index.js.map