buninu-browser 0.1.0 → 0.2.0

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 (57) hide show
  1. package/AGENTS.md +17 -0
  2. package/LICENSES/brotli.txt +19 -0
  3. package/README.md +41 -7
  4. package/THIRD_PARTY_NOTICES.md +26 -4
  5. package/buninu-browser.js +10 -2
  6. package/lib/audio/pcm.js +16 -0
  7. package/lib/audio/piano.js +109 -0
  8. package/lib/audio/pulse-client.js +413 -0
  9. package/lib/cdp-server.js +25 -6
  10. package/lib/document-types.js +308 -0
  11. package/lib/download-directory.js +28 -0
  12. package/lib/download.js +63 -0
  13. package/lib/headless-shell.js +108 -14
  14. package/lib/input/hit-test.js +57 -8
  15. package/lib/layout/text-layout.js +112 -27
  16. package/lib/network/network-service.js +7 -3
  17. package/lib/network/resolver.js +16 -0
  18. package/lib/paint/display-list.js +2 -2
  19. package/lib/render-tree/index.js +13 -0
  20. package/lib/renderer/bindings.js +541 -53
  21. package/lib/renderer/flags.js +8 -5
  22. package/lib/renderer/host.js +102 -6
  23. package/lib/renderer/main.js +35 -3
  24. package/lib/renderer/module-transform.js +30 -23
  25. package/lib/renderer/page-realm.js +226 -22
  26. package/lib/renderer/page-renderer.js +1293 -212
  27. package/lib/screenshot-cli.js +61 -0
  28. package/lib/style/computed-style.js +268 -20
  29. package/lib/user-agent.js +12 -0
  30. package/package.json +1 -1
  31. package/patches/happy-dom/0003-defer-script-events-to-renderer.patch +40 -0
  32. package/patches/happy-dom/0004-insert-adjacent-html-in-order.patch +19 -0
  33. package/patches/happy-dom/0005-invalidate-matches-on-structure-changes.patch +43 -0
  34. package/patches/happy-dom/README.md +12 -0
  35. package/patches/ses/0001-allow-regexp-own-tostring.patch +13 -0
  36. package/patches/ses/README.md +16 -0
  37. package/test/computed-style.test.js +52 -0
  38. package/test/document-types.test.js +111 -0
  39. package/test/download.test.js +192 -0
  40. package/test/fixtures/tone.mp3 +0 -0
  41. package/test/flex.test.js +11 -0
  42. package/test/happy-dom-matches.test.js +25 -0
  43. package/test/headless-shell.test.js +27 -0
  44. package/test/hit-test.test.js +37 -0
  45. package/test/page-scripts.test.js +388 -29
  46. package/test/piano.test.js +65 -0
  47. package/test/positioning.test.js +11 -0
  48. package/test/pulse-client.test.js +130 -0
  49. package/test/render-tree.test.js +14 -0
  50. package/test/resolver.test.js +12 -1
  51. package/test/screenshot-cli.test.js +19 -0
  52. package/test/text-layout.test.js +22 -0
  53. package/test/user-agent.test.js +42 -0
  54. package/vendor/happy-dom/packages/happy-dom/src/nodes/element/Element.ts +3 -6
  55. package/vendor/happy-dom/packages/happy-dom/src/nodes/html-script-element/HTMLScriptElement.ts +2 -20
  56. package/vendor/happy-dom/packages/happy-dom/src/query-selector/QuerySelector.ts +15 -3
  57. package/vendor/ses/dist/ses.mjs +2 -0
