buninu-browser 0.5.1 → 0.7.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 (136) hide show
  1. package/README.md +13 -6
  2. package/index.js +10 -6
  3. package/lib/audio/pcm.js +11 -0
  4. package/lib/audio/pulse-client.js +7 -2
  5. package/lib/cdp-server.js +94 -46
  6. package/lib/happy-dom/document-base.js +29 -1
  7. package/lib/happy-dom/mutation-observer.js +157 -0
  8. package/lib/happy-dom/parser.js +2 -0
  9. package/lib/headless-shell.js +49 -9
  10. package/lib/layout/repaint.js +89 -0
  11. package/lib/media/mp4-keyframes.js +144 -0
  12. package/lib/media/video-keyframe.js +29 -1
  13. package/lib/media/webm-snapshots.js +282 -0
  14. package/lib/network/cookie-jar.js +18 -2
  15. package/lib/paint/display-list.js +4 -3
  16. package/lib/paint/frame.js +12 -5
  17. package/lib/render-tree/index.js +3 -0
  18. package/lib/renderer/animation-frames.js +76 -0
  19. package/lib/renderer/bindings.js +2 -0
  20. package/lib/renderer/compositor-channel.js +95 -10
  21. package/lib/renderer/compositor.js +54 -18
  22. package/lib/renderer/document-cookies.js +87 -0
  23. package/lib/renderer/font-loading.js +60 -16
  24. package/lib/renderer/host.js +48 -19
  25. package/lib/renderer/idle-callbacks.js +109 -0
  26. package/lib/renderer/main.js +5 -1
  27. package/lib/renderer/media-source.js +4 -0
  28. package/lib/renderer/page-realm.js +159 -49
  29. package/lib/renderer/page-renderer.js +567 -197
  30. package/lib/renderer/resource-elements.js +88 -0
  31. package/lib/renderer/script-rewrite.js +18 -1
  32. package/lib/renderer/seccomp.js +6 -4
  33. package/lib/renderer/timers.js +71 -0
  34. package/lib/renderer/video-channel.js +94 -0
  35. package/lib/renderer/video-mask.js +115 -0
  36. package/lib/renderer/video-presentation.js +304 -39
  37. package/lib/renderer/video-worker.js +46 -0
  38. package/lib/renderer/workers.js +7 -17
  39. package/lib/style/computed-style.js +257 -61
  40. package/package.json +1 -1
  41. package/test/animation-frames.test.js +67 -0
  42. package/test/canvas-style-snapshot.test.js +18 -0
  43. package/test/cdp-stream.test.js +96 -3
  44. package/test/cdp-viewers.test.js +43 -0
  45. package/test/compositor-channel-ownership.test.js +194 -0
  46. package/test/compositor.test.js +27 -1
  47. package/test/computed-style-binding.test.js +49 -0
  48. package/test/computed-style-connectivity.test.js +13 -0
  49. package/test/computed-style.test.js +86 -0
  50. package/test/cookie-sync.test.js +82 -0
  51. package/test/document-base-cache.test.js +103 -0
  52. package/test/document-cookies.test.js +117 -0
  53. package/test/duplicate-style-keys.test.js +11 -0
  54. package/test/fixtures/animation-frames.js +56 -0
  55. package/test/fixtures/canvas-style-snapshot.js +81 -0
  56. package/test/fixtures/computed-style-connectivity.js +101 -0
  57. package/test/fixtures/computed-style-lazy.js +135 -0
  58. package/test/fixtures/duplicate-style-keys.js +97 -0
  59. package/test/fixtures/fake-renderer.js +12 -0
  60. package/test/fixtures/font-definition-invalidation.js +97 -0
  61. package/test/fixtures/font-metrics-invalidation.js +87 -0
  62. package/test/fixtures/full-style-identity.js +36 -0
  63. package/test/fixtures/hidden-style-engine.js +260 -0
  64. package/test/fixtures/hidden-style-reentry.js +232 -0
  65. package/test/fixtures/hidden-style-renderer.js +203 -0
  66. package/test/fixtures/layout-repaint.js +69 -0
  67. package/test/fixtures/media-presentation-continuation.js +141 -0
  68. package/test/fixtures/media-presentation-refill.js +113 -0
  69. package/test/fixtures/media-probe.js +148 -0
  70. package/test/fixtures/media-refill-cancel.js +79 -0
  71. package/test/fixtures/media-repaint.js +48 -0
  72. package/test/fixtures/media-resource-reselection.js +94 -0
  73. package/test/fixtures/media-source-attachment.js +136 -0
  74. package/test/fixtures/media-source-no-decoder.js +17 -0
  75. package/test/fixtures/media-track-refill.js +132 -0
  76. package/test/fixtures/media-webm-seek.js +51 -0
  77. package/test/fixtures/mutation-observer.js +201 -0
  78. package/test/fixtures/observer-invalidation.js +105 -0
  79. package/test/fixtures/prepaint-scheduling.js +73 -0
  80. package/test/fixtures/resource-discovery.js +32 -0
  81. package/test/fixtures/spare-renderer-scheduling.js +124 -0
  82. package/test/fixtures/style-propagation.js +26 -0
  83. package/test/fixtures/svg-node-reuse.js +83 -0
  84. package/test/fixtures/video-black-replay.js +140 -0
  85. package/test/fixtures/video-controls-invalidation.js +71 -0
  86. package/test/fixtures/viewport-bounds.js +148 -0
  87. package/test/font-loading.test.js +77 -0
  88. package/test/font-metrics-invalidation.test.js +60 -0
  89. package/test/full-style-identity.test.js +191 -0
  90. package/test/hidden-style-lazy.test.js +31 -0
  91. package/test/hidden-style-reentry.test.js +20 -0
  92. package/test/idle-callback.test.js +89 -0
  93. package/test/idle-scheduler.test.js +148 -0
  94. package/test/index.test.js +1 -0
  95. package/test/inline-style-cascade.test.js +63 -0
  96. package/test/layout-repaint-renderer.test.js +9 -0
  97. package/test/layout-repaint.test.js +222 -0
  98. package/test/media-presentation-continuation.test.js +14 -0
  99. package/test/media-presentation-refill.test.js +8 -0
  100. package/test/media-probe.test.js +16 -0
  101. package/test/media-repaint.test.js +8 -0
  102. package/test/media-source-attachment.test.js +34 -0
  103. package/test/media-track-refill.test.js +26 -0
  104. package/test/media-webm-seek.test.js +11 -0
  105. package/test/mp4-keyframes.test.js +36 -0
  106. package/test/mutation-observer.test.js +91 -0
  107. package/test/observer-invalidation.test.js +12 -0
  108. package/test/page-scripts.test.js +35 -0
  109. package/test/page-timers.test.js +67 -0
  110. package/test/paint-frame.test.js +18 -1
  111. package/test/pcm.test.js +63 -0
  112. package/test/prepaint-scheduling.test.js +13 -0
  113. package/test/pulse-client.test.js +165 -0
  114. package/test/raw-capture.test.js +34 -5
  115. package/test/renderer-host.test.js +51 -0
  116. package/test/resource-discovery.test.js +12 -0
  117. package/test/resource-elements.test.js +234 -0
  118. package/test/root-self-style.test.js +104 -0
  119. package/test/script-rewrite.test.js +35 -0
  120. package/test/seccomp.test.js +45 -0
  121. package/test/spare-renderer-scheduling.test.js +8 -0
  122. package/test/style-propagation.test.js +225 -0
  123. package/test/style-sheets.test.js +19 -0
  124. package/test/svg.test.js +45 -0
  125. package/test/timers.test.js +58 -0
  126. package/test/video-black-replay.test.js +34 -0
  127. package/test/video-capture.test.js +170 -0
  128. package/test/video-channel.test.js +253 -0
  129. package/test/video-continuation.test.js +57 -0
  130. package/test/video-controls-invalidation.test.js +12 -0
  131. package/test/video-keyframe.test.js +104 -4
  132. package/test/video-mask.test.js +94 -0
  133. package/test/video-presentation.test.js +239 -1
  134. package/test/video-timing-presentation.test.js +115 -0
  135. package/test/viewport-bounds.test.js +13 -0
  136. package/test/webm-snapshots.test.js +372 -0
