buninu-browser 0.2.1 → 0.2.3

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.
Files changed (90) hide show
  1. package/AGENTS.md +4 -0
  2. package/README.md +74 -9
  3. package/ai-debug.log +5537 -0
  4. package/bunfig.toml +1 -0
  5. package/buninu-browser.js +4 -0
  6. package/index.js +5 -0
  7. package/lib/audio/piano.js +24 -1
  8. package/lib/audio/playback-buffer.js +27 -0
  9. package/lib/audio/pulse-client.js +137 -19
  10. package/lib/cdp-server.js +22 -11
  11. package/lib/happy-dom/parser.js +29 -4
  12. package/lib/happy-dom/selector-pseudo-adapter.js +14 -0
  13. package/lib/headless-shell.js +69 -19
  14. package/lib/input/hit-test.js +25 -7
  15. package/lib/layout/text-layout.js +268 -85
  16. package/lib/log.js +5 -2
  17. package/lib/media/byte-pipe.js +82 -0
  18. package/lib/media/position-audio.js +42 -0
  19. package/lib/media/video-keyframe.js +29 -0
  20. package/lib/network/network-service.js +161 -14
  21. package/lib/paint/display-list.js +316 -15
  22. package/lib/paint/gradient.js +11 -5
  23. package/lib/render-tree/index.js +19 -2
  24. package/lib/render-tree/svg.js +2 -1
  25. package/lib/renderer/bindings.js +995 -69
  26. package/lib/renderer/canvas-2d.js +92 -0
  27. package/lib/renderer/compositor.js +16 -1
  28. package/lib/renderer/custom-element-reactions.js +18 -0
  29. package/lib/renderer/history.js +42 -0
  30. package/lib/renderer/host.js +166 -9
  31. package/lib/renderer/image-loading.js +27 -0
  32. package/lib/renderer/interfaces.js +215 -0
  33. package/lib/renderer/keyboard-input.js +12 -0
  34. package/lib/renderer/main.js +162 -12
  35. package/lib/renderer/media-playback-events.js +13 -0
  36. package/lib/renderer/media-source.js +819 -0
  37. package/lib/renderer/messaging.js +411 -0
  38. package/lib/renderer/native-functions.js +113 -0
  39. package/lib/renderer/page-realm.js +1408 -242
  40. package/lib/renderer/page-renderer.js +1893 -223
  41. package/lib/renderer/piano-input.js +22 -0
  42. package/lib/renderer/pointer-move.js +29 -0
  43. package/lib/renderer/rejections.js +31 -0
  44. package/lib/renderer/rewrite-worker.js +22 -0
  45. package/lib/renderer/script-rewrite.js +805 -57
  46. package/lib/renderer/trusted-types.js +129 -0
  47. package/lib/renderer/video-presentation.js +126 -0
  48. package/lib/renderer/workers.js +327 -0
  49. package/lib/screenshot-cli.js +2 -0
  50. package/lib/style/computed-style.js +186 -29
  51. package/lib/user-agent.js +14 -2
  52. package/package.json +4 -1
  53. package/patches/happy-dom/0002-use-bun-platform-adapters.patch +139 -19
  54. package/patches/happy-dom/0006-parse-css-escapes-in-selectors.patch +115 -0
  55. package/patches/happy-dom/README.md +26 -0
  56. package/patches/ses/0002-embedder-native-functions-source-text-sloppy-code.patch +106 -0
  57. package/patches/ses/0003-cache-sloppy-evaluators.patch +47 -0
  58. package/patches/ses/README.md +4 -0
  59. package/test/canvas-2d.test.js +55 -0
  60. package/test/compositor.test.js +41 -1
  61. package/test/computed-style.test.js +59 -1
  62. package/test/display-list.test.js +88 -1
  63. package/test/download.test.js +43 -0
  64. package/test/fixtures/long-video.mp4 +0 -0
  65. package/test/fixtures/media-sync.js +57 -0
  66. package/test/happy-dom-platform-adapters.test.js +25 -0
  67. package/test/headless-shell.test.js +23 -1
  68. package/test/hit-test.test.js +25 -1
  69. package/test/media-playback-events.test.js +22 -0
  70. package/test/media-source.test.js +156 -0
  71. package/test/media-sync.test.js +12 -0
  72. package/test/mobile.test.js +2 -2
  73. package/test/navigation.test.js +61 -0
  74. package/test/network-service.test.js +14 -0
  75. package/test/page-scripts.test.js +1203 -7
  76. package/test/piano-input.test.js +44 -0
  77. package/test/playback-buffer.test.js +31 -0
  78. package/test/position-audio.test.js +67 -0
  79. package/test/positioning.test.js +95 -1
  80. package/test/pulse-client.test.js +84 -0
  81. package/test/renderer-host.test.js +5 -2
  82. package/test/screenshot-cli.test.js +25 -1
  83. package/test/script-rewrite.test.js +111 -6
  84. package/test/setup.js +5 -0
  85. package/test/text-layout.test.js +62 -0
  86. package/test/user-agent.test.js +60 -4
  87. package/test/video-presentation.test.js +67 -0
  88. package/vendor/happy-dom/packages/happy-dom/src/nodes/html-image-element/HTMLImageElement.ts +52 -22
  89. package/vendor/happy-dom/packages/happy-dom/src/query-selector/SelectorParser.ts +18 -17
  90. package/vendor/ses/dist/ses.mjs +52 -6