@@ -0,0 +1,130 @@
1
+ import { expect, test } from "bun:test";
2
+ import { connect } from "node:net";
3
+ import { floatToS16, PulseClient } from "../lib/audio/pulse-client.js";
4
+ import { splitSamples } from "../lib/audio/pcm.js";
5
+
6
+ // The playback test needs a PulseAudio native-protocol server (jspulse, or a
7
+ // daemon with module-native-protocol-tcp) on 127.0.0.1:4713; without one it is skipped.
8
+ const serverRunning = await new Promise((resolve) => {
9
+ const socket = connect({ host: "127.0.0.1", port: 4713 });
10
+ const done = (running) => {
11
+ socket.destroy();
12
+ resolve(running);
13
+ };
14
+ socket.once("connect", () => done(true));
15
+ socket.once("error", () => done(false));
16
+ setTimeout(() => done(false), 1000);
17
+ });
18
+
19
+ test("floatToS16 clamps and scales interleaved samples to s16le", () => {
20
+ const bytes = floatToS16(new Float32Array([0, 1, -1, 2, 0.5]));
21
+ expect([...new Int16Array(bytes.buffer)]).toEqual([0, 32767, -32768, 32767, 16384]);
22
+ });
23
+
24
+ test("small audio requests consume only whole frames and preserve the remainder", () => {
25
+ const original = Float32Array.from({ length: 2048 }, (_, index) => index / 2048);
26
+ let pending = original;
27
+ const sent = [];
28
+ for (const bytes of [4, 80, 3840, 172]) {
29
+ const [part, rest] = splitSamples(pending, 2, bytes);
30
+ expect(part.length * 2).toBeLessThanOrEqual(bytes);
31
+ expect(part.length % 2).toBe(0);
32
+ sent.push(part);
33
+ pending = rest;
34
+ }
35
+ expect([...sent.flatMap((part) => [...part]), ...pending]).toEqual([...original]);
36
+ });
37
+
38
+ test.skipIf(!serverRunning)("plays a stream through the PulseAudio server on 127.0.0.1:4713", async () => {
39
+ const client = await PulseClient.connect();
40
+ const requests = [];
41
+ const stream = await client.openPlayback({ rate: 44100, channels: 2, onRequest: (bytes) => requests.push(bytes) });
42
+ expect(requests[0]).toBeGreaterThan(0);
43
+ stream.write(floatToS16(new Float32Array(44100 * 2 / 10))); // 0.1 s of stereo silence
44
+ await stream.finish();
45
+ expect(stream.killed).toBe(true);
46
+ client.close();
47
+ });
48
+
49
+ test.skipIf(!serverRunning)("opens independent playback streams at different sample rates", async () => {
50
+ const client = await PulseClient.connect();
51
+ const first = await client.openPlayback({ rate: 48000, channels: 1, name: "first" });
52
+ const second = await client.openPlayback({ rate: 44100, channels: 2, name: "second" });
53
+ expect(first.index).not.toBe(second.index);
54
+ await second.close();
55
+ await first.close();
56
+ client.close();
57
+ });
58
+
59
+ test("connecting without a server fails with an error, not a hang", async () => {
60
+ await expect(PulseClient.connect({ port: 1 })).rejects.toThrow();
61
+ });
62
+
63
+ test("audio is sent in whole frames, so stereo channels never swap", async () => {
64
+ // A minimal server: replies to every command, grants odd-sized requests, records audio frame sizes.
65
+ const sizes = [];
66
+ const offsets = [];
67
+ const flags = [];
68
+ const bufferAttributes = [];
69
+ let nextStreamIndex = 1;
70
+ const { createServer } = await import("node:net");
71
+ const server = createServer((socket) => {
72
+ let buffer = Buffer.alloc(0);
73
+ const reply = (tag, extra = []) => {
74
+ const body = Buffer.from([0x4c, 0, 0, 0, 2, 0x4c, ...u32(tag), ...extra]);
75
+ socket.write(Buffer.concat([header(body.length, 0xffffffff), body]));
76
+ };
77
+ socket.on("data", (chunk) => {
78
+ buffer = Buffer.concat([buffer, chunk]);
79
+ while (buffer.length >= 20 && buffer.length >= 20 + buffer.readUInt32BE(0)) {
80
+ const length = buffer.readUInt32BE(0);
81
+ const channel = buffer.readUInt32BE(4);
82
+ const offset = buffer.readBigInt64BE(8);
83
+ const flag = buffer.readUInt32BE(16);
84
+ const body = buffer.subarray(20, 20 + length);
85
+ buffer = buffer.subarray(20 + length);
86
+ if (channel !== 0xffffffff) {
87
+ sizes.push(length);
88
+ offsets.push(offset);
89
+ flags.push(flag);
90
+ continue;
91
+ }
92
+ const command = body.readUInt32BE(1);
93
+ const tag = body.readUInt32BE(6);
94
+ if (command === 3) {
95
+ const nameEnd = body.indexOf(0, 11);
96
+ const channelCount = body[nameEnd + 3];
97
+ const attrs = nameEnd + 1 + 7 + 2 + channelCount + 5 + 1 + 5 + 1;
98
+ bufferAttributes.push([0, 1, 2].map((index) => body.readUInt32BE(attrs + index * 5 + 1)));
99
+ reply(tag, [0x4c, ...u32(nextStreamIndex++), 0x4c, ...u32(1), 0x4c, ...u32(96003)]); // odd request, enough for several packets
100
+ }
101
+ else reply(tag);
102
+ }
103
+ });
104
+ });
105
+ await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
106
+ const client = await PulseClient.connect({ port: server.address().port });
107
+ const stream = await client.openPlayback({ rate: 48000, channels: 2 });
108
+ stream.write(new Uint8Array(4 * 24000));
109
+ await Bun.sleep(100);
110
+ expect(sizes.length).toBeGreaterThan(1);
111
+ expect(sizes.every((size) => size % 4 === 0)).toBeTrue();
112
+ expect(offsets.every((offset) => offset === 0n)).toBeTrue();
113
+ expect(flags.every((flag) => flag === 0)).toBeTrue();
114
+ expect(bufferAttributes[0]).toEqual([76800, 15360, 7680]); // 400/80/40 ms at 48 kHz stereo
115
+ await client.openPlayback({ rate: 48000, channels: 1, leadSeconds: 0.12 });
116
+ expect(bufferAttributes[1]).toEqual([15360, 3840, 3840]); // 160/40/40 ms at 48 kHz mono
117
+ client.close();
118
+ server.close();
119
+ });
120
+
121
+ function u32(value) {
122
+ return [(value >>> 24) & 255, (value >>> 16) & 255, (value >>> 8) & 255, value & 255];
123
+ }
124
+
125
+ function header(length, channel) {
126
+ const out = Buffer.alloc(20);
127
+ out.writeUInt32BE(length, 0);
128
+ out.writeUInt32BE(channel, 4);
129
+ return out;
130
+ }
@@ -101,3 +101,17 @@ test("form controls render their live value, placeholder, selection and checked
101
101
  expect(renderTreeText(tree)).toContain("bold label");
102
102
  window.happyDOM.abort();
103
103
  });
