buninu-browser 0.0.2 → 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 (61) 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 +30 -6
  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 +110 -9
  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/patches/wasmpeg/0001-windows-wasm-path.patch +14 -0
  38. package/patches/wasmpeg/README.md +28 -0
  39. package/test/computed-style.test.js +52 -0
  40. package/test/document-types.test.js +111 -0
  41. package/test/download.test.js +192 -0
  42. package/test/fixtures/tone.mp3 +0 -0
  43. package/test/flex.test.js +11 -0
  44. package/test/happy-dom-matches.test.js +25 -0
  45. package/test/headless-shell.test.js +27 -0
  46. package/test/hit-test.test.js +37 -0
  47. package/test/page-scripts.test.js +388 -29
  48. package/test/piano.test.js +65 -0
  49. package/test/positioning.test.js +11 -0
  50. package/test/pulse-client.test.js +130 -0
  51. package/test/render-tree.test.js +14 -0
  52. package/test/resolver.test.js +12 -1
  53. package/test/screenshot-cli.test.js +19 -0
  54. package/test/text-layout.test.js +22 -0
  55. package/test/user-agent.test.js +42 -0
  56. package/vendor/happy-dom/packages/happy-dom/src/nodes/element/Element.ts +3 -6
  57. package/vendor/happy-dom/packages/happy-dom/src/nodes/html-script-element/HTMLScriptElement.ts +2 -20
  58. package/vendor/happy-dom/packages/happy-dom/src/query-selector/QuerySelector.ts +15 -3
  59. package/vendor/ses/dist/ses.mjs +2 -0
  60. package/vendor/wasmpeg/VENDORING.md +7 -3
  61. package/vendor/wasmpeg/src/js/gpu.js +2 -1
@@ -15,6 +15,12 @@ git -C vendor/happy-dom apply --check ../../patches/happy-dom/0001-use-bun-entit
15
15
  git -C vendor/happy-dom apply ../../patches/happy-dom/0001-use-bun-entity-adapter.patch
16
16
  git -C vendor/happy-dom apply --check ../../patches/happy-dom/0002-use-bun-platform-adapters.patch
17
17
  git -C vendor/happy-dom apply ../../patches/happy-dom/0002-use-bun-platform-adapters.patch
18
+ git -C vendor/happy-dom apply --check ../../patches/happy-dom/0003-defer-script-events-to-renderer.patch
19
+ git -C vendor/happy-dom apply ../../patches/happy-dom/0003-defer-script-events-to-renderer.patch
20
+ git -C vendor/happy-dom apply --check ../../patches/happy-dom/0004-insert-adjacent-html-in-order.patch
21
+ git -C vendor/happy-dom apply ../../patches/happy-dom/0004-insert-adjacent-html-in-order.patch
22
+ git -C vendor/happy-dom apply --check ../../patches/happy-dom/0005-invalidate-matches-on-structure-changes.patch
23
+ git -C vendor/happy-dom apply ../../patches/happy-dom/0005-invalidate-matches-on-structure-changes.patch
18
24
  ```
19
25
 
20
26
  For the already-patched vendored tree, verify that the recorded patch can be
@@ -23,6 +29,9 @@ removed cleanly without changing files:
23
29
  ```sh
24
30
  git -C vendor/happy-dom apply --reverse --check ../../patches/happy-dom/0001-use-bun-entity-adapter.patch
25
31
  git -C vendor/happy-dom apply --reverse --check ../../patches/happy-dom/0002-use-bun-platform-adapters.patch
