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

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 (56) 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 +71 -1
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/index.js +302 -50
  11. package/dist/index.js.map +1 -1
  12. package/dist/stable-dom.d.ts +13 -1
  13. package/dist/stable-dom.d.ts.map +1 -1
  14. package/dist/stable-dom.js +22 -3
  15. package/dist/stable-dom.js.map +1 -1
  16. package/package.json +6 -6
  17. package/dist/browser-connection.test.d.ts +0 -2
  18. package/dist/browser-connection.test.d.ts.map +0 -1
  19. package/dist/browser-connection.test.js +0 -210
  20. package/dist/browser-connection.test.js.map +0 -1
  21. package/dist/dialogs.test.d.ts +0 -2
  22. package/dist/dialogs.test.d.ts.map +0 -1
  23. package/dist/dialogs.test.js +0 -165
  24. package/dist/dialogs.test.js.map +0 -1
  25. package/dist/frames.test.d.ts +0 -2
  26. package/dist/frames.test.d.ts.map +0 -1
  27. package/dist/frames.test.js +0 -157
  28. package/dist/frames.test.js.map +0 -1
  29. package/dist/history-tabs.test.d.ts +0 -2
  30. package/dist/history-tabs.test.d.ts.map +0 -1
  31. package/dist/history-tabs.test.js +0 -176
  32. package/dist/history-tabs.test.js.map +0 -1
  33. package/dist/input-fidelity.test.d.ts +0 -2
  34. package/dist/input-fidelity.test.d.ts.map +0 -1
  35. package/dist/input-fidelity.test.js +0 -260
  36. package/dist/input-fidelity.test.js.map +0 -1
  37. package/dist/pinning-proxy.test.d.ts +0 -2
  38. package/dist/pinning-proxy.test.d.ts.map +0 -1
  39. package/dist/pinning-proxy.test.js +0 -80
  40. package/dist/pinning-proxy.test.js.map +0 -1
  41. package/dist/popups-profile.test.d.ts +0 -2
  42. package/dist/popups-profile.test.d.ts.map +0 -1
  43. package/dist/popups-profile.test.js +0 -170
  44. package/dist/popups-profile.test.js.map +0 -1
  45. package/dist/see-act.test.d.ts +0 -2
  46. package/dist/see-act.test.d.ts.map +0 -1
  47. package/dist/see-act.test.js +0 -152
  48. package/dist/see-act.test.js.map +0 -1
  49. package/dist/selector.test.d.ts +0 -2
  50. package/dist/selector.test.d.ts.map +0 -1
  51. package/dist/selector.test.js +0 -34
  52. package/dist/selector.test.js.map +0 -1
  53. package/dist/stable-dom.test.d.ts +0 -2
  54. package/dist/stable-dom.test.d.ts.map +0 -1
  55. package/dist/stable-dom.test.js +0 -22
  56. 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,30 @@ 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