104
+
105
+ test("audio with controls is a 300x32 player painted from its state; without controls it is not rendered", async () => {
106
+ const { StyleEngine } = await import("../lib/style/computed-style.js");
107
+ const { document, window } = parseHTMLDocument(`<body><audio id="a" controls src="t.mp3">fallback</audio><audio id="b" src="t.mp3"></audio></body>`);
108
+ const engine = new StyleEngine().compute(document);
109
+ const state = { audio: true, playing: false };
110
+ const tree = new RenderTreeBuilder().build(document, engine, new Map([[document.getElementById("a"), state]]));
111
+ const node = (id) => [...tree.nodesById.values()].find((candidate) => candidate.domNode.id === id && !candidate.pseudo);
112
+ expect(node("a")).toMatchObject({ type: "image", tagName: "AUDIO", widthAttribute: 300, heightAttribute: 32 });
113
+ expect(node("a").resource).toBe(state);
114
+ expect(node("b")).toBeUndefined();
115
+ expect(renderTreeText(tree)).not.toContain("fallback");
116
+ window.happyDOM.abort();
117
+ });
@@ -1,5 +1,5 @@
1
1
  import { expect, test } from "bun:test";
2
- import { fetchResolved, HostResolver } from "../lib/network/resolver.js";
2
+ import { fetchResolved, HostResolver, isLocalhostName } from "../lib/network/resolver.js";
3
3
 