32
+ git -C vendor/happy-dom apply --reverse --check ../../patches/happy-dom/0003-defer-script-events-to-renderer.patch
33
+ git -C vendor/happy-dom apply --reverse --check ../../patches/happy-dom/0004-insert-adjacent-html-in-order.patch
34
+ git -C vendor/happy-dom apply --reverse --check ../../patches/happy-dom/0005-invalidate-matches-on-structure-changes.patch
26
35
  ```
27
36
 
28
37
  ## Patch manifest
@@ -31,6 +40,9 @@ git -C vendor/happy-dom apply --reverse --check ../../patches/happy-dom/0002-use
31
40
  |---|---|---|---|
32
41
  | `0001-use-bun-entity-adapter.patch` | `packages/happy-dom/src/utilities/XMLEncodeUtility.ts` | Route entity decoding through the project-owned `Bun.markdown.render()` adapter instead of installing the external `entities` package. | `bun test test/entities-adapter.test.js` |
33
42
  | `0002-use-bun-platform-adapters.patch` | `packages/happy-dom/src/file/FileReader.ts`, `packages/happy-dom/src/nodes/html-image-element/HTMLImageElement.ts`, `packages/happy-dom/src/web-socket/WebSocket.ts` | Route the remaining static package imports through project-owned adapters backed by Bun APIs, so importing the DOM tree does not require installing npm dependencies. | `bun test test/html-parser.test.js test/happy-dom-platform-adapters.test.js` |
43
+ | `0003-defer-script-events-to-renderer.patch` | `packages/happy-dom/src/nodes/html-script-element/HTMLScriptElement.ts` | Suppress Happy DOM's synthetic load/error event when its own script loader is disabled; the renderer fetches and executes scripts and dispatches the real outcome itself. | `bun test test/page-scripts.test.js` (`page-inserted scripts run; innerHTML scripts do not; on* handlers fire`) |
44
+ | `0004-insert-adjacent-html-in-order.patch` | `packages/happy-dom/src/nodes/element/Element.ts` | `insertAdjacentHTML` inserted the parsed nodes one at a time, which reversed their order at `afterbegin` and `afterend`; insert the parsed fragment as a whole. | `bun test test/page-scripts.test.js` (`insertAdjacentHTML keeps the order of the inserted nodes at every position`) |
45
+ | `0005-invalidate-matches-on-structure-changes.patch` | `packages/happy-dom/src/query-selector/QuerySelector.ts` | `Element.matches()` cached results that were invalidated only by changes to the elements the matcher visited, so structural pseudo-classes and sibling combinators stayed stale after a child list change, and `:has()` after any change in the subtree; register the parent of every visited element, and do not cache `:has()` matches. | `bun test test/happy-dom-matches.test.js` |
34
46
 
35
47
  Keep each local Happy DOM change in a separate numbered patch and add it to
36
48
  this table with its reason and focused test. When updating Happy DOM, regenerate
@@ -0,0 +1,13 @@
1
+ diff --git a/dist/ses.mjs b/dist/ses.mjs
2
+ index c10f41b..8bda46b 100644
3
+ --- a/dist/ses.mjs
4
+ +++ b/dist/ses.mjs
5
+ @@ -8108,6 +8108,8 @@ const methods = {
6
+ * is accepted.
7
+ */
8
+ '%SetPrototype%': '*',
9
+ + // Page scripts may assign an own toString method to a RegExp instance.
10
+ + '%RegExpPrototype%': { toString: true },
11
+ };$h͏_once.severeEnablements(severeEnablements);
12
+ })()
13
+ ,
@@ -0,0 +1,16 @@
1
+ # SES patch queue
2
+
3
+ The vendored SES bundle is the `ses` 2.3.0 npm release. Patch paths are
4
+ relative to `vendor/ses`.
5
+
6
+ For the already-patched tree, verify the recorded patch without changing files:
7
+
8
+ ```sh
9
+ git -C vendor/ses apply --reverse --check ../../patches/ses/0001-allow-regexp-own-tostring.patch
10
+ ```
11
+
12
+ | Patch | Modified file | Reason | Verification |
13
+ |---|---|---|---|
14
+ | `0001-allow-regexp-own-tostring.patch` | `dist/ses.mjs` | Allow a page script to assign an own `toString` method to a `RegExp` instance despite the frozen prototype. This does not make the prototype writable. | `bun test test/page-scripts.test.js` |
15
+
16
+ Keep future local SES changes in numbered patches and update this manifest.
@@ -0,0 +1,14 @@
1
+ diff --git a/src/js/gpu.js b/src/js/gpu.js
2
+ index 2bb56f8..a9f4dc6 100644
3
+ --- a/src/js/gpu.js
4
+ +++ b/src/js/gpu.js
5
+ @@ -51,7 +51,8 @@ async function instantiate(path) {
6
+ let nodeOpts = {};
7
+ if (isNode) {
8
+ const { default: fsMod } = await import('node:fs');
9
+ - nodeOpts = { wasmBinary: fsMod.readFileSync(new URL(path).pathname.replace(/\.js$/, '.wasm')) };
10
+ + const { fileURLToPath } = await import('node:url');
11
+ + nodeOpts = { wasmBinary: fsMod.readFileSync(fileURLToPath(path).replace(/\.js$/, '.wasm')) };
12
+ }
13
+ const { default: factory } = await import(/* @vite-ignore */ path);
14
+ return factory({
@@ -0,0 +1,28 @@
1
+ # wasmpeg patch queue
2
+
3
+ The vendored wasmpeg JavaScript is the `@wasmpeg/cpu` 1.0.1 npm release, which
4
+ matches upstream tag `v1.0.1`, commit `173b41e30f4dd21d15f4e564c3ad26cba5d89d80`:
5
+
6
+ - Upstream: <https://github.com/wasmpeg/wasmpeg>
7
+ - Vendored tree: `vendor/wasmpeg`
8
+ - Patch paths are relative to the root of the npm package (`vendor/wasmpeg`).
9
+
10
+ The patches change only wasmpeg's JavaScript loader; `dist/cpu.js` and
11
+ `dist/cpu.wasm` (FFmpeg) are the published files, unmodified.
12
+
13
+ For the already-patched vendored tree, verify that the recorded patch can be
14
+ removed cleanly without changing files:
15
+
16
+ ```sh
17
+ git -C vendor/wasmpeg apply --reverse --check ../../patches/wasmpeg/0001-windows-wasm-path.patch
18
+ ```
19
+
20
+ ## Patch manifest
21
+
22
+ | Patch | Modified upstream file | Reason | Verification |
23
+ |---|---|---|---|
24
+ | `0001-windows-wasm-path.patch` | `src/js/gpu.js` | Read `cpu.wasm` from `fileURLToPath(path)` instead of `new URL(path).pathname`, which is `/C:/...` on Windows and not a file path there. | `bun test test/page-scripts.test.js` (every renderer start loads the decoder) |
25
+
26
+ Keep each local change in a separate numbered patch and add it to this table
27
+ with its reason and focused test. When updating wasmpeg, re-check the queue
28
+ against the new release and update `vendor/wasmpeg/VENDORING.md`.
@@ -43,6 +43,15 @@ test("inline declarations validate values and preserve important priority", () =
43
43
  window.happyDOM.abort();
44
44
  });
45
45
 
46
+ test("light-dark colors follow the element's color scheme", () => {
47
+ const { document, window } = parseHTMLDocument(`<div id="light" style="color-scheme: light dark; background: light-dark(#eee, #222)"><span id="child" style="color: light-dark(black, white)">text</span></div><div id="dark" style="background-color: light-dark(#eee, #222); color-scheme: only dark"></div>`);
48
+ const engine = new StyleEngine().compute(document);
49
+ expect(engine.get(document.getElementById("light")).backgroundColor).toBe("rgba(238, 238, 238, 1)");
50
+ expect(engine.get(document.getElementById("child")).color).toBe("rgba(0, 0, 0, 1)");
51
+ expect(engine.get(document.getElementById("dark")).backgroundColor).toBe("rgba(34, 34, 34, 1)");
52
+ window.happyDOM.abort();
53
+ });
54
+
46
55
  test("display none removes a subtree and inline white-space reaches layout input", () => {
47
56
  const { document, window } = parseHTMLDocument(`<body>
