buninu-browser 0.1.0 → 0.2.1
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/AGENTS.md +17 -0
- package/LICENSES/brotli.txt +19 -0
- package/README.md +41 -7
- package/THIRD_PARTY_NOTICES.md +26 -4
- package/buninu-browser.js +10 -2
- package/lib/audio/pcm.js +16 -0
- package/lib/audio/piano.js +109 -0
- package/lib/audio/pulse-client.js +413 -0
- package/lib/cdp-server.js +28 -7
- package/lib/document-types.js +308 -0
- package/lib/download-directory.js +28 -0
- package/lib/download.js +63 -0
- package/lib/happy-dom/selector-matcher.js +83 -0
- package/lib/headless-shell.js +165 -26
- package/lib/input/hit-test.js +57 -8
- package/lib/layout/text-layout.js +290 -41
- package/lib/network/network-service.js +177 -40
- package/lib/network/resolver.js +16 -0
- package/lib/paint/display-list.js +25 -10
- package/lib/render-tree/index.js +58 -0
- package/lib/renderer/bindings.js +543 -55
- package/lib/renderer/flags.js +8 -5
- package/lib/renderer/host.js +103 -7
- package/lib/renderer/main.js +35 -3
- package/lib/renderer/module-transform.js +30 -23
- package/lib/renderer/page-realm.js +228 -24
- package/lib/renderer/page-renderer.js +1264 -221
- package/lib/screenshot-cli.js +61 -0
- package/lib/style/computed-style.js +558 -229
- package/lib/style/selector-keys.js +302 -0
- package/lib/style/selector-traits.js +240 -0
- package/lib/user-agent.js +12 -0
- package/package.json +1 -1
- package/patches/happy-dom/0003-defer-script-events-to-renderer.patch +40 -0
- package/patches/happy-dom/0004-insert-adjacent-html-in-order.patch +19 -0
- package/patches/happy-dom/0005-invalidate-matches-on-structure-changes.patch +43 -0
- package/patches/happy-dom/README.md +12 -0
- package/patches/ses/0001-allow-regexp-own-tostring.patch +13 -0
- package/patches/ses/README.md +16 -0
- package/test/computed-style.test.js +113 -1
- package/test/document-types.test.js +111 -0
- package/test/download.test.js +192 -0
- package/test/fixtures/tone.mp3 +0 -0
- package/test/flex.test.js +11 -0
- package/test/happy-dom-matches.test.js +25 -0
- package/test/headless-shell.test.js +56 -0
- package/test/hit-test.test.js +37 -0
- package/test/log.test.js +4 -1
- package/test/network-service.test.js +67 -0
- package/test/page-scripts.test.js +437 -29
- package/test/piano.test.js +65 -0
- package/test/positioning.test.js +11 -0
- package/test/pulse-client.test.js +130 -0
- package/test/render-tree.test.js +36 -1
- package/test/resolver.test.js +12 -1
- package/test/screenshot-cli.test.js +19 -0
- package/test/selector-keys.test.js +39 -0
- package/test/selector-traits.test.js +19 -0
- package/test/text-layout.test.js +91 -1
- package/test/user-agent.test.js +42 -0
- package/vendor/happy-dom/packages/happy-dom/src/nodes/element/Element.ts +3 -6
- package/vendor/happy-dom/packages/happy-dom/src/nodes/html-script-element/HTMLScriptElement.ts +2 -20
- package/vendor/happy-dom/packages/happy-dom/src/query-selector/QuerySelector.ts +15 -3
- package/vendor/ses/dist/ses.mjs +2 -0
package/lib/headless-shell.js
CHANGED
|
@@ -11,10 +11,13 @@
|
|
|
11
11
|
* the renderer sandbox, for every page.
|
|
12
12
|
*/
|
|
13
13
|
import { CdpServer } from "./cdp-server.js";
|
|
14
|
+
import { DEFAULT_DOWNLOAD_DIR, isAttachment, saveDownload, suggestedFilename } from "./download.js";
|
|
15
|
+
import { audioDemoDocument, displayDocument } from "./document-types.js";
|
|
14
16
|
import { installLog, logFileFromEnv, writeLog } from "./log.js";
|
|
15
17
|
import { NetworkService } from "./network/network-service.js";
|
|
16
18
|
import { RendererHost } from "./renderer/host.js";
|
|
17
19
|
import { MOBILE_FLAG, MOBILE_LAYOUT_WIDTH, MOBILE_USER_AGENT } from "./renderer/flags.js";
|
|
20
|
+
import { DESKTOP_USER_AGENT } from "./user-agent.js";
|
|
18
21
|
|
|
19
22
|
/** Opt-in phone presentation: mobile user agent and touch media features. */
|
|
20
23
|
export const MOBILE = "--mobile";
|
|
@@ -34,15 +37,23 @@ export async function runHeadlessShell(args = Bun.argv.slice(2)) {
|
|
|
34
37
|
const mobile = args.includes(MOBILE);
|
|
35
38
|
if (mobile) console.error(`buninu-browser: ${MOBILE}: mobile user agent and touch media features; user agent overrides are ignored`);
|
|
36
39
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
+ `ses ${layerStatus(browser.sandbox?.ses)}`);
|
|
41
|
-
|
|
40
|
+
// The endpoint opens while the first renderer starts: a client's first
|
|
41
|
+
// navigation fetches its document meanwhile.
|
|
42
|
+
const browser = await createBrowser({ allowHostJs, mobile, waitForRenderer: false });
|
|
42
43
|
const portArgument = args.find((argument) => argument.startsWith("--remote-debugging-port="));
|
|
43
44
|
const requestedPort = Number(portArgument?.slice("--remote-debugging-port=".length) ?? 9222);
|
|
44
45
|
const server = CdpServer.create(browser.context).listen(requestedPort, "127.0.0.1");
|
|
45
46
|
console.error(`DevTools listening on ws://127.0.0.1:${server.port}/devtools/browser/cdp-server`);
|
|
47
|
+
try {
|
|
48
|
+
await browser.ready;
|
|
49
|
+
} catch (error) {
|
|
50
|
+
server.stop(true);
|
|
51
|
+
browser.close();
|
|
52
|
+
throw error;
|
|
53
|
+
}
|
|
54
|
+
const layerStatus = (layer) => layer?.installed ? "on" : `off (${layer?.reason})`;
|
|
55
|
+
console.error(`buninu-browser: renderer sandbox: seccomp ${layerStatus(browser.sandbox?.seccomp)}, `
|
|
56
|
+
+ `ses ${layerStatus(browser.sandbox?.ses)}`);
|
|
46
57
|
|
|
47
58
|
const shutdown = () => {
|
|
48
59
|
server.stop(true);
|
|
@@ -64,26 +75,38 @@ export async function runHeadlessShell(args = Bun.argv.slice(2)) {
|
|
|
64
75
|
* not wait for CanvasKit and fonts to load. Site isolation is by origin
|
|
65
76
|
* because no Public Suffix List is bundled to compute sites.
|
|
66
77
|
*
|
|
67
|
-
*
|
|
78
|
+
* With `waitForRenderer: false` it returns before the first renderer is up
|
|
79
|
+
* (`ready` settles then): navigations fetch their documents meanwhile, and
|
|
80
|
+
* anything that needs a renderer waits for it.
|
|
81
|
+
*
|
|
82
|
+
* @param {{ allowHostJs?: boolean, spareRenderer?: boolean, network?: NetworkService, rendererTimeout?: number,
|
|
83
|
+
* waitForRenderer?: boolean }} options
|
|
68
84
|
*/
|
|
69
85
|
export async function createBrowser({
|
|
70
86
|
allowHostJs = false, spareRenderer = true, network = new NetworkService(), rendererTimeout = 30_000, mobile = false,
|
|
87
|
+
waitForRenderer = true,
|
|
71
88
|
} = {}) {
|
|
72
89
|
// --mobile fixes the user agent for page requests and navigator.userAgent.
|
|
73
|
-
|
|
90
|
+
network.userAgent = mobile ? MOBILE_USER_AGENT : DESKTOP_USER_AGENT;
|
|
74
91
|
const history = [{ url: "about:blank", x: 0, y: 0 }];
|
|
75
92
|
let historyIndex = 0;
|
|
76
93
|
let currentUrl = "about:blank";
|
|
94
|
+
// The current document's response as fetched (raw bytes), for downloads.
|
|
95
|
+
let resource = null;
|
|
77
96
|
let current = null;
|
|
78
97
|
let currentKey = null;
|
|
79
98
|
let spare = null;
|
|
80
99
|
let closed = false;
|
|
100
|
+
// Settles when the first renderer shows about:blank (assigned below).
|
|
101
|
+
let rendererReady = null;
|
|
81
102
|
// Renderer-independent state replayed into every new renderer.
|
|
82
103
|
const viewport = { width: 800, height: 600, deviceScaleFactor: 1 };
|
|
83
104
|
let userAgent = null;
|
|
84
105
|
// Listeners for frame and lifecycle notifications (the CDP server).
|
|
85
106
|
const frameListeners = new Set();
|
|
86
107
|
const lifecycleListeners = new Set();
|
|
108
|
+
const downloadListeners = new Set();
|
|
109
|
+
let downloadBehavior = { behavior: "allow", downloadPath: DEFAULT_DOWNLOAD_DIR, eventsEnabled: false };
|
|
87
110
|
// The committed document: its renderer and load generation. Lifecycle
|
|
88
111
|
// messages can arrive before loadDocument returns, so each host records
|
|
89
112
|
// what every generation has reached.
|
|
@@ -104,6 +127,30 @@ export async function createBrowser({
|
|
|
104
127
|
}
|
|
105
128
|
}
|
|
106
129
|
|
|
130
|
+
async function download({ url, body, disposition = "", attributeName = "", totalBytes = 0 }) {
|
|
131
|
+
const guid = crypto.randomUUID();
|
|
132
|
+
const filename = suggestedFilename(url, disposition, attributeName);
|
|
133
|
+
if (downloadBehavior.eventsEnabled) notify(downloadListeners, "willBegin", { guid, url, suggestedFilename: filename });
|
|
134
|
+
if (downloadBehavior.behavior === "deny") {
|
|
135
|
+
if (downloadBehavior.eventsEnabled) notify(downloadListeners, "progress", { guid, state: "canceled", receivedBytes: 0, totalBytes });
|
|
136
|
+
return { downloaded: true };
|
|
137
|
+
}
|
|
138
|
+
try {
|
|
139
|
+
if (downloadBehavior.eventsEnabled) notify(downloadListeners, "progress", { guid, state: "inProgress", receivedBytes: 0, totalBytes });
|
|
140
|
+
const saved = await saveDownload({ url, body, disposition, attributeName,
|
|
141
|
+
filename: downloadBehavior.behavior === "allowAndName" ? guid : null, dir: downloadBehavior.downloadPath });
|
|
142
|
+
if (downloadBehavior.eventsEnabled) notify(downloadListeners, "progress", {
|
|
143
|
+
guid, state: "completed", receivedBytes: saved.bytes, totalBytes: totalBytes || saved.bytes, filePath: saved.path,
|
|
144
|
+
});
|
|
145
|
+
writeLog("download", `${url} -> ${saved.path}`);
|
|
146
|
+
return { downloaded: true, path: saved.path };
|
|
147
|
+
} catch (error) {
|
|
148
|
+
if (downloadBehavior.eventsEnabled) notify(downloadListeners, "progress", { guid, state: "canceled", receivedBytes: 0, totalBytes });
|
|
149
|
+
console.error(`buninu-browser: download failed: ${error?.message ?? error}`);
|
|
150
|
+
return { downloaded: true, error };
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
107
154
|
function createHost() {
|
|
108
155
|
const host = new RendererHost({
|
|
109
156
|
network,
|
|
@@ -115,9 +162,17 @@ export async function createBrowser({
|
|
|
115
162
|
await load(currentUrl, invoke);
|
|
116
163
|
},
|
|
117
164
|
// Only the renderer showing the current page may navigate it.
|
|
118
|
-
onNavigate: (url) => {
|
|
165
|
+
onNavigate: (url, downloadName, fragmentFrom) => {
|
|
166
|
+
if (host !== current) return;
|
|
167
|
+
if (fragmentFrom) {
|
|
168
|
+
recordFragment(url, fragmentFrom);
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
navigate(url, { downloadName }).catch((error) => console.error(`buninu-browser: navigation failed: ${error.message}`));
|
|
172
|
+
},
|
|
173
|
+
onDownload: ({ url, name, data }) => {
|
|
119
174
|
if (host !== current) return;
|
|
120
|
-
|
|
175
|
+
void download({ url, body: data, attributeName: name, totalBytes: data.byteLength });
|
|
121
176
|
},
|
|
122
177
|
onFrame: () => {
|
|
123
178
|
if (host === current) notify(frameListeners);
|
|
@@ -177,29 +232,57 @@ export async function createBrowser({
|
|
|
177
232
|
return current;
|
|
178
233
|
}
|
|
179
234
|
|
|
180
|
-
const call = (method, ...params) =>
|
|
235
|
+
const call = async (method, ...params) => {
|
|
236
|
+
await rendererReady;
|
|
237
|
+
return current.call(method, ...params);
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Applies viewport or user agent state the renderer is told about. Before
|
|
242
|
+
* the first renderer is up there is nothing to wait for: it starts with the
|
|
243
|
+
* state (replayState), and the latest state is applied again once it is.
|
|
244
|
+
*/
|
|
245
|
+
function applyState(method, args) {
|
|
246
|
+
if (current) return current.call(method, ...args());
|
|
247
|
+
rendererReady.then(() => current?.call(method, ...args())).catch(() => {});
|
|
248
|
+
return undefined;
|
|
249
|
+
}
|
|
181
250
|
|
|
182
251
|
/**
|
|
183
252
|
* Fetches a top-level document, then commits it in the renderer for its
|
|
184
253
|
* final origin. `invoke` pins the renderer (used when restoring a
|
|
185
254
|
* restarted renderer).
|
|
186
255
|
*/
|
|
187
|
-
async function load(url, invoke = null) {
|
|
256
|
+
async function load(url, invoke = null, downloadName = null) {
|
|
188
257
|
const started = performance.now();
|
|
189
|
-
|
|
258
|
+
if (downloadName !== null && resource && withoutHash(url) === withoutHash(resource.url)) {
|
|
259
|
+
return download({ url: resource.url, body: resource.body, attributeName: downloadName, totalBytes: resource.body.byteLength });
|
|
260
|
+
}
|
|
190
261
|
let document;
|
|
191
262
|
if (url === "about:blank") {
|
|
192
263
|
document = { url };
|
|
264
|
+
} else if (url === "about:audio") {
|
|
265
|
+
document = { url, ...audioDemoDocument() };
|
|
193
266
|
} else {
|
|
194
267
|
try {
|
|
195
268
|
const target = new URL(url);
|
|
196
269
|
const response = ["http:", "https:"].includes(target.protocol)
|
|
197
|
-
? await network.navigate(target.href)
|
|
270
|
+
? await network.navigate(target.href, { onResponse: (raw, finalUrl) => {
|
|
271
|
+
if (!raw.ok || (downloadName === null && !isAttachment(raw.headers.get("content-disposition")))) return null;
|
|
272
|
+
return download({ url: finalUrl, body: raw.body, disposition: raw.headers.get("content-disposition") ?? "",
|
|
273
|
+
attributeName: downloadName ?? "", totalBytes: Number(raw.headers.get("content-length")) || 0 });
|
|
274
|
+
} })
|
|
198
275
|
: await localDocument(target);
|
|
276
|
+
if (response.downloaded) return response;
|
|
277
|
+
if (downloadName !== null) return download({ url: response.url, body: response.body,
|
|
278
|
+
attributeName: downloadName, totalBytes: response.body.byteLength });
|
|
279
|
+
resource = { url: response.url, contentType: response.contentType ?? "", body: response.body };
|
|
280
|
+
// Non-HTML responses (images, JSON, TOML, text...) are shown through a generated page.
|
|
281
|
+
const shown = displayDocument({ url: response.url, contentType: response.contentType, body: response.body });
|
|
199
282
|
document = {
|
|
200
283
|
url: response.url,
|
|
201
|
-
source:
|
|
202
|
-
contentType:
|
|
284
|
+
source: shown.source,
|
|
285
|
+
contentType: shown.contentType,
|
|
203
286
|
status: response.status,
|
|
204
287
|
statusText: response.statusText,
|
|
205
288
|
cookie: network.documentCookie(response.url),
|
|
@@ -208,10 +291,12 @@ export async function createBrowser({
|
|
|
208
291
|
document = { url, error: String(error) };
|
|
209
292
|
}
|
|
210
293
|
}
|
|
294
|
+
if (document.url === "about:blank" || document.url === "about:audio" || document.error) resource = null;
|
|
211
295
|
currentUrl = document.url;
|
|
212
296
|
const fetched = performance.now();
|
|
213
297
|
writeLog("navigation", `${url}: ${document.error ?? `HTTP ${document.status ?? 200}`}`
|
|
214
298
|
+ `${document.url === url ? "" : ` -> ${document.url}`} in ${Math.round(fetched - started)}ms`);
|
|
299
|
+
if (!invoke) await rendererReady;
|
|
215
300
|
const host = invoke ? current : await rendererFor(document.url);
|
|
216
301
|
const generation = await (invoke ?? ((method, ...params) => host.call(method, ...params)))("loadDocument", document);
|
|
217
302
|
committed = { host, generation };
|
|
@@ -221,14 +306,19 @@ export async function createBrowser({
|
|
|
221
306
|
|
|
222
307
|
// Screenshots and scrolling go to the compositor thread, so they are
|
|
223
308
|
// answered even while the renderer's main thread is busy.
|
|
224
|
-
const composite = (method, ...params) =>
|
|
309
|
+
const composite = async (method, ...params) => {
|
|
310
|
+
await rendererReady;
|
|
311
|
+
return current.composite(method, ...params);
|
|
312
|
+
};
|
|
225
313
|
|
|
226
314
|
async function rememberScroll() {
|
|
315
|
+
// Before the first renderer is up nothing has been shown or scrolled.
|
|
316
|
+
if (!current) return;
|
|
227
317
|
const offset = await composite("scrollOffset");
|
|
228
318
|
Object.assign(history[historyIndex], { x: offset.x, y: offset.y });
|
|
229
319
|
}
|
|
230
320
|
|
|
231
|
-
async function navigate(url) {
|
|
321
|
+
async function navigate(url, { downloadName = null } = {}) {
|
|
232
322
|
const resolved = new URL(url, currentUrl).href;
|
|
233
323
|
// A javascript: URL runs in the current document; history does not change.
|
|
234
324
|
if (resolved.startsWith("javascript:")) {
|
|
@@ -236,10 +326,11 @@ export async function createBrowser({
|
|
|
236
326
|
return;
|
|
237
327
|
}
|
|
238
328
|
await rememberScroll();
|
|
239
|
-
if (withoutHash(resolved) === withoutHash(currentUrl)) {
|
|
329
|
+
if (downloadName === null && withoutHash(resolved) === withoutHash(currentUrl)) {
|
|
240
330
|
currentUrl = resolved;
|
|
241
331
|
} else {
|
|
242
|
-
await load(resolved);
|
|
332
|
+
const result = await load(resolved, null, downloadName);
|
|
333
|
+
if (result?.downloaded) return null;
|
|
243
334
|
}
|
|
244
335
|
await call("showFragment", resolved);
|
|
245
336
|
history.splice(historyIndex + 1);
|
|
@@ -247,6 +338,16 @@ export async function createBrowser({
|
|
|
247
338
|
historyIndex = history.length - 1;
|
|
248
339
|
}
|
|
249
340
|
|
|
341
|
+
/** The renderer already navigated to a fragment of its document; only history changes. */
|
|
342
|
+
function recordFragment(url, from) {
|
|
343
|
+
if (withoutHash(url) !== withoutHash(currentUrl)) return;
|
|
344
|
+
Object.assign(history[historyIndex], { x: from.x, y: from.y });
|
|
345
|
+
currentUrl = url;
|
|
346
|
+
history.splice(historyIndex + 1);
|
|
347
|
+
history.push({ url, x: 0, y: 0 });
|
|
348
|
+
historyIndex = history.length - 1;
|
|
349
|
+
}
|
|
350
|
+
|
|
250
351
|
async function traverseHistory(index) {
|
|
251
352
|
if (index < 0 || index >= history.length || index === historyIndex) return;
|
|
252
353
|
await rememberScroll();
|
|
@@ -258,25 +359,49 @@ export async function createBrowser({
|
|
|
258
359
|
|
|
259
360
|
const context = {
|
|
260
361
|
navigate,
|
|
362
|
+
setDownloadBehavior({ behavior = "allow", downloadPath = DEFAULT_DOWNLOAD_DIR, eventsEnabled = false } = {}) {
|
|
363
|
+
if (!["allow", "allowAndName", "default", "deny"].includes(behavior)) throw new TypeError(`Unsupported download behavior ${behavior}`);
|
|
364
|
+
downloadBehavior = { behavior, downloadPath, eventsEnabled: Boolean(eventsEnabled) };
|
|
365
|
+
},
|
|
366
|
+
onDownload(listener) {
|
|
367
|
+
downloadListeners.add(listener);
|
|
368
|
+
return () => downloadListeners.delete(listener);
|
|
369
|
+
},
|
|
261
370
|
reload: () => load(currentUrl),
|
|
262
371
|
goBack: () => traverseHistory(historyIndex - 1),
|
|
263
372
|
goForward: () => traverseHistory(historyIndex + 1),
|
|
264
373
|
resize(width, height, deviceScaleFactor = 1) {
|
|
265
374
|
Object.assign(viewport, { width, height, deviceScaleFactor });
|
|
266
|
-
return
|
|
375
|
+
return applyState("resize", resizeArguments);
|
|
267
376
|
},
|
|
268
377
|
setUserAgent(value) {
|
|
269
378
|
// With --mobile the phone user agent stays (casty, for one, overrides it with a desktop one).
|
|
270
379
|
if (mobile) return undefined;
|
|
271
380
|
userAgent = typeof value === "string" && value ? value : null;
|
|
272
381
|
network.userAgent = userAgent;
|
|
273
|
-
return userAgent ?
|
|
382
|
+
return userAgent ? applyState("setUserAgent", () => [userAgent]) : undefined;
|
|
274
383
|
},
|
|
275
384
|
screenshot: (options = {}) => composite("screenshot", {
|
|
276
385
|
format: options.format === "jpeg" || options.format === "webp" ? options.format : "png",
|
|
277
386
|
quality: Number.isFinite(options.quality) ? options.quality : 80,
|
|
278
387
|
}),
|
|
279
388
|
title: () => call("title"),
|
|
389
|
+
/**
|
|
390
|
+
* The document's response body as it arrived (CDP Page.getResourceContent),
|
|
391
|
+
* for the current document's URL with or without its fragment; else null.
|
|
392
|
+
*/
|
|
393
|
+
resourceContent(url = currentUrl) {
|
|
394
|
+
if (!resource) return null;
|
|
395
|
+
let wanted;
|
|
396
|
+
try {
|
|
397
|
+
wanted = withoutHash(String(url));
|
|
398
|
+
} catch {
|
|
399
|
+
return null;
|
|
400
|
+
}
|
|
401
|
+
return wanted === withoutHash(resource.url) || wanted === withoutHash(currentUrl)
|
|
402
|
+
? { url: resource.url, contentType: resource.contentType, body: resource.body }
|
|
403
|
+
: null;
|
|
404
|
+
},
|
|
280
405
|
scroll: (deltaX, deltaY) => composite("scroll", deltaX / pageScale(), deltaY / pageScale()),
|
|
281
406
|
press: (key, options) => call("press", key, options),
|
|
282
407
|
type: (text) => call("type", text),
|
|
@@ -296,10 +421,15 @@ export async function createBrowser({
|
|
|
296
421
|
return [...reachedLifecycle(committed.host, committed.generation)];
|
|
297
422
|
},
|
|
298
423
|
async click(x, y) {
|
|
299
|
-
const
|
|
424
|
+
const clicked = await call("click", x / pageScale(), y / pageScale());
|
|
425
|
+
const href = typeof clicked === "string" ? clicked : clicked?.url;
|
|
300
426
|
if (typeof href !== "string") return null;
|
|
301
|
-
|
|
302
|
-
|
|
427
|
+
if (clicked.fragmentFrom) {
|
|
428
|
+
recordFragment(href, clicked.fragmentFrom);
|
|
429
|
+
return { url: href, title: await call("title") };
|
|
430
|
+
}
|
|
431
|
+
const result = await navigate(href, { downloadName: typeof clicked === "object" ? clicked.download ?? null : null });
|
|
432
|
+
return result === null ? null : { url: href, title: await call("title") };
|
|
303
433
|
},
|
|
304
434
|
evaluate: (expression) => call("evaluate", String(expression)),
|
|
305
435
|
cdp() {
|
|
@@ -307,12 +437,21 @@ export async function createBrowser({
|
|
|
307
437
|
},
|
|
308
438
|
};
|
|
309
439
|
|
|
310
|
-
|
|
311
|
-
|
|
440
|
+
// The spare starts with the first renderer rather than after it, ready
|
|
441
|
+
// for the first navigation to an origin.
|
|
442
|
+
rendererReady = (async () => {
|
|
443
|
+
const first = rendererFor("about:blank");
|
|
444
|
+
prepareSpare();
|
|
445
|
+
await first;
|
|
446
|
+
await current.call("loadDocument", { url: "about:blank" });
|
|
447
|
+
})();
|
|
448
|
+
if (waitForRenderer) await rendererReady;
|
|
449
|
+
else rendererReady.catch(() => {}); // the caller awaits `ready`
|
|
312
450
|
|
|
313
451
|
return {
|
|
314
452
|
context,
|
|
315
453
|
network,
|
|
454
|
+
ready: rendererReady,
|
|
316
455
|
get sandbox() {
|
|
317
456
|
return current?.sandbox ?? null;
|
|
318
457
|
},
|
package/lib/input/hit-test.js
CHANGED
|
@@ -1,14 +1,19 @@
|
|
|
1
|
-
const INTERACTIVE_TAGS = new Set(["A", "BUTTON", "INPUT", "SELECT", "SUMMARY", "TEXTAREA", "VIDEO"]);
|
|
1
|
+
const INTERACTIVE_TAGS = new Set(["A", "AUDIO", "BUTTON", "INPUT", "LABEL", "SELECT", "SUMMARY", "TEXTAREA", "VIDEO"]);
|
|
2
|
+
// Form controls and labels are hit over their whole box, not only where text
|
|
3
|
+
// is drawn (an unchecked checkbox or an empty field has none; a block label
|
|
4
|
+
// is clickable beside its text).
|
|
5
|
+
const CONTROL_TAGS = new Set(["AUDIO", "BUTTON", "INPUT", "LABEL", "SELECT", "TEXTAREA"]);
|
|
2
6
|
|
|
3
7
|
export function interactiveRegions(tree, layout, scroll = { x: 0, y: 0 }, viewport = {}) {
|
|
4
8
|
const regions = [];
|
|
5
9
|
const byElement = new Map();
|
|
10
|
+
const hittable = hitTestable(tree);
|
|
6
11
|
|
|
7
12
|
for (const fragment of layout.fragments) {
|
|
8
13
|
for (const run of fragment.runs) {
|
|
9
14
|
const renderNode = tree.nodesById.get(run.nodeId);
|
|
10
15
|
const element = closestInteractive(renderNode?.domNode);
|
|
11
|
-
if (!element) continue;
|
|
16
|
+
if (!element || !hittable(renderNode)) continue;
|
|
12
17
|
const top = fragment.y - scroll.y;
|
|
13
18
|
const left = run.x - scroll.x;
|
|
14
19
|
const right = left + run.width;
|
|
@@ -27,19 +32,43 @@ export function interactiveRegions(tree, layout, scroll = { x: 0, y: 0 }, viewpo
|
|
|
27
32
|
}
|
|
28
33
|
}
|
|
29
34
|
|
|
35
|
+
for (const box of layout.boxes ?? []) {
|
|
36
|
+
const renderNode = tree.nodesById.get(box.nodeId);
|
|
37
|
+
const element = renderNode?.domNode;
|
|
38
|
+
if (!element || element.nodeType !== 1 || !CONTROL_TAGS.has(element.tagName) || !hittable(renderNode)) continue;
|
|
39
|
+
const top = box.y - scroll.y;
|
|
40
|
+
const left = box.x - scroll.x;
|
|
41
|
+
const right = left + box.width;
|
|
42
|
+
const bottom = top + box.height;
|
|
43
|
+
if (box.width <= 0 || box.height <= 0 || right <= 0 || bottom <= 0
|
|
44
|
+
|| left >= (viewport.width ?? Infinity) || top >= (viewport.height ?? Infinity)) {
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
const rect = Object.freeze({ left, top, right, bottom, width: box.width, height: box.height });
|
|
48
|
+
let region = byElement.get(element);
|
|
49
|
+
if (!region) {
|
|
50
|
+
region = { element, rects: [] };
|
|
51
|
+
byElement.set(element, region);
|
|
52
|
+
regions.push(region);
|
|
53
|
+
}
|
|
54
|
+
region.rects.push(rect);
|
|
55
|
+
}
|
|
56
|
+
|
|
30
57
|
return regions.map((region) => Object.freeze({
|
|
31
58
|
element: region.element,
|
|
32
59
|
rects: Object.freeze(region.rects),
|
|
33
60
|
}));
|
|
34
61
|
}
|
|
35
62
|
|
|
63
|
+
/** The innermost interactive element under a point (a checkbox before its label). */
|
|
36
64
|
export function hitTest(tree, layout, x, y, scroll, viewport) {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
}
|
|
42
|
-
|
|
65
|
+
const hits = interactiveRegions(tree, layout, scroll, viewport).map((region) => ({
|
|
66
|
+
...region,
|
|
67
|
+
area: Math.min(...region.rects.filter((rect) => x >= rect.left && x < rect.right && y >= rect.top && y < rect.bottom)
|
|
68
|
+
.map((rect) => rect.width * rect.height)),
|
|
69
|
+
})).filter((region) => Number.isFinite(region.area));
|
|
70
|
+
const deepest = hits.filter((region) => !hits.some((other) => other !== region && region.element.contains(other.element)));
|
|
71
|
+
return deepest.sort((left, right) => left.area - right.area)[0] ?? null;
|
|
43
72
|
}
|
|
44
73
|
|
|
45
74
|
/** Returns the content-coordinate bounds contributed by an element's text descendants. */
|
|
@@ -68,6 +97,26 @@ export function elementBounds(tree, layout, element) {
|
|
|
68
97
|
return Object.freeze({ left, top, right, bottom, width: right - left, height: bottom - top });
|
|
69
98
|
}
|
|
70
99
|
|
|
100
|
+
/**
|
|
101
|
+
* Whether a render node takes clicks: not when its element is
|
|
102
|
+
* visibility: hidden or pointer-events: none (both inherited, so a visible
|
|
103
|
+
* or pointer-events: auto descendant still does). Text uses its element's style.
|
|
104
|
+
*/
|
|
105
|
+
function hitTestable(tree) {
|
|
106
|
+
const styles = new Map();
|
|
107
|
+
for (const node of tree.nodesById.values()) {
|
|
108
|
+
if (node.domNode?.nodeType === 1 && node.style && !node.pseudo && !styles.has(node.domNode)) {
|
|
109
|
+
styles.set(node.domNode, node.style);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return (renderNode) => {
|
|
113
|
+
const domNode = renderNode?.domNode;
|
|
114
|
+
const element = domNode?.nodeType === 1 ? domNode : domNode?.parentElement;
|
|
115
|
+
const style = (renderNode?.pseudo ? renderNode.style : null) ?? styles.get(element);
|
|
116
|
+
return !style || (style.visibility !== "hidden" && style.pointerEvents !== "none");
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
71
120
|
function closestInteractive(node) {
|
|
72
121
|
for (let current = node?.nodeType === 1 ? node : node?.parentElement; current; current = current.parentElement) {
|
|
73
122
|
if (INTERACTIVE_TAGS.has(current.tagName) || current.hasAttribute("onclick") || current.hasAttribute("tabindex")) {
|