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