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