@@ -0,0 +1,71 @@
1
+ import assert from "node:assert/strict";
2
+ import { createPageRenderer } from "../../lib/renderer/page-renderer.js";
3
+ import { LayoutEngine } from "../../lib/layout/text-layout.js";
4
+
5
+ let layouts = 0;
6
+ const originalLayout = LayoutEngine.prototype.layout;
7
+ LayoutEngine.prototype.layout = function (...args) {
8
+ layouts++;
9
+ return Reflect.apply(originalLayout, this, args);
10
+ };
11
+ const errors = [];
12
+ const renderer = await createPageRenderer({ reportError: error => errors.push(String(error)) });
13
+ await import("../../vendor/ses/dist/ses.mjs");
14
+ lockdown({ errorTaming: "safe", overrideTaming: "severe", consoleTaming: "unsafe",
15
+ localeTaming: "unsafe", stackFiltering: "concise", reporting: "none" });
16
+
17
+ const source = controls => `<!doctype html>
18
+ <style id=sheet>body{margin:0;background:white}video{display:block;width:160px;height:90px}</style>
19
+ <video id=v ${controls ? "controls" : ""}></video>`;
20
+ const load = async controls => {
21
+ await renderer.loadDocument({ url: "https://controls.test/", contentType: "text/html", source: source(controls) });
22
+ await renderer.whenLoaded();
23
+ };
24
+ const snapshot = async () => ({
25
+ pixels: new Bun.CryptoHasher("sha256").update(Buffer.from(await renderer.screenshot(), "base64")).digest("hex"),
26
+ geometry: await renderer.evaluate(`JSON.stringify((() => {
27
+ const rect = document.getElementById("v").getBoundingClientRect();
28
+ return [rect.x, rect.y, rect.width, rect.height];
29
+ })())`),
30
+ });
31
+
32
+ try {
33
+ renderer.setScripting("ses");
34
+ renderer.resize(240, 140, 1);
35
+ const references = [];
36
+ for (const controls of [false, true]) {
37
+ await load(controls);
38
+ references.push(await snapshot());
39
+ }
40
+ assert.notEqual(references[0].pixels, references[1].pixels, "native controls visibly change the video");
41
+ assert.equal(references[0].geometry, references[1].geometry, "native controls retain the video's box");
42
+
43
+ await load(false);
44
+ const transitions = [
45
+ ["add attribute", true, `document.getElementById("v").setAttribute("controls", "")`],
46
+ ["remove after style-only read", false, `
47
+ document.getElementById("v").removeAttribute("controls");
48
+ getComputedStyle(document.getElementById("v")).color;
49
+ document.getElementById("v").getBoundingClientRect()`],
50
+ ["add during full stylesheet pass", true, `
51
+ document.getElementById("sheet").textContent += " p { color:red }";
52
+ document.getElementById("v").setAttribute("controls", "")`, true],
53
+ ["remove after mutation delivery", false, `
54
+ document.getElementById("v").removeAttribute("controls");
55
+ new Promise(resolve => setTimeout(resolve, 0))`],
56
+ ["enable reflected property", true, `document.getElementById("v").controls = true`],
57
+ ["disable reflected property", false, `document.getElementById("v").controls = false`],
58
+ ];
59
+ for (const [name, controls, expression, changesSheet] of transitions) {
60
+ const before = layouts;
61
+ await renderer.evaluate(expression);
62
+ const actual = await snapshot();
63
+ assert.equal(actual.pixels, references[Number(controls)].pixels, name + " pixels match a fresh document");
64
+ assert.equal(actual.geometry, references[Number(controls)].geometry, name + " retains geometry");
65
+ if (!changesSheet) assert.equal(layouts - before, 0, name + " reuses layout");
66
+ }
67
+ assert.deepEqual(errors, []);
68
+ console.log("video controls paint invalidation passed");
69
+ } finally {
70
+ renderer.dispose();
71
+ }
@@ -0,0 +1,148 @@
1
+ import { createPageRenderer } from "../../lib/renderer/page-renderer.js";
2
+ import { StyleEngine } from "../../lib/style/computed-style.js";
3
+
4
+ const originalGet = StyleEngine.prototype.get;
5
+ let counting = false, styleReads = 0, checks = 0;
6
+ StyleEngine.prototype.get = function (element) {
7
+ if (counting) styleReads++;
8
+ return originalGet.call(this, element);
9
+ };
10
+
11
+ await import("../../vendor/ses/dist/ses.mjs");
12
+ lockdown({ errorTaming: "safe", overrideTaming: "severe", consoleTaming: "unsafe",
13
+ localeTaming: "unsafe", stackFiltering: "concise", reporting: "none" });
14
+
15
+ function check(condition, message) {
16
+ if (!condition) throw new Error(message);
17
+ checks++;
18
+ }
19
+ function equal(actual, expected, message) {
20
+ check(Bun.deepEquals(actual, expected, true), `${message}: ${JSON.stringify(actual)}`);
21
+ }
22
+ const errors = [];
23
+ const renderer = await createPageRenderer({ embedded: true,
24
+ reportError: error => errors.push(String(error)) });
25
+ const evaluate = source => renderer.evaluate(source);
26
+ async function countsStyles(source) {
27
+ styleReads = 0;
28
+ counting = true;
29
+ try { return await evaluate(source); }
30
+ finally { counting = false; }
31
+ }
32
+ const settle = () => Bun.sleep(20);
33
+
34
+ try {
35
+ renderer.setScripting("ses");
36
+ renderer.resize(260, 180, 1);
37
+ await renderer.loadDocument({ url: "https://bounds.test/", contentType: "text/html", source: `
38
+ <!doctype html><style>
39
+ body { margin:0; min-height:1000px; min-width:500px; font:14px sans-serif }
40
+ #hidden { display:none; --tone:rgb(12,34,56); color:var(--tone) }
41
+ #leaf { display:inline-block; width:7ch; height:28px; font-family:monospace }
42
+ #ownNone { display:none }
43
+ #contents, #emptyContents { display:contents }
44
+ #fixed { position:fixed; left:140px; top:8px; width:90px; height:55px; background:rgb(0,128,0) }
45
+ #scroller { width:120px; height:55px; overflow:auto; border:3px solid blue; padding:2px }
46
+ #inner { width:300px; height:220px }
47
+ #button { width:80px; height:30px }
48
+ </style>
49
+ <div id="hidden"><section id="chain"><span id="leaf">hidden</span></section></div>
50
+ <div id="ownNone">none</div><div id="emptyContents"></div><span id="emptyInline"></span>
51
+ <div id="contents"><span id="inline">inline text</span></div>
52
+ <svg id="svg" width="28" height="24"><path id="path" d="M1 1h20v16H1z" fill="red"/></svg>
53
+ <div id="fixed"></div><div id="scroller"><div id="inner"></div></div>
54
+ <button id="button">hit</button>` });
55
+ await renderer.whenLoaded();
56
+ await evaluate(`
57
+ window.leaf=document.getElementById('leaf');
58
+ window.detached=document.createElement('div');
59
+ window.rect=e=>{const r=e.getBoundingClientRect();return[r.x,r.y,r.width,r.height]};
60
+ window.sizes=e=>[...rect(e),e.offsetWidth,e.offsetHeight,e.clientWidth,e.clientHeight,
61
+ e.scrollWidth,e.scrollHeight,e.scrollLeft,e.scrollTop];
62
+ window.clicks=0;document.getElementById('button').onclick=()=>clicks++;
63
+ window.scrolls=0;document.getElementById('scroller').onscroll=()=>scrolls++;
64
+ `);
65
+
66
+ const empty = await countsStyles(`(() => {
67
+ const elements=['leaf','ownNone','emptyContents','emptyInline','path'].map(id=>document.getElementById(id));
68
+ elements.push(detached);
69
+ const range=document.createRange();range.selectNodeContents(leaf);
70
+ return [...elements.map(sizes),rect({getBoundingClientRect:()=>range.getBoundingClientRect()})];
71
+ })()`);
72
+ check(empty.every(values => values.every(value => value === 0)), "boxless geometry is zero");
73
+ equal(styleReads, 0, "boxless bounds and sizes do not materialize hidden styles");
74
+ equal(await countsStyles(`(() => {
75
+ leaf.scrollTo(20,30);leaf.scrollTop=12;leaf.scrollLeft=3;
76
+ return [leaf.scrollLeft,leaf.scrollTop];
77
+ })()`), [0, 0], "hidden scroll setters remain ineffective");
78
+ equal(styleReads, 0, "hidden scroll setters do not read styles");
79
+
80
+ const css = await countsStyles(`(() => {
81
+ const s=getComputedStyle(leaf);return[s.color,s.width,s.getPropertyValue('--tone')];
82
+ })()`);
83
+ equal([css[0], css[2]], ["rgba(12, 34, 56, 1)", "rgb(12,34,56)"], "explicit hidden CSS still resolves");
84
+ check(css[1].endsWith("px") && Number.parseFloat(css[1]) > 0, "hidden ch width resolves to positive pixels");
85
+ check(styleReads > 0, "explicit CSS materializes the deferred style");
86
+ await evaluate(`leaf.style.width='81px';leaf.style.height='29px';leaf.style.color='rgb(9,8,7)';
87
+ document.getElementById('hidden').style.setProperty('--tone','purple')`);
88
+ equal(await evaluate(`(() => {const s=getComputedStyle(leaf);
89
+ return[s.width,s.height,s.color,s.getPropertyValue('--tone')];})()`),
90
+ ["81px", "29px", "rgba(9, 8, 7, 1)", "purple"], "hidden CSS updates remain live");
91
+
92
+ const rendered = await evaluate(`['inline','contents','svg'].map(id=>rect(document.getElementById(id)))`);
93
+ check(rendered[0][2] > 0 && rendered[1][2] > 0, "inline and display-contents text retain bounds");
94
+ equal(rendered[2].slice(2), [28, 24], "SVG retains its rendered dimensions");
95
+
96
+ await evaluate(`window.resizeWidths=[];window.intersections=[];
97
+ window.ro=new ResizeObserver(es=>resizeWidths.push(es[0].contentRect.width));ro.observe(leaf);
98
+ window.io=new IntersectionObserver(es=>intersections.push(es[0].isIntersecting));io.observe(leaf)`);
99
+ await settle();
100
+ equal(await evaluate(`[resizeWidths.length,intersections.at(-1)]`), [0, false], "initial zero size does not notify ResizeObserver");
101
+ await evaluate(`document.getElementById('hidden').style.display='block'`);
102
+ await settle();
103
+ equal(await evaluate(`[resizeWidths.at(-1),intersections.at(-1)]`), [81, true], "reveal updates observer geometry");
104
+ await evaluate(`document.getElementById('hidden').style.display='none'`);
105
+ await settle();
106
+ equal(await evaluate(`[resizeWidths.at(-1),intersections.at(-1)]`), [0, false], "hiding updates observer geometry");
107
+
108
+ await evaluate(`document.getElementById('fixed').append(leaf)`);
109
+ const fixed = await evaluate(`rect(leaf)`);
110
+ renderer.scrollTo(60, 130);
111
+ equal(await evaluate(`rect(leaf)`), fixed, "a fixed ancestor cancels viewport scrolling");
112
+ renderer.scrollTo(0, 0);
113
+ await evaluate(`document.getElementById('inner').append(leaf)`);
114
+ const beforeScroll = await evaluate(`rect(leaf)`);
115
+ await evaluate(`document.getElementById('scroller').scrollTo(60,70)`);
116
+ await settle();
117
+ const afterScroll = await evaluate(`rect(leaf)`);
118
+ equal(afterScroll, [beforeScroll[0] - 60, beforeScroll[1] - 70, beforeScroll[2], beforeScroll[3]],
119
+ "scroll-container offsets remain part of viewport bounds");
120
+ check(await evaluate("scrolls > 0"), "scroll event still fires");
121
+ await evaluate(`leaf.style.visibility='hidden'`);
122
+ equal(await evaluate(`rect(leaf)`), afterScroll, "visibility hidden retains geometry");
123
+ await evaluate(`leaf.style.visibility='visible';leaf.style.transform='translate(8px,5px)'`);
124
+ const translated = await evaluate(`rect(leaf)`);
125
+ equal(translated, [afterScroll[0] + 8, afterScroll[1] + 5, afterScroll[2], afterScroll[3]],
126
+ "translated coordinates remain intact");
127
+
128
+ await evaluate(`leaf.remove()`);
129
+ equal(await countsStyles("sizes(leaf)"), Array(12).fill(0), "detached geometry is zero");
130
+ equal(styleReads, 0, "detached geometry does not read stale styles");
131
+ await evaluate(`document.getElementById('chain').append(leaf);window.destroyed=document.getElementById('chain');
132
+ document.getElementById('hidden').innerHTML=''`);
133
+ equal(await countsStyles("[...sizes(leaf),...sizes(destroyed)]"), Array(24).fill(0), "removed subtree has no geometry");
134
+ equal(styleReads, 0, "removed subtree does not materialize styles");
135
+
136
+ renderer.resize(220, 160, 2);
137
+ const image = await renderer.screenshot({ format: "rgba" });
138
+ equal([image.width, image.height], [440, 320], "DPR scales the raster");
139
+ const pixel = (100 * image.width + 400) * 4;
140
+ equal([...image.pixels.slice(pixel, pixel + 4)], [0, 128, 0, 255], "fixed box still paints at the expected viewport position");
141
+ const button = await evaluate("rect(document.getElementById('button'))");
142
+ renderer.click(button[0] + 10, button[1] + 10);
143
+ equal(await evaluate("clicks"), 1, "visible geometry still targets pointer activation");
144
+ equal(errors, [], "renderer reports no errors");
145
+ console.log(JSON.stringify({ passed: true, checks }));
146
+ } finally {
147
+ renderer.dispose();
148
+ }
@@ -72,6 +72,83 @@ test("font sources try local/fetch alternatives and pending requests abort on do
72
72
  expect(aborted).toBe(1);
73
73
  });
