@melra/browser-runtime 0.3.0-alpha.14 → 0.3.0-alpha.15
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/README.md +6 -1
- package/dist/index.d.ts +276 -15
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1277 -197
- package/dist/index.js.map +1 -1
- package/package.json +4 -4
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
|
-
|
|
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
|
-
/**
|
|
243
|
-
|
|
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
|
-
/**
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
477
|
-
//
|
|
478
|
-
//
|
|
479
|
-
this.
|
|
480
|
-
|
|
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
|
-
|
|
700
|
+
const opening = this.opening;
|
|
701
|
+
if (opened === this.activePage && !attached) {
|
|
702
|
+
this.release(opened);
|
|
494
703
|
return;
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
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
|
|
508
|
-
*
|
|
509
|
-
*
|
|
510
|
-
*
|
|
511
|
-
*
|
|
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.
|
|
518
|
-
this.
|
|
755
|
+
this.owed = new Map();
|
|
756
|
+
this.forgetLapsing();
|
|
519
757
|
this.contextId = undefined;
|
|
520
|
-
|
|
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
|
-
|
|
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
|
|
537
|
-
|
|
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
|
|
548
|
-
*
|
|
549
|
-
*
|
|
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
|
-
*
|
|
553
|
-
*
|
|
554
|
-
*
|
|
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.
|
|
563
|
-
|
|
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
|
|
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
|
-
|
|
580
|
-
|
|
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
|
|
596
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
613
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
//
|
|
717
|
-
//
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
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
|
-
|
|
728
|
-
|
|
729
|
-
|
|
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
|
-
|
|
733
|
-
|
|
734
|
-
|
|
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
|
|
753
|
-
|
|
754
|
-
|
|
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
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
853
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
949
|
-
|
|
950
|
-
|
|
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
|
|
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
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
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)
|
|
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)
|
|
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
|
|
1104
|
-
|
|
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.
|
|
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
|
-
|
|
1129
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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.
|
|
2299
|
+
this.activate(opened);
|
|
1225
2300
|
if (operation.url !== undefined) {
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
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
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
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
|
-
|
|
1244
|
-
|
|
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.
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
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
|
-
|
|
1261
|
-
const target =
|
|
1262
|
-
|
|
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.
|
|
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
|
}
|