+ /** Playwright's terminal colour codes, which mean nothing in a stored record. */
115
+ function withoutColour(text) {
116
+ return text.replace(/\u001b\[[0-9;]*m/g, "");
117
+ }
74
118
  /**
75
119
  * Collect the readable text and addressable elements of one frame.
76
120
  *
@@ -172,9 +216,47 @@ export class BrowserRuntime {
172
216
  closing = [];
173
217
  /** True only while `tab_new` is opening the tab the caller asked for. */
174
218
  openingTab = false;
219
+ /**
220
+ * Browser-level session that hears about a new window the moment Chromium
221
+ * creates it, which is what lets an action wait for a popup it knows exists
222
+ * instead of guessing how long one might take. Absent if the browser would
223
+ * not grant one; popups are then reported on Playwright's timing alone.
224
+ */
225
+ targets;
226
+ /** Windows Chromium has created since tracking began, by browser context. */
227
+ windowsCreated = new Map();
228
+ /** Windows Playwright has handed to the `page` handler, popup or not. */
229
+ windowsAdopted = 0;
230
+ /** This context's id in CDP, resolved on first use and then fixed. */
231
+ contextId;
232
+ /**
233
+ * The running action's cancel signal and `timeoutMs`, read by `call`. Same
234
+ * per-action lifetime as `dialogs`, set by `execute`.
235
+ */
236
+ signal;
237
+ callMs = 30_000;
238
+ /**
239
+ * Set once the running action's effect call has returned. From then on the
240
+ * page may have acted on it, so a failure is no longer evidence that nothing
241
+ * happened — see `execute`.
242
+ */
243
+ dispatched = false;
175
244
  constructor(options) {
176
245
  this.options = options;
177
246
  }
247
+ /**
248
+ * Bound a Playwright call that takes no timeout of its own by the running
249
+ * action's `timeoutMs` and cancel signal.
250
+ *
251
+ * `title()`, `evaluate()`, `count()`, `isVisible()`, `close()` and CDP
252
+ * `send` all wait for the renderer or the browser to answer, and one that
253
+ * stopped answering held the action until the task budget ran out — or
254
+ * forever, where nothing armed one. Failing with `browser_call_timeout:<what>`
255
+ * instead spends the budget on something that can still answer.
256
+ */
257
+ call(promise, what, ms = this.callMs) {
258
+ return bounded(promise, ms, what, this.signal);
259
+ }
178
260
  async uploadPaths(paths) {
179
261
  const root = await realpath(this.options.workspaceRoot);
180
262
  const resolved = [];
@@ -276,16 +358,25 @@ export class BrowserRuntime {
276
358
  await route.abort("blockedbyclient");
277
359
  }
278
360
  };
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
- });
361
+ try {
362
+ await this.call(this.context.route("**/*", this.routeHandler), "route", SETUP_TIMEOUT_MS);
363
+ if (this.options.replayHarPath !== undefined) {
364
+ // Registered after the destination guard so it runs first: a replayed
365
+ // request is served from the file and never becomes a socket, and
366
+ // `notFound: "abort"` means a request the archive is missing fails
367
+ // visibly rather than falling through to the network the guard would
368
+ // have allowed.
369
+ await this.call(this.context.routeFromHAR(this.options.replayHarPath, {
370
+ notFound: "abort",
371
+ }), "route_from_har", SETUP_TIMEOUT_MS);
372
+ }
373
+ }
374
+ catch (error) {
375
+ // `this.context` is already set, so the next action would skip straight
376
+ // past this method onto a context whose destination guard may never
377
+ // have been installed. Tearing it down makes the next action start over.
378
+ await this.close();
379
+ throw error;
289
380
  }
290
381
  /**
291
382
  * Answer dialogs instead of letting Playwright discard them.
@@ -338,6 +429,10 @@ export class BrowserRuntime {
338
429
  * this governs whether it stays.
339
430
  */