48
57
  <div style="display:none">hidden</div>
@@ -432,3 +441,46 @@ test("links get the UA link style; text decorations propagate to in-flow descend
432
441
  expect(style("invalid").textDecorations).toBeNull();
433
442
  window.happyDOM.abort();
434
443
  });
444
+
445
+ test("the font shorthand allows spaces around the line-height slash", () => {
446
+ for (const font of ['14px / 1.5 "Mona Sans VF", sans-serif', '14px/ 1.5 "Mona Sans VF", sans-serif',
447
+ '14px /1.5 "Mona Sans VF", sans-serif', '14px/1.5 "Mona Sans VF", sans-serif']) {
448
+ const { document, window } = parseHTMLDocument(`<p style='font: ${font}'><span>x</span></p>`);
449
+ const engine = new StyleEngine().compute(document);
450
+ for (const element of [document.querySelector("p"), document.querySelector("span")]) {
451
+ const style = engine.get(element);
452
+ expect([style.fontSize, style.lineHeight, style.fontFamily]).toEqual([14, 21, ["Mona Sans VF", "sans-serif"]]);
453
+ }
454
+ window.happyDOM.abort();
455
+ }
456
+ });
457
+
458
+ test("incremental restyles of changed subtrees and added sheets equal a full restyle", () => {
459
+ const { document, window } = parseHTMLDocument(`<body><div id="a" class="x"><p>one</p><p>two</p></div><div id="b"><span>s</span></div></body>`);
460
+ const sheets = [".x p { color: red } .y p { color: blue; font-size: 20px } #b span { font-weight: 700 }"];
461
+ const engine = new StyleEngine().compute(document, sheets);
462
+ const same = (label) => {
463
+ const reference = new StyleEngine().compute(document, engine.sheets ?? sheets);
464
+ for (const element of document.querySelectorAll("*")) {
465
+ expect([label, JSON.stringify(engine.get(element))]).toEqual([label, JSON.stringify(reference.get(element))]);
466
+ }
467
+ };
468
+ // An attribute change: only #a's subtree.
469
+ document.getElementById("a").className = "y";
470
+ engine.compute(document, sheets, {}, new Set([document.getElementById("a")]));
471
+ expect([engine.incremental, engine.restyled]).toEqual([true, 3]);
472
+ same("class change");
473
+ // Inserted elements: their parent's subtree.
474
+ const b = document.getElementById("b");
475
+ b.insertAdjacentHTML("beforeend", "<span>t</span><p>u</p>");
476
+ engine.compute(document, sheets, {}, new Set([b]));
477
+ expect(engine.restyled).toBe(4);
478
+ same("insertion");
479
+ // An added sheet restyles what its rules match, nothing else.
480
+ const more = [...sheets, "span { color: green }"];
481
+ engine.compute(document, more, {}, new Set());
482
+ expect([engine.incremental, engine.restyled]).toEqual([true, 2]); // the two spans
483
+ engine.sheets = more;
484
+ same("added sheet");
485
+ window.happyDOM.abort();
486
+ });
@@ -0,0 +1,111 @@
1
+ import { expect, test } from "bun:test";
2
+ import { audioDemoDocument, displayDocument, highlightJson, parseContentType } from "../lib/document-types.js";
3
+
4
+ const bytes = (text) => new TextEncoder().encode(text);
5
+ const show = (url, contentType, body) => displayDocument({ url, contentType, body: typeof body === "string" ? bytes(body) : body });
6
+
7
+ test("HTML stays itself, decoded with its charset", () => {
8
+ expect(show("https://x.test/", "text/html; charset=utf-8", "<p>hi</p>")).toEqual({ source: "<p>hi</p>", contentType: "text/html; charset=utf-8" });
9
+ const big5 = new Uint8Array([0x3c, 0x70, 0x3e, 0xa4, 0xa4, 0xa4, 0xe5]);
10
+ expect(show("https://x.test/", "text/html; charset=big5", big5).source).toBe("<p>中文");
11
+ // No type: HTML-looking bytes are HTML.
12
+ expect(show("https://x.test/", "", "<!doctype html><p>x").source).toBe("<!doctype html><p>x");
13
+ });
14
+
15
+ test("images are shown alone: SVG inline, others as a data: URL", () => {
16
+ const png = show("https://x.test/a/pic.png?x=1", "image/png", new Uint8Array([137, 80, 78, 71]));
17
+ expect(png.contentType).toBe("text/html");
18
+ expect(png.source).toContain('<img src="data:image/png;base64,iVBORw==" alt="pic.png">');
19
+ expect(png.source).toContain("<title>pic.png</title>");
20
+ const svg = show("https://x.test/i.svg", "image/svg+xml", '<?xml version="1.0"?><svg width="4" height="4"></svg>');
21
+ expect(svg.source).toContain('<div class="image"><svg width="4" height="4"></svg></div>');
22
+ });
23
+
24
+ test("bunmsh serve's formats get its preview under a Pretty-print checkbox, checked", () => {
25
+ const toml = show("https://x.test/Cargo.toml", "application/toml", 'name = "buninu"\n').source;
26
+ expect(toml).toContain('<input type="checkbox" id="pretty-print" checked> Pretty-print');
27
+ expect(toml).toContain('<a class="download" href="https://x.test/Cargo.toml" download>Download</a>');
28
+ expect(toml).toContain("<title>Cargo.toml — Bun.TOML.parse</title>");
29
+ expect(toml).toContain("(Bun.TOML.parse)");
30
+ expect(toml).toContain('<span class="json-statement">&quot;name&quot;:</span> <span class="json-string">&quot;buninu&quot;</span>');
31
+ expect(toml).toContain('<pre id="raw" hidden>name = &quot;buninu&quot;');
32
+ // The extension decides first, as in serve.js (it sends octet-stream for .jsonc and .jsonl).
33
+ expect(show("https://x.test/a.jsonc", "application/octet-stream", '{ "a": 1, // c\n}').source).toContain("(Bun.JSONC.parse)");
34
+ expect(show("https://x.test/l.ndjson", "application/octet-stream", '{"a":1}\n').source).toContain("(Bun.JSONL.parse)");
35
+ expect(show("https://x.test/c.yml", "text/yaml", "a: [1, true]").source).toContain('<span class="json-constant">true</span>');
36
+ expect(show("https://x.test/f.xml", "application/xml", '<a x="1"><b>hi</b></a>').source).toContain("(Bun.XML.parse)");
37
+ // Markdown is rendered by Bun.markdown.html with headings.
38
+ const md = show("https://x.test/README.md", "text/markdown", "# Title\n\n- item").source;
39
+ expect(md).toContain("(Bun.markdown.html)");
40
+ expect(md).toContain('<div id="formatted"><h1 id="title"><a href="#title">Title</a></h1>');
41
+ expect(md).toContain('<pre id="raw" hidden># Title');
42
+ // Without a known extension, the Content-Type names the format.
43
+ expect(show("https://x.test/api", "application/vnd.api+json", '{"x":null}').source).toContain("(JSON.parse)");
44
+ expect(show("https://x.test/doc", "text/markdown", "*a*").source).toContain("<em>a</em>");
45
+ // What cannot be parsed is shown as serve.js reports it, with the text below.
46
+ const bad = show("https://x.test/b.json", "application/json", "{nope").source;
47
+ expect(bad).toContain("<h1>Cannot preview b.json</h1>");
48
+ expect(bad).toContain('<pre id="raw">{nope</pre>');
49
+ expect(bad).not.toContain("checkbox");
50
+ // Bun.TOML refuses integers a JavaScript number cannot hold exactly.
51
+ expect(show("https://x.test/n.toml", "application/toml", "n = 9007199254740993").source).toContain("Integer cannot be losslessly represented");
52
+ });
53
+
54
+ test("everything else is text, whatever the bytes: no file is unviewable", () => {
55
+ expect(show("https://x.test/a.txt", "text/plain", "a < b").source).toContain("<pre>a &lt; b</pre>");
56
+ expect(show("https://x.test/app.js", "text/javascript", "let x").source).toContain("<pre>let x</pre>");
57
+ expect(show("https://x.test/a.zip", "application/zip", new Uint8Array([80, 75, 3, 4])).source).toContain("<pre>PK\u0003\u0004</pre>");
58
+ expect(show("https://x.test/d.pdf", "application/pdf", "%PDF-1.7").source).toContain("<pre>%PDF-1.7</pre>");
59
+ // No meaningful type: shown as text even when the bytes are binary.
60
+ expect(show("https://x.test/blob", "application/octet-stream", new Uint8Array([65, 0, 66, 255])).source).toContain("<pre>A\u0000B\uFFFD</pre>");
61
+ for (const [url, type] of [["https://x.test/f.woff2", "font/woff2"], ["https://x.test/m.wasm", "application/wasm"]]) {
62
+ expect(show(url, type, new Uint8Array([0, 97])).source).toContain("<pre>");
63
+ }
64
+ });
65
+
66
+ test("video and audio get a media document playing the URL itself", () => {
67
+ const video = show("https://x.test/v/clip.mp4?t=1", "video/mp4", new Uint8Array(10)).source;
68
+ expect(video).toContain('<video controls autoplay src="https://x.test/v/clip.mp4?t=1"></video>');
69
+ expect(video).toContain("clip.mp4 (video/mp4, 10 bytes). Click the video to play.");
70
+ const audio = show("https://x.test/tone.mp3", "audio/mpeg", new Uint8Array(3)).source;
71
+ expect(audio).toContain('<audio controls src="https://x.test/tone.mp3"></audio><p class="note">tone.mp3 (audio/mpeg, 3 bytes). Click to play;');
72
+ expect(audio).toContain('id="buninu-piano-input"');
73
+ expect(video).not.toContain('id="buninu-piano-input"');
74
+ expect(show("https://x.test/tone.wav", "audio/wav", new Uint8Array(3)).source).not.toContain('id="buninu-piano-input"');
75
+ });
76
+
77
+ test("about:audio has a CSS piano and a focused-play input", () => {
78
+ const demo = audioDemoDocument();
79
+ expect(demo.contentType).toBe("text/html");
80
+ expect(demo.source).toContain('<div class="keyboard"');
81
+ expect(demo.source.match(/class="white-key"/g)).toHaveLength(8);
82
+ expect(demo.source.match(/class="black-key"/g)).toHaveLength(5);
83
+ expect(demo.source).toContain('class="black-key" data-piano-note="73"');
84
+ expect(demo.source).toContain('id="buninu-piano-input"');
85
+ expect(demo.source).toContain('Play Piano · 彈鋼琴 · ピアノを弾く');
86
+ });
87
+
88
+ test("content type parsing and JSON highlighting", () => {
89
+ expect(parseContentType('Text/HTML; Charset="Big5"')).toEqual({ type: "text/html", charset: "Big5" });
90
+ expect(parseContentType("")).toEqual({ type: "", charset: null });
91
+ expect(highlightJson('{"a": [1, "b\\n", false]}'))
92
+ .toBe('{<span class="json-statement">&quot;a&quot;:</span> [<span class="json-number">1</span>, '
93
+ + '<span class="json-string">&quot;b<span class="json-special">\\n</span>&quot;</span>, <span class="json-constant">false</span>]}');
94
+ });
95
+
96
+ test("large files show their start with a bar pointing to download; large previews show the text", async () => {
97
+ const { DISPLAY_LIMIT, PRETTY_LIMIT } = await import("../lib/document-types.js");
98
+ const big = "x".repeat(DISPLAY_LIMIT + 10);
99
+ const text = show("https://x.test/big.txt", "text/plain", big).source;
100
+ expect(text).toContain(`Large file (256 KB); showing the first 256 KB. The whole file: casty's <code>download</code>`);
101
+ expect(text).toContain('<a class="download" href="https://x.test/big.txt" download>Download</a>');
102
+ expect(text).toContain(`<pre>${"x".repeat(DISPLAY_LIMIT)}</pre>`);
103
+ // A small file still offers a download button.
104
+ expect(show("https://x.test/a.txt", "text/plain", "hi").source).toContain('href="https://x.test/a.txt" download');
105
+ const json = JSON.stringify(Array.from({ length: PRETTY_LIMIT / 4 }, (_, i) => i));
106
+ expect(json.length).toBeGreaterThan(PRETTY_LIMIT);
107
+ const preview = show("https://x.test/big.json", "application/json", json).source;
108
+ expect(preview).toContain("Too large to pretty-print quickly");
109
+ expect(preview).not.toContain("pretty-print\" checked");
110
+ expect(preview).toContain(`<pre>${json}</pre>`);
111
+ });
@@ -0,0 +1,192 @@
1
+ import { afterAll, beforeAll, expect, test } from "bun:test";
2
+ import { mkdtempSync, readFileSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { createBrowser } from "../lib/headless-shell.js";
6
+ import { isAttachment, suggestedFilename } from "../lib/download.js";
7
+ import { downloadsDirectory } from "../lib/download-directory.js";
8
+
9
+ // Big5 for 中文, which a UTF-8 decode would mangle: the download must be the bytes as sent.
10
+ const BIG5 = new Uint8Array([0x3c, 0x70, 0x3e, 0xa4, 0xa4, 0xa4, 0xe5, 0x3c, 0x2f, 0x70, 0x3e]);
11
+ let server;
12
+ beforeAll(() => {
13
+ server = Bun.serve({
14
+ port: 0,
15
+ fetch: (request) => {
16
+ const path = new URL(request.url).pathname;
17
+ if (path === "/page") return new Response(BIG5, { headers: { "content-type": "text/html; charset=big5" } });
18
+ if (path === "/links") return new Response('<a id="explicit" href="/plain" download="chosen.txt">explicit</a>'
19
+ + '<a id="attachment" href="/attachment">attachment</a>'
20
+ + '<a id="data" href="data:text/plain,hello%20data" download="data.txt">data</a>'
21
+ + '<a id="blob" download="blob.txt">blob</a>'
22
+ + '<script>document.getElementById("blob").href = URL.createObjectURL(new Blob(["blob bytes"]));</script>',
23
+ { headers: { "content-type": "text/html" } });
24
+ if (path === "/plain") return new Response("original bytes", { headers: { "content-type": "text/plain" } });
25
+ if (path === "/attachment") return new Response("photo bytes", { headers: {
26
+ "content-type": "image/jpeg", "content-disposition": 'attachment; filename="photo.jpg"',
27
+ } });
28
+ if (path === "/preview.json") return new Response('{"a":1}\n', { headers: { "content-type": "application/json" } });
29
+ return Response.redirect("/page");
30
+ },
31
+ });
32
+ });
33
+
34
+ test("download names from headers and attributes are safe", () => {
35
+ expect(isAttachment('attachment; filename="x.jpg"')).toBeTrue();
36
+ expect(isAttachment('inline; filename="x.jpg"')).toBeFalse();
37
+ expect(suggestedFilename("https://x.test/a", "attachment; filename*=UTF-8''%E4%B8%AD%E6%96%87.jpg")).toBe("中文.jpg");
38
+ expect(suggestedFilename("https://x.test/a", "", "../escape.txt")).toBe("escape.txt");
39
+ });
40
+
41
+ test("download directory follows macOS defaults and relocated Windows folders", () => {
42
+ expect(downloadsDirectory({ platform: "darwin", home: "/Users/Ada" })).toBe("/Users/Ada/Downloads");
43
+ expect(downloadsDirectory({ platform: "win32", home: "C:\\Users\\Ada", queryWindows: () => "D:\\資料\\下載" }))
44
+ .toBe("D:\\資料\\下載");
45
+ expect(downloadsDirectory({ platform: "win32", home: "C:\\Users\\Ada", queryWindows: () => null }))
46
+ .toBe("C:\\Users\\Ada\\Downloads");
47
+ });
48
+
49
+ test("clicked downloads save files and leave the current page in place", async () => {
50
+ const dir = mkdtempSync(join(tmpdir(), "buninu-download-"));
51
+ const browser = await createBrowser({ spareRenderer: false });
52
+ const events = [];
53
+ browser.context.setDownloadBehavior({ behavior: "allow", downloadPath: dir, eventsEnabled: true });
54
+ browser.context.onDownload((event, params) => events.push({ event, ...params }));
55
+ const origin = `http://127.0.0.1:${server.port}`;
56
+ const click = async (selector) => {
57
+ const bounds = await browser.context.evaluate(`JSON.stringify(document.querySelector('${selector}').getBoundingClientRect())`);
58
+ const rect = JSON.parse(bounds);
59
+ await browser.context.click(rect.left + rect.width / 2, rect.top + rect.height / 2);
60
+ };
61
+ try {
62
+ await browser.context.navigate(`${origin}/links`);
63
+ await click("#explicit");
64
+ expect(readFileSync(join(dir, "chosen.txt"), "utf8")).toBe("original bytes");
65
+ expect(browser.context.resourceContent()?.url).toBe(`${origin}/links`);
66
+ await click("#explicit");
67
+ expect(readFileSync(join(dir, "chosen (2).txt"), "utf8")).toBe("original bytes");
68
+ await browser.context.evaluate("document.getElementById('explicit').click()");
69
+ for (let attempt = 0; attempt < 30; attempt++) {
70
+ try { if (readFileSync(join(dir, "chosen (3).txt"), "utf8") === "original bytes") break; } catch {}
71
+ await Bun.sleep(20);
72
+ }
73
+ expect(readFileSync(join(dir, "chosen (3).txt"), "utf8")).toBe("original bytes");
74
+ await click("#attachment");
75
+ expect(readFileSync(join(dir, "photo.jpg"), "utf8")).toBe("photo bytes");
76
+ expect(browser.context.resourceContent()?.url).toBe(`${origin}/links`);
77
+ await click("#data");
78
+ expect(readFileSync(join(dir, "data.txt"), "utf8")).toBe("hello data");
79
+ await browser.context.evaluate("document.getElementById('blob').click()");
80
+ for (let attempt = 0; attempt < 30; attempt++) {
81
+ try { if (readFileSync(join(dir, "blob.txt"), "utf8") === "blob bytes") break; } catch {}
82
+ await Bun.sleep(20);
83
+ }
84
+ expect(readFileSync(join(dir, "blob.txt"), "utf8")).toBe("blob bytes");
85
+ await browser.context.navigate(`${origin}/preview.json`);
86
+ const positions = JSON.parse(await browser.context.evaluate(`JSON.stringify({
87
+ pretty: document.getElementById('pretty-print').getBoundingClientRect().right,
88
+ download: document.querySelector('.download').getBoundingClientRect().left,
89
+ right: document.querySelector('.download').getBoundingClientRect().right
90
+ })`));
91
+ expect(positions.download).toBeGreaterThan(positions.pretty);
92
+ expect(positions.right).toBeGreaterThan(700);
93
+ await click(".download");
94
+ expect(readFileSync(join(dir, "preview.json"), "utf8")).toBe('{"a":1}\n');
95
+ expect(browser.context.resourceContent()?.url).toBe(`${origin}/preview.json`);
96
+ expect(events.filter((item) => item.event === "willBegin")).toHaveLength(7);
97
+ expect(events.filter((item) => item.event === "progress" && item.state === "completed")).toHaveLength(7);
98
+ } finally {
99
+ browser.close();
100
+ rmSync(dir, { recursive: true, force: true });
101
+ }
102
+ }, 30_000);
103
+ afterAll(() => server?.stop(true));
104
+
105
+ test("the current document's raw bytes stay available for Page.getResourceContent", async () => {
106
+ const browser = await createBrowser({ spareRenderer: false });
107
+ try {
108
+ const { context } = browser;
109
+ expect(context.resourceContent()).toBeNull();
110
+ await context.navigate(`http://127.0.0.1:${server.port}/start`);
111
+ const page = `http://127.0.0.1:${server.port}/page`;
112
+ const resource = context.resourceContent(page);
113
+ expect(resource).toMatchObject({ url: page, contentType: "text/html; charset=big5" });
114
+ expect([...resource.body]).toEqual([...BIG5]);
115
+ // The URL that was asked for, a fragment of it, and no URL at all mean the current document.
116
+ await context.navigate(`${page}#part`);
117
+ expect(context.resourceContent(`${page}#other`)?.body).toBe(resource.body);
118
+ expect(context.resourceContent()?.body).toBe(resource.body);
119
+ expect(context.resourceContent(`http://127.0.0.1:${server.port}/elsewhere`)).toBeNull();
120
+ await context.navigate("about:blank");
121
+ expect(context.resourceContent()).toBeNull();
122
+ } finally {
123
+ browser.close();
124
+ }
125
+ }, 30_000);
126
+
127
+ test("CDP Page.getResourceContent returns the bytes base64-encoded", async () => {
128
+ const { CdpServer } = await import("../lib/cdp-server.js");
129
+ const body = new Uint8Array([0, 1, 2, 255]);
130
+ const cdp = new CdpServer({ resourceContent: (url) => url === "https://x.test/f" ? { body, contentType: "image/png" } : null }).listen(0, "127.0.0.1");
131
+ try {
132
+ const target = await (await fetch(`http://127.0.0.1:${cdp.port}/json/new?https://x.test/f`, { method: "PUT" })).json();
133
+ const socket = new WebSocket(target.webSocketDebuggerUrl);
134
+ await new Promise((resolve) => socket.addEventListener("open", resolve));
135
+ const call = (id, method, params) => new Promise((resolve) => {
136
+ socket.addEventListener("message", ({ data }) => {
137
+ const message = JSON.parse(data);
138
+ if (message.id === id) resolve(message);
139
+ });
140
+ socket.send(JSON.stringify({ id, method, params }));
141
+ });
142
+ // The frame reports the document's own type, not the HTML shown for it.
143
+ expect((await call(0, "Page.getFrameTree", {})).result.frameTree.frame).toMatchObject({ url: "https://x.test/f", mimeType: "image/png" });
144
+ expect((await call(1, "Page.getResourceContent", { frameId: target.id, url: "https://x.test/f" })).result)
145
+ .toEqual({ content: "AAEC/w==", base64Encoded: true });
146
+ expect((await call(2, "Page.getResourceContent", { frameId: target.id, url: "https://x.test/g" })).error?.message)
147
+ .toBe("No resource with given URL found");
148
+ socket.close();
149
+ } finally {
150
+ cdp.stop(true);
151
+ }
152
+ });
153
+
154
+ test("CDP download behavior controls the directory and emits completion events", async () => {
155
+ const { CdpServer } = await import("../lib/cdp-server.js");
156
+ const dir = mkdtempSync(join(tmpdir(), "buninu-cdp-download-"));
157
+ const browser = await createBrowser({ spareRenderer: false });
158
+ const cdp = CdpServer.create(browser.context).listen(0, "127.0.0.1");
159
+ const socket = new WebSocket(`ws://127.0.0.1:${cdp.port}/devtools/browser/cdp-server`);
160
+ const events = [];
161
+ try {
162
+ await new Promise((resolve) => socket.addEventListener("open", resolve, { once: true }));
163
+ socket.addEventListener("message", ({ data }) => {
164
+ const message = JSON.parse(data);
165
+ if (message.method?.startsWith("Browser.download")) events.push(message);
166
+ });
167
+ const configured = new Promise((resolve) => socket.addEventListener("message", ({ data }) => {
168
+ if (JSON.parse(data).id === 1) resolve();
169
+ }));
170
+ socket.send(JSON.stringify({ id: 1, method: "Browser.setDownloadBehavior", params: {
171
+ behavior: "allowAndName", downloadPath: dir, eventsEnabled: true,
172
+ } }));
173
+ await configured;
174
+ await browser.context.navigate(`http://127.0.0.1:${server.port}/links`);
175
+ const rect = JSON.parse(await browser.context.evaluate("JSON.stringify(document.getElementById('attachment').getBoundingClientRect())"));
176
+ await browser.context.click(rect.left + rect.width / 2, rect.top + rect.height / 2);
177
+ await Bun.sleep(30);
178
+ expect(readFileSync(join(dir, events[0].params.guid), "utf8")).toBe("photo bytes");
179
+ expect(events.map((item) => item.method)).toEqual([
180
+ "Browser.downloadWillBegin", "Browser.downloadProgress", "Browser.downloadProgress",
181
+ ]);
182
+ expect(events[0].params.suggestedFilename).toBe("photo.jpg");
183
+ expect(events[1].params.state).toBe("inProgress");
184
+ expect(events[2].params.state).toBe("completed");
185
+ expect(events[2].params.filePath).toBe(join(dir, events[0].params.guid));
186
+ } finally {
187
+ socket.close();
188
+ cdp.stop(true);
189
+ browser.close();
190
+ rmSync(dir, { recursive: true, force: true });
191
+ }
192
+ }, 30_000);
Binary file
package/test/flex.test.js CHANGED
@@ -148,3 +148,14 @@ test("a calc() max-width with a percentage caps a flex item's automatic minimum
148
148
  expect(rect("#a")).toMatchObject({ left: 0, width: 200 });
149
149
  done();
150
150
  });
