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.
Files changed (64) 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 +28 -7
  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/happy-dom/selector-matcher.js +83 -0
  14. package/lib/headless-shell.js +165 -26
  15. package/lib/input/hit-test.js +57 -8
  16. package/lib/layout/text-layout.js +290 -41
  17. package/lib/network/network-service.js +177 -40
  18. package/lib/network/resolver.js +16 -0
  19. package/lib/paint/display-list.js +25 -10
  20. package/lib/render-tree/index.js +58 -0
  21. package/lib/renderer/bindings.js +543 -55
  22. package/lib/renderer/flags.js +8 -5
  23. package/lib/renderer/host.js +103 -7
  24. package/lib/renderer/main.js +35 -3
  25. package/lib/renderer/module-transform.js +30 -23
  26. package/lib/renderer/page-realm.js +228 -24
  27. package/lib/renderer/page-renderer.js +1264 -221
  28. package/lib/screenshot-cli.js +61 -0
  29. package/lib/style/computed-style.js +558 -229
  30. package/lib/style/selector-keys.js +302 -0
  31. package/lib/style/selector-traits.js +240 -0
  32. package/lib/user-agent.js +12 -0
  33. package/package.json +1 -1
  34. package/patches/happy-dom/0003-defer-script-events-to-renderer.patch +40 -0
  35. package/patches/happy-dom/0004-insert-adjacent-html-in-order.patch +19 -0
  36. package/patches/happy-dom/0005-invalidate-matches-on-structure-changes.patch +43 -0
  37. package/patches/happy-dom/README.md +12 -0
  38. package/patches/ses/0001-allow-regexp-own-tostring.patch +13 -0
  39. package/patches/ses/README.md +16 -0
  40. package/test/computed-style.test.js +113 -1
  41. package/test/document-types.test.js +111 -0
  42. package/test/download.test.js +192 -0
  43. package/test/fixtures/tone.mp3 +0 -0
  44. package/test/flex.test.js +11 -0
  45. package/test/happy-dom-matches.test.js +25 -0
  46. package/test/headless-shell.test.js +56 -0
  47. package/test/hit-test.test.js +37 -0
  48. package/test/log.test.js +4 -1
  49. package/test/network-service.test.js +67 -0
  50. package/test/page-scripts.test.js +437 -29
  51. package/test/piano.test.js +65 -0
  52. package/test/positioning.test.js +11 -0
  53. package/test/pulse-client.test.js +130 -0
  54. package/test/render-tree.test.js +36 -1
  55. package/test/resolver.test.js +12 -1
  56. package/test/screenshot-cli.test.js +19 -0
  57. package/test/selector-keys.test.js +39 -0
  58. package/test/selector-traits.test.js +19 -0
  59. package/test/text-layout.test.js +91 -1
  60. package/test/user-agent.test.js +42 -0
  61. package/vendor/happy-dom/packages/happy-dom/src/nodes/element/Element.ts +3 -6
  62. package/vendor/happy-dom/packages/happy-dom/src/nodes/html-script-element/HTMLScriptElement.ts +2 -20
  63. package/vendor/happy-dom/packages/happy-dom/src/query-selector/QuerySelector.ts +15 -3
  64. package/vendor/ses/dist/ses.mjs +2 -0
@@ -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.
@@ -1,7 +1,7 @@
1
1
  import { expect, test } from "bun:test";
2
2
  import { parseHTMLDocument } from "../lib/happy-dom/parser.js";
3
3
  import { RenderTreeBuilder, renderTreeText } from "../lib/render-tree/index.js";
4
- import { computeElementStyle, parseDeclarations, StyleEngine } from "../lib/style/computed-style.js";
4
+ import { computeElementStyle, matchSelectors, parseDeclarations, StyleEngine } from "../lib/style/computed-style.js";
5
5
 