4
4
  const lookupFrom = (table, calls = []) => async (name, { family }) => {
5
5
  calls.push(`${name}/${family}`);
@@ -49,3 +49,14 @@ test("connects to the address with the host name in Host and TLS, falling back o
49
49
  const failing = async () => { throw Object.assign(new Error("bad cert"), { code: "ERR_TLS_CERT_ALTNAME_INVALID" }); };
50
50
  await expect(fetchResolved(failing, resolver, "https://a.test/", {})).rejects.toThrow("bad cert");
51
51
  });
52
+
53
+ test("localhost names are loopback without a lookup (no /etc/hosts needed)", async () => {
54
+ const resolver = new HostResolver({ lookup: async () => { throw Object.assign(new Error("no hosts"), { code: "ENOTFOUND" }); } });
55
+ const loopback = [{ address: "127.0.0.1", family: 4 }, { address: "::1", family: 6 }];
56
+ for (const name of ["localhost", "LocalHost", "localhost.", "app.localhost", "a.b.localhost."]) {
57
+ expect(await resolver.addresses(name)).toEqual(loopback);
58
+ }
59
+ for (const name of ["localhost.com", "notlocalhost", "localhost..", "my-localhost"]) {
60
+ expect(isLocalhostName(name)).toBe(false);
61
+ }
62
+ });
@@ -0,0 +1,19 @@
1
+ import { expect, test } from "bun:test";
2
+ import { screenshotTargetUrl } from "../lib/screenshot-cli.js";
3
+
4
+ test("screenshot target treats Windows drive and UNC paths as files", () => {
5
+ const options = {
6
+ platform: "win32",
7
+ resolvePath: (path) => path,
8
+ fileURL: (path) => ({ href: `file:${path}` }),
9
+ };
10
+ for (const path of [String.raw`C:\Users\Ada\page.html`, "C:/Users/Ada/page.html", String.raw`\\server\share\page.html`, String.raw`pages\page.html`]) {
11
+ expect(screenshotTargetUrl(path, options)).toBe(`file:${path}`);
12
+ }
13
+ });
14
+
15
+ test("screenshot target preserves URLs and bare hostnames", () => {
16
+ expect(screenshotTargetUrl("file:///C:/Users/Ada/page.html", { platform: "win32" })).toBe("file:///C:/Users/Ada/page.html");
17
+ expect(screenshotTargetUrl("https://example.com", { platform: "win32" })).toBe("https://example.com");
18
+ expect(screenshotTargetUrl("example.com", { platform: "win32" })).toBe("https://example.com");
19
+ });
@@ -417,3 +417,25 @@ test("an auto grid track is limited by a min-width: 0 item instead of its conten
417
417
  expect(layout.boxes.map((box) => box.width)).toContain(100);
418
418
  expect(layout.boxes.map((box) => box.width)).not.toContain(160);
419
419
  });