151
+
152
+ test("justify-content distributes a column container's extra height (min-height taller than the items)", () => {
153
+ const cases = { center: [40, 50], "flex-end": [80, 90], "space-between": [0, 90], "space-evenly": [26.666, 63.333], normal: [0, 10] };
154
+ for (const [justify, [first, second]] of Object.entries(cases)) {
155
+ const { rect, done } = flex(`<div id="root"><div id="a"></div><div id="b"></div></div>`,
156
+ `#root { display:flex; flex-direction:column; min-height:100px; justify-content:${justify} } #a, #b { height:10px }`);
157
+ expect(rect("#a").top).toBeCloseTo(first, 2);
158
+ expect(rect("#b").top).toBeCloseTo(second, 2);
159
+ done();
160
+ }
161
+ });
@@ -0,0 +1,25 @@
1
+ import { expect, test } from "bun:test";
2
+ import Window from "../vendor/happy-dom/packages/happy-dom/src/window/Window.ts";
3
+
4
+ // Element.matches() caches its result; each case matches once, changes the
5
+ // DOM, and expects the same answer querySelectorAll gives.
6
+ const cases = [
7
+ [":has() after a removal deep inside", '<div id="x"><p><img></p></div>', "div:has(img)", (d) => d.querySelector("img").remove()],
8
+ [":has() after an insertion deep inside", '<div id="x"><p></p></div>', "div:has(img)", (d) => d.querySelector("p").append(d.createElement("img"))],
9
+ ["+ after an insertion between", '<i></i><li id="x"></li>', "i + li", (d) => d.querySelector("i").after(d.createElement("b"))],
10
+ ["+ on an ancestor", '<i></i><li><b id="x"></b></li>', "i + li b", (d) => d.querySelector("i").after(d.createElement("b"))],
11
+ [":first-child after a prepend", '<li id="x"></li>', "li:first-child", (d) => d.body.prepend(d.createElement("b"))],
12
+ [":first-child on an ancestor", '<ul><li><b id="x"></b></li></ul>', "li:first-child b", (d) => d.querySelector("ul").prepend(d.createElement("li"))],
13
+ [":last-child after an append", '<li id="x"></li>', "li:last-child", (d) => d.body.append(d.createElement("b"))],
14
+ ];
15
+
16
+ for (const [name, html, selector, change] of cases) {
17
+ test(`matches() is current for ${name}`, () => {
18
+ const document = new Window().document;
19
+ document.body.innerHTML = html;
20
+ const element = document.getElementById("x");
21
+ element.matches(selector);
22
+ change(document);
23
+ expect(element.matches(selector)).toBe([...document.querySelectorAll(selector)].includes(element));
24
+ });
25
+ }
@@ -63,6 +63,11 @@ test("top-level shell exposes the parsed document through Bun.WebView", async ()
63
63
  headers: { "Content-Type": "text/html; charset=utf-8" },
64
64
  });