6
6
  test("computes the initial UA and inherited style subset", () => {
7
7
  const { document, window } = parseHTMLDocument(`<body>
@@ -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>
@@ -372,6 +381,50 @@ test("cascade layers: unlayered beats layered, later layers win, !important reve
372
381
  window.happyDOM.abort();
373
382
  });
374
383
 
384
+ test("sheets parsed once keep layer order and cascade when the sheet list changes or repeats", () => {
385
+ const { document, window } = parseHTMLDocument(`<p id="a" class="x">t</p><p id="b" class="y">u</p>`);
386
+ const base = `@layer one, two; @layer two { .x { color: blue } } @layer one { .x { color: red; display: flex } }`;
387
+ const anonymous = `@layer { .y { color: green !important } } .y { display: inline }`;
388
+ const later = `@layer three { .x { display: block } } .y { color: black }`;
389
+ const lists = [
390
+ [base, later],
391
+ [base, later, base], // a repeated sheet: its earlier copy still declares the layers first
392
+ [later, base],
393
+ [anonymous, later, anonymous], // anonymous layers: both copies count under !important
394
+ [later, `@layer two { .x { display: grid } }`, base],
395
+ ];
396
+ const engine = new StyleEngine();
397
+ for (const sheets of lists) {
398
+ engine.compute(document, sheets, {}, new Set([document.body]), null);
399
+ const fresh = new StyleEngine().compute(document, sheets.map((sheet) => `${sheet} `));
400
+ for (const id of ["a", "b"]) expect(engine.get(document.getElementById(id))).toEqual(fresh.get(document.getElementById(id)));
401
+ }
402
+ engine.compute(document, [base, later, base]);
403
+ expect(engine.get(document.getElementById("a")).color).toBe("rgba(0, 0, 255, 1)");
404
+ expect(engine.get(document.getElementById("a")).display).toBe("block");
405
+ window.happyDOM.abort();
406
+ });
407
+
408
+ test("matchSelectors finds what querySelectorAll finds, in one walk", () => {
409
+ const { document, window } = parseHTMLDocument(`<main><ul><li class="a">1</li><li id="i">2</li><li class="a b">3</li></ul>
410
+ <p>x</p><p class="a"><span>y</span></p><div><span hidden>z</span></div></main>`);
411
+ const selectors = ["li:first-child", ".a + li", "li ~ .b", "p:has(span)", ":is(ul, div) > *", "#i", "[hidden]", "*", ":root", "main span", "1x", "li:nth-child(2n+1)"];
412
+ // Elements compared as sets of document positions (deep equality of DOM
413
+ // nodes is slow, and querySelectorAll() may list an element twice).
414
+ const all = [...document.querySelectorAll("*")];
415
+ const positions = (elements) => [...new Set(elements)].map((element) => all.indexOf(element)).sort((x, y) => x - y);
416
+ const matched = matchSelectors(document, selectors);
417
+ for (const selector of selectors) {
418
+ let expected = null;
419
+ try { expected = positions(document.querySelectorAll(selector)); } catch {}
420
+ expect(matched.has(selector) ? positions(matched.get(selector)) : null).toEqual(expected);
421
+ }
422
+ // From an element: its descendants and the element itself.
423
+ const ul = document.querySelector("ul");
424
+ expect(positions(matchSelectors(ul, [":is(ul, li)"]).get(":is(ul, li)"))).toEqual(positions([ul, ...ul.children]));
425
+ window.happyDOM.abort();
426
+ });
427
+
375
428
  test("the font shorthand resets its parts and competes with font-size in the cascade", () => {
376
429
  const { document, window } = parseHTMLDocument(`<h2 id="a" style="font-size:32px;line-height:3">t</h2><h2 id="b">x</h2>
377
430
  <p id="c" style="font:italic small-caps bold 12px/1.5 Georgia, serif">u</p><p id="d" style="font-size:large">v</p>`);
@@ -432,3 +485,62 @@ test("links get the UA link style; text decorations propagate to in-flow descend
432
485
  expect(style("invalid").textDecorations).toBeNull();
433
486
  window.happyDOM.abort();
434
487
  });
488
+
489
+ test("the font shorthand allows spaces around the line-height slash", () => {
490
+ for (const font of ['14px / 1.5 "Mona Sans VF", sans-serif', '14px/ 1.5 "Mona Sans VF", sans-serif',
491
+ '14px /1.5 "Mona Sans VF", sans-serif', '14px/1.5 "Mona Sans VF", sans-serif']) {
492
+ const { document, window } = parseHTMLDocument(`<p style='font: ${font}'><span>x</span></p>`);
493
+ const engine = new StyleEngine().compute(document);
494
+ for (const element of [document.querySelector("p"), document.querySelector("span")]) {
495
+ const style = engine.get(element);
496
+ expect([style.fontSize, style.lineHeight, style.fontFamily]).toEqual([14, 21, ["Mona Sans VF", "sans-serif"]]);
497
+ }
498
+ window.happyDOM.abort();
499
+ }
500
+ });
501
+
502
+ test("incremental restyling retains equal computed-style identities", () => {
503
+ const { document, window } = parseHTMLDocument(`<body><div><span>same</span></div></body>`);
504
+ const div = document.querySelector("div");
505
+ const span = document.querySelector("span");
506
+ const engine = new StyleEngine().compute(document, [".active { color: red }"]);
507
+ const before = [engine.get(div), engine.get(span)];
508
+ // Visiting this subtree is conservative: the class changes, but neither
509
+ // element's final values change.
510
+ div.className = "unmatched";
511
+ engine.compute(document, [".active { color: red }"], {}, new Set([div]));
512
+ expect([engine.get(div), engine.get(span)]).toEqual(before);
513
+ expect(engine.get(div)).toBe(before[0]);
514
+ expect(engine.get(span)).toBe(before[1]);
515
+ window.happyDOM.abort();
516
+ });
517
+
518
+ test("incremental restyles of changed subtrees and added sheets equal a full restyle", () => {
519
+ 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>`);
520
+ const sheets = [".x p { color: red } .y p { color: blue; font-size: 20px } #b span { font-weight: 700 }"];
521
+ const engine = new StyleEngine().compute(document, sheets);
522
+ const same = (label) => {
523
+ const reference = new StyleEngine().compute(document, engine.sheets ?? sheets);
524
+ for (const element of document.querySelectorAll("*")) {
525
+ expect([label, JSON.stringify(engine.get(element))]).toEqual([label, JSON.stringify(reference.get(element))]);
526
+ }
527
+ };
528
+ // An attribute change: only #a's subtree.
529
+ document.getElementById("a").className = "y";
530
+ engine.compute(document, sheets, {}, new Set([document.getElementById("a")]));
531
+ expect([engine.incremental, engine.restyled]).toEqual([true, 3]);
532
+ same("class change");
533
+ // Inserted elements: their parent's subtree.
534
+ const b = document.getElementById("b");
535
+ b.insertAdjacentHTML("beforeend", "<span>t</span><p>u</p>");
536
+ engine.compute(document, sheets, {}, new Set([b]));
537
+ expect(engine.restyled).toBe(4);
538
+ same("insertion");
539
+ // An added sheet restyles what its rules match, nothing else.
540
+ const more = [...sheets, "span { color: green }"];
541
+ engine.compute(document, more, {}, new Set());
542
+ expect([engine.incremental, engine.restyled]).toEqual([true, 2]); // the two spans
543
+ engine.sheets = more;
544
+ same("added sheet");
545
+ window.happyDOM.abort();
546
+ });
@@ -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
+ }