74
74
 
75
+ test("font indexes preserve subset order, case-insensitive families, and changed face lists", async () => {
76
+ const { CanvasKit, builtinTypefaces, fontCollection } = await loadGraphics();
77
+ const local = { sources: [{ local: builtinTypefaces[0].family }] };
78
+ const fonts = new DocumentFonts({ CanvasKit, builtins: builtinTypefaces, defaultCollection: fontCollection,
79
+ fetch: () => { throw new Error("local faces must not fetch"); } });
80
+ try {
81
+ const latin = face("Indexed", 400, [[0, 127]], local);
82
+ const capitals = face("iNDEXED", 400, [[65, 90]], local);
83
+ const cjk = face("Indexed", 400, [[0x4e00, 0x9fff]], local);
84
+ fonts.update([latin, capitals, cjk], [run("Aa中", ["INDEXED"])]);
85
+ await fonts.ready();
86
+ const latinAlias = fonts.resolve("indexed", 400, "normal", 97)[0];
87
+ const capitalsAlias = fonts.resolve("INDEXED", 400, "normal", 65)[0];
88
+ const cjkAlias = fonts.resolve("Indexed", 400, "normal", 0x4e2d)[0];
89
+ expect(new Set([latinAlias, capitalsAlias, cjkAlias]).size).toBe(3);
90
+ expect(fonts.resolve("iNdExEd", 400, "normal", 65)).toEqual([capitalsAlias, latinAlias]);
91
+ expect(fonts.resolve("indexed")).toEqual([cjkAlias, capitalsAlias, latinAlias]);
92
+
93
+ fonts.setFaces([cjk, capitals, latin]);
94
+ expect(fonts.resolve("Indexed", 400, "normal", 65)).toEqual([latinAlias, capitalsAlias]);
95
+ fonts.setFaces([cjk]);
96
+ expect(fonts.resolve("Indexed", 400, "normal", 65)).toEqual([]);
97
+ expect(fonts.resolve("Indexed", 400, "normal", 0x4e2d)).toEqual([cjkAlias]);
98
+ fonts.setFaces([]);
99
+ expect(fonts.hasUnicodeRanges).toBe(false);
100
+ expect(fonts.resolve("Indexed")).toEqual([]);
101
+ // Equivalent new descriptor objects still refer to the existing record.
102
+ fonts.setFaces([structuredClone(latin)]);
103
+ expect(fonts.resolve("Indexed", 400, "normal", 65)).toEqual([latinAlias]);
104
+ const returned = fonts.resolve("Indexed"); returned.length = 0;
105
+ expect(fonts.resolve("Indexed")).toEqual([latinAlias]);
106
+
107
+ // setFaces must also notice edits to an existing descriptor/array.
108
+ const mutable = [latin];
109
+ fonts.setFaces(mutable);
110
+ latin.family = "Renamed";
111
+ latin.unicodeRanges = [[0x400, 0x4ff]];
112
+ fonts.update(mutable, [run("Ж", ["renamed"])]);
113
+ await fonts.ready();
114
+ expect(fonts.resolve("Indexed")).toEqual([]);
115
+ expect(fonts.resolve("Renamed", 400, "normal", 65)).toEqual([]);
116
+ expect(fonts.resolve("Renamed", 400, "normal", 0x416)).toHaveLength(1);
117
+ } finally { fonts.dispose(); }
118
+ });
119
+
120
+ test("ranked faces observe aliases loaded later and changed font weights/styles", async () => {
121
+ const { CanvasKit, builtinTypefaces, fontCollection } = await loadGraphics();
122
+ const bytes = await Bun.file(new URL("../usr/share/fonts/DejaVuSansMono.ttf", import.meta.url)).bytes();
123
+ let finish;
124
+ const response = new Promise(resolve => { finish = resolve; });
125
+ const fonts = new DocumentFonts({ CanvasKit, builtins: builtinTypefaces, defaultCollection: fontCollection,
126
+ fetch: () => response });
127
+ try {
128
+ const regular = face("Delayed", 400, [[0, 127]]);
129
+ const medium = face("Delayed", 500, [[0, 127]]);
130
+ const italic = face("Delayed", 400, [[0, 127]], { style: "italic" });
131
+ fonts.update([regular, medium, italic], [run("a", ["Delayed"]), run("a", ["Delayed"], 500), run("a", ["Delayed"], 400, "italic")]);
132
+ expect(fonts.resolve("Delayed", 400, "normal", 65)).toEqual([]);
133
+ expect(fonts.resolve("Delayed", 450, "normal", 65)).toEqual([]);
134
+ expect(fonts.resolve("Delayed", 400, "italic", 65)).toEqual([]);
135
+ finish({ status: 200, body: bytes });
136
+ await fonts.ready();
137
+ const regularAlias = fonts.resolve("Delayed", 400, "normal", 65)[0];
138
+ const mediumAlias = fonts.resolve("Delayed", 500, "normal", 65)[0];
139
+ const italicAlias = fonts.resolve("Delayed", 400, "italic", 65)[0];
140
+ expect(new Set([regularAlias, mediumAlias, italicAlias]).size).toBe(3);
141
+ expect(fonts.resolve("Delayed", 450, "normal", 65)).toEqual([mediumAlias, regularAlias, italicAlias]);
142
+ expect(fonts.resolve("Delayed", 400, "italic", 65)).toEqual([italicAlias, regularAlias, mediumAlias]);
143
+ fonts.setFaces([medium]);
144
+ expect(fonts.resolve("Delayed", 400, "normal", 65)).toEqual([mediumAlias]);
145
+ // More style combinations than the bounded rank cache can hold must not
146
+ // change either a new lookup or one evicted earlier.
147
+ for (let weight = 1; weight <= 160; weight++) expect(fonts.resolve("Delayed", weight, "normal", 65)).toEqual([mediumAlias]);
148
+ expect(fonts.resolve("Delayed", 450, "normal", 65)).toEqual([mediumAlias]);
149
+ } finally { fonts.dispose(); }
150
+ });
151
+
75
152
  test("loaded web font updates geometry, respects Unicode ranges, and resets on navigation", async () => {
76
153
  const bytes = await Bun.file(new URL("../usr/share/fonts/DejaVuSansMono.ttf", import.meta.url)).bytes();
77
154
  let finish;
@@ -0,0 +1,60 @@
1
+ import { expect, test } from "bun:test";
2
+ import { parseHTMLDocument } from "../lib/happy-dom/parser.js";
3
+ import { StyleEngine } from "../lib/style/computed-style.js";
4
+
5
+ test("computed font-metric dependencies include resolved variables, pseudos and root lengths", () => {
6
+ const cases = [
7
+ ["#box{font-size:20px;line-height:normal;width:10em;height:2rem}", false],
8
+ ["#box{--unused:10ch;width:20px}", false],
9
+ ["#box{width:10ch}", true],
10
+ ["body{--size:4ch}#box{width:calc(var(--size) + 2px)}", true],
11
+ ["#box{width:var(--missing,3ch)}", true],
12
+ ["#box::before{content:'x';width:2ch}", true],
13
+ ["html{font-size:2ch}#box{width:10rem}", true],
14
+ ];
15
+ for (const [sheet, dependent] of cases) {
16
+ const { document, window } = parseHTMLDocument('<div id="box">text</div>');
17
+ try {
18
+ const engine = new StyleEngine({ measureText: () => 7 }).compute(document, [sheet]);
19
+ expect(engine.usesFontMetrics).toBe(dependent);
20
+ engine.compute(document, [sheet], {}, new Set(), new Set([document.getElementById("box")]));
21
+ expect(engine.usesFontMetrics).toBe(dependent);
22
+ } finally { window.happyDOM.abort(); }
23
+ }
24
+ });
25
+
26
+ test("font-metric dependencies survive incremental changes but reset for a new document", () => {
27
+ const first = parseHTMLDocument('<div id="box" style="width:2ch">first</div>');
28
+ const second = parseHTMLDocument('<div>second</div>');
29
+ try {
30
+ const engine = new StyleEngine({ measureText: () => 8 }).compute(first.document);
31
+ expect(engine.usesFontMetrics).toBe(true);
32
+ first.document.getElementById("box").style.width = "20px";
33
+ engine.compute(first.document, [], {}, new Set(), new Set([first.document.getElementById("box")]));
34
+ expect(engine.usesFontMetrics).toBe(true);
35
+ engine.compute(second.document);
36
+ expect(engine.usesFontMetrics).toBe(false);
37
+ } finally { first.window.happyDOM.abort(); second.window.happyDOM.abort(); }
38
+ });
39
+
40
+ test("font completion refreshes geometry with pixel parity while avoiding unnecessary cascades", async () => {
41
+ const child = Bun.spawn([process.execPath, new URL("./fixtures/font-metrics-invalidation.js", import.meta.url).pathname], {
42
+ stdout: "pipe", stderr: "pipe",
43
+ });
44
+ const [out, error, code] = await Promise.all([
45
+ new Response(child.stdout).text(), new Response(child.stderr).text(), child.exited,
46
+ ]);
47
+ expect({ code, error }).toEqual({ code: 0, error: "" });
48
+ expect(out).toContain("font metrics invalidation integration passed");
49
+ }, 30_000);
50
+
51
+ test("font definition edits refresh geometry and pixels without unnecessary full cascades", async () => {
52
+ const child = Bun.spawn([process.execPath, new URL("./fixtures/font-definition-invalidation.js", import.meta.url).pathname], {
53
+ stdout: "pipe", stderr: "pipe",
54
+ });
55
+ const [out, error, code] = await Promise.all([
56
+ new Response(child.stdout).text(), new Response(child.stderr).text(), child.exited,
57
+ ]);
58
+ expect({ code, error }).toEqual({ code: 0, error: "" });
59
+ expect(out).toContain("font definition invalidation integration passed");
60
+ }, 30_000);
@@ -0,0 +1,191 @@
1
+ import { expect, test } from 'bun:test';
2
+ import { parseHTMLDocument } from '../lib/happy-dom/parser.js';
3
+ import { StyleEngine } from '../lib/style/computed-style.js';
4
+
5
+ function fixture(source, sheets = [], options = {}) {
6
+ const parsed = parseHTMLDocument(source);
7
+ const viewport = { width:800, height:600 };
8
+ const engine = new StyleEngine(options).compute(parsed.document, sheets, viewport);
9
+ return { ...parsed, sheets, options, viewport, engine, get:id => parsed.document.getElementById(id) };
10
+ }
11
+
12
+ function full(f) {
13
+ f.engine.compute(f.document, f.sheets, f.viewport);
14
+ expect(f.engine.incremental).toBe(false);
15
+ expect(f.engine.restyled).toBe(f.document.querySelectorAll('*').length);
16
+ const reference = new StyleEngine(f.options).compute(f.document, f.sheets, f.viewport);
17
+ for (const element of f.document.querySelectorAll('*')) {
18
+ expect(f.engine.get(element)).toEqual(reference.get(element));
19
+ expect(f.engine.customPropertiesOf(element)).toEqual(reference.customPropertiesOf(element));
20
+ for (const pseudo of ['before', 'after']) expect(f.engine.getPseudo(element, pseudo)).toEqual(reference.getPseudo(element, pseudo));
21
+ }
22
+ }
23
+
24
+ test('full cascades retain equal style, custom property and pseudo identities', () => {
25
+ const f = fixture('<div id="parent" style="--tone:red"><span id="child">text</span></div>', [
26
+ '#child {color:var(--tone)} #child::before {content:"before";color:var(--tone)}',
27
+ ]);
28
+ try {
29
+ const child = f.get('child'), style = f.engine.get(child);
30
+ const custom = f.engine.customPropertiesOf(child), pseudo = f.engine.getPseudo(child, 'before');
31
+ f.sheets.push('.unmatched { width:20px }');
32
+ full(f);
33
+ expect(f.engine.get(child)).toBe(style);
34
+ expect(f.engine.customPropertiesOf(child)).toBe(custom);
35
+ expect(f.engine.getPseudo(child, 'before')).toBe(pseudo);
36
+ f.get('parent').style.setProperty('--tone', 'blue');
37
+ full(f);
38
+ expect(f.engine.get(child)).not.toBe(style);
39
+ expect(f.engine.customPropertiesOf(child)).not.toBe(custom);
40
+ expect(f.engine.getPseudo(child, 'before')).not.toBe(pseudo);
41
+ expect(style.color).toBe('rgba(255, 0, 0, 1)');
42
+ expect(custom.get('--tone')).toBe('red');
43
+ expect(pseudo.color).toBe('rgba(255, 0, 0, 1)');
44
+ f.sheets.push('#child::before {content:none} #child::after {content:"after";width:inherit}');
45
+ full(f);
46
+ expect(f.engine.getPseudo(child, 'before')).toBeNull();
47
+ expect(f.engine.getPseudo(child, 'after').content).toBe('after');
48
+ } finally { f.window.happyDOM.abort(); }
49
+ });
50
+
51
+ for (const incremental of [false, true]) {
52
+ test(`style identity comparison preserves signed-zero and NaN decisions (${incremental ? 'incremental' : 'full'})`, () => {
53
+ const f = fixture('<div id="zero" style="margin:0px">zero</div><div id="nan" style="width:calc(1e309 * 0px)">non-finite</div>');
54
+ try {
55
+ const zero = f.get('zero'), nan = f.get('nan');
56
+ const zeroStyle = f.engine.get(zero), nanStyle = f.engine.get(nan);
57
+ expect(Number.isNaN(nanStyle.width.px)).toBe(true);
58
+ expect(Number.isNaN(nanStyle.width.percent)).toBe(true);
59
+ zero.setAttribute('style', 'margin:-0px');
60
+ const fresh = new StyleEngine().compute(f.document, f.sheets);
61
+ expect(Object.is(fresh.get(zero).margin[0], -0)).toBe(true);
62
+ f.engine.compute(f.document, f.sheets, f.viewport, incremental ? new Set([zero, nan]) : null);
63
+ expect(f.engine.get(zero)).toBe(zeroStyle);
64
+ expect(f.engine.get(nan)).not.toBe(nanStyle);
65
+ expect(Number.isNaN(f.engine.get(nan).width.px)).toBe(true);
66
+ } finally { f.window.happyDOM.abort(); }
67
+ });
68
+ }
69
+
70
+ test('full cascades recompute media, viewport, root rem and font metric inputs', () => {
71
+ let advance = 7;
72
+ const f = fixture('<html style="font-size:2rem"><body><div id="parent" style="width:10vw;direction:rtl"><span id="child" style="font-size:50%;width:2rem;margin-inline-start:3px">text</span><i id="metric" style="width:10ch">metric</i></div></body></html>', [
73
+ '@media(min-width:1000px) { #child {padding:7px} }',
74
+ ], { measureText:() => advance });
75
+ try {
76
+ const oldMetric = f.engine.get(f.get('metric'));
77
+ full(f);
78
+ expect(f.engine.get(f.get('metric'))).toBe(oldMetric);
79
+ advance = 11;
80
+ f.viewport = { width:1200, height:700 };
81
+ f.document.documentElement.style.fontSize = '1.5rem';
82
+ full(f);
83
+ expect(f.engine.get(f.get('parent')).width).toBe(120);
84
+ expect(f.engine.get(f.get('child'))).toMatchObject({ width:48, fontSize:12, padding:[7,7,7,7], margin:[0,3,0,0] });
85
+ expect(f.engine.get(f.get('metric')).width).toBe(110);
86
+ expect(f.engine.get(f.get('metric'))).not.toBe(oldMetric);
87
+ expect(f.engine.usesFontMetrics).toBe(true);
88
+ } finally { f.window.happyDOM.abort(); }
89
+ });
90
+
91
+ test('retaining a full-pass style still refreshes explicit inheritance dependencies', () => {
92
+ const f = fixture('<div id="parent" style="width:100px"><span id="child" style="width:100px">text</span></div>');
93
+ try {
94
+ const original = f.engine.get(f.get('child'));
95
+ f.get('child').style.width = 'inherit';
96
+ full(f);
97
+ expect(f.engine.get(f.get('child'))).toBe(original);
98
+ f.get('parent').style.width = '200px';
99
+ f.engine.compute(f.document, f.sheets, f.viewport, new Set(), new Set([f.get('parent')]));
100
+ expect(f.engine.get(f.get('child')).width).toBe(200);
101
+ const inherited = f.engine.get(f.get('child'));
102
+ f.get('child').style.width = '200px';
103
+ full(f);
104
+ expect(f.engine.get(f.get('child'))).toBe(inherited);
105
+ f.get('parent').style.width = '300px';
106
+ f.engine.compute(f.document, f.sheets, f.viewport, new Set(), new Set([f.get('parent')]));
107
+ expect(f.engine.restyled).toBe(1);
108
+ expect(f.engine.get(f.get('child')).width).toBe(200);
109
+ } finally { f.window.happyDOM.abort(); }
110
+ });
111
+
112
+ test('full-pass maps drop detached nodes and do not restore them on later insertion', () => {
113
+ const f = fixture('<div id="parent"><span id="child" style="width:41px;--private:red">text</span></div>', ['#child::before {content:"x"}']);
114
+ try {
115
+ const child = f.get('child'), old = f.engine.get(child);
116
+ child.remove();
117
+ full(f);
118
+ expect(f.engine.get(child).width).toBe('auto');
119
+ expect(f.engine.customPropertiesOf(child).size).toBe(0);
120
+ expect(f.engine.getPseudo(child, 'before')).toBeNull();
121
+ f.get('parent').appendChild(child);
122
+ f.engine.compute(f.document, f.sheets, f.viewport, new Set([child]));
123
+ expect(f.engine.get(child).width).toBe(41);
124
+ expect(f.engine.get(child)).not.toBe(old);
125
+ f.document.documentElement.remove();
126
+ full(f);
127
+ expect(f.engine.get(child).width).toBe('auto');
128
+ expect(f.engine.getPseudo(child, 'before')).toBeNull();
129
+ } finally { f.window.happyDOM.abort(); }
130
+ });
131
+
132
+ test('a new document cannot reuse adopted nodes from the previous document', () => {
133
+ const f = fixture('<p id="child" style="width:41px;--private:red">text</p>', ['#child::before {content:"x"}']);
134
+ const other = parseHTMLDocument('<body></body>');
135
+ try {
136
+ const child = f.get('child'), oldRoot = f.document.documentElement;
137
+ const old = [f.engine.get(child), f.engine.customPropertiesOf(child), f.engine.getPseudo(child, 'before')];
138
+ other.document.body.appendChild(child);
139
+ f.engine.compute(other.document, f.sheets);
140
+ const current = [f.engine.get(child), f.engine.customPropertiesOf(child), f.engine.getPseudo(child, 'before')];
141
+ for (let i = 0; i < old.length; i++) {
142
+ expect(current[i]).toEqual(old[i]);
143
+ expect(current[i]).not.toBe(old[i]);
144
+ }
145
+ expect(f.engine.get(oldRoot).display).toBe('inline');
146
+ } finally { f.window.happyDOM.abort(); other.window.happyDOM.abort(); }
147
+ });
148
+
149
+ test('a callback starting another document compute cannot access the outer fallback', () => {
150
+ const first = parseHTMLDocument('<html style="width:1ch"><body><p style="color:red">moved</p></body></html>');
151
+ const second = parseHTMLDocument('<body></body>');
152
+ try {
153
+ let trigger = false, nestedStyle;
154
+ const child = first.document.querySelector('p');
155
+ const engine = new StyleEngine({ measureText() {
156
+ if (trigger) {
157
+ trigger = false;
158
+ second.document.body.appendChild(child);
159
+ engine.compute(second.document);
160
+ nestedStyle = engine.get(child);
161
+ }
162
+ return 8;
163
+ } });
164
+ engine.compute(first.document);
165
+ const old = engine.get(child);
166
+ trigger = true;
167
+ engine.compute(first.document);
168
+ expect(nestedStyle).toEqual(old);
169
+ expect(nestedStyle).not.toBe(old);
170
+ } finally { first.window.happyDOM.abort(); second.window.happyDOM.abort(); }
171
+ });
172
+
173
+ test('a failed full traversal cannot retain fallback maps for a later incremental pass', () => {
174
+ const f = fixture('<div id="parent"><span id="child" style="width:41px">text</span></div>');
175
+ try {
176
+ const child = f.get('child'), old = f.engine.get(child);
177
+ Object.defineProperty(f.get('parent'), 'getAttribute', { configurable:true, value() { throw Error('injected failure'); } });
178
+ expect(() => f.engine.compute(f.document, f.sheets)).toThrow('injected failure');
179
+ delete f.get('parent').getAttribute;
180
+ f.engine.compute(f.document, f.sheets, f.viewport, new Set([f.get('parent')]));
181
+ expect(f.engine.get(child).width).toBe(41);
182
+ expect(f.engine.get(child)).not.toBe(old);
183
+ full(f);
184
+ } finally { f.window.happyDOM.abort(); }
185
+ });
186
+
187
+ test('full identity reuse preserves render and layout caches under SES', async () => {
188
+ const child = Bun.spawn([process.execPath, new URL('./fixtures/full-style-identity.js', import.meta.url).pathname], { stdout:'pipe', stderr:'pipe' });
189
+ const [exit, stdout, stderr] = await Promise.all([child.exited, new Response(child.stdout).text(), new Response(child.stderr).text()]);
190
+ expect({ exit, stdout, stderr }).toEqual({ exit:0, stdout:'', stderr:'' });
191
+ }, 15_000);
@@ -0,0 +1,31 @@
1
+ import { expect, test } from "bun:test";
2
+
3
+ async function fixture(name, args = []) {
4
+ const child = Bun.spawn(
5
+ [
6
+ process.execPath,
7
+ new URL(`./fixtures/${name}.js`, import.meta.url).pathname,
8
+ ...args,
9
+ ],
10
+ { stdout: "pipe", stderr: "pipe" },
11
+ );
12
+ const [exit, stdout, stderr] = await Promise.all([
13
+ child.exited,
14
+ new Response(child.stdout).text(),
15
+ new Response(child.stderr).text(),
16
+ ]);
17
+ expect(stderr).toBe("");
18
+ expect(exit).toBe(0);
19
+ expect(JSON.parse(stdout).pass).toBe(true);
20
+ }
21
+ for (const args of [[], ["--ses"]])
22
+ test(
23
+ `deferred hidden styles equal eager styles ${args.length ? "under SES" : "normally"}`,
24
+ () => fixture("hidden-style-engine", args),
25
+ 20_000,
26
+ );
27
+ test(
28
+ "hidden updates and reveals preserve pixels, geometry and resource discovery",
29
+ () => fixture("hidden-style-renderer"),
30
+ 60_000,
31
+ );
@@ -0,0 +1,20 @@
1
+ import { expect, test } from "bun:test";
2
+ for (const args of [[], ["--ses"]])
3
+ test(`hidden style measurement callbacks cannot restart unfinished chains ${args.length ? "under SES" : "normally"}`, async () => {
4
+ const child = Bun.spawn(
5
+ [
6
+ process.execPath,
7
+ new URL("./fixtures/hidden-style-reentry.js", import.meta.url).pathname,
8
+ ...args,
9
+ ],
10
+ { stdout: "pipe", stderr: "pipe" },
11
+ );
12
+ const [exit, stdout, stderr] = await Promise.all([
13
+ child.exited,
14
+ new Response(child.stdout).text(),
15
+ new Response(child.stderr).text(),
16
+ ]);
17
+ expect(stderr).toBe("");
18
+ expect(exit).toBe(0);
19
+ expect(JSON.parse(stdout).pass).toBe(true);
20
+ }, 20_000);