420
+
421
+ test("a grid body stacks items sharing an area and aligns its content with place-*", () => {
422
+ const { document, window } = parseHTMLDocument(`<body><i id="icon" style="display:block;width:40px;height:40px"></i>
423
+ <p id="a">one</p><p id="b">two two</p><p id="c">three</p></body>`);
424
+ const styles = new StyleEngine().compute(document, [`
425
+ body { margin:0; display:grid; min-height:300px; place-content:center; place-items:center }
426
+ p { margin:0; grid-area:2/1 }
427
+ `]);
428
+ const tree = new RenderTreeBuilder().build(document, styles);
429
+ const layout = layoutText(tree, {
430
+ x: 0, y: 0, width: 400, measureText: (text) => text.length * 10, fontMetrics: () => ({ ascent: 16, height: 20 }),
431
+ });
432
+ const boxOf = (id) => layout.boxes.find((box) => tree.nodesById.get(box.nodeId)?.domNode?.id === id);
433
+ const [icon, a, b, c] = ["icon", "a", "b", "c"].map(boxOf);
434
+ // One shared row for the paragraphs, centered in 300px under the 40px icon row.
435
+ expect([a.y, b.y, c.y]).toEqual([a.y, a.y, a.y]);
436
+ expect(icon.y).toBe((300 - 40 - 20) / 2);
437
+ expect(a.y).toBe(icon.y + 40);
438
+ // place-items: center centers each item in the 70px-wide column ("two two").
439
+ expect([icon.x, a.x, b.x]).toEqual([180, 185, 165]);
440
+ window.happyDOM.abort();
441
+ });
@@ -0,0 +1,42 @@
1
+ import { expect, test } from "bun:test";
2
+ import { CdpServer } from "../lib/cdp-server.js";
3
+ import { createBrowser } from "../lib/headless-shell.js";
4
+ import { MOBILE_USER_AGENT } from "../lib/renderer/flags.js";
5
+ import { CHROMIUM_VERSION, DESKTOP_USER_AGENT, desktopUserAgent } from "../lib/user-agent.js";
6
+
7
+ test("desktop user agent uses the same version on each platform", () => {
8
+ for (const [platform, arch, system] of [
9
+ ["linux", "arm64", "X11; Linux aarch64"],
10
+ ["linux", "x64", "X11; Linux x86_64"],
11
+ ["win32", "x64", "Windows NT 10.0; Win64; x64"],
12
+ ["darwin", "arm64", "Macintosh; Intel Mac OS X 10_15_7"],
13
+ ]) {
14
+ expect(desktopUserAgent(platform, arch)).toBe(`Mozilla/5.0 (${system}) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${CHROMIUM_VERSION} Safari/537.36`);
15
+ }
16
+ });
17
+
18
+ test("mobile uses current Chrome for Android rather than WebView", () => {
19
+ expect(MOBILE_USER_AGENT).toBe(`Mozilla/5.0 (Linux; Android 16; ASUSAI2501C Build/BQ2A.250525.001) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${CHROMIUM_VERSION} Mobile Safari/537.36`);
20
+ });
21
+
22
+ test("CDP discovery, HTTP requests, and navigator report the same desktop user agent", async () => {
23
+ let requestUserAgent;
24
+ const site = Bun.serve({ port: 0, fetch(request) {
25
+ requestUserAgent = request.headers.get("user-agent");
26
+ return new Response("<!doctype html><title>UA test</title>", { headers: { "content-type": "text/html" } });
27
+ } });
28
+ const browser = await createBrowser({ spareRenderer: false });
29
+ const cdp = CdpServer.create(browser.context).listen(0, "127.0.0.1");
30
+ try {
31
+ const version = await fetch(`http://127.0.0.1:${cdp.port}/json/version`).then((response) => response.json());
32
+ expect(version.Browser).toBe(`Chrome/${CHROMIUM_VERSION}`);
33
+ expect(version["User-Agent"]).toBe(DESKTOP_USER_AGENT);
34
+ await browser.context.navigate(`http://127.0.0.1:${site.port}/`);
35
+ expect(requestUserAgent).toBe(DESKTOP_USER_AGENT);
36
+ expect(await browser.context.evaluate("navigator.userAgent")).toBe(DESKTOP_USER_AGENT);
37
+ } finally {
38
+ cdp.stop(true);
39
+ browser.close();
40
+ site.stop(true);
41
+ }
42
+ });
@@ -706,12 +706,9 @@ export default class Element
706
706
  * @param text HTML string to insert.
707
707
  */
708
708
  public insertAdjacentHTML(position: InsertAdjacentPosition, text: string): void {
709
- const childNodes = new HTMLParser(this[PropertySymbol.window]).parse(text)[
710
- PropertySymbol.nodeArray
711
- ];
712
- while (childNodes.length) {
713
- this.insertAdjacentElement(position, childNodes[0]);
714
- }
709
+ // Insert the parsed fragment as a whole: inserting its nodes one at a
710
+ // time at "afterbegin" or "afterend" reversed their order.
711
+ this.insertAdjacentElement(position, new HTMLParser(this[PropertySymbol.window]).parse(text));
715
712
  }