@@ -17,7 +17,7 @@ import { installLog, logFileFromEnv, writeLog } from "./log.js";
17
17
  import { NetworkService } from "./network/network-service.js";
18
18
  import { RendererHost } from "./renderer/host.js";
19
19
  import { MOBILE_FLAG, MOBILE_LAYOUT_WIDTH, MOBILE_USER_AGENT } from "./renderer/flags.js";
20
- import { DESKTOP_USER_AGENT } from "./user-agent.js";
20
+ import { DESKTOP_USER_AGENT, WEBKIT_USER_AGENT, webkitFromArgs } from "./user-agent.js";
21
21
 
22
22
  /** Opt-in phone presentation: mobile user agent and touch media features. */
23
23
  export const MOBILE = "--mobile";
@@ -39,7 +39,7 @@ export async function runHeadlessShell(args = Bun.argv.slice(2)) {
39
39
 
40
40
  // The endpoint opens while the first renderer starts: a client's first
41
41
  // navigation fetches its document meanwhile.
42
- const browser = await createBrowser({ allowHostJs, mobile, waitForRenderer: false });
42
+ const browser = await createBrowser({ allowHostJs, mobile, webkit: webkitFromArgs(args), waitForRenderer: false });
43
43
  const portArgument = args.find((argument) => argument.startsWith("--remote-debugging-port="));
44
44
  const requestedPort = Number(portArgument?.slice("--remote-debugging-port=".length) ?? 9222);
45
45
  const server = CdpServer.create(browser.context).listen(requestedPort, "127.0.0.1");
@@ -80,14 +80,16 @@ export async function runHeadlessShell(args = Bun.argv.slice(2)) {
80
80
  * anything that needs a renderer waits for it.
81
81
  *
82
82
  * @param {{ allowHostJs?: boolean, spareRenderer?: boolean, network?: NetworkService, rendererTimeout?: number,
83
- * waitForRenderer?: boolean }} options
83
+ * waitForRenderer?: boolean, mobile?: boolean, webkit?: boolean }} options
84
+ * WebKit identity defaults on; webkit: false selects Chromium. Mobile takes priority.
84
85
  */