65
65
  }
66
+ if (pathname === "/piano-input") {
67
+ return new Response('<!doctype html><input id="buninu-piano-input" type="text">', {
68
+ headers: { "Content-Type": "text/html; charset=utf-8" },
69
+ });
70
+ }
66
71
  if (["/image", "/broken-image", "/data-image", "/dynamic-data-image"].includes(pathname)) {
67
72
  const dataUrl = `data:image/png;base64,${pixelPng.toString("base64")}`;
68
73
  const src = pathname === "/image" ? "/pixel.png" : pathname === "/data-image" ? dataUrl : "/missing.png";
@@ -107,6 +112,20 @@ test("top-level shell exposes the parsed document through Bun.WebView", async ()
107
112
  height: 240,
108
113
  });
109
114
  await view.resize(480, 240);
115
+ await view.resize(480, 500);
116
+ await view.navigate("about:audio");
117
+ expect(view.url).toBe("about:audio");
118
+ expect(await view.evaluate("document.getElementById('buninu-piano-input').placeholder"))
119
+ .toBe("Play Piano · 彈鋼琴 · ピアノを弾く");
120
+ const blackWidths = await view.evaluate("[...document.querySelectorAll('.black-key')].map(key => key.getBoundingClientRect().width)");
121
+ expect(new Set(blackWidths).size).toBe(1);
122
+ const demoKey = await view.evaluate("document.querySelector('[data-piano-note=\"69\"]').getBoundingClientRect().toJSON()");
123
+ await view.cdp("Input.dispatchMouseEvent", { type: "mousePressed", x: demoKey.x + demoKey.width / 2,
124
+ y: demoKey.y + demoKey.height - 15, button: "left", clickCount: 1 });
125
+ await view.cdp("Input.dispatchMouseEvent", { type: "mouseReleased", x: demoKey.x + demoKey.width / 2,
126
+ y: demoKey.y + demoKey.height - 15, button: "left", clickCount: 1 });
127
+ expect(await view.evaluate("document.activeElement.id")).toBe("buninu-piano-input");
128
+ await view.resize(480, 240);
110
129
  await view.navigate(`http://127.0.0.1:${fixture.port}/`);