716
713
 
717
714
  /**
@@ -566,16 +566,7 @@ export default class HTMLScriptElement extends HTMLElement {
566
566
  browserSettings &&
567
567
  (browserSettings.disableJavaScriptFileLoading || !browserSettings.enableJavaScriptEvaluation)
568
568
  ) {
569
- if (browserSettings.handleDisabledFileLoadingAsSuccess) {
570
- this.dispatchEvent(new Event('load'));
571
- } else {
572
- const error = new window.DOMException(
573
- `Failed to load module "${url}". JavaScript file loading is disabled.`,
574
- DOMExceptionNameEnum.notSupportedError
575
- );
576
- browserFrame.page.console.error(error);
577
- this.dispatchEvent(new Event('error'));
578
- }
569
+ // The renderer owns script fetching and dispatches load/error after execution.
579
570
  return;
580
571
  }
581
572
 
@@ -647,16 +638,7 @@ export default class HTMLScriptElement extends HTMLElement {
647
638
  browserSettings &&
648
639
  (browserSettings.disableJavaScriptFileLoading || !browserSettings.enableJavaScriptEvaluation)
649
640
  ) {
650
- if (browserSettings.handleDisabledFileLoadingAsSuccess) {
651
- this.dispatchEvent(new Event('load'));
652
- } else {
653
- const error = new window.DOMException(
654
- `Failed to load script "${absoluteURL}". JavaScript file loading is disabled.`,
655
- DOMExceptionNameEnum.notSupportedError
656
- );
657
- browserFrame.page.console.error(error);
658
- this.dispatchEvent(new Event('error'));
659
- }
641
+ // The renderer owns script fetching and dispatches load/error after execution.
660
642
  return;
661
643
  }
662
644
 
@@ -373,7 +373,10 @@ export default class QuerySelector {
373
373
  );
374
374
  }
375
375
 
376
- const cachedResult = element[PropertySymbol.cache].matches.get(selector);
376
+ // A :has() match depends on the whole subtree, which no affected-cache
377
+ // registration covers, so it is not cached.
378
+ const cacheable = !selector.includes(':has(');
379
+ const cachedResult = cacheable ? element[PropertySymbol.cache].matches.get(selector) : null;
377
380
 
378
381
  if (cachedResult) {
379
382
  if (cachedResult.result !== null) {
@@ -386,9 +389,11 @@ export default class QuerySelector {
386
389
  result: { match: null }
387
390
  };
388
391
 
389
- element[PropertySymbol.cache].matches.set(selector, cachedItem);
392
+ if (cacheable) {
393
+ element[PropertySymbol.cache].matches.set(selector, cachedItem);
394
+ }
390
395
 
391
- if (element[PropertySymbol.isConnected]) {
396
+ if (cacheable && element[PropertySymbol.isConnected]) {
392
397
  // Document is affected for the ":target" selector
393
398
  (element[PropertySymbol.ownerDocument] || element)[PropertySymbol.affectsCache].push(
394
399
  cachedItem
@@ -462,6 +467,13 @@ export default class QuerySelector {
462
467
  return null;
463
468
  }
464
469
 
470
+ // Structural pseudo-classes and sibling combinators read the parent's
471
+ // child list, whose changes are reported on the parent.
472
+ const parent = element.parentNode;
473
+ if (parent) {
474
+ this.affectsCache(<Element>parent, cachedItem);
475
+ }
476
+
465
477
  const result = selectorItem.match(scope, element, ignoreErrors);
466
478
 
467
479
  if (result) {
@@ -8108,6 +8108,8 @@ const methods = {
8108
8108
  * is accepted.
8109
8109
  */
8110
8110
  '%SetPrototype%': '*',
8111
+ // Page scripts may assign an own toString method to a RegExp instance.
8112
+ '%RegExpPrototype%': { toString: true },
8111
8113
  };$h͏_once.severeEnablements(severeEnablements);
8112
8114
  })()
8113
8115
  ,