85
86
  export async function createBrowser({
86
87
  allowHostJs = false, spareRenderer = true, network = new NetworkService(), rendererTimeout = 30_000, mobile = false,
87
- waitForRenderer = true,
88
+ waitForRenderer = true, webkit = true,
88
89
  } = {}) {
89
- // --mobile fixes the user agent for page requests and navigator.userAgent.
90
- network.userAgent = mobile ? MOBILE_USER_AGENT : DESKTOP_USER_AGENT;
90
+ // Explicit presentation profiles fix the identity for requests and scripts.
91
+ const initialUserAgent = mobile ? MOBILE_USER_AGENT : webkit ? WEBKIT_USER_AGENT : DESKTOP_USER_AGENT;
92
+ network.userAgent = initialUserAgent;
91
93
  const history = [{ url: "about:blank", x: 0, y: 0 }];
92
94
  let historyIndex = 0;
93
95
  let currentUrl = "about:blank";
@@ -101,7 +103,10 @@ export async function createBrowser({
101
103
  let rendererReady = null;
102
104
  // Renderer-independent state replayed into every new renderer.
103
105
  const viewport = { width: 800, height: 600, deviceScaleFactor: 1 };
104
- let userAgent = null;
106
+ let userAgent = webkit ? initialUserAgent : null;
107
+ // Page.addScriptToEvaluateOnNewDocument: source by identifier, run in every new document before its scripts.
108
+ const initScripts = new Map();
109
+ let nextInitScript = 1;
105
110
  // Listeners for frame and lifecycle notifications (the CDP server).
106
111
  const frameListeners = new Set();
107
112
  const lifecycleListeners = new Set();
@@ -162,13 +167,13 @@ export async function createBrowser({
162
167
  await load(currentUrl, invoke);
163
168
  },
164
169
  // Only the renderer showing the current page may navigate it.
165
- onNavigate: (url, downloadName, fragmentFrom) => {
170
+ onNavigate: (url, downloadName, fragmentFrom, post = null) => {
166
171
  if (host !== current) return;
167
172
  if (fragmentFrom) {
168
173
  recordFragment(url, fragmentFrom);
169
174
  return;
170
175
  }
171
- navigate(url, { downloadName }).catch((error) => console.error(`buninu-browser: navigation failed: ${error.message}`));
176
+ navigate(url, { downloadName, post }).catch((error) => console.error(`buninu-browser: navigation failed: ${error.message}`));
172
177
  },
173
178
  onDownload: ({ url, name, data }) => {
174
179
  if (host !== current) return;
@@ -206,6 +211,7 @@ export async function createBrowser({
206
211
  async function replayState(invoke) {
207
212
  await invoke("resize", ...resizeArguments());
208
213
  if (userAgent) await invoke("setUserAgent", userAgent);
214
+ if (initScripts.size) await invoke("setInitScripts", [...initScripts.values()]);
209
215
  }
210
216
 
211
217
  function prepareSpare() {
@@ -253,7 +259,7 @@ export async function createBrowser({
253
259
  * final origin. `invoke` pins the renderer (used when restoring a
254
260
  * restarted renderer).
255
261
  */
256
- async function load(url, invoke = null, downloadName = null) {
262
+ async function load(url, invoke = null, downloadName = null, post = null) {
257
263
  const started = performance.now();
258
264
  if (downloadName !== null && resource && withoutHash(url) === withoutHash(resource.url)) {
259
265
  return download({ url: resource.url, body: resource.body, attributeName: downloadName, totalBytes: resource.body.byteLength });
@@ -267,7 +273,7 @@ export async function createBrowser({
267
273
  try {
268
274
  const target = new URL(url);
269
275
  const response = ["http:", "https:"].includes(target.protocol)
270
- ? await network.navigate(target.href, { onResponse: (raw, finalUrl) => {
276
+ ? await network.navigate(target.href, { post, onResponse: (raw, finalUrl) => {
271
277
  if (!raw.ok || (downloadName === null && !isAttachment(raw.headers.get("content-disposition")))) return null;
272
278
  return download({ url: finalUrl, body: raw.body, disposition: raw.headers.get("content-disposition") ?? "",
273
279
  attributeName: downloadName ?? "", totalBytes: Number(raw.headers.get("content-length")) || 0 });
@@ -318,7 +324,7 @@ export async function createBrowser({
318
324
  Object.assign(history[historyIndex], { x: offset.x, y: offset.y });
319
325
  }
320
326
 
321
- async function navigate(url, { downloadName = null } = {}) {
327
+ async function navigate(url, { downloadName = null, post = null } = {}) {
322
328
  const resolved = new URL(url, currentUrl).href;
323
329
  // A javascript: URL runs in the current document; history does not change.
324
330
  if (resolved.startsWith("javascript:")) {
@@ -326,15 +332,20 @@ export async function createBrowser({
326
332
  return;
327
333
  }
328
334
  await rememberScroll();
329
- if (downloadName === null && withoutHash(resolved) === withoutHash(currentUrl)) {
335
+ let shown = resolved;
336
+ if (!post && downloadName === null && withoutHash(resolved) === withoutHash(currentUrl)) {
330
337
  currentUrl = resolved;
331
338
  } else {
332
- const result = await load(resolved, null, downloadName);
339
+ const result = await load(resolved, null, downloadName, post);
333
340
  if (result?.downloaded) return null;
341
+ // The document is at the URL redirects ended on; the request's fragment
342
+ // carries over to it unless it has its own (Fetch §4.4, HTML §7.4.6).
343
+ shown = withFragment(currentUrl, resolved);
344
+ currentUrl = shown;
334
345
  }
335
- await call("showFragment", resolved);
346
+ await call("showFragment", shown);
336
347
  history.splice(historyIndex + 1);
337
- history.push({ url: resolved, x: 0, y: 0 });
348
+ history.push({ url: shown, x: 0, y: 0 });
338
349
  historyIndex = history.length - 1;
339
350
  }
340
351
 
@@ -359,6 +370,7 @@ export async function createBrowser({
359
370
 
360
371
  const context = {
361
372
  navigate,
373
+ get userAgent() { return userAgent ?? initialUserAgent; },
362
374
  setDownloadBehavior({ behavior = "allow", downloadPath = DEFAULT_DOWNLOAD_DIR, eventsEnabled = false } = {}) {
363
375
  if (!["allow", "allowAndName", "default", "deny"].includes(behavior)) throw new TypeError(`Unsupported download behavior ${behavior}`);
364
376
  downloadBehavior = { behavior, downloadPath, eventsEnabled: Boolean(eventsEnabled) };
@@ -375,12 +387,22 @@ export async function createBrowser({
375
387
  return applyState("resize", resizeArguments);
376
388
  },
377
389
  setUserAgent(value) {
378
- // With --mobile the phone user agent stays (casty, for one, overrides it with a desktop one).
379
- if (mobile) return undefined;
390
+ // A client's default override must not replace an explicit CLI profile.
391
+ if (mobile || webkit) return undefined;
380
392
  userAgent = typeof value === "string" && value ? value : null;
381
393
  network.userAgent = userAgent;
382
394
  return userAgent ? applyState("setUserAgent", () => [userAgent]) : undefined;
383
395
  },
396
+ addInitScript(source) {
397
+ const identifier = String(nextInitScript++);
398
+ initScripts.set(identifier, String(source));
399
+ applyState("setInitScripts", () => [[...initScripts.values()]]);
400
+ return identifier;
401
+ },
402
+ removeInitScript(identifier) {
403
+ if (!initScripts.delete(String(identifier))) return;
404
+ applyState("setInitScripts", () => [[...initScripts.values()]]);
405
+ },
384
406
  screenshot: (options = {}) => composite("screenshot", {
385
407
  format: options.format === "jpeg" || options.format === "webp" ? options.format : "png",
386
408
  quality: Number.isFinite(options.quality) ? options.quality : 80,
@@ -402,7 +424,20 @@ export async function createBrowser({
402
424
  ? { url: resource.url, contentType: resource.contentType, body: resource.body }
403
425
  : null;
404
426
  },
405
- scroll: (deltaX, deltaY) => composite("scroll", deltaX / pageScale(), deltaY / pageScale()),
427
+ /**
428
+ * Scrolls by a wheel delta. With the pointer's viewport position, the
429
+ * scroll container under it (an element, an iframe's document) scrolls
430
+ * first; the page scrolls when nothing there takes it.
431
+ */
432
+ async scroll(deltaX, deltaY, at = null) {
433
+ const scale = pageScale();
434
+ let left = { x: deltaX / scale, y: deltaY / scale };
435
+ if (at && Number.isFinite(at.x) && Number.isFinite(at.y)) {
436
+ left = await call("wheel", at.x / scale, at.y / scale, left.x, left.y) ?? left;
437
+ if (!left.x && !left.y) return undefined;
438
+ }
439
+ return composite("scroll", left.x, left.y);
440
+ },
406
441
  press: (key, options) => call("press", key, options),
407
442
  type: (text) => call("type", text),
408
443
  scrollOffset: () => composite("scrollOffset"),
@@ -420,6 +455,9 @@ export async function createBrowser({
420
455
  lifecycleState() {
421
456
  return [...reachedLifecycle(committed.host, committed.generation)];
422
457
  },
458
+ move(x, y) {
459
+ return call("move", x / pageScale(), y / pageScale());
460
+ },
423
461
  async click(x, y) {
424
462
  const clicked = await call("click", x / pageScale(), y / pageScale());
425
463
  const href = typeof clicked === "string" ? clicked : clicked?.url;
@@ -494,6 +532,18 @@ async function localDocument(url) {
494
532
  };
495
533
  }
496
534
 
535
+ /** `url`, with the fragment of `requested` when it has none of its own. */
536
+ function withFragment(url, requested) {
537
+ try {
538
+ const target = new URL(url);
539
+ const hash = new URL(requested).hash;
540
+ if (!target.hash && hash) target.hash = hash;
541
+ return target.href;
542
+ } catch {
543
+ return url;
544
+ }
545
+ }
546
+
497
547
  function withoutHash(href) {
498
548
  const copy = new URL(href);
499
549
  copy.hash = "";
@@ -1,8 +1,11 @@
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"]);
1
+ import { hitTarget } from "../paint/display-list.js";
2
+
3
+ // An iframe takes clicks for its own document (the renderer forwards them).
4
+ const INTERACTIVE_TAGS = new Set(["A", "AUDIO", "BUTTON", "IFRAME", "INPUT", "LABEL", "SELECT", "SUMMARY", "TEXTAREA", "VIDEO"]);
5
+ // Form controls, labels and iframes are hit over their whole box, not only
6
+ // where text is drawn (an unchecked checkbox or an empty field has none; a
7
+ // block label is clickable beside its text).
8
+ const CONTROL_TAGS = new Set(["AUDIO", "BUTTON", "IFRAME", "INPUT", "LABEL", "SELECT", "TEXTAREA"]);
6
9
 
7
10
  export function interactiveRegions(tree, layout, scroll = { x: 0, y: 0 }, viewport = {}) {
8
11
  const regions = [];
@@ -71,6 +74,18 @@ export function hitTest(tree, layout, x, y, scroll, viewport) {
71
74
  return deepest.sort((left, right) => left.area - right.area)[0] ?? null;
72
75
  }
73
76
 
77
+ /**
78
+ * The element a pointer event at a viewport point targets: the topmost one
79
+ * painted there (its own box or its text), skipping those that take no
80
+ * pointer events. Null when the point hits nothing.
81
+ */
82
+ export function elementAtPoint(tree, displayList, x, y, scroll) {
83
+ const hittable = hitTestable(tree);
84
+ const nodeId = hitTarget(displayList, x, y, scroll, (id) => !hittable(tree.nodesById.get(id)));
85
+ const domNode = nodeId == null ? null : tree.nodesById.get(nodeId)?.domNode;
86
+ return domNode?.nodeType === 1 ? domNode : domNode?.parentElement ?? null;
87
+ }
88
+
74
89
  /** Returns the content-coordinate bounds contributed by an element's text descendants. */
75
90
  export function elementBounds(tree, layout, element) {
76
91
  const rects = [];
@@ -81,11 +96,14 @@ export function elementBounds(tree, layout, element) {
81
96
  ? renderNode.domNode
82
97
  : renderNode?.domNode?.parentElement;
83
98
  if (!owner || (owner !== element && !element.contains(owner))) continue;
99
+ // A replaced element (an image) spans its own box, not its line's height.
100
+ const replaced = run.type === "image" && run.y != null;
101
+ const top = replaced ? run.y : fragment.y;
84
102
  rects.push({
85
103
  left: run.x,
86
- top: fragment.y,
104
+ top,
87
105
  right: run.x + run.width,
88
- bottom: fragment.y + fragment.height,
106
+ bottom: top + (replaced ? run.height : fragment.height),
89
107
  });
90
108
  }
91
109
  }