340
431
  this.context.on("page", (opened) => {
432
+ // Counted before the early return: every window Chromium creates raises
433
+ // exactly one of these, including tabs MELRA opened and windows that
434
+ // closed before Playwright finished with them.
435
+ this.windowsAdopted += 1;
341
436
  if (opened === this.activePage || this.openingTab)
342
437
  return;
343
438
  // Closing a window the page opened is MELRA's judgement about what the
@@ -353,8 +448,98 @@ export class BrowserRuntime {
353
448
  // must not turn into the action's error.
354
449
  this.closing.push(opened.close().catch(() => undefined));
355
450
  });
451
+ /**
452
+ * Hear about new windows when Chromium creates them, not when Playwright
453
+ * announces them.
454
+ *
455
+ * Playwright raises `page` for a popup only once the popup's first response
456
+ * has committed, so a click whose window loaded slowly returned with no
457
+ * `popups` field and the report — and, in block mode, the close — landed in
458
+ * whatever ran next. Chromium creates the target synchronously inside
459
+ * `window.open` or the `target=_blank` click, so this count is already
460
+ * right when the action finishes and `awaitOpenedWindows` knows how many
461
+ * reports are still owed. It sees every way of opening a window, and the
462
+ * page cannot see or tamper with it, which an injected `window.open` hook
463
+ * could not promise.
464
+ *
465
+ * Registered after the `page` handler, so any miscount during setup is a
466
+ * window adopted but never counted — at worst one later wait ends early —
467
+ * rather than one counted and never adopted, which no wait could satisfy.
468
+ */
469
+ this.windowsCreated = new Map();
470
+ this.windowsAdopted = 0;
471
+ this.contextId = undefined;
472
+ // A session left from a context that closed under us would count into the
473
+ // fresh map below and make every action wait for windows that never come.
474
+ if (this.targets !== undefined) {
475
+ await this.call(this.targets.detach(), "cdp_detach", SETUP_TIMEOUT_MS).catch(() => undefined);
476
+ }
477
+ try {
478
+ const targets = await this.call(connection.browser.newBrowserCDPSession(), "cdp_session", SETUP_TIMEOUT_MS);
479
+ targets.on("Target.targetCreated", ({ targetInfo }) => {
480
+ // `browser_ui`, workers, and prerenders never become Playwright pages.
481
+ if (targetInfo.type !== "page" || targetInfo.subtype !== undefined)
482
+ return;
483
+ const id = targetInfo.browserContextId ?? "";
484
+ this.windowsCreated.set(id, (this.windowsCreated.get(id) ?? 0) + 1);
485
+ });
486
+ await this.call(targets.send("Target.setDiscoverTargets", { discover: true }), "cdp:Target.setDiscoverTargets", SETUP_TIMEOUT_MS);
487
+ // Chromium announces the windows that already exist before it replies.
488
+ // Playwright had them before the `page` handler did, so they are owed no
489
+ // event here.
490
+ this.windowsCreated.clear();
491
+ this.targets = targets;
492
+ }
493
+ catch {
494
+ this.targets = undefined;
495
+ }
356
496
  return this.context;
357
497
  }
498
+ /**
499
+ * Hold the action's result until Playwright has handed over every window
500
+ * Chromium created, so the action that opened a window is the one that
501
+ * reports it.
502
+ *
503
+ * Costs one round trip when nothing was opened. Waits only while a window is
504
+ * known to exist and is still unreported, bounded by the action's own
505
+ * timeout: the window is part of what the action did. A window still pending
506
+ * after that is reported by the action running when Playwright delivers it.
507
+ */
508
+ async awaitOpenedWindows(timeoutMs) {
509
+ const targets = this.targets;
510
+ const context = this.context;
511
+ if (targets === undefined || context === undefined)
512
+ return;
513
+ try {
514
+ if (this.contextId === undefined) {
515
+ const page = context.pages()[0];
516
+ if (page === undefined)
517
+ return;
518
+ const session = await this.call(context.newCDPSession(page), "cdp_session");
519
+ try {
520
+ const { targetInfo } = await this.call(session.send("Target.getTargetInfo"), "cdp:Target.getTargetInfo");
521
+ this.contextId = targetInfo.browserContextId ?? "";
522
+ }
523
+ finally {
524
+ await this.call(session.detach(), "cdp_detach").catch(() => undefined);
525
+ }
526
+ }
527
+ // Events arrive ahead of the reply on the same session, so after this
528
+ // every window created before now has been counted.
529
+ await this.call(targets.send("Browser.getVersion"), "cdp:Browser.getVersion");
530
+ const deadline = Date.now() + timeoutMs;
531
+ while (this.windowsAdopted < (this.windowsCreated.get(this.contextId) ?? 0) &&
532
+ Date.now() < deadline) {
533
+ await new Promise((done) => setTimeout(done, WINDOW_POLL_MS));
534
+ }
535
+ }
536
+ catch {
537
+ // The browser went away mid-action, or stopped answering within the
538
+ // action's bound; either way nothing more will be delivered in time, and
539
+ // this wait only ever decides which action reports a window, so it is
540
+ // not worth failing an action over.
541
+ }
542
+ }
358
543
  /**
359
544
  * Opens a page MELRA asked for, not one the site opened by itself.
360
545
  *
@@ -365,7 +550,7 @@ export class BrowserRuntime {
365
550
  async openPage(context) {
366
551
  this.openingTab = true;
367
552
  try {
368
- return await context.newPage();
553
+ return await this.call(context.newPage(), "new_page");
369
554
  }
370
555
  finally {
371
556
  this.openingTab = false;
@@ -390,7 +575,7 @@ export class BrowserRuntime {
390
575
  return await Promise.all(context.pages().map(async (item, index) => ({
391
576
  index,
392
577
  url: item.url(),
393
- title: await item.title(),
578
+ title: await this.call(item.title(), "title"),
394
579
  active: item === this.activePage,
395
580
  })));
396
581
  }
@@ -444,12 +629,16 @@ export class BrowserRuntime {
444
629
  *
445
630
  * A target that matches nothing is reported as such instead of being handed to
446
631
  * Playwright to fail as an opaque action timeout thirty seconds later.
632
+ *
633
+ * Resolution only reads the page and always runs before an action's effect,
634
+ * so a `count` that times out here fails a click or a fill cleanly: nothing
635
+ * was sent, and `execute` does not report it as an unknown outcome.
447
636
  */