111
130
 
112
131
  // The inline script runs (in the page sandbox) and renames the document.
@@ -173,6 +192,14 @@ test("top-level shell exposes the parsed document through Bun.WebView", async ()
173
192
  await view.cdp("Input.insertText", { text: "q" });
174
193
  expect(await view.evaluate("document.getElementById('result').textContent")).toBe("q");
175
194
  expect(await view.evaluate("document.getElementById('editor').value")).toBe("original");
195
+ await view.navigate(`http://127.0.0.1:${fixture.port}/piano-input`);
196
+ const pianoRect = await view.evaluate("document.getElementById('buninu-piano-input').getBoundingClientRect().toJSON()");
197
+ await view.cdp("Input.dispatchMouseEvent", { type: "mousePressed", x: pianoRect.x + 10, y: pianoRect.y + 10, button: "left", clickCount: 1 });
198
+ await view.cdp("Input.dispatchMouseEvent", { type: "mouseReleased", x: pianoRect.x + 10, y: pianoRect.y + 10, button: "left", clickCount: 1 });
199
+ await view.cdp("Input.insertText", { text: "j" });
200
+ await view.cdp("Input.dispatchKeyEvent", { type: "rawKeyDown", key: "k", code: "KeyK" });
201
+ await view.cdp("Input.dispatchKeyEvent", { type: "keyUp", key: "k", code: "KeyK" });
202
+ expect(await view.evaluate("document.getElementById('buninu-piano-input').value")).toBe("");
176
203
  await view.navigate(`http://127.0.0.1:${fixture.port}/`);
177
204
 
178
205
  expect(await view.evaluate("scrollY")).toBe(0);
@@ -3,6 +3,7 @@ import { parseHTMLDocument } from "../lib/happy-dom/parser.js";
3
3
  import { elementBounds, hitTest, interactiveRegions } from "../lib/input/hit-test.js";
4
4
  import { layoutText } from "../lib/layout/text-layout.js";
5
5
  import { RenderTreeBuilder } from "../lib/render-tree/index.js";
6
+ import { StyleEngine } from "../lib/style/computed-style.js";
6
7
 
7
8
  test("creates separate clickable regions for links sharing a line", () => {
8
9
  const { document, window } = parseHTMLDocument(
@@ -47,3 +48,39 @@ test("hit testing accounts for viewport scrolling", () => {
47
48
  expect(hitTest(tree, layout, rect.left + 1, rect.top + 1, scroll, { width: 800, height: 100 })?.element.getAttribute("href")).toBe("target");
48
49
  window.happyDOM.abort();
49
50
  });
51
+
52
+ test("an overlapping broad text region does not steal a form control's click", () => {
53
+ const { document, window } = parseHTMLDocument(`<button id="button">+</button><textarea id="field"></textarea>`);
54
+ const button = document.getElementById("button");
55
+ const field = document.getElementById("field");
56
+ const tree = { nodesById: new Map([
57
+ [1, { domNode: button, style: {} }],
58
+ [2, { domNode: field, style: {} }],
59
+ ]) };
60
+ const layout = {
61
+ fragments: [{ y: 0, height: 40, runs: [{ nodeId: 1, x: 0, width: 500 }] }],
62
+ boxes: [
63
+ { nodeId: 1, x: 0, y: 0, width: 40, height: 40 },
64
+ { nodeId: 2, x: 50, y: 0, width: 400, height: 40 },
65
+ ],
66
+ };
67
+ expect(hitTest(tree, layout, 100, 20, { x: 0, y: 0 }, { width: 500, height: 100 })?.element).toBe(field);
68
+ window.happyDOM.abort();
69
+ });
70
+
71
+ test("hidden and pointer-events: none boxes let clicks through, but their opted-in descendants take them", () => {
72
+ const { document, window } = parseHTMLDocument(
73
+ `<body>
74
+ <div class="menu"><a href="through">through</a> <a class="open" href="open">open</a></div>
75
+ <div style="visibility: hidden"><a href="hidden">hidden</a> <button>ghost</button>
76
+ <a style="visibility: visible" href="shown">shown</a></div>
77
+ <a href="plain">plain</a>
78
+ </body>`,
79
+ );
80
+ const tree = new RenderTreeBuilder().build(document, new StyleEngine().compute(document, [`.menu { pointer-events: none } .menu .open { pointer-events: auto }`]));
81
+ const layout = layoutText(tree);
82
+ const regions = interactiveRegions(tree, layout, { x: 0, y: 0 }, { width: 800, height: 600 });
83
+
84
+ expect(regions.map((region) => region.element.getAttribute("href") ?? region.element.tagName)).toEqual(["open", "shown", "plain"]);
85
+ window.happyDOM.abort();
86
+ });