@melra/browser-runtime 0.3.0-alpha.14 → 0.3.0-alpha.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -4,10 +4,11 @@ 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 { fileURLToPath } from "node:url";
7
8
  import { outcomeUnknown } from "@melra/protocol";
8
9
  import { bounded, BrowserCallTimeoutError } from "./bounded.js";
9
10
  import { connectBrowser, } from "./browser-connection.js";
10
- import { assertSafeUrl, } from "@melra/policy-core";
11
+ import { assertSafeUrl, classifyOperation, } from "@melra/policy-core";
11
12
  import { startPinningProxy } from "./pinning-proxy.js";
12
13
  import { buildSelector } from "./selector.js";
13
14
  import { waitForStableDom } from "./stable-dom.js";
@@ -16,6 +17,13 @@ import { waitForStableDom } from "./stable-dom.js";
16
17
  * dozens; the interactive ones a caller cares about are always near the front.
17
18
  */
18
19
  const MAX_FRAMES = 20;
20
+ /**
21
+ * Whether a page call failed because its document went away under it, on
22
+ * Playwright's own wording only: that comes before anything an error quotes,
23
+ * and a quoted selector, the caller's, may say anything.
24
+ */
25
+ const navigatedAway = (error) => error instanceof Error &&
26
+ /execution context was destroyed|cannot find context with specified id|unable to adopt element handle|frame (?:was|has been|got) detached|in detached frame|not attached to the dom|interrupted by another navigation/i.test(error.message.split(/["'`]/)[0] ?? "");
19
27
  /**
20
28
  * Total elements one `inspect` reports. Each frame contributes at most 250
21
29
  * (the cap inside `collectFrame`), so the main document can never consume the
@@ -111,6 +119,49 @@ export function captchaReport(frameUrls) {
111
119
  export function inputSent(error) {
112
120
  return error instanceof Error && /^\s*- performing .+ action$/m.test(withoutColour(error.message));
113
121
  }
122
+ /** The actions that send a page input, each pinned to the document it was checked on. */
123
+ const INPUT_ACTIONS = new Set([
124
+ "click",
125
+ "type",
126
+ "fill_form",
127
+ "select",
128
+ "press",
129
+ "upload",
130
+ "download",
131
+ ]);
132
+ /** The actions that navigate the active page or send it input. */
133
+ const DRIVING_ACTIONS = new Set([
134
+ "navigate",
135
+ "back",
136
+ "forward",
137
+ "reload",
138
+ ...INPUT_ACTIONS,
139
+ ]);
140
+ /** A Playwright wait, or one of ours, that ran out. */
141
+ const timedOut = (error) => error instanceof BrowserCallTimeoutError || (error instanceof Error && error.name === "TimeoutError");
142
+ /**
143
+ * A wait that ran out at an action's deadline, named for the action's whole
144
+ * `timeout`: whichever call held the last of it (a re-find's slice, a check
145
+ * of the page, the input's own wait) is no measure of how long it waited.
146
+ */
147
+ function ranOut(error, timeout) {
148
+ if (error instanceof Error && error.name === "TimeoutError") {
149
+ error.message = error.message.replace(/Timeout \d+ms exceeded/, `Timeout ${timeout}ms exceeded`);
150
+ return error;
151
+ }
152
+ return Object.assign(new Error(`Timeout ${timeout}ms exceeded.`), { name: "TimeoutError" });
153
+ }
154
+ /** Playwright's word for an element its document no longer holds. */
155
+ const detached = (error) => error instanceof Error && error.message.includes("Element is not attached to the DOM");
156
+ /** How long a target found again is searched for before the page is asked whether it left. */
157
+ const REFIND_SLICE_MS = 500;
158
+ /** What a click waits for, short of the hit-target check only the click itself makes. */
159
+ const CLICKABLE = ["visible", "enabled", "stable"];
160
+ /** A URL without its fragment. */
161
+ function withoutFragment(url) {
162
+ const hash = url.indexOf("#");
163
+ return hash === -1 ? url : url.slice(0, hash);
164
+ }
114
165
  /**
115
166
  * A URL as policy names a browser target, without query or fragment. Chrome
116
167
  * escapes two path characters Node's URL can keep raw: `|` on every Node, `^`
@@ -129,6 +180,28 @@ export function pageAddress(url) {
129
180
  return url;
130
181
  }
131
182
  }
183
+ /**
184
+ * Whether this document, shadow roots included, has a secret field with
185
+ * something in it. Passed to `evaluate`, so it must stand alone; the secret
186
+ * test is the one `collectFrame` and `extract` use.
187
+ */
188
+ function filledSecretField() {
189
+ const secret = (field) => (field instanceof HTMLInputElement && field.type === "password") ||
190
+ ((field instanceof HTMLInputElement || field instanceof HTMLTextAreaElement) &&
191
+ !["", "none"].includes(getComputedStyle(field).getPropertyValue("-webkit-text-security")));
192
+ const roots = [document];
193
+ while (roots.length > 0) {
194
+ for (const element of roots.pop().querySelectorAll("*")) {
195
+ if (element.shadowRoot !== null)
196
+ roots.push(element.shadowRoot);
197
+ // The markup counts as well as the live value: a target can match the
198
+ // `value` attribute, or a textarea's text, after the field was cleared.
199
+ if (secret(element) && (element.value !== "" || element.defaultValue !== ""))
200
+ return true;
201
+ }
202
+ }
203
+ return false;
204
+ }
132
205
  /** Playwright's terminal colour codes, which mean nothing in a stored record. */
133
206
  function withoutColour(text) {
134
207
  return text.replace(/\u001b\[[0-9;]*m/g, "");
@@ -142,6 +215,12 @@ function withoutColour(text) {
142
215
  */
143
216
  function collectFrame(limit) {
144
217
  const text = document.body?.innerText ?? "";
218
+ // A field holding a secret: a password input, or one masked on screen with
219
+ // CSS; one that shows its text is not hiding it. Kept in here: this function
220
+ // runs in the page, where nothing outside it exists.
221
+ const secret = (element) => (element instanceof HTMLInputElement && element.type === "password") ||
222
+ ((element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement) &&
223
+ !["", "none"].includes(getComputedStyle(element).getPropertyValue("-webkit-text-security")));
145
224
  /**
146
225
  * Walk up from the element recording the position of each ancestor, and
147
226
  * stop at the first one the page has labelled. The selector string is
@@ -190,9 +269,12 @@ function collectFrame(limit) {
190
269
  attributeName: element.getAttribute("name"),
191
270
  placeholder: element.getAttribute("placeholder"),
192
271
  href: element.getAttribute("href"),
193
- value: element instanceof HTMLInputElement ||
272
+ // Never a secret field's: in a browser someone else also types into,
273
+ // that is their secret, and this is a read anything may ask for.
274
+ value: (element instanceof HTMLInputElement ||
194
275
  element instanceof HTMLTextAreaElement ||
195
- element instanceof HTMLSelectElement
276
+ element instanceof HTMLSelectElement) &&
277
+ !secret(element)
196
278
  ? element.value.slice(0, 200)
197
279
  : null,
198
280
  disabled: element instanceof HTMLInputElement ||
@@ -209,9 +291,13 @@ function collectFrame(limit) {
209
291
  elements,
210
292
  };
211
293
  }
294
+ /**
295
+ * The `<meta>` name of a page only an operator may act on: `melra console`
296
+ * serves its approval page with it, and its CSP lets no script take it off.
297
+ */
298
+ export const APPROVAL_SURFACE = "melra-approval-surface";
212
299
  export class BrowserRuntime {
213
300
  options;
214
- browser;
215
301
  context;
216
302
  activePage;
217
303
  connection;
@@ -239,8 +325,66 @@ export class BrowserRuntime {
239
325
  popups = [];
240
326
  /** Pending closes of blocked popups, awaited before the action reports. */
241
327
  closing = [];
242
- /** True only while `tab_new` is opening the tab the caller asked for. */
243
- openingTab = false;
328
+ /**
329
+ * The `newPage` call in flight while `openPage` opens a tab MELRA asked for:
330
+ * the page it resolves to is that tab, not a window a site opened.
331
+ */
332
+ opening;
333
+ /** In a launched browser, pages handed over while `openPage`'s `newPage` is in flight, held until it settles. */
334
+ openingPagesQueue = undefined;
335
+ /**
336
+ * The windows this runtime owns, by CDP target id: tabs it opened, and every
337
+ * window one it owns or the tab it is driving opened. Decided when Chromium
338
+ * creates the window, from the opener it names then: Playwright's `opener()`
339
+ * comes later, and is null by then if the opener has closed. In a browser
340
+ * this runtime is attached to, the rest belong to someone else, the
341
+ * operator above all.
342
+ *
343
+ * ponytail: a tab a link opens with a modifier or middle click has no
344
+ * opener at all, so between actions it counts as the operator's whichever
345
+ * page clicked it, and while an action runs as the action's. A page's request for one (`Page.frameRequestedNavigation`)
346
+ * cannot be tied to the tab that follows: a refused request leaves nothing
347
+ * to match, and claiming the next opener-less tab would let a page have the
348
+ * operator's tabs, a console's among them, closed. Closing that gap needs a
349
+ * signal Chromium does not give.
350
+ */
351
+ ownTargets = new Set();
352
+ /**
353
+ * The active page's target id once this runtime has driven it, navigated it
354
+ * or sent it input, since it became active. Until then it may be a tab of
355
+ * the operator's the runtime only picked up, and what it opens is theirs.
356
+ */
357
+ drivenTarget;
358
+ /** Target ids already looked up; a page keeps its id across navigations. */
359
+ targetIds = new WeakMap();
360
+ /**
361
+ * Windows Chromium created while an action was running, with that action's
362
+ * number: its popups, whenever Playwright gets round to handing them over.
363
+ */
364
+ createdActing = new Map();
365
+ /**
366
+ * The page targets of this context Chromium has announced and not yet
367
+ * destroyed, the operator's among them, so an opener can be known for a tab
368
+ * rather than, say, a frame of one.
369
+ */
370
+ pageTargets = new Set();
371
+ /** The running action: what its page opens is the action's if it navigates the page or sends it input. */
372
+ action;
373
+ /**
374
+ * The target id of the page an action that navigates it or sends it input
375
+ * acts on, in a browser this runtime is attached to, once looked up: what
376
+ * it opens while the action runs is the action's, even if the runtime has
377
+ * not driven it yet. A read leaves it out: like between actions, what a tab
378
+ * the runtime only landed on opens while the runtime reads it is the
379
+ * operator's, though the read's own calls into the page give it activation.
380
+ */
381
+ actingTarget;
382
+ /** Counts actions, so a popup reported by a later one says so. */
383
+ actions = 0;
384
+ /** The page the running action acts on, once it has one. */
385
+ actingOn;
386
+ /** Whether the running action has sent its step back or forward. */
387
+ stepped = false;
244
388
  /**
245
389
  * Browser-level session that hears about a new window the moment Chromium
246
390
  * creates it, which is what lets an action wait for a popup it knows exists
@@ -248,10 +392,30 @@ export class BrowserRuntime {
248
392
  * not grant one; popups are then reported on Playwright's timing alone.
249
393
  */
250
394
  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;
395
+ /**
396
+ * The windows an action's wait is owed, by CDP target id, with their browser
397
+ * context: every page Chromium creates once tracking began, bar one that is
398
+ * the operator's in a browser this runtime is attached to. A window leaves
399
+ * when Playwright has handed it over and its id is looked up (`targetOf`),
400
+ * or when it is destroyed, which for a blocked popup is the runtime's own
401
+ * close. Those still owed when an action's wait ends, however it ends, or
402
+ * when the action fails before it waits, are given up on: kept, one whose
403
+ * first response never comes would hold every later action to its whole
404
+ * timeout (#226). One handed over later stands in for no other window
405
+ * (#234), and is reported by the action running then (`giveUpOwed`).
406
+ * `popup`: whether it has an opener, as a page's window does and the
407
+ * runtime's own tab does not.
408
+ *
409
+ * ponytail: a window a page opens between actions is owed to the next
410
+ * action, so one that never loads still holds that action up once.
411
+ */
412
+ owed = new Map();
413
+ /** Popups given up on in block mode, by target id, with the timer that closes them (`giveUpOwed`). */
414
+ lapsing = new Map();
415
+ /** Popups those timers closed, until their targets are destroyed. */
416
+ lapsedIds = new Set();
417
+ /** Pages handed over closed and blank while one of those was closing: those popups. */
418
+ lapsedPages = new WeakSet();
255
419
  /** This context's id in CDP, resolved on first use and then fixed. */
256
420
  contextId;
257
421
  /**
@@ -260,14 +424,27 @@ export class BrowserRuntime {
260
424
  */
261
425
  signal;
262
426
  callMs = 30_000;
427
+ /** A wait's absolute deadline, applied to every bounded call it makes. */
428
+ waitDeadline;
263
429
  /**
264
430
  * Set once the running action's effect call has returned. From then on the
265
431
  * page may have acted on it, so a failure is no longer evidence that nothing
266
432
  * happened — see `execute`.
267
433
  */
268
434
  dispatched = false;
435
+ /** Set when the route refused reserved data during the running action. */
436
+ refusedReserved = false;
437
+ /**
438
+ * The workspace root with its links resolved, once, as the file runtime
439
+ * resolves it: a root link re-pointed later does not move uploads to a
440
+ * different directory than file operations. An upload awaits it and fails
441
+ * if it did.
442
+ */
443
+ root;
269
444
  constructor(options) {
270
445
  this.options = options;
446
+ this.root = realpath(options.workspaceRoot);
447
+ this.root.catch(() => undefined);
271
448
  }
272
449
  /**
273
450
  * Bound a Playwright call that takes no timeout of its own by the running
@@ -280,26 +457,38 @@ export class BrowserRuntime {
280
457
  * instead spends the budget on something that can still answer.
281
458
  */
282
459
  call(promise, what, ms = this.callMs) {
283
- return bounded(promise, ms, what, this.signal);
460
+ const timeout = this.waitDeadline === undefined
461
+ ? ms
462
+ : Math.min(ms, Math.max(Math.min(500, this.callMs), this.waitDeadline - Date.now()));
463
+ return bounded(promise, timeout, what, this.signal);
284
464
  }
285
465
  async uploadPaths(paths) {
286
- const root = await realpath(this.options.workspaceRoot);
466
+ const root = await this.root;
467
+ const configured = resolve(this.options.workspaceRoot);
468
+ const outside = (from, to) => {
469
+ const rel = relative(from, to);
470
+ return rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel);
471
+ };
287
472
  const resolved = [];
288
473
  for (const input of paths) {
289
- const candidate = resolve(root, input);
290
- const rel = relative(root, candidate);
291
- if (rel === ".." ||
292
- rel.startsWith(`..${sep}`) ||
293
- isAbsolute(rel)) {
474
+ // An absolute path spelled through the root as configured is the same
475
+ // path under the resolved root; both checks below still apply to it.
476
+ const candidate = isAbsolute(input) && !outside(configured, resolve(input))
477
+ ? resolve(root, relative(configured, resolve(input)))
478
+ : resolve(root, input);
479
+ if (outside(root, candidate)) {
294
480
  throw new Error("browser_upload_outside_workspace");
295
481
  }
482
+ // Before anything looks the name up, so a reserved name gets the same
483
+ // answer whether it exists or not.
484
+ if ((await this.options.reserved?.(candidate)) === true)
485
+ throw new Error("browser_path_reserved");
296
486
  const actual = await realpath(candidate);
297
- const actualRelative = relative(root, actual);
298
- if (actualRelative === ".." ||
299
- actualRelative.startsWith(`..${sep}`) ||
300
- isAbsolute(actualRelative)) {
487
+ if (outside(root, actual)) {
301
488
  throw new Error("browser_upload_outside_workspace");
302
489
  }
490
+ if ((await this.options.reserved?.(actual)) === true)
491
+ throw new Error("browser_path_reserved");
303
492
  if (!(await stat(actual)).isFile()) {
304
493
  throw new Error("browser_upload_requires_regular_file");
305
494
  }
@@ -307,6 +496,25 @@ export class BrowserRuntime {
307
496
  }
308
497
  return resolved;
309
498
  }
499
+ /**
500
+ * `assertSafeUrl`, and a file: URL naming reserved data is refused too. Only
501
+ * unhinged mode lets a file: URL through the first, and the reserved data
502
+ * stays out of reach there as it does for the file and terminal adapters.
503
+ */
504
+ async assertAllowed(url) {
505
+ await assertSafeUrl(url, this.options);
506
+ if (this.options.reserved === undefined || !/^file:/i.test(url))
507
+ return;
508
+ let path;
509
+ try {
510
+ path = fileURLToPath(url);
511
+ }
512
+ catch {
513
+ throw new Error("browser_path_reserved");
514
+ }
515
+ if (await this.options.reserved(path))
516
+ throw new Error("browser_path_reserved");
517
+ }
310
518
  async ensureContext() {
311
519
  if (this.context !== undefined)
312
520
  return this.context;
@@ -369,7 +577,6 @@ export class BrowserRuntime {
369
577
  ...(proxy === undefined ? {} : { proxyServer: proxy.server }),
370
578
  });
371
579
  this.connection = connection;
372
- this.browser = connection.browser;
373
580
  this.context = connection.context;
374
581
  this.routeHandler = async (route) => {
375
582
  const requestUrl = route.request().url();
@@ -380,10 +587,13 @@ export class BrowserRuntime {
380
587
  return;
381
588
  }
382
589
  try {
383
- await assertSafeUrl(requestUrl, this.options);
590
+ await this.assertAllowed(requestUrl);
384
591
  await route.continue();
385
592
  }
386
- catch {
593
+ catch (error) {
594
+ if (this.acting && error instanceof Error && error.message === "browser_path_reserved") {
595
+ this.refusedReserved = true;
596
+ }
387
597
  await route.abort("blockedbyclient");
388
598
  }
389
599
  };
@@ -461,6 +671,7 @@ export class BrowserRuntime {
461
671
  this.context.on("close", () => {
462
672
  this.context = undefined;
463
673
  this.activePage = undefined;
674
+ this.drivenTarget = undefined;
464
675
  });
465
676
  /**
466
677
  * Report every window the page opened itself, and by default close it.
@@ -473,28 +684,59 @@ export class BrowserRuntime {
473
684
  * this governs whether it stays.
474
685
  */
475
686
  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;
480
- if (opened === this.activePage || this.openingTab)
687
+ // One the runtime closed when its time ran out (`giveUpOwed`) is handed
688
+ // over closed and blank just before its target is destroyed: told apart
689
+ // now, while its id is still among those closing.
690
+ if (this.lapsedIds.size > 0 && opened.isClosed() && opened.url() === "")
691
+ this.lapsedPages.add(opened);
692
+ const attached = this.options.cdpEndpoint !== undefined && this.targets !== undefined;
693
+ // In a launched browser the tab `openPage` asked for and a popup handed
694
+ // over in the same instant look alike until `newPage` says which is the
695
+ // tab; `openPage` sorts them then.
696
+ if (!attached && this.openingPagesQueue !== undefined) {
697
+ this.openingPagesQueue.push(opened);
481
698
  return;
482
- // Closing a window the page opened is MELRA's judgement about what the
483
- // caller should be looking at, so unhinged mode drops it. Reporting it is
484
- // not a judgement — that stays on, like every other receipt.
485
- const allowed = this.options.popups === "allow" || this.options.unhinged === true;
486
- if (this.popups.length < MAX_POPUPS) {
487
- this.popups.push({
488
- url: opened.url(),
489
- blocked: !allowed,
490
- ...(this.acting ? {} : { betweenActions: true }),
491
- });
492
699
  }
493
- if (allowed)
700
+ const opening = this.opening;
701
+ if (opened === this.activePage && !attached) {
702
+ this.release(opened);
494
703
  return;
495
- // A popup that closed on its own, or whose opener navigated away first,
496
- // must not turn into the action's error.
497
- this.closing.push(opened.close().catch(() => undefined));
704
+ }
705
+ const acting = this.acting;
706
+ // `during`: whether the window came while an action ran, which in a
707
+ // browser this runtime is attached to is known from when it was created.
708
+ const adopt = (during = acting) => this.adopt(opened, during);
709
+ // In a browser this runtime is attached to, a tab Chromium created
710
+ // between its actions is the operator's (one they opened, a console
711
+ // `melra console` opened) unless it is one of this runtime's
712
+ // (`ownTargets`): that tab is left alone and not reported. Judged by when
713
+ // it was created, not by when Playwright hands it over, which for a slow
714
+ // first response can be during the next action.
715
+ if (attached) {
716
+ this.closing.push((async () => {
717
+ // The tab this runtime is opening is the page `newPage` resolves
718
+ // to, and is not judged; any other window handed over in that
719
+ // instant is judged like any other, by its id looked up now, while
720
+ // it is still open: one that closes before `newPage` settles could
721
+ // no longer be looked up, and would be taken for a popup.
722
+ if (opening !== undefined) {
723
+ const looked = this.targetOf(opened).catch(() => undefined);
724
+ if (opened === (await opening.catch(() => undefined)))
725
+ return;
726
+ await looked;
727
+ }
728
+ const { adopt: yes, during } = await this.adoptable(opened, acting);
729
+ return yes ? adopt(during) : undefined;
730
+ })());
731
+ return;
732
+ }
733
+ const closing = adopt();
734
+ // A blocked popup stops being owed when its close destroys it; one left
735
+ // open, once looked up.
736
+ if (closing === undefined)
737
+ this.release(opened);
738
+ else
739
+ this.closing.push(closing);
498
740
  });
499
741
  /**
500
742
  * Hear about new windows when Chromium creates them, not when Playwright
@@ -504,38 +746,93 @@ export class BrowserRuntime {
504
746
  * has committed, so a click whose window loaded slowly returned with no
505
747
  * `popups` field and the report — and, in block mode, the close — landed in
506
748
  * 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.
749
+ * `window.open` or the `target=_blank` click, so `owed` is already complete
750
+ * when the action finishes and `awaitOpenedWindows` knows which reports
751
+ * are still owed. It sees every way of opening a window, and the page
752
+ * cannot see or tamper with it, which an injected `window.open` hook could
753
+ * not promise.
516
754
  */
517
- this.windowsCreated = new Map();
518
- this.windowsAdopted = 0;
755
+ this.owed = new Map();
756
+ this.forgetLapsing();
519
757
  this.contextId = undefined;
520
- // A session left from a context that closed under us would count into the
758
+ this.ownTargets = new Set();
759
+ this.drivenTarget = undefined;
760
+ this.targetIds = new WeakMap();
761
+ this.createdActing = new Map();
762
+ this.pageTargets = new Set();
763
+ // A session left from a context that closed under us would record into the
521
764
  // fresh map below and make every action wait for windows that never come.
522
765
  if (this.targets !== undefined) {
523
766
  await this.call(this.targets.detach(), "cdp_detach", SETUP_TIMEOUT_MS).catch(() => undefined);
524
767
  }
525
768
  try {
526
769
  const targets = await this.call(connection.browser.newBrowserCDPSession(), "cdp_session", SETUP_TIMEOUT_MS);
770
+ // Set once Chromium has announced the windows that were already there:
771
+ // those are no action's popups, and no window of theirs is the runtime's.
772
+ let announced = false;
527
773
  targets.on("Target.targetCreated", ({ targetInfo }) => {
528
774
  // `browser_ui`, workers, and prerenders never become Playwright pages.
529
775
  if (targetInfo.type !== "page" || targetInfo.subtype !== undefined)
530
776
  return;
777
+ this.pageTargets.add(targetInfo.targetId);
531
778
  const id = targetInfo.browserContextId ?? "";
532
- this.windowsCreated.set(id, (this.windowsCreated.get(id) ?? 0) + 1);
779
+ if (announced) {
780
+ const opener = targetInfo.openerId;
781
+ // A window an action's popup opens, before that popup is even judged,
782
+ // is the runtime's like the popup.
783
+ const runtimes = (target) => this.ownTargets.has(target) ||
784
+ target === this.drivenTarget ||
785
+ target === this.actingTarget ||
786
+ this.createdActing.has(target);
787
+ // Between actions, a window is the operator's unless the runtime's
788
+ // own tab opened it. While an action runs, it is that action's
789
+ // popup unless it is plainly the operator's: one Chromium says an
790
+ // operator's tab opened, or one with no opener that Chromium creates
791
+ // blank or at one of its own pages (a client's new tab, a tab from
792
+ // the toolbar), which no page can have the browser open. A page's
793
+ // new tab starts out empty, and a window the browser opens for a
794
+ // page (a sign-in window, say) starts at the provider's address, so
795
+ // both stay the action's, and so does any page's activation: the
796
+ // runtime's own calls into a page give it some. A frame's opener, or
797
+ // a page's tab while the page a navigation or an input goes to is
798
+ // not known yet, is the action's.
799
+ const action = this.action;
800
+ const theirs = this.options.cdpEndpoint !== undefined &&
801
+ (this.acting
802
+ ? opener === undefined
803
+ ? targetInfo.url === "about:blank" || targetInfo.url.startsWith("chrome://")
804
+ : !(action !== undefined && DRIVING_ACTIONS.has(action) && this.actingTarget === undefined) &&
805
+ this.pageTargets.has(opener) &&
806
+ !runtimes(opener)
807
+ : opener === undefined || !runtimes(opener));
808
+ // The operator's, which no action reports, so no wait is owed it.
809
+ if (theirs)
810
+ return;
811
+ if (this.acting)
812
+ this.createdActing.set(targetInfo.targetId, this.actions);
813
+ else if (opener !== undefined && runtimes(opener))
814
+ this.ownTargets.add(targetInfo.targetId);
815
+ this.owed.set(targetInfo.targetId, { context: id, popup: opener !== undefined });
816
+ }
817
+ });
818
+ // Ids stay after their window closes: one that closes itself as soon as
819
+ // it loads is still to be judged when Playwright hands it over.
820
+ // ponytail: `ownTargets` keeps one id per window the runtime owned for
821
+ // the context's life; prune once its `page` event is handled if that
822
+ // ever matters.
823
+ targets.on("Target.targetDestroyed", ({ targetId }) => {
824
+ if (this.drivenTarget === targetId)
825
+ this.drivenTarget = undefined;
826
+ this.pageTargets.delete(targetId);
827
+ this.owed.delete(targetId);
828
+ clearTimeout(this.lapsing.get(targetId));
829
+ this.lapsing.delete(targetId);
830
+ this.lapsedIds.delete(targetId);
533
831
  });
534
832
  await this.call(targets.send("Target.setDiscoverTargets", { discover: true }), "cdp:Target.setDiscoverTargets", SETUP_TIMEOUT_MS);
535
833
  // 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();
834
+ // Playwright had them before the `page` handler did, so none is owed.
835
+ announced = true;
539
836
  this.targets = targets;
540
837
  }
541
838
  catch {
@@ -544,14 +841,19 @@ export class BrowserRuntime {
544
841
  return this.context;
545
842
  }
546
843
  /**
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.
844
+ * Hold the action's result while this context's windows are still owed
845
+ * (`owed`), so the action that opened a window is the one that reports it.
846
+ * A window stops being owed once it is handed over and looked up, or once it
847
+ * is destroyed: in a launched browser a blocked popup when the runtime's
848
+ * close lands and an allowed one when its lookup returns; in one this
849
+ * runtime is attached to, every window once it is looked up to be judged;
850
+ * and one destroyed before it is handed over, at once.
550
851
  *
551
852
  * 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.
853
+ * owed, bounded by the action's own timeout: the window is part of what the
854
+ * action did. One still owed after that is given up on, so no later action
855
+ * waits for it, and is reported by the action running when Playwright
856
+ * delivers it, if it ever does.
555
857
  */
556
858
  async awaitOpenedWindows(timeoutMs) {
557
859
  const targets = this.targets;
@@ -559,25 +861,17 @@ export class BrowserRuntime {
559
861
  if (targets === undefined || context === undefined)
560
862
  return;
561
863
  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
- }
864
+ if (!(await this.learnContextId()))
865
+ return;
575
866
  // Events arrive ahead of the reply on the same session, so after this
576
- // every window created before now has been counted.
867
+ // every window created before now is in `owed`.
577
868
  await this.call(targets.send("Browser.getVersion"), "cdp:Browser.getVersion");
869
+ const contextId = this.contextId ?? "";
870
+ const owed = () => [...this.owed.values()].some((window) => window.context === contextId);
578
871
  const deadline = Date.now() + timeoutMs;
579
- while (this.windowsAdopted < (this.windowsCreated.get(this.contextId) ?? 0) &&
580
- Date.now() < deadline) {
872
+ // A cancel ends the wait at once: what is still owed is given up below,
873
+ // and the queue behind this action is not held to its deadline.
874
+ while (owed() && Date.now() < deadline && this.signal?.aborted !== true) {
581
875
  await new Promise((done) => setTimeout(done, WINDOW_POLL_MS));
582
876
  }
583
877
  }
@@ -587,30 +881,255 @@ export class BrowserRuntime {
587
881
  // this wait only ever decides which action reports a window, so it is
588
882
  // not worth failing an action over.
589
883
  }
884
+ finally {
885
+ // However the wait ended, what is still owed is no later action's to
886
+ // wait for. Other contexts' windows go too: none is handed over here.
887
+ this.giveUpOwed(timeoutMs);
888
+ }
889
+ }
890
+ /**
891
+ * Gives up on the windows still owed (`owed`). In block mode a popup among
892
+ * them, which Playwright has not handed over to be closed, is closed by its
893
+ * id once it has had as long again to load (#240). One that loads in that
894
+ * time is handed over, reported with its address, and closed as any popup
895
+ * is; one that never does is handed over closed, with no address, and
896
+ * reported between actions (`adopt`). In a launched browser every window is
897
+ * this runtime's; in one it is attached to, only a window of this context is
898
+ * closed, checked when the timer fires. The context is known by then even
899
+ * when the action giving up failed before its wait: any page looked up
900
+ * tells it (`targetOf`), and an action that drives a page looks it up first.
901
+ *
902
+ * ponytail: "as long again" is the giving-up action's own `grace`
903
+ * (its `timeoutMs`), not a setting; a window that loads later is closed
904
+ * before it can be seen.
905
+ */
906
+ giveUpOwed(grace) {
907
+ const targets = this.targets;
908
+ const block = this.options.popups !== "allow" && this.options.unhinged !== true;
909
+ for (const [targetId, window] of this.owed) {
910
+ if (!block || targets === undefined || !window.popup)
911
+ continue;
912
+ const timer = setTimeout(() => {
913
+ this.lapsing.delete(targetId);
914
+ if (this.options.cdpEndpoint !== undefined && window.context !== this.contextId)
915
+ return;
916
+ this.lapsedIds.add(targetId);
917
+ void targets.send("Target.closeTarget", { targetId }).catch(() => this.lapsedIds.delete(targetId));
918
+ }, grace);
919
+ // Nothing to close once the process is done with everything else.
920
+ timer.unref();
921
+ this.lapsing.set(targetId, timer);
922
+ }
923
+ this.owed.clear();
924
+ }
925
+ forgetLapsing() {
926
+ for (const timer of this.lapsing.values())
927
+ clearTimeout(timer);
928
+ this.lapsing.clear();
929
+ this.lapsedIds.clear();
930
+ }
931
+ /**
932
+ * This context's id in CDP, looked up once: what owed windows are kept
933
+ * under. False when there is no page yet to ask.
934
+ */
935
+ async learnContextId() {
936
+ if (this.contextId !== undefined)
937
+ return true;
938
+ // A cancelled action does not open a session it would only abandon.
939
+ if (this.signal?.aborted === true)
940
+ return false;
941
+ const page = this.context?.pages()[0];
942
+ if (page === undefined)
943
+ return false;
944
+ const opening = page.context().newCDPSession(page);
945
+ const session = await this.call(opening, "cdp_session").catch((error) => {
946
+ // One that attaches after the bound or a cancel is let go, not left on the page.
947
+ void opening.then((late) => late.detach()).catch(() => undefined);
948
+ throw error;
949
+ });
950
+ try {
951
+ const { targetInfo } = await this.call(session.send("Target.getTargetInfo"), "cdp:Target.getTargetInfo");
952
+ this.contextId = targetInfo.browserContextId ?? "";
953
+ return true;
954
+ }
955
+ finally {
956
+ // Not waited for, as in `targetOf`: a detach waits on the page's main
957
+ // thread, which a page busy with a long script holds.
958
+ void session.detach().catch(() => undefined);
959
+ }
960
+ }
961
+ /** A window handed over is owed by no wait once its id is known (`owed`). */
962
+ release(page) {
963
+ if (this.targets !== undefined)
964
+ void this.targetOf(page).catch(() => undefined);
965
+ }
966
+ /**
967
+ * Reports a window a page opened and, in block mode, closes it. `during`:
968
+ * whether it came while an action ran.
969
+ */
970
+ adopt(opened, during = this.acting) {
971
+ // Closing a window the page opened is MELRA's judgement about what the
972
+ // caller should be looking at, so unhinged mode drops it. Reporting it is
973
+ // not a judgement — that stays on, like every other receipt.
974
+ const allowed = this.options.popups === "allow" || this.options.unhinged === true;
975
+ // One the runtime closed when its time ran out (`giveUpOwed`) came after
976
+ // the action that opened it returned, whatever action runs by now.
977
+ const lapsed = this.lapsedPages.has(opened);
978
+ if (this.popups.length < MAX_POPUPS) {
979
+ this.popups.push({
980
+ // Without its fragment: a console opened by the operator carries its
981
+ // one-time pairing code there until its first script runs.
982
+ url: withoutFragment(opened.url()),
983
+ blocked: !allowed,
984
+ ...(during && !lapsed ? {} : { betweenActions: true }),
985
+ });
986
+ }
987
+ if (allowed)
988
+ return;
989
+ // A popup that closed on its own, or whose opener navigated away first,
990
+ // must not turn into the action's error.
991
+ return opened.close().catch(() => undefined);
590
992
  }
591
993
  /**
592
994
  * Opens a page MELRA asked for, not one the site opened by itself.
593
995
  *
594
996
  * The popup handler cannot tell the two apart — both arrive as a `page`
595
- * event — so this flag is the difference, and it is held across exactly the
596
- * await that emits the event.
997
+ * event — so `opening` holds the `newPage` call that emits the event, and a
998
+ * browser this runtime is attached to compares the page with what it
999
+ * resolves to. A launched browser looks no window up as it is handed over,
1000
+ * so the handler queues every page it is handed then (#233), and they are
1001
+ * sorted here once `newPage` has said which one is the tab.
597
1002
  */
598
1003
  async openPage(context) {
599
- this.openingTab = true;
1004
+ const queued = [];
1005
+ // Where the judgements of windows handed over from here on begin, in a
1006
+ // browser this runtime is attached to.
1007
+ const judged = this.closing.length;
1008
+ this.openingPagesQueue = queued;
1009
+ const opening = this.call(context.newPage(), "new_page");
1010
+ this.opening = opening;
1011
+ // Any page queued but the tab `newPage` returned is a window a page
1012
+ // opened. One that failed may still have handed over the tab it was
1013
+ // opening: Playwright does so just before it rejects, having closed it, so
1014
+ // a closed page queued last is taken for that tab.
1015
+ //
1016
+ // ponytail: a window a page opened that is handed over last and closed
1017
+ // itself already, during a `newPage` that fails, is taken for the tab and
1018
+ // not reported: a closed page can no longer be looked up by id.
1019
+ const sortQueued = (tab) => {
1020
+ this.opening = undefined;
1021
+ this.openingPagesQueue = undefined;
1022
+ const last = queued.at(-1);
1023
+ const mine = tab ?? (last?.isClosed() === true ? last : undefined);
1024
+ return queued.flatMap((page) => {
1025
+ const closing = page === mine ? undefined : this.adopt(page);
1026
+ if (closing === undefined)
1027
+ this.release(page);
1028
+ return closing ?? [];
1029
+ });
1030
+ };
1031
+ let opened;
1032
+ try {
1033
+ opened = await opening;
1034
+ }
1035
+ catch (error) {
1036
+ // Their closes are waited for like any other.
1037
+ this.closing.push(...sortQueued());
1038
+ throw error;
1039
+ }
1040
+ // Closed before `tab_new` lists the tabs, so the list it returns does not
1041
+ // show a window the same result reports as closed: those queued here, and
1042
+ // those judged meanwhile in a browser this runtime is attached to, whose
1043
+ // judgement waits for `newPage`.
1044
+ await this.call(Promise.all([...sortQueued(opened), ...this.closing.slice(judged)]), "close_popup");
1045
+ if (this.options.cdpEndpoint !== undefined)
1046
+ this.ownTargets.add(await this.targetOf(opened));
1047
+ return opened;
1048
+ }
1049
+ /**
1050
+ * A page's CDP target id. Bounded on its own, not by the action's budget or
1051
+ * cancel: between actions those belong to an action that has finished.
1052
+ */
1053
+ async targetOf(page) {
1054
+ const known = this.targetIds.get(page);
1055
+ if (known !== undefined)
1056
+ return known;
1057
+ const session = await bounded(page.context().newCDPSession(page), SETUP_TIMEOUT_MS, "cdp_session");
600
1058
  try {
601
- return await this.call(context.newPage(), "new_page");
1059
+ const { targetInfo } = await bounded(session.send("Target.getTargetInfo"), SETUP_TIMEOUT_MS, "cdp:Target.getTargetInfo");
1060
+ this.targetIds.set(page, targetInfo.targetId);
1061
+ // Handed over, since there is a page to ask: owed by no wait now.
1062
+ this.owed.delete(targetInfo.targetId);
1063
+ // Any page of this context also tells the context, if no action has yet.
1064
+ this.contextId ??= targetInfo.browserContextId ?? "";
1065
+ return targetInfo.targetId;
602
1066
  }
603
1067
  finally {
604
- this.openingTab = false;
1068
+ // Not waited for: a detach waits on the page's main thread, which a
1069
+ // page busy with a long script holds for as long as it runs.
1070
+ void session.detach().catch(() => undefined);
605
1071
  }
606
1072
  }
1073
+ /**
1074
+ * Whether to treat a new window as this runtime's, in a browser it is
1075
+ * attached to: one created while an action ran, which then counts as the
1076
+ * runtime's for the windows it opens, or one of `ownTargets`. `during`
1077
+ * says whether the action reporting it is the one it came in; any other
1078
+ * reports it as between actions. Asked twice before giving up. One that
1079
+ * closed before it could be asked is reported, as everywhere else; that
1080
+ * one, and one still open that cannot be asked, count by when Playwright
1081
+ * handed it over.
1082
+ */
1083
+ async adoptable(page, acting) {
1084
+ for (let attempt = 0; attempt < 2; attempt += 1) {
1085
+ try {
1086
+ const id = await this.targetOf(page);
1087
+ const createdIn = this.createdActing.get(id);
1088
+ if (createdIn === undefined)
1089
+ return { adopt: this.ownTargets.has(id), during: false };
1090
+ this.createdActing.delete(id);
1091
+ this.ownTargets.add(id);
1092
+ return { adopt: true, during: this.acting && createdIn === this.actions };
1093
+ }
1094
+ catch {
1095
+ if (page.isClosed())
1096
+ return { adopt: true, during: acting };
1097
+ }
1098
+ }
1099
+ return { adopt: acting, during: acting };
1100
+ }
1101
+ /**
1102
+ * Makes `page` the one actions land on. It is not driven yet: it may be a
1103
+ * tab of the operator's the runtime only picked up, and what it opens stays
1104
+ * theirs until the runtime navigates it or sends it input (`drive`). The
1105
+ * page already active stays driven if it was.
1106
+ */
1107
+ activate(page) {
1108
+ if (page !== this.activePage)
1109
+ this.drivenTarget = undefined;
1110
+ this.activePage = page;
1111
+ }
1112
+ /**
1113
+ * The runtime is navigating the active page or has sent it input, so from
1114
+ * here what that page opens is the runtime's, until another page is made
1115
+ * active. Not before: an action refused, or one whose target was never
1116
+ * found, leaves an operator's tab theirs.
1117
+ */
1118
+ async drive(page) {
1119
+ if (this.options.cdpEndpoint === undefined || page === undefined)
1120
+ return;
1121
+ const id = await this.targetOf(page);
1122
+ if (this.activePage === page)
1123
+ this.drivenTarget = id;
1124
+ }
607
1125
  async page() {
608
1126
  const context = await this.ensureContext();
609
1127
  if (this.activePage !== undefined && !this.activePage.isClosed()) {
610
1128
  return this.activePage;
611
1129
  }
612
- this.activePage = context.pages()[0] ?? (await this.openPage(context));
613
- return this.activePage;
1130
+ const page = context.pages()[0] ?? (await this.openPage(context));
1131
+ this.activate(page);
1132
+ return page;
614
1133
  }
615
1134
  /**
616
1135
  * The tab list, with the index each entry can be addressed by.
@@ -618,14 +1137,109 @@ export class BrowserRuntime {
618
1137
  * Returned by every tab action rather than only by `tabs`, because opening,
619
1138
  * switching, and closing all renumber the list — a caller that had to issue a
620
1139
  * separate `tabs` call after each one would be acting on a stale index.
1140
+ *
1141
+ * Never throws: it follows the effect of every tab action but `tabs`, and a
1142
+ * listing that failed made a tab opened or closed read as nothing done. A
1143
+ * tab that does not answer within a second, or the action's timeout if
1144
+ * shorter, or before a cancel, is listed with `title: null`;
1145
+ * one closed meanwhile is left out, and the indices are the ones after it.
1146
+ * `pages` is the list they index, for an action to report its own tab's.
621
1147
  */
622
1148
  async describeTabs(context) {
623
- return await Promise.all(context.pages().map(async (item, index) => ({
1149
+ const titles = new Map(await Promise.all(context
1150
+ .pages()
1151
+ .map(async (item) => [item, await this.call(item.title(), "title", Math.min(1_000, this.callMs)).catch(() => null)])));
1152
+ const pages = context.pages();
1153
+ const tabs = pages.map((item, index) => ({
624
1154
  index,
625
1155
  url: item.url(),
626
- title: await this.call(item.title(), "title"),
1156
+ title: titles.get(item) ?? null,
627
1157
  active: item === this.activePage,
628
- })));
1158
+ }));
1159
+ return { pages, tabs };
1160
+ }
1161
+ /**
1162
+ * Whether any frame of the page holds a filled secret field, and the frames
1163
+ * it read: a targeted read searches only those, so it never looks in a frame
1164
+ * this did not vouch for. Every frame, not the bounded set a snapshot reads:
1165
+ * a selector can enter any of them, including one that attached while the
1166
+ * others were being asked, so the list is read again until it stops growing.
1167
+ * "unknown" when some frame could not answer, which a read must not take for
1168
+ * "clear". The whole check takes at most `ms`.
1169
+ */
1170
+ async secretFieldFilled(page, ms = this.callMs) {
1171
+ const deadline = Date.now() + ms;
1172
+ const asked = new Set();
1173
+ const read = new Set();
1174
+ let state = "clear";
1175
+ for (let round = 0; round < 3; round += 1) {
1176
+ const fresh = page.frames().filter((frame) => !asked.has(frame));
1177
+ if (fresh.length === 0)
1178
+ return { state, read };
1179
+ for (const frame of fresh)
1180
+ asked.add(frame);
1181
+ const answers = await Promise.all(fresh.map((frame) => this.frameHoldsSecret(frame, deadline - Date.now())));
1182
+ if (answers.includes("filled"))
1183
+ return { state: "filled", read };
1184
+ if (answers.includes("unknown"))
1185
+ state = "unknown";
1186
+ fresh.forEach((frame, index) => answers[index] === "clear" && read.add(frame));
1187
+ }
1188
+ return { state: page.frames().some((frame) => !asked.has(frame)) ? "unknown" : state, read };
1189
+ }
1190
+ /** The frames a targeted read may search, or its refusal while a secret field is filled. */
1191
+ async clearOfSecrets(page) {
1192
+ const secret = await this.secretFieldFilled(page);
1193
+ if (secret.state === "filled")
1194
+ throw new Error("browser_secret_field_filled");
1195
+ if (secret.state === "unknown")
1196
+ throw new Error("browser_secret_check_unavailable");
1197
+ return secret.read;
1198
+ }
1199
+ /**
1200
+ * A frame mid-navigation is asked again once its next document has loaded;
1201
+ * a detached one holds nothing. A frame with no document yet (a lazy or
1202
+ * still-loading iframe) that does not answer is "unread": it does not make
1203
+ * the page unknown, and a targeted read does not search it. A cancelled task
1204
+ * is not "unknown": it stops. Each frame is given at most `ms` in all, and
1205
+ * one that gets its document while being asked is asked again with what is
1206
+ * left, which is not one of its retries.
1207
+ */
1208
+ async frameHoldsSecret(frame, ms = this.callMs) {
1209
+ const deadline = Date.now() + ms;
1210
+ let retries = 0;
1211
+ while (retries < 3) {
1212
+ if (frame.isDetached())
1213
+ return "clear";
1214
+ const documentless = frame.url() === "";
1215
+ const left = deadline - Date.now();
1216
+ if (left <= 0)
1217
+ break;
1218
+ try {
1219
+ const filled = await this.call(frame.evaluate(filledSecretField), "secret_check", documentless ? Math.min(left, 250) : left);
1220
+ return filled ? "filled" : "clear";
1221
+ }
1222
+ catch (error) {
1223
+ this.signal?.throwIfAborted();
1224
+ // Gone while being asked: whatever the error says, it holds nothing.
1225
+ if (frame.isDetached())
1226
+ return "clear";
1227
+ if (documentless) {
1228
+ if (frame.url() === "")
1229
+ return "unread";
1230
+ continue;
1231
+ }
1232
+ if (!navigatedAway(error))
1233
+ return "unknown";
1234
+ retries += 1;
1235
+ if (retries < 3) {
1236
+ await frame
1237
+ .waitForLoadState("domcontentloaded", { timeout: Math.max(1, Math.min(2_000, deadline - Date.now())) })
1238
+ .catch(() => undefined);
1239
+ }
1240
+ }
1241
+ }
1242
+ return frame.url() === "" ? "unread" : "unknown";
629
1243
  }
630
1244
  /**
631
1245
  * The frames a target may live in, main frame first.
@@ -646,9 +1260,12 @@ export class BrowserRuntime {
646
1260
  * Every way the caller gave us to find the element, in the order we trust
647
1261
  * them. Split out from `locator` because waiting and acting need the same
648
1262
  * list but disagree about what to do when nothing matches yet.
1263
+ *
1264
+ * `within` starts a checked read only in the frames its check read; where a
1265
+ * match may end up is `readFirst`'s to judge.
649
1266
  */
650
- candidates(page, target) {
651
- return this.frames(page).flatMap((frame) => {
1267
+ candidates(page, target, within) {
1268
+ return this.frames(page).filter((frame) => within?.has(frame) ?? true).flatMap((frame) => {
652
1269
  const found = [];
653
1270
  if (target.role !== undefined) {
654
1271
  found.push(frame.getByRole(target.role, {
@@ -681,6 +1298,7 @@ export class BrowserRuntime {
681
1298
  * Resolution only reads the page and always runs before an action's effect,
682
1299
  * so a `count` that times out here fails a click or a fill cleanly: nothing
683
1300
  * was sent, and `execute` does not report it as an unknown outcome.
1301
+ *
684
1302
  */
685
1303
  async locator(page, target) {
686
1304
  if (target === undefined)
@@ -691,6 +1309,43 @@ export class BrowserRuntime {
691
1309
  }
692
1310
  throw new Error(`browser_target_not_found:${JSON.stringify(target)}`);
693
1311
  }
1312
+ /**
1313
+ * A candidate's first match, if its own document is one a secret check
1314
+ * read. A selector or role can lead from the frame it starts in to another,
1315
+ * so a checked read judges the element it would use, not the route to it:
1316
+ * a match anywhere else counts as none, and what it is makes no difference.
1317
+ */
1318
+ async readFirst(candidate, within) {
1319
+ const pending = candidate.first().elementHandles();
1320
+ const [element] = await this.call(pending, "count").catch((error) => {
1321
+ // Released once it arrives, if it arrives after the bound or a cancel.
1322
+ void pending.then((late) => late.forEach((handle) => void handle.dispose().catch(() => undefined)), () => undefined);
1323
+ throw error;
1324
+ });
1325
+ if (element === undefined || within === undefined)
1326
+ return element;
1327
+ let kept = false;
1328
+ try {
1329
+ const frame = await this.call(element.ownerFrame(), "count");
1330
+ kept = frame !== null && within.has(frame);
1331
+ return kept ? element : undefined;
1332
+ }
1333
+ finally {
1334
+ if (!kept)
1335
+ await element.dispose().catch(() => undefined);
1336
+ }
1337
+ }
1338
+ /** `locator` for a checked read: the element itself, from the read frames. */
1339
+ async readTarget(page, target, within) {
1340
+ if (target === undefined)
1341
+ throw new Error("browser_action_requires_target");
1342
+ for (const candidate of this.candidates(page, target, within)) {
1343
+ const element = await this.readFirst(candidate, within);
1344
+ if (element !== undefined)
1345
+ return element;
1346
+ }
1347
+ throw new Error(`browser_target_not_found:${JSON.stringify(target)}`);
1348
+ }
694
1349
  /**
695
1350
  * Wait for a target to reach a state, re-resolving it on every poll.
696
1351
  *
@@ -708,33 +1363,102 @@ export class BrowserRuntime {
708
1363
  */
709
1364
  async waitForTarget(page, target, state, timeoutMs) {
710
1365
  const negated = state === "hidden" || state === "detached";
1366
+ const floor = Math.min(500, timeoutMs);
711
1367
  const deadline = Date.now() + timeoutMs;
712
- for (;;) {
713
- let reached = negated;
714
- for (const candidate of this.candidates(page, target)) {
715
- const element = candidate.first();
716
- // `isVisible`/`count` report on a missing element rather than throwing,
717
- // so absence is an answer here: it satisfies `hidden` and `detached`.
718
- const present = state === "attached" || state === "detached"
719
- ? (await this.call(element.count(), "count")) > 0
720
- : await this.call(element.isVisible(), "is_visible");
721
- if (negated) {
722
- if (present) {
1368
+ const previousDeadline = this.waitDeadline;
1369
+ this.waitDeadline = deadline;
1370
+ try {
1371
+ for (;;) {
1372
+ // Asked on every poll: someone can fill a secret field while this waits.
1373
+ // A poll that finds one filled, or cannot tell, looks for nothing and
1374
+ // waits for the next one: a login form stays filled while it signs in.
1375
+ this.signal?.throwIfAborted();
1376
+ const secret = await this.secretFieldFilled(page, Math.max(floor, deadline - Date.now()));
1377
+ let reached = negated && secret.state === "clear";
1378
+ let looked = false;
1379
+ for (const candidate of secret.state === "clear" ? this.candidates(page, target, secret.read) : []) {
1380
+ // Let even a short wait look once, but do not spend a whole poll
1381
+ // walking the remaining frames after its deadline.
1382
+ if (looked && Date.now() >= deadline) {
1383
+ reached = false;
1384
+ break;
1385
+ }
1386
+ looked = true;
1387
+ // Absence is an answer here: it satisfies `hidden` and `detached`. A
1388
+ // document that went away mid-poll is not: the poll decides nothing.
1389
+ let present;
1390
+ try {
1391
+ const element = await this.readFirst(candidate, secret.read);
1392
+ try {
1393
+ if (element === undefined)
1394
+ present = false;
1395
+ else if (state === "attached" || state === "detached")
1396
+ present = true;
1397
+ else if (await this.call(element.isVisible(), "is_visible"))
1398
+ present = true;
1399
+ // isVisible() also answers false for an element whose document went
1400
+ // away, so a false counts only for one still in its document.
1401
+ else
1402
+ present = (await this.call(element.evaluate((node) => node.isConnected), "is_visible")) ? false : undefined;
1403
+ }
1404
+ finally {
1405
+ await element?.dispose().catch(() => undefined);
1406
+ }
1407
+ }
1408
+ catch (error) {
1409
+ if (!navigatedAway(error)) {
1410
+ // A search that failed outright is answered like any other: the
1411
+ // check after it wins over what it met. An incomplete check must
1412
+ // refuse the wait rather than trust the result it could not verify.
1413
+ const after = (await this.secretFieldFilled(page, Math.max(floor, deadline - Date.now()))).state;
1414
+ if (after === "filled")
1415
+ throw new Error("browser_secret_field_filled");
1416
+ if (after === "unknown")
1417
+ throw new Error("browser_secret_check_unavailable");
1418
+ if (error instanceof BrowserCallTimeoutError &&
1419
+ deadline - Date.now() <= WAIT_POLL_MS) {
1420
+ throw new Error(`browser_wait_timeout:${state}:${JSON.stringify(target)}`);
1421
+ }
1422
+ throw error;
1423
+ }
1424
+ }
1425
+ if (present === undefined) {
723
1426
  reached = false;
724
1427
  break;
725
1428
  }
1429
+ if (negated) {
1430
+ if (present) {
1431
+ reached = false;
1432
+ break;
1433
+ }
1434
+ }
1435
+ else if (present) {
1436
+ reached = true;
1437
+ break;
1438
+ }
726
1439
  }
727
- else if (present) {
728
- reached = true;
729
- break;
1440
+ // What a poll found, or did not, counts only if no field was filled
1441
+ // while it looked: every poll that searched is checked again after.
1442
+ let last = secret.state;
1443
+ if (secret.state === "clear") {
1444
+ last = (await this.secretFieldFilled(page, Math.max(floor, deadline - Date.now()))).state;
1445
+ if (reached && last === "clear")
1446
+ return;
730
1447
  }
1448
+ if (Date.now() >= deadline) {
1449
+ // Not the target's fault when the last poll could not look for it,
1450
+ // or could not vouch for what it saw.
1451
+ if (last === "filled")
1452
+ throw new Error("browser_secret_field_filled");
1453
+ if (last === "unknown")
1454
+ throw new Error("browser_secret_check_unavailable");
1455
+ throw new Error(`browser_wait_timeout:${state}:${JSON.stringify(target)}`);
1456
+ }
1457
+ await page.waitForTimeout(Math.min(WAIT_POLL_MS, Math.max(1, deadline - Date.now())));
731
1458
  }
732
- if (reached)
733
- return;
734
- if (Date.now() >= deadline) {
735
- throw new Error(`browser_wait_timeout:${state}:${JSON.stringify(target)}`);
736
- }
737
- await page.waitForTimeout(WAIT_POLL_MS);
1459
+ }
1460
+ finally {
1461
+ this.waitDeadline = previousDeadline;
738
1462
  }
739
1463
  }
740
1464
  /**
@@ -748,22 +1472,225 @@ export class BrowserRuntime {
748
1472
  * `.fill("")` is still how the field gets cleared, because it empties one
749
1473
  * reliably no matter what is in it.
750
1474
  */
751
- async typeInto(locator, value, operation) {
752
- const field = locator.first();
753
- if (operation.clearFirst) {
754
- await field.fill("", { timeout: operation.timeoutMs });
1475
+ async typeInto(pin, locator, value, operation) {
1476
+ const ready = operation.clearFirst ? ["visible", "editable"] : ["visible"];
1477
+ await this.actOn(pin, locator, operation.timeoutMs, async (field, timeout) => {
1478
+ if (operation.clearFirst) {
1479
+ await field.fill("", { timeout });
1480
+ this.dispatched = true;
1481
+ }
1482
+ // Found first, then counted as sent before the first key: typing is one
1483
+ // key per round trip, so a deadline can land after some keys (an Enter
1484
+ // among them) reached the page, and that is not "nothing happened".
1485
+ await field.waitForElementState("visible", { timeout });
1486
+ this.dispatched = true;
1487
+ // Focused as `pressSequentially` focuses it (typing nothing does just
1488
+ // that), then typed as it types, one key at a time, each only while the
1489
+ // page is still the checked document: keys go wherever focus is, so a
1490
+ // page that navigates midway would otherwise get the rest. Its address
1491
+ // was checked before the first input; a page may rewrite its own path as
1492
+ // it is typed into. Once the keys start, nothing here fails on the field
1493
+ // going away.
1494
+ await field.type("", { timeout });
1495
+ // The value gets the whole timeout once typing starts, as
1496
+ // `pressSequentially` gave it; a key still going then is not waited for.
1497
+ // A new document shows up as Playwright's `framenavigated` for the main
1498
+ // frame, which costs no round trip, so the document is asked about only
1499
+ // then (a page rewriting its own path raises it too): asking before
1500
+ // every key halved how fast a long value went in.
1501
+ const deadline = Date.now() + operation.timeoutMs;
1502
+ let navigated = false;
1503
+ const onNavigated = (frame) => {
1504
+ if (frame === pin.page.mainFrame())
1505
+ navigated = true;
1506
+ };
1507
+ pin.page.on("framenavigated", onNavigated);
1508
+ try {
1509
+ for (const character of value) {
1510
+ const left = deadline - Date.now();
1511
+ if (left <= 0)
1512
+ throw new BrowserCallTimeoutError("type");
1513
+ if (navigated) {
1514
+ navigated = false;
1515
+ if (await this.moved(pin, false, left))
1516
+ throw new Error("browser_page_changed");
1517
+ }
1518
+ await this.call(pin.page.keyboard.type(character, { delay: operation.delayMs }), "type", Math.max(1, deadline - Date.now()));
1519
+ }
1520
+ }
1521
+ finally {
1522
+ pin.page.off("framenavigated", onNavigated);
1523
+ }
755
1524
  this.dispatched = true;
1525
+ }, ready);
1526
+ }
1527
+ /**
1528
+ * An input action on its target's element, held to the documents it was
1529
+ * checked on: the page's, and the one the target was first found in, which
1530
+ * for a target inside a frame is that frame's. The input goes through the
1531
+ * element, which a navigation takes away, so a page or frame that navigates
1532
+ * itself while the action waits (for the element to show, say) fails the
1533
+ * action rather than having the target found, and acted on, in the new
1534
+ * document. The target is found again only in the document it was found
1535
+ * in, and only before any input reached it.
1536
+ *
1537
+ * The element is first waited for as the action would wait (`ready`: the
1538
+ * states named, which sends the page nothing), so the check after that is
1539
+ * the last thing before the input, the address it named included. Once the
1540
+ * action has sent input (an earlier field of a form, say), only its
1541
+ * document is held: a page may rewrite its own path in answer, as it may
1542
+ * while it is typed into. A target found again after a re-render is not
1543
+ * waited for twice, so one re-rendered faster than that can still be hit.
1544
+ * Whatever runs out is reported as the action's `timeout` running out
1545
+ * (`ranOut`).
1546
+ *
1547
+ * ponytail: Playwright's own hit-target check and dispatch come after this,
1548
+ * so a navigation that commits in that instant can still receive the one
1549
+ * click or key; closing it needs input that names its document, which CDP
1550
+ * lacks.
1551
+ */
1552
+ async actOn(pin, locator, timeout, act, ready = []) {
1553
+ const deadline = Date.now() + timeout;
1554
+ const left = () => Math.max(1, deadline - Date.now());
1555
+ let found;
1556
+ try {
1557
+ for (;;) {
1558
+ const element = found === undefined
1559
+ ? // Bounded by its own timeout; the call only adds the cancel.
1560
+ await this.handle(locator.first().elementHandle({ timeout: left() }), "count", Infinity)
1561
+ : await this.refind(pin, locator, found, deadline);
1562
+ try {
1563
+ let moved;
1564
+ if (found === undefined) {
1565
+ found = await this.handle(element.evaluateHandle((node) => node.ownerDocument), "evaluate", left());
1566
+ for (const state of ready)
1567
+ await element.waitForElementState(state, { timeout: left() });
1568
+ moved = await this.moved(pin, !this.dispatched, left());
1569
+ }
1570
+ else {
1571
+ const same = await this.call(element.evaluate((node, first) => node.ownerDocument === first, found), "evaluate", left()).catch((error) => {
1572
+ // No answer is not a different document.
1573
+ if (timedOut(error) || this.signal?.aborted === true)
1574
+ throw error;
1575
+ return false;
1576
+ });
1577
+ if (!same) {
1578
+ throw new Error("browser_page_changed: the target's frame is now at another document");
1579
+ }
1580
+ // Still in the document it was found in, so the page has not
1581
+ // navigated either: a navigation takes its frames' documents with
1582
+ // it. Only the address is left, and that needs no round trip.
1583
+ moved = !this.dispatched && pin.address !== undefined && pageAddress(pin.page.url()) !== pin.address;
1584
+ }
1585
+ if (!moved)
1586
+ return await act(element, left());
1587
+ }
1588
+ catch (error) {
1589
+ // Replaced below, so what `execute` reads from it is kept here.
1590
+ if (inputSent(error))
1591
+ this.dispatched = true;
1592
+ // A wait that ran out is the answer; the page is not asked again.
1593
+ if (timedOut(error) || Date.now() >= deadline)
1594
+ throw error;
1595
+ if (!(await this.moved(pin, !this.dispatched, left()))) {
1596
+ // Re-rendered, or its frame reloaded, while the page stayed: found
1597
+ // again, as Playwright's own locator would, in the same document
1598
+ // or not at all, which the next pass checks before it acts. That
1599
+ // holds even after Playwright logged the click as performed: its
1600
+ // hit-target guard stops the events that did not reach the
1601
+ // element, which is why its locator retries this too. The page is
1602
+ // asked first every time, so one that navigated is refused at
1603
+ // once, one that leaves while the target is searched for again
1604
+ // within a slice, and a target first found in a document that
1605
+ // came in after the checks is never acted on.
1606
+ if (detached(error))
1607
+ continue;
1608
+ throw error;
1609
+ }
1610
+ }
1611
+ finally {
1612
+ await element.dispose().catch(() => undefined);
1613
+ }
1614
+ throw new Error(`browser_page_changed: the page is now at ${pageAddress(pin.page.url())}`);
1615
+ }
1616
+ }
1617
+ catch (error) {
1618
+ // Every bound in here is the action's deadline (typing's, which starts
1619
+ // later, is as long), so whatever timed out ran out the action's time.
1620
+ // Not read off the clock: a 1 ms bound can fire just before it ticks.
1621
+ if (timedOut(error))
1622
+ throw ranOut(error, timeout);
1623
+ throw error;
1624
+ }
1625
+ finally {
1626
+ void found?.dispose().catch(() => undefined);
1627
+ }
1628
+ }
1629
+ /**
1630
+ * The target found again after it went away, searched for in slices that
1631
+ * each end by asking whether the page, or the document it was first found
1632
+ * in (a frame's), has left: a page that takes the target away and then
1633
+ * navigates to a document without it would otherwise be waited on until
1634
+ * the timeout. A slice cut short, by a frame detaching as the page around
1635
+ * it leaves, is asked about the same way.
1636
+ */
1637
+ async refind(pin, locator, found, deadline) {
1638
+ const left = () => Math.max(1, deadline - Date.now());
1639
+ for (;;) {
1640
+ let failure;
1641
+ try {
1642
+ return await this.handle(locator.first().elementHandle({ timeout: Math.min(REFIND_SLICE_MS, left()) }), "count", Infinity);
1643
+ }
1644
+ catch (error) {
1645
+ // A cancel, or a page that closed, has its own error.
1646
+ if (this.signal?.aborted === true || pin.page.isClosed())
1647
+ throw error;
1648
+ failure = error;
1649
+ }
1650
+ if (Date.now() >= deadline)
1651
+ throw failure;
1652
+ if (await this.moved(pin, !this.dispatched, left())) {
1653
+ throw new Error(`browser_page_changed: the page is now at ${pageAddress(pin.page.url())}`);
1654
+ }
1655
+ const there = await this.call(found.evaluate((first) => first.defaultView?.document === first), "evaluate", left()).catch((error) => {
1656
+ // No answer is not a different document, and a closed page has its own error.
1657
+ if (timedOut(error) || this.signal?.aborted === true || pin.page.isClosed())
1658
+ throw error;
1659
+ return false;
1660
+ });
1661
+ if (!there)
1662
+ throw new Error("browser_page_changed: the target's frame has left the document it was found in");
1663
+ if (!timedOut(failure))
1664
+ throw failure;
756
1665
  }
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;
762
- await field.pressSequentially(value, {
763
- delay: operation.delayMs,
764
- timeout: operation.timeoutMs,
1666
+ }
1667
+ /** `call` for a handle: one that arrives after the bound or a cancel is released, not left in the page. */
1668
+ handle(pending, what, ms) {
1669
+ return this.call(pending, what, ms).catch((error) => {
1670
+ void pending.then((late) => late.dispose().catch(() => undefined), () => undefined);
1671
+ throw error;
765
1672
  });
766
- this.dispatched = true;
1673
+ }
1674
+ /**
1675
+ * Whether the page has left the document an input action was checked on,
1676
+ * or, with `address`, the address it named. A navigation destroys the old
1677
+ * document's context, so asking its handle fails. A call that timed out or
1678
+ * was cancelled is no answer either way and is passed on, and a closed page
1679
+ * has not moved anywhere: the action's own error says what happened.
1680
+ */
1681
+ async moved(pin, address = true, ms = this.callMs) {
1682
+ if (pin.page.isClosed())
1683
+ return false;
1684
+ if (address && pin.address !== undefined && pageAddress(pin.page.url()) !== pin.address)
1685
+ return true;
1686
+ try {
1687
+ return !(await this.call(pin.document.evaluate((checked) => checked === document), "evaluate", ms));
1688
+ }
1689
+ catch (error) {
1690
+ if (error instanceof BrowserCallTimeoutError || this.signal?.aborted === true)
1691
+ throw error;
1692
+ return true;
1693
+ }
767
1694
  }
768
1695
  /**
769
1696
  * An attached CDP browser has no pinning proxy, and Chromium follows a
@@ -846,12 +1773,30 @@ export class BrowserRuntime {
846
1773
  * page text and cut it up itself. `inspect` already accepts a target, so
847
1774
  * scoping it needs no new action.
848
1775
  */
849
- async extract(page, operation) {
850
- const locator = (await this.locator(page, operation.target)).first();
1776
+ async extract(page, operation, within) {
1777
+ const element = await this.readTarget(page, operation.target, within);
851
1778
  const [text, html] = await Promise.all([
852
- locator.innerText({ timeout: operation.timeoutMs }),
853
- locator.innerHTML({ timeout: operation.timeoutMs }),
854
- ]);
1779
+ this.call(element.innerText(), "inner_text", operation.timeoutMs),
1780
+ // From a copy with secret fields emptied: frameworks such as React
1781
+ // mirror what is typed into the `value` attribute, which markup carries.
1782
+ this.call(element.evaluate((node) => {
1783
+ const element = node;
1784
+ const secret = (field) => (field instanceof HTMLInputElement && field.type === "password") ||
1785
+ ((field instanceof HTMLInputElement || field instanceof HTMLTextAreaElement) &&
1786
+ !["", "none"].includes(getComputedStyle(field).getPropertyValue("-webkit-text-security")));
1787
+ // The target itself too: a secret textarea's markup is its content.
1788
+ const live = [element, ...element.querySelectorAll("input, textarea")];
1789
+ const copy = element.cloneNode(true);
1790
+ const copies = [copy, ...copy.querySelectorAll("input, textarea")];
1791
+ live.forEach((field, index) => {
1792
+ if (!secret(field))
1793
+ return;
1794
+ copies[index].removeAttribute("value");
1795
+ copies[index].textContent = "";
1796
+ });
1797
+ return copy.innerHTML;
1798
+ }), "evaluate", operation.timeoutMs),
1799
+ ]).finally(() => element.dispose().catch(() => undefined));
855
1800
  return {
856
1801
  url: page.url(),
857
1802
  title: await this.call(page.title(), "title"),
@@ -897,9 +1842,31 @@ export class BrowserRuntime {
897
1842
  this.signal = signal;
898
1843
  this.callMs = operation.timeoutMs;
899
1844
  this.dispatched = false;
1845
+ this.refusedReserved = false;
1846
+ this.actingOn = undefined;
1847
+ this.actingTarget = undefined;
1848
+ this.action = operation.action;
1849
+ this.stepped = false;
1850
+ // Chromium reports a window's creation on the browser-level session,
1851
+ // ahead of any later reply there; one round trip before the action
1852
+ // starts takes every window created until now as created between
1853
+ // actions, however late that report would otherwise be handled.
1854
+ if (this.options.cdpEndpoint !== undefined && this.targets !== undefined) {
1855
+ await bounded(this.targets.send("Browser.getVersion"), operation.timeoutMs, "cdp:Browser.getVersion", signal).catch(() => undefined);
1856
+ }
1857
+ // The round trip above swallows a cancel, and nothing has run yet. (The
1858
+ // check at the top narrowed `aborted`, which an await can change.)
1859
+ if (signal?.aborted === true)
1860
+ throw new Error("task_cancelled");
1861
+ this.actions += 1;
900
1862
  this.acting = true;
901
1863
  try {
902
1864
  const ran = await this.run(operation);
1865
+ // An input action that went through, or a step back or forward that
1866
+ // moved, drove its page; windows it opened meanwhile are the action's
1867
+ // anyway (`createdActing`). Navigations mark it before they are sent.
1868
+ if (DRIVING_ACTIONS.has(operation.action) && ran.moved !== false)
1869
+ await this.drive(this.actingOn).catch(() => undefined);
903
1870
  await this.awaitOpenedWindows(operation.timeoutMs);
904
1871
  // A blocked popup is closed from an event handler, which has no way to
905
1872
  // join the action that provoked it. Waiting here is what stops the next
@@ -927,40 +1894,130 @@ export class BrowserRuntime {
927
1894
  // including a timeout resolving the target, is an ordinary failure:
928
1895
  // nothing was sent, and for a read the controller may simply retry.
929
1896
  // The message is stored on the task record and shown in the console.
1897
+ // An action that failed before its wait gives up on what it opened here.
1898
+ this.giveUpOwed(operation.timeoutMs);
930
1899
  if (error instanceof Error)
931
1900
  error.message = withoutColour(error.message);
932
- if (!this.dispatched && !inputSent(error))
1901
+ const sent = this.dispatched || inputSent(error);
1902
+ // So did one that failed after its input went out, or a step back or
1903
+ // forward that may have moved before it ran out of time. A `close`
1904
+ // drives nothing, whatever page it leaves active.
1905
+ if (DRIVING_ACTIONS.has(operation.action) && (sent || this.stepped)) {
1906
+ await this.drive(this.actingOn).catch(() => undefined);
1907
+ }
1908
+ if (!sent)
933
1909
  throw error;
934
1910
  throw outcomeUnknown(error instanceof Error ? error.message : String(error));
935
1911
  }
936
1912
  finally {
937
1913
  this.acting = false;
1914
+ this.actingTarget = undefined;
1915
+ this.action = undefined;
938
1916
  }
939
1917
  }
940
1918
  async run(operation) {
1919
+ // Before a browser is started, so a refused upload never starts one.
1920
+ const files = operation.action === "upload" && operation.filePaths !== undefined ? await this.uploadPaths(operation.filePaths) : [];
941
1921
  const page = await this.page();
1922
+ this.actingOn = page;
1923
+ // What it opens while this action navigates it or sends it input is the
1924
+ // action's, which in a browser this runtime is attached to is judged by
1925
+ // target id (`Target.targetCreated`).
1926
+ if (DRIVING_ACTIONS.has(operation.action) && this.options.cdpEndpoint !== undefined && this.targets !== undefined) {
1927
+ this.actingTarget = await this.targetOf(page).catch(() => undefined);
1928
+ }
1929
+ // Taken before the checks below, so an input action that passes them
1930
+ // reaches the document they read or nothing (`actOn`).
1931
+ const pin = INPUT_ACTIONS.has(operation.action)
1932
+ ? {
1933
+ page,
1934
+ document: await this.handle(page.evaluateHandle(() => document), "evaluate"),
1935
+ address: operation.url === undefined ? undefined : pageAddress(operation.url),
1936
+ }
1937
+ : undefined;
1938
+ try {
1939
+ return await this.act(operation, page, pin, files);
1940
+ }
1941
+ finally {
1942
+ void pin?.document.dispose().catch(() => undefined);
1943
+ }
1944
+ }
1945
+ async act(operation, page, pin, files) {
942
1946
  // Anywhere but on the way to it, a url names the page the action acts on:
943
1947
  // the active page, or for `close` and `tab_switch` the tab they name.
944
1948
  // Policy shows it to the approver as the target and matches grants against
945
1949
  // 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.
1950
+ // elsewhere. Compared as policy shows it, without query or fragment. An
1951
+ // input action stays on the page checked here: one that navigates itself
1952
+ // while the action waits is refused with `browser_page_changed`.
1953
+ const acted = (operation.action === "close" || operation.action === "tab_switch") && operation.tabIndex !== undefined
1954
+ ? (await this.ensureContext()).pages()[operation.tabIndex]
1955
+ : page;
951
1956
  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
1957
  const at = acted === undefined ? "no such tab" : pageAddress(acted.url());
956
1958
  if (at !== pageAddress(operation.url))
957
1959
  throw new Error(`browser_page_not_at_url: the page is at ${at}`);
958
1960
  }
1961
+ // An approval console open in a browser this runtime is attached to is a
1962
+ // read away (`tabs`), and an action without a url acts on whatever page is
1963
+ // active. So no input reaches one, whatever approved the action and in
1964
+ // every mode: an approval there is the operator's act alone. Nor do the two
1965
+ // reads that can put its Approve under the operator's pointer: raising its
1966
+ // tab (`tab_switch`) and scrolling it. Reads that move its URL still work,
1967
+ // and the console unpairs when they do.
1968
+ // The console holds its buttons back for a second after it is raised, but
1969
+ // in a browser this runtime is attached to, Playwright's focus emulation
1970
+ // makes every page report itself visible and focused, so the console never
1971
+ // sees being raised. Whatever of ours could raise it is refused instead.
1972
+ // An input action is then held to the document checked here, as above.
1973
+ if ((operation.action === "tab_switch" ||
1974
+ operation.action === "scroll" ||
1975
+ (operation.action !== "close" && classifyOperation(operation).effect !== "read")) &&
1976
+ acted !== undefined &&
1977
+ (await this.call(acted.locator(`meta[name="${APPROVAL_SURFACE}"]`).count(), "count")) > 0) {
1978
+ throw new Error("browser_refuses_approval_surface");
1979
+ }
1980
+ // Closing a tab lets the browser raise a neighbour, which can be a
1981
+ // console, and switching to a tab raises its split-view partner with it,
1982
+ // which CDP does not say. So in a browser this runtime is attached to,
1983
+ // where an operator can have one open, `close` and `tab_switch` are
1984
+ // refused while any other page here carries the marker, whatever the tab
1985
+ // acted on carries itself: any page can carry it. A marked tab of the agent's can be navigated away first. A browser
1986
+ // this runtime launched has no operator's console in it. A page that does
1987
+ // not answer within a second could be a console too busy to, so it holds
1988
+ // the others open the same way, under its own reason naming its tab; it
1989
+ // can be closed itself unless another tab also does not answer. One closed
1990
+ // meanwhile holds nothing. Attached over CDP, every page of the browser is
1991
+ // in this one context.
1992
+ if ((operation.action === "close" || operation.action === "tab_switch") && this.options.cdpEndpoint !== undefined) {
1993
+ if (operation.tabIndex !== undefined && acted === undefined)
1994
+ throw new Error("browser_tab_not_found");
1995
+ const pages = (await this.ensureContext()).pages();
1996
+ const marks = await Promise.all(pages.map((other) => other === acted
1997
+ ? 0
1998
+ : bounded(other.locator(`meta[name="${APPROVAL_SURFACE}"]`).count(), 1_000, "count").catch(() => other.isClosed() ? 0 : -1)));
1999
+ if (marks.some((count) => count > 0))
2000
+ throw new Error("browser_refuses_approval_surface");
2001
+ const silent = marks.flatMap((count, index) => (count < 0 ? [index] : []));
2002
+ if (silent.length > 0) {
2003
+ throw new Error(`browser_approval_surface_check_unavailable: tab ${silent.join(", ")} did not answer`);
2004
+ }
2005
+ }
2006
+ // A targeted read says whether its target exists, and whether a target
2007
+ // exists can be made to depend on a secret field's content, however the
2008
+ // target is written. So while any frame holds a filled secret field a read
2009
+ // may look at the whole page but not aim at part of it: the refusal
2010
+ // depends on the page, never on the target. Asked again once the read has
2011
+ // looked, since a field can be filled while it does. `wait` asks on every
2012
+ // poll instead, within its own deadline.
2013
+ const targetedRead = ["inspect", "scroll", "screenshot"].includes(operation.action) && operation.target !== undefined;
2014
+ const within = targetedRead ? await this.clearOfSecrets(page) : undefined;
959
2015
  switch (operation.action) {
960
2016
  case "navigate": {
961
2017
  if (operation.url === undefined)
962
2018
  throw new Error("browser_navigate_requires_url");
963
- await assertSafeUrl(operation.url, this.options);
2019
+ await this.assertAllowed(operation.url);
2020
+ await this.drive(page);
964
2021
  const response = await page.goto(operation.url, {
965
2022
  waitUntil: "domcontentloaded",
966
2023
  timeout: operation.timeoutMs,
@@ -973,6 +2030,7 @@ export class BrowserRuntime {
973
2030
  case "back":
974
2031
  case "forward": {
975
2032
  const before = page.url();
2033
+ this.stepped = true;
976
2034
  const moved = operation.action === "back"
977
2035
  ? await page.goBack({
978
2036
  waitUntil: "domcontentloaded",
@@ -999,6 +2057,7 @@ export class BrowserRuntime {
999
2057
  // meaning of reload and matches `navigate`, whose target URL may have any
1000
2058
  // server-side effect; the domain allowlist, not the read/mutate split, is
1001
2059
  // what bounds what a navigation may reach.
2060
+ await this.drive(page);
1002
2061
  const response = await page.reload({
1003
2062
  waitUntil: "domcontentloaded",
1004
2063
  timeout: operation.timeoutMs,
@@ -1008,10 +2067,18 @@ export class BrowserRuntime {
1008
2067
  ...(await this.settledSnapshot(page, operation)),
1009
2068
  };
1010
2069
  }
1011
- case "inspect":
1012
- return operation.target === undefined
1013
- ? await this.snapshot(page, operation.maxChars)
1014
- : await this.extract(page, operation);
2070
+ case "inspect": {
2071
+ if (operation.target === undefined)
2072
+ return await this.snapshot(page, operation.maxChars);
2073
+ // Whatever the read found, or failed to find, a field filled while it
2074
+ // looked voids it: the refusal in `finally` replaces either.
2075
+ try {
2076
+ return await this.extract(page, operation, within);
2077
+ }
2078
+ finally {
2079
+ await this.clearOfSecrets(page);
2080
+ }
2081
+ }
1015
2082
  /**
1016
2083
  * Block until the page reaches a state, instead of guessing a sleep.
1017
2084
  *
@@ -1045,9 +2112,7 @@ export class BrowserRuntime {
1045
2112
  };
1046
2113
  }
1047
2114
  case "click": {
1048
- await (await this.locator(page, operation.target)).first().click({
1049
- timeout: operation.timeoutMs,
1050
- });
2115
+ await this.actOn(pin, await this.locator(page, operation.target), operation.timeoutMs, (element, timeout) => element.click({ timeout }), CLICKABLE);
1051
2116
  // Set after the click returns, not before: Playwright's own timeout
1052
2117
  // mostly fires while it waits for the element to become clickable,
1053
2118
  // and reporting that as "may have clicked" would park every covered
@@ -1062,7 +2127,7 @@ export class BrowserRuntime {
1062
2127
  case "type": {
1063
2128
  if (operation.value === undefined)
1064
2129
  throw new Error("browser_type_requires_value");
1065
- await this.typeInto(await this.locator(page, operation.target), operation.value, operation);
2130
+ await this.typeInto(pin, await this.locator(page, operation.target), operation.value, operation);
1066
2131
  return {
1067
2132
  typed: true,
1068
2133
  ...(await this.settledSnapshot(page, operation)),
@@ -1082,13 +2147,11 @@ export class BrowserRuntime {
1082
2147
  throw new Error("browser_fill_form_requires_fields");
1083
2148
  }
1084
2149
  for (const field of operation.fields) {
1085
- await this.typeInto(await this.locator(page, field.target), field.value, operation);
2150
+ await this.typeInto(pin, await this.locator(page, field.target), field.value, operation);
1086
2151
  }
1087
2152
  const submitted = operation.target !== undefined;
1088
2153
  if (submitted) {
1089
- await (await this.locator(page, operation.target)).first().click({
1090
- timeout: operation.timeoutMs,
1091
- });
2154
+ await this.actOn(pin, await this.locator(page, operation.target), operation.timeoutMs, (element, timeout) => element.click({ timeout }), CLICKABLE);
1092
2155
  this.dispatched = true;
1093
2156
  }
1094
2157
  return {
@@ -1100,9 +2163,8 @@ export class BrowserRuntime {
1100
2163
  case "select": {
1101
2164
  if (operation.values === undefined)
1102
2165
  throw new Error("browser_select_requires_values");
1103
- const selected = await (await this.locator(page, operation.target))
1104
- .first()
1105
- .selectOption(operation.values, { timeout: operation.timeoutMs });
2166
+ const values = operation.values;
2167
+ const selected = await this.actOn(pin, await this.locator(page, operation.target), operation.timeoutMs, (element, timeout) => element.selectOption(values, { timeout }), ["visible", "enabled"]);
1106
2168
  this.dispatched = true;
1107
2169
  return {
1108
2170
  selected,
@@ -1112,10 +2174,11 @@ export class BrowserRuntime {
1112
2174
  case "press": {
1113
2175
  if (operation.key === undefined)
1114
2176
  throw new Error("browser_press_requires_key");
2177
+ const key = operation.key;
1115
2178
  const locator = operation.target === undefined
1116
2179
  ? page.locator("body")
1117
2180
  : await this.locator(page, operation.target);
1118
- await locator.first().press(operation.key, { timeout: operation.timeoutMs });
2181
+ await this.actOn(pin, locator, operation.timeoutMs, (element, timeout) => element.press(key, { timeout }));
1119
2182
  this.dispatched = true;
1120
2183
  return {
1121
2184
  pressed: operation.key,
@@ -1125,9 +2188,16 @@ export class BrowserRuntime {
1125
2188
  case "scroll": {
1126
2189
  const direction = operation.direction ?? "down";
1127
2190
  if (direction === "into_view") {
1128
- await (await this.locator(page, operation.target)).first().scrollIntoViewIfNeeded({
1129
- timeout: operation.timeoutMs,
1130
- });
2191
+ // As for inspect: the check after the read wins over its outcome.
2192
+ try {
2193
+ const element = await this.readTarget(page, operation.target, within);
2194
+ await element
2195
+ .scrollIntoViewIfNeeded({ timeout: operation.timeoutMs })
2196
+ .finally(() => element.dispose().catch(() => undefined));
2197
+ }
2198
+ finally {
2199
+ await this.clearOfSecrets(page);
2200
+ }
1131
2201
  }
1132
2202
  else {
1133
2203
  // A fixed ±600 was too small for a long article and too large for a
@@ -1171,10 +2241,7 @@ export class BrowserRuntime {
1171
2241
  if (operation.filePaths === undefined) {
1172
2242
  throw new Error("browser_upload_requires_file_paths");
1173
2243
  }
1174
- const files = await this.uploadPaths(operation.filePaths);
1175
- await (await this.locator(page, operation.target)).first().setInputFiles(files, {
1176
- timeout: operation.timeoutMs,
1177
- });
2244
+ await this.actOn(pin, await this.locator(page, operation.target), operation.timeoutMs, (element, timeout) => element.setInputFiles(files, { timeout }));
1178
2245
  this.dispatched = true;
1179
2246
  return {
1180
2247
  uploaded: files.length,
@@ -1189,11 +2256,13 @@ export class BrowserRuntime {
1189
2256
  // download wait, and its own timeout would surface as an unhandled
1190
2257
  // rejection long after this action failed.
1191
2258
  downloadPromise.catch(() => undefined);
1192
- await (await this.locator(page, operation.target)).first().click({
1193
- timeout: operation.timeoutMs,
1194
- });
2259
+ await this.actOn(pin, await this.locator(page, operation.target), operation.timeoutMs, (element, timeout) => element.click({ timeout }), CLICKABLE);
1195
2260
  this.dispatched = true;
1196
- const download = await downloadPromise;
2261
+ // The click went out, so this stays an unknown outcome, but one that
2262
+ // names the refusal when the route turned the file away.
2263
+ const download = await downloadPromise.catch((error) => {
2264
+ throw this.refusedReserved ? new Error("browser_path_reserved") : error;
2265
+ });
1197
2266
  const suggested = download.suggestedFilename().replaceAll(/[^A-Za-z0-9._-]/g, "_");
1198
2267
  const path = join(this.options.artifactDirectory, `${randomUUID()}-${suggested || "download"}`);
1199
2268
  // Bounded by the task budget only. Saving waits for the whole file to
@@ -1216,52 +2285,63 @@ export class BrowserRuntime {
1216
2285
  }
1217
2286
  case "tabs": {
1218
2287
  const context = await this.ensureContext();
1219
- return { tabs: await this.describeTabs(context) };
2288
+ return { tabs: (await this.describeTabs(context)).tabs };
1220
2289
  }
1221
2290
  case "tab_new": {
1222
2291
  const context = await this.ensureContext();
2292
+ // Checked before the tab opens, and the tab closed again when its page
2293
+ // fails to load: `tab_new` is a read, so it is retried, and every run
2294
+ // that failed with its tab open left one more tab.
2295
+ if (operation.url !== undefined)
2296
+ await this.assertAllowed(operation.url);
2297
+ const previous = this.activePage;
1223
2298
  const opened = await this.openPage(context);
1224
- this.activePage = opened;
2299
+ this.activate(opened);
1225
2300
  if (operation.url !== undefined) {
1226
- await assertSafeUrl(operation.url, this.options);
1227
- await opened.goto(operation.url, {
1228
- waitUntil: "domcontentloaded",
1229
- timeout: operation.timeoutMs,
1230
- });
2301
+ try {
2302
+ await opened.goto(operation.url, {
2303
+ waitUntil: "domcontentloaded",
2304
+ timeout: operation.timeoutMs,
2305
+ });
2306
+ }
2307
+ catch (error) {
2308
+ await this.call(opened.close(), "close").catch(() => undefined);
2309
+ this.activate(previous?.isClosed() === false ? previous : context.pages().at(-1));
2310
+ throw error;
2311
+ }
1231
2312
  }
1232
- return {
1233
- opened: true,
1234
- index: context.pages().indexOf(opened),
1235
- tabs: await this.describeTabs(context),
1236
- };
2313
+ const { pages, tabs } = await this.describeTabs(context);
2314
+ const index = pages.indexOf(opened);
2315
+ // Its own tab closed during the listing: there is no tab to report.
2316
+ if (index === -1)
2317
+ throw new Error("browser_tab_not_found");
2318
+ return { opened: true, index, tabs };
1237
2319
  }
1238
2320
  case "tab_switch": {
1239
2321
  const context = await this.ensureContext();
1240
2322
  if (operation.tabIndex === undefined) {
1241
2323
  throw new Error("browser_tab_switch_requires_tab_index");
1242
2324
  }
1243
- const target = context.pages()[operation.tabIndex];
1244
- if (target === undefined)
2325
+ // The page checked above, not whichever holds its index by now.
2326
+ const target = acted;
2327
+ if (target === undefined || target.isClosed())
1245
2328
  throw new Error("browser_tab_not_found");
1246
2329
  // Bring the tab to the front so a screenshot of it is what the user
1247
2330
  // would actually see; a background page still renders, but modal and
1248
2331
  // focus behaviour differ.
1249
2332
  await this.call(target.bringToFront(), "bring_to_front");
1250
- this.activePage = target;
1251
- return {
1252
- switched: true,
1253
- index: operation.tabIndex,
1254
- url: target.url(),
1255
- tabs: await this.describeTabs(context),
1256
- };
2333
+ this.activate(target);
2334
+ const { pages, tabs } = await this.describeTabs(context);
2335
+ const index = pages.indexOf(target);
2336
+ if (index === -1)
2337
+ throw new Error("browser_tab_not_found");
2338
+ return { switched: true, index, url: target.url(), tabs };
1257
2339
  }
1258
2340
  case "close": {
1259
2341
  const context = await this.ensureContext();
1260
- const pages = context.pages();
1261
- const target = operation.tabIndex === undefined
1262
- ? page
1263
- : pages[operation.tabIndex];
1264
- if (target === undefined)
2342
+ // The page checked above, not whichever holds its index by now.
2343
+ const target = acted;
2344
+ if (target === undefined || target.isClosed())
1265
2345
  throw new Error("browser_tab_not_found");
1266
2346
  const url = target.url();
1267
2347
  // Set before the close, unlike the other mutations: this call is the
@@ -1269,8 +2349,8 @@ export class BrowserRuntime {
1269
2349
  // bound running out cannot say whether the tab went away.
1270
2350
  this.dispatched = true;
1271
2351
  await this.call(target.close(), "close");
1272
- this.activePage = context.pages().at(-1);
1273
- return { closed: true, url, tabs: await this.describeTabs(context) };
2352
+ this.activate(context.pages().at(-1));
2353
+ return { closed: true, url, tabs: (await this.describeTabs(context)).tabs };
1274
2354
  }
1275
2355
  }
1276
2356
  }
@@ -1281,6 +2361,7 @@ export class BrowserRuntime {
1281
2361
  if (this.targets !== undefined)
1282
2362
  await quietly(this.targets.detach(), "cdp_detach");
1283
2363
  this.targets = undefined;
2364
+ this.forgetLapsing();
1284
2365
  if (connection !== undefined &&
1285
2366
  !connection.ownsContext &&
1286
2367
  this.routeHandler !== undefined) {
@@ -1297,7 +2378,6 @@ export class BrowserRuntime {
1297
2378
  this.proxy = undefined;
1298
2379
  this.activePage = undefined;
1299
2380
  this.context = undefined;
1300
- this.browser = undefined;
1301
2381
  this.connection = undefined;
1302
2382
  this.routeHandler = undefined;
1303
2383
  }