448
637
  async locator(page, target) {
449
638
  if (target === undefined)
450
639
  throw new Error("browser_action_requires_target");
451
640
  for (const candidate of this.candidates(page, target)) {
452
- if ((await candidate.count()) > 0)
641
+ if ((await this.call(candidate.count(), "count")) > 0)
453
642
  return candidate;
454
643
  }
455
644
  throw new Error(`browser_target_not_found:${JSON.stringify(target)}`);
@@ -479,8 +668,8 @@ export class BrowserRuntime {
479
668
  // `isVisible`/`count` report on a missing element rather than throwing,
480
669
  // so absence is an answer here: it satisfies `hidden` and `detached`.
481
670
  const present = state === "attached" || state === "detached"
482
- ? (await element.count()) > 0
483
- : await element.isVisible();
671
+ ? (await this.call(element.count(), "count")) > 0
672
+ : await this.call(element.isVisible(), "is_visible");
484
673
  if (negated) {
485
674
  if (present) {
486
675
  reached = false;
@@ -515,21 +704,28 @@ export class BrowserRuntime {
515
704
  const field = locator.first();
516
705
  if (operation.clearFirst) {
517
706
  await field.fill("", { timeout: operation.timeoutMs });
707
+ this.dispatched = true;
518
708
  }
519
709
  await field.pressSequentially(value, {
520
710
  delay: operation.delayMs,
521
711
  timeout: operation.timeoutMs,
522
712
  });
713
+ this.dispatched = true;
523
714
  }
524
715
  async snapshot(page, maxChars) {
525
716
  const frames = this.frames(page);
526
- const collected = await Promise.all(frames.map(async (frame) => {
717
+ const collected = await Promise.all(frames.map(async (frame, index) => {
527
718
  try {
528
- return await frame.evaluate(collectFrame, maxChars);
719
+ return await this.call(frame.evaluate(collectFrame, maxChars), "evaluate");
529
720
  }
530
- catch {
721
+ catch (error) {
722
+ // The main document not answering is a failed inspect: reporting it
723
+ // as empty would hand back a plausible snapshot of nothing.
724
+ if (index === 0 && error instanceof BrowserCallTimeoutError)
725
+ throw error;
531
726
  // A frame that navigated or detached mid-evaluate is not a failed
532
- // inspect. Losing an ad frame should not lose the page.
727
+ // inspect. Losing an ad frame should not lose the page, and neither
728
+ // should one whose out-of-process renderer stopped answering.
533
729
  return undefined;
534
730
  }
535
731
  }));
@@ -549,7 +745,7 @@ export class BrowserRuntime {
549
745
  .map((element, index) => ({ index, ...element }));
550
746
  return {
551
747
  url: page.url(),
552
- title: await page.title(),
748
+ title: await this.call(page.title(), "title"),
553
749
  text: main?.text ?? "",
554
750
  truncated: main?.truncated ?? false,
555
751
  elements,
@@ -573,7 +769,7 @@ export class BrowserRuntime {
573
769
  ]);
574
770
  return {
575
771
  url: page.url(),
576
- title: await page.title(),
772
+ title: await this.call(page.title(), "title"),
577
773
  target: operation.target,
578
774
  text: text.slice(0, operation.maxChars),
579
775
  html: html.slice(0, operation.maxChars),
@@ -582,16 +778,18 @@ export class BrowserRuntime {
582
778
  };
583
779
  }
584
780
  async settledSnapshot(page, operation) {
781
+ // Bounded by its own `settleTimeoutMs` plus a grace, not by `timeoutMs`:
782
+ // the settle window is a deliberate wait and must not be cut short.
585
783
  const settle = await waitForStableDom(page, {
586
784
  quietWindowMs: operation.settleQuietMs,
587
785
  timeoutMs: operation.settleTimeoutMs,
588
- });
786
+ }, this.signal);
589
787
  return {
590
788
  settle,
591
789
  ...(await this.snapshot(page, operation.maxChars)),
592
790
  };
593
791
  }
594
- async execute(operation) {
792
+ async execute(operation, signal) {
595
793
  // Every branch reports `success: true` on the way out, because a caller who
596
794
  // declared no evidence is held to exactly that field: policy derives
597
795
  // `result_equals success true` for browser mutations, and a result without
@@ -599,19 +797,42 @@ export class BrowserRuntime {
599
797
  // throw, so reaching a return is what success means here.
600
798
  this.dialogs = [];
601
799
  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
- };
800
+ this.signal = signal;
801
+ this.callMs = operation.timeoutMs;
802
+ this.dispatched = false;
803
+ try {
804
+ const ran = await this.run(operation);
805
+ await this.awaitOpenedWindows(operation.timeoutMs);
806
+ // A blocked popup is closed from an event handler, which has no way to
807
+ // join the action that provoked it. Waiting here is what stops the next
808
+ // action from seeing a tab this one already reported as closed.
809
+ await this.call(Promise.all(this.closing.splice(0)), "close_popup");
810
+ const result = { success: true, ...ran };
811
+ // Only present when the page actually asked something, so a caller can
812
+ // test for the field rather than for an empty array.
813
+ return {
814
+ ...result,
815
+ ...(this.dialogs.length === 0 ? {} : { dialogs: this.dialogs }),
816
+ ...(this.popups.length === 0 ? {} : { popups: this.popups }),
817
+ };
818
+ }
819
+ catch (error) {
820
+ // Only mutations ever set `dispatched`, and only once their effect call
821
+ // has returned. Anything that fails after that — the settle, the
822
+ // snapshot, the popup close, a bound or the task budget running out —
823
+ // fails after the page may have acted, so it is reported as an unknown
824
+ // outcome for the controller to park rather than as a failure it would
825
+ // read as "nothing happened". So is a pointer action that Playwright
826
+ // itself timed out after it had sent the input. A failure before either,
827
+ // including a timeout resolving the target, is an ordinary failure:
828
+ // nothing was sent, and for a read the controller may simply retry.
829
+ // The message is stored on the task record and shown in the console.
830
+ if (error instanceof Error)
831
+ error.message = withoutColour(error.message);
832
+ if (!this.dispatched && !inputSent(error))
833
+ throw error;
834
+ throw outcomeUnknown(error instanceof Error ? error.message : String(error));
835
+ }
615
836
  }
616
837
  async run(operation) {
617
838
  const page = await this.page();
@@ -707,6 +928,12 @@ export class BrowserRuntime {
707
928
  await (await this.locator(page, operation.target)).first().click({
708
929
  timeout: operation.timeoutMs,
709
930
  });
931
+ // Set after the click returns, not before: Playwright's own timeout
932
+ // mostly fires while it waits for the element to become clickable,
933
+ // and reporting that as "may have clicked" would park every covered
934
+ // button for reconciliation. A timeout after the input went out is
935
+ // caught by `inputSent` instead. See `execute` for what this changes.
936
+ this.dispatched = true;
710
937
  return {
711
938
  clicked: true,
712
939
  ...(await this.settledSnapshot(page, operation)),
@@ -742,6 +969,7 @@ export class BrowserRuntime {
742
969
  await (await this.locator(page, operation.target)).first().click({
743
970
  timeout: operation.timeoutMs,
744
971
  });
972
+ this.dispatched = true;
745
973
  }
746
974
  return {
747
975
  filled: operation.fields.length,
@@ -755,6 +983,7 @@ export class BrowserRuntime {
755
983
  const selected = await (await this.locator(page, operation.target))
756
984
  .first()
757
985
  .selectOption(operation.values, { timeout: operation.timeoutMs });
986
+ this.dispatched = true;
758
987
  return {
759
988
  selected,
760
989
  ...(await this.settledSnapshot(page, operation)),
@@ -767,6 +996,7 @@ export class BrowserRuntime {
767
996
  ? page.locator("body")
768
997
  : await this.locator(page, operation.target);
769
998
  await locator.first().press(operation.key, { timeout: operation.timeoutMs });
999
+ this.dispatched = true;
770
1000
  return {
771
1001
  pressed: operation.key,
772
1002
  ...(await this.settledSnapshot(page, operation)),
@@ -782,26 +1012,31 @@ export class BrowserRuntime {
782
1012
  else {
783
1013
  // A fixed ±600 was too small for a long article and too large for a
784
1014
  // short scroll container, and nothing could change it.
785
- await page.evaluate(({ value, pixels }) => {
1015
+ await this.call(page.evaluate(({ value, pixels }) => {
786
1016
  if (value === "top")
787
1017
  window.scrollTo(0, 0);
788
1018
  else if (value === "bottom")
789
1019
  window.scrollTo(0, document.body.scrollHeight);
790
1020
  else
791
1021
  window.scrollBy(0, value === "up" ? -pixels : pixels);
792
- }, { value: direction, pixels: operation.pixels });
1022
+ }, { value: direction, pixels: operation.pixels }), "evaluate");
793
1023
  }
794
1024
  return {
795
1025
  scrolled: direction,
796
1026
  // Where we ended up, so a caller paging through a document can tell
797
1027
  // it has hit the bottom rather than scrolling forever.
798
- scrollY: await page.evaluate(() => window.scrollY),
1028
+ scrollY: await this.call(page.evaluate(() => window.scrollY), "evaluate"),
799
1029
  ...(await this.settledSnapshot(page, operation)),
800
1030
  };
801
1031
  }
802
1032
  case "screenshot": {
803
1033
  const path = join(this.options.artifactDirectory, `browser-${randomUUID()}.png`);
804
- await page.screenshot({ path, fullPage: operation.fullPage, type: "png" });
1034
+ await page.screenshot({
1035
+ path,
1036
+ fullPage: operation.fullPage,
1037
+ type: "png",
1038
+ timeout: operation.timeoutMs,
1039
+ });
805
1040
  const bytes = await readFile(path);
806
1041
  return {
807
1042
  captured: true,
@@ -809,7 +1044,7 @@ export class BrowserRuntime {
809
1044
  size: bytes.byteLength,
810
1045
  sha256: createHash("sha256").update(bytes).digest("hex"),
811
1046
  url: page.url(),
812
- title: await page.title(),
1047
+ title: await this.call(page.title(), "title"),
813
1048
  };
814
1049
  }
815
1050
  case "upload": {
@@ -820,6 +1055,7 @@ export class BrowserRuntime {
820
1055
  await (await this.locator(page, operation.target)).first().setInputFiles(files, {
821
1056
  timeout: operation.timeoutMs,
822
1057
  });
1058
+ this.dispatched = true;
823
1059
  return {
824
1060
  uploaded: files.length,
825
1061
  ...(await this.settledSnapshot(page, operation)),
@@ -829,13 +1065,21 @@ export class BrowserRuntime {
829
1065
  const downloadPromise = page.waitForEvent("download", {
830
1066
  timeout: operation.timeoutMs,
831
1067
  });
1068
+ // If resolving or clicking the target throws first, nobody awaits the
1069
+ // download wait, and its own timeout would surface as an unhandled
1070
+ // rejection long after this action failed.
1071
+ downloadPromise.catch(() => undefined);
832
1072
  await (await this.locator(page, operation.target)).first().click({
833
1073
  timeout: operation.timeoutMs,
834
1074
  });
1075
+ this.dispatched = true;
835
1076
  const download = await downloadPromise;
836
1077
  const suggested = download.suggestedFilename().replaceAll(/[^A-Za-z0-9._-]/g, "_");
837
1078
  const path = join(this.options.artifactDirectory, `${randomUUID()}-${suggested || "download"}`);
838
- await download.saveAs(path);
1079
+ // Bounded by the task budget only. Saving waits for the whole file to
1080
+ // arrive, and how long that legitimately takes depends on the file, not
1081
+ // on the per-call `timeoutMs`.
1082
+ await this.call(download.saveAs(path), "save_download", Infinity);
839
1083
  const bytes = await readFile(path);
840
1084
  return {
841
1085
  downloaded: true,
@@ -847,7 +1091,7 @@ export class BrowserRuntime {
847
1091
  settle: await waitForStableDom(page, {
848
1092
  quietWindowMs: operation.settleQuietMs,
849
1093
  timeoutMs: operation.settleTimeoutMs,
850
- }),
1094
+ }, this.signal),
851
1095
  };
852
1096
  }
853
1097
  case "tabs": {
@@ -882,7 +1126,7 @@ export class BrowserRuntime {
882
1126
  // Bring the tab to the front so a screenshot of it is what the user
883
1127
  // would actually see; a background page still renders, but modal and
884
1128
  // focus behaviour differ.
885
- await target.bringToFront();
1129
+ await this.call(target.bringToFront(), "bring_to_front");
886
1130
  this.activePage = target;
887
1131
  return {
888
1132
  switched: true,
@@ -900,7 +1144,11 @@ export class BrowserRuntime {
900
1144
  if (target === undefined)
901
1145
  throw new Error("browser_tab_not_found");
902
1146
  const url = target.url();
903
- await target.close();
1147
+ // Set before the close, unlike the other mutations: this call is the
1148
+ // effect and has no wait-for-ready phase, so once it is in flight a
1149
+ // bound running out cannot say whether the tab went away.
1150
+ this.dispatched = true;
1151
+ await this.call(target.close(), "close");
904
1152
  this.activePage = context.pages().at(-1);
905
1153
  return { closed: true, url, tabs: await this.describeTabs(context) };
906
1154
  }
@@ -908,18 +1156,21 @@ export class BrowserRuntime {
908
1156
  }
909
1157
  async close() {
910
1158
  const connection = this.connection;
1159
+ const quietly = (call, what) => bounded(call, CLOSE_TIMEOUT_MS, what).catch(() => undefined);
1160
+ // An attached browser outlives us; stop asking it to narrate its targets.
1161
+ if (this.targets !== undefined)
1162
+ await quietly(this.targets.detach(), "cdp_detach");
1163
+ this.targets = undefined;
911
1164
  if (connection !== undefined &&
912
1165
  !connection.ownsContext &&
913
1166
  this.routeHandler !== undefined) {
914
- await connection.context
915
- .unroute("**/*", this.routeHandler)
916
- .catch(() => undefined);
1167
+ await quietly(connection.context.unroute("**/*", this.routeHandler), "unroute");
917
1168
  }
918
1169
  if (connection?.ownsContext === true) {
919
- await connection.context.close().catch(() => undefined);
1170
+ await quietly(connection.context.close(), "close_context");
920
1171
  }
921
1172
  if (connection?.ownsBrowser === true) {
922
- await connection.browser.close().catch(() => undefined);
1173
+ await quietly(connection.browser.close(), "close_browser");
923
1174
  }
924
1175
  // After the browser, so a request in flight is not left without a proxy.
925
1176
  await this.proxy?.close().catch(() => undefined);
@@ -939,4 +1190,5 @@ export * from "./pinning-proxy.js";
939
1190
  export * from "./selector.js";
940
1191
  export * from "./stable-dom.js";
941
1192
  export * from "./browser-connection.js";
1193
+ export * from "./bounded.js";
942
1194
  //# sourceMappingURL=index.js.map