buninu-browser 0.5.0 → 0.6.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 (146) hide show
  1. package/README.md +9 -3
  2. package/lib/audio/pcm.js +11 -0
  3. package/lib/audio/pulse-client.js +7 -2
  4. package/lib/cdp-server.js +31 -7
  5. package/lib/happy-dom/document-base.js +29 -1
  6. package/lib/happy-dom/inert-link.js +26 -0
  7. package/lib/happy-dom/mutation-observer.js +157 -0
  8. package/lib/happy-dom/node-iterator-removal.js +36 -0
  9. package/lib/happy-dom/node-replacement.js +60 -0
  10. package/lib/happy-dom/parser.js +6 -1
  11. package/lib/headless-shell.js +49 -9
  12. package/lib/layout/repaint.js +89 -0
  13. package/lib/layout/text-layout.js +4 -2
  14. package/lib/media/mp4-keyframes.js +144 -0
  15. package/lib/media/video-keyframe.js +29 -1
  16. package/lib/media/webm-snapshots.js +282 -0
  17. package/lib/network/cookie-jar.js +18 -2
  18. package/lib/paint/display-list.js +4 -3
  19. package/lib/paint/frame.js +12 -5
  20. package/lib/render-tree/index.js +3 -0
  21. package/lib/renderer/animation-frames.js +76 -0
  22. package/lib/renderer/bindings.js +107 -3
  23. package/lib/renderer/compositor-channel.js +95 -10
  24. package/lib/renderer/compositor.js +54 -18
  25. package/lib/renderer/document-cookies.js +87 -0
  26. package/lib/renderer/font-loading.js +60 -16
  27. package/lib/renderer/host.js +48 -19
  28. package/lib/renderer/idle-callbacks.js +109 -0
  29. package/lib/renderer/main.js +5 -1
  30. package/lib/renderer/media-source.js +4 -0
  31. package/lib/renderer/page-realm.js +159 -49
  32. package/lib/renderer/page-renderer.js +577 -201
  33. package/lib/renderer/resource-elements.js +88 -0
  34. package/lib/renderer/script-rewrite.js +18 -1
  35. package/lib/renderer/seccomp.js +6 -4
  36. package/lib/renderer/timers.js +71 -0
  37. package/lib/renderer/video-channel.js +94 -0
  38. package/lib/renderer/video-mask.js +115 -0
  39. package/lib/renderer/video-presentation.js +304 -39
  40. package/lib/renderer/video-worker.js +46 -0
  41. package/lib/renderer/workers.js +7 -17
  42. package/lib/style/computed-style.js +257 -61
  43. package/package.json +1 -1
  44. package/test/animation-frames.test.js +67 -0
  45. package/test/canvas-style-snapshot.test.js +18 -0
  46. package/test/cdp-stream.test.js +96 -3
  47. package/test/compare-node-iterator.js +42 -0
  48. package/test/compositor-channel-ownership.test.js +194 -0
  49. package/test/compositor.test.js +27 -1
  50. package/test/computed-style-binding.test.js +49 -0
  51. package/test/computed-style-connectivity.test.js +13 -0
  52. package/test/computed-style.test.js +86 -0
  53. package/test/cookie-sync.test.js +82 -0
  54. package/test/document-base-cache.test.js +103 -0
  55. package/test/document-cookies.test.js +117 -0
  56. package/test/duplicate-style-keys.test.js +11 -0
  57. package/test/fixtures/animation-frames.js +56 -0
  58. package/test/fixtures/canvas-style-snapshot.js +81 -0
  59. package/test/fixtures/computed-style-connectivity.js +101 -0
  60. package/test/fixtures/computed-style-lazy.js +135 -0
  61. package/test/fixtures/duplicate-style-keys.js +97 -0
  62. package/test/fixtures/fake-renderer.js +12 -0
  63. package/test/fixtures/font-definition-invalidation.js +97 -0
  64. package/test/fixtures/font-metrics-invalidation.js +87 -0
  65. package/test/fixtures/fractional-text-scale.js +38 -0
  66. package/test/fixtures/full-style-identity.js +36 -0
  67. package/test/fixtures/hidden-style-engine.js +260 -0
  68. package/test/fixtures/hidden-style-reentry.js +232 -0
  69. package/test/fixtures/hidden-style-renderer.js +203 -0
  70. package/test/fixtures/layout-repaint.js +69 -0
  71. package/test/fixtures/media-presentation-continuation.js +141 -0
  72. package/test/fixtures/media-presentation-refill.js +113 -0
  73. package/test/fixtures/media-probe.js +148 -0
  74. package/test/fixtures/media-refill-cancel.js +79 -0
  75. package/test/fixtures/media-repaint.js +48 -0
  76. package/test/fixtures/media-resource-reselection.js +94 -0
  77. package/test/fixtures/media-source-attachment.js +136 -0
  78. package/test/fixtures/media-source-no-decoder.js +17 -0
  79. package/test/fixtures/media-track-refill.js +132 -0
  80. package/test/fixtures/media-webm-seek.js +51 -0
  81. package/test/fixtures/mutation-observer.js +201 -0
  82. package/test/fixtures/node-iterator-cases.js +59 -0
  83. package/test/fixtures/node-iterator-chromium.json +315 -0
  84. package/test/fixtures/observer-invalidation.js +105 -0
  85. package/test/fixtures/prepaint-scheduling.js +73 -0
  86. package/test/fixtures/resource-discovery.js +32 -0
  87. package/test/fixtures/spare-renderer-scheduling.js +124 -0
  88. package/test/fixtures/style-propagation.js +26 -0
  89. package/test/fixtures/svg-node-reuse.js +83 -0
  90. package/test/fixtures/video-black-replay.js +140 -0
  91. package/test/fixtures/video-controls-invalidation.js +71 -0
  92. package/test/fixtures/viewport-bounds.js +148 -0
  93. package/test/font-loading.test.js +77 -0
  94. package/test/font-metrics-invalidation.test.js +60 -0
  95. package/test/fractional-text-scale.test.js +6 -0
  96. package/test/full-style-identity.test.js +191 -0
  97. package/test/hidden-style-lazy.test.js +31 -0
  98. package/test/hidden-style-reentry.test.js +20 -0
  99. package/test/idle-callback.test.js +89 -0
  100. package/test/idle-scheduler.test.js +148 -0
  101. package/test/inert-link.test.js +37 -0
  102. package/test/inline-style-cascade.test.js +63 -0
  103. package/test/layout-repaint-renderer.test.js +9 -0
  104. package/test/layout-repaint.test.js +222 -0
  105. package/test/media-presentation-continuation.test.js +14 -0
  106. package/test/media-presentation-refill.test.js +8 -0
  107. package/test/media-probe.test.js +16 -0
  108. package/test/media-repaint.test.js +8 -0
  109. package/test/media-source-attachment.test.js +34 -0
  110. package/test/media-track-refill.test.js +26 -0
  111. package/test/media-webm-seek.test.js +11 -0
  112. package/test/mp4-keyframes.test.js +36 -0
  113. package/test/mutation-observer.test.js +91 -0
  114. package/test/node-iterator.test.js +81 -0
  115. package/test/observer-invalidation.test.js +12 -0
  116. package/test/page-scripts.test.js +151 -1
  117. package/test/page-timers.test.js +67 -0
  118. package/test/paint-frame.test.js +18 -1
  119. package/test/pcm.test.js +63 -0
  120. package/test/prepaint-scheduling.test.js +13 -0
  121. package/test/pulse-client.test.js +165 -0
  122. package/test/raw-capture.test.js +34 -5
  123. package/test/renderer-host.test.js +51 -0
  124. package/test/resource-discovery.test.js +12 -0
  125. package/test/resource-elements.test.js +234 -0
  126. package/test/root-self-style.test.js +104 -0
  127. package/test/script-rewrite.test.js +35 -0
  128. package/test/seccomp.test.js +45 -0
  129. package/test/spare-renderer-scheduling.test.js +8 -0
  130. package/test/style-propagation.test.js +225 -0
  131. package/test/style-sheets.test.js +19 -0
  132. package/test/svg.test.js +45 -0
  133. package/test/text-layout.test.js +15 -0
  134. package/test/timers.test.js +58 -0
  135. package/test/video-black-replay.test.js +34 -0
  136. package/test/video-capture.test.js +170 -0
  137. package/test/video-channel.test.js +253 -0
  138. package/test/video-continuation.test.js +57 -0
  139. package/test/video-controls-invalidation.test.js +12 -0
  140. package/test/video-keyframe.test.js +104 -4
  141. package/test/video-mask.test.js +94 -0
  142. package/test/video-presentation.test.js +239 -1
  143. package/test/video-timing-presentation.test.js +115 -0
  144. package/test/viewport-bounds.test.js +13 -0
  145. package/test/webm-snapshots.test.js +372 -0
  146. package/test/window-resize.test.js +29 -0
@@ -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,6 @@
1
+ import {test,expect} from 'bun:test';
2
+ test('fractional font sizes retain the final glyph at different device scales',async()=>{
3
+ const child=Bun.spawn([process.execPath,new URL('./fixtures/fractional-text-scale.js',import.meta.url).pathname],{stdout:'pipe',stderr:'pipe'});
4
+ const [exit,stderr]=await Promise.all([child.exited,new Response(child.stderr).text()]);
5
+ expect({exit,stderr}).toEqual({exit:0,stderr:''});
6
+ },30000);
@@ -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);
@@ -0,0 +1,89 @@
1
+ import { afterAll, beforeAll, expect, test } from "bun:test";
2
+ import { RendererHost } from "../lib/renderer/host.js";
3
+
4
+ let renderer;
5
+ beforeAll(async () => {
6
+ renderer = new RendererHost({ timeout: 20_000 });
7
+ await renderer.start();
8
+ await renderer.call("loadDocument", {
9
+ url: "https://page.test/", source: "<!doctype html><p>idle callbacks</p>",
10
+ contentType: "text/html", status: 200,
11
+ });
12
+ await renderer.call("whenLoaded");
13
+ });
14
+ afterAll(() => renderer?.close());
15
+ const evaluate = (source) => renderer.call("evaluate", source);
16
+
17
+ test("idle work consumes its monotonic budget and yields", async () => {
18
+ const result = await evaluate(`new Promise(resolve => requestIdleCallback(deadline => {
19
+ const start = performance.now(), before = deadline.timeRemaining();
20
+ // Bound the regression: the old constant deadline must not hang the test.
21
+ while (deadline.timeRemaining() > 0 && performance.now() - start < 30) {}
22
+ resolve({before, after: deadline.timeRemaining(), didTimeout: deadline.didTimeout});
23
+ }))`);
24
+ expect(result.before).toBeGreaterThanOrEqual(0);
25
+ expect(result.before).toBeLessThanOrEqual(10);
26
+ expect(result.after).toBe(0);
27
+ expect(result.didTimeout).toBeFalse();
28
+ });
29
+
30
+ test("a burst of idle work does not drain before an ordinary due timer", async () => {
31
+ const result = await evaluate(`new Promise(resolve => {
32
+ let completed = 0, whenTimer = null;
33
+ const finish = () => { if (completed === 16 && whenTimer !== null) resolve({completed, whenTimer}); };
34
+ for (let i = 0; i < 16; i++) requestIdleCallback(deadline => {
35
+ const start = performance.now();
36
+ while (deadline.timeRemaining() > 0 && performance.now() - start < 30) {}
37
+ completed++; finish();
38
+ });
39
+ setTimeout(() => { whenTimer = completed; finish(); }, 20);
40
+ })`);
41
+ expect(result.completed).toBe(16);
42
+ // Check task ordering, rather than imposing a real-clock upper bound on a
43
+ // slow CI machine. The old separate 1ms timers drain all sixteen first.
44
+ expect(result.whenTimer).toBeLessThan(16);
45
+ });
46
+
47
+ test("an expired timeout has no idle budget", async () => {
48
+ const result = await evaluate(`new Promise(resolve => {
49
+ requestIdleCallback(deadline => resolve({
50
+ didTimeout: deadline.didTimeout, remaining: deadline.timeRemaining()
51
+ }), {timeout: 1});
52
+ const start = performance.now();
53
+ while (performance.now() - start < 20) {}
54
+ })`);
55
+ expect(result).toEqual({ didTimeout: true, remaining: 0 });
56
+ });
57
+
58
+ test("zero timeout does not expire and cancellation prevents delivery", async () => {
59
+ const result = await evaluate(`new Promise(resolve => {
60
+ const calls = [];
61
+ const id = requestIdleCallback(() => calls.push("cancelled"), {timeout: 1});
62
+ cancelIdleCallback(id);
63
+ requestIdleCallback(deadline => {
64
+ calls.push(deadline.didTimeout);
65
+ setTimeout(() => resolve(calls), 10);
66
+ }, {timeout: 0});
67
+ const start = performance.now();
68
+ while (performance.now() - start < 10) {}
69
+ })`);
70
+ expect(result).toEqual([false]);
71
+ });
72
+
73
+ test("a timeout above the host timer limit does not expire immediately", async () => {
74
+ const result = await evaluate(`new Promise(resolve => {
75
+ requestIdleCallback(deadline => resolve({didTimeout: deadline.didTimeout}), {timeout: 2147483648});
76
+ })`);
77
+ expect(result).toEqual({ didTimeout: false });
78
+ });
79
+
80
+ test("idle callbacks validate their Web IDL inputs", async () => {
81
+ expect(await evaluate(`(() => {
82
+ const failures = [];
83
+ for (const args of [[null], ["code"], [() => {}, 1], [() => {}, {timeout: Symbol()}]]) {
84
+ try { requestIdleCallback(...args); failures.push(false); }
85
+ catch (error) { failures.push(error.name === "TypeError"); }
86
+ }
87
+ return failures;
88
+ })()`)).toEqual([true, true, true, true]);
89
+ });
@@ -0,0 +1,148 @@
1
+ import { expect, test } from 'bun:test';
2
+ import { createIdleCallbacks } from '../lib/renderer/idle-callbacks.js';
3
+
4
+ function fixture() {
5
+ let time = 0, id = 0;
6
+ const tasks = new Map(), errors = [];
7
+ const clock = {
8
+ now: () => time,
9
+ consume: ms => { time += ms; },
10
+ setTimer(callback, delay) {
11
+ const handle = ++id;
12
+ tasks.set(handle, { callback, at: time + Math.max(1, delay) });
13
+ return handle;
14
+ },
15
+ clearTimer: handle => tasks.delete(handle),
16
+ run() {
17
+ let iterations = 0;
18
+ while (tasks.size) {
19
+ if (++iterations > 1000) throw new Error('Idle scheduler did not finish');
20
+ const [handle, task] = [...tasks].sort((a, b) => a[1].at - b[1].at || a[0] - b[0])[0];
21
+ tasks.delete(handle);
22
+ time = Math.max(time, task.at);
23
+ task.callback();
24
+ }
25
+ },
26
+ };
27
+ const idle = createIdleCallbacks({ ...clock, invoke(callback, deadline) {
28
+ try { callback(deadline); } catch (error) { errors.push(error.message); }
29
+ } });
30
+ return { clock, idle, tasks, errors };
31
+ }
32
+
33
+ test('a burst of cooperative idle work gives an already due timer a turn', () => {
34
+ const { clock, idle, tasks } = fixture();
35
+ let completed = 0, timer;
36
+ for (let i = 0; i < 16; i++) idle.request(i, deadline => {
37
+ expect(deadline.didTimeout).toBe(false);
38
+ clock.consume(deadline.timeRemaining());
39
+ expect(deadline.timeRemaining()).toBe(0);
40
+ completed++;
41
+ }, 0);
42
+ clock.setTimer(() => { timer = { at: clock.now(), completed }; }, 20);
43
+ // Sixteen requests share one dispatch task; they are not sixteen overdue timers.
44
+ expect(tasks.size).toBe(2);
45
+ clock.run();
46
+ expect(timer).toEqual({ at: 22, completed: 2 });
47
+ expect(completed).toBe(16);
48
+ expect(tasks.size).toBe(0);
49
+ });
50
+
51
+ test('callbacks share their period deadline and nested requests wait for the next period', () => {
52
+ const { clock, idle } = fixture();
53
+ const seen = [];
54
+ idle.request(1, deadline => {
55
+ seen.push({ id: 1, at: clock.now(), budget: deadline.timeRemaining() });
56
+ idle.request(3, nested => seen.push({ id: 3, at: clock.now(), budget: nested.timeRemaining() }), 0);
57
+ clock.consume(3);
58
+ }, 0);
59
+ idle.request(2, deadline => seen.push({ id: 2, at: clock.now(), budget: deadline.timeRemaining() }), 0);
60
+ clock.run();
61
+ expect(seen).toEqual([{ id: 1, at: 1, budget: 10 }, { id: 2, at: 5, budget: 6 }, { id: 3, at: 11, budget: 10 }]);
62
+ });
63
+
64
+ test('positive timeout interrupts an idle backlog once, with no budget or retained timeout', () => {
65
+ const { clock, idle, tasks } = fixture();
66
+ let completed = 0;
67
+ const seen = [];
68
+ for (let i = 0; i < 16; i++) idle.request(i, deadline => {
69
+ clock.consume(deadline.timeRemaining()); completed++;
70
+ }, 0);
71
+ idle.request(20, deadline => seen.push({ at: clock.now(), completed, didTimeout: deadline.didTimeout, remaining: deadline.timeRemaining() }), 5);
72
+ clock.run();
73
+ expect(seen).toEqual([{ at: 11, completed: 1, didTimeout: true, remaining: 0 }]);
74
+ expect(completed).toBe(16);
75
+ expect(tasks.size).toBe(0);
76
+ });
77
+
78
+ test('unsigned-long timeouts are chunked and rechecked before expiring', () => {
79
+ let time = 0, next = 0;
80
+ const timers = new Map(), seen = [];
81
+ const idle = createIdleCallbacks({
82
+ now: () => time,
83
+ setTimer(callback, delay) {
84
+ expect(delay).toBeGreaterThanOrEqual(1);
85
+ expect(delay).toBeLessThanOrEqual(2147483647);
86
+ timers.set(++next, { callback, delay });
87
+ return next;
88
+ },
89
+ clearTimer: id => timers.delete(id),
90
+ invoke: (callback, deadline) => callback(deadline),
91
+ });
92
+ idle.request(1, deadline => seen.push([deadline.didTimeout, deadline.timeRemaining()]), 4294967295);
93
+ const fire = (id, at) => {
94
+ time = at;
95
+ const task = timers.get(id);
96
+ timers.delete(id);
97
+ task.callback();
98
+ };
99
+ expect(timers.get(1).delay).toBe(2147483647);
100
+ // Exercise timeout tasks without running the idle-dispatch task: reaching a
101
+ // host timer's maximum delay is not the idle callback's expiration.
102
+ fire(1, 2147483647);
103
+ expect(seen).toEqual([]);
104
+ expect(timers.get(3).delay).toBe(2147483647);
105
+ fire(3, 4294967294);
106
+ expect(seen).toEqual([]);
107
+ expect(timers.get(4).delay).toBe(1);
108
+ fire(4, 4294967295);
109
+ expect(seen).toEqual([[true, 0]]);
110
+ expect(timers.size).toBe(0);
111
+ });
112
+
113
+ test('cancellation works before dispatch, inside a period and for timeout tasks', () => {
114
+ const { clock, idle, tasks } = fixture();
115
+ const seen = [];
116
+ idle.request(1, () => seen.push(1), 1);
117
+ idle.cancel(1);
118
+ expect(tasks.size).toBe(0);
119
+ idle.request(2, () => { seen.push(2); idle.cancel(3); }, 0);
120
+ idle.request(3, () => seen.push(3), 3);
121
+ idle.request(4, () => seen.push(4), 50);
122
+ clock.run();
123
+ expect(seen).toEqual([2, 4]);
124
+ expect(clock.now()).toBeLessThan(50);
125
+ expect(tasks.size).toBe(0);
126
+ });
127
+
128
+ test('disposal inside a callback clears remaining dispatch and timeout work', () => {
129
+ const { clock, idle, tasks } = fixture();
130
+ const seen = [];
131
+ idle.request(1, () => { seen.push(1); idle.dispose(); idle.request(3, () => seen.push(3), 1); }, 0);
132
+ idle.request(2, () => seen.push(2), 50);
133
+ clock.run();
134
+ expect(seen).toEqual([1]);
135
+ expect(tasks.size).toBe(0);
136
+ idle.dispose();
137
+ });
138
+
139
+ test('reported callback errors leave subsequent idle work runnable', () => {
140
+ const { clock, idle, tasks, errors } = fixture();
141
+ let complete = false;
142
+ idle.request(1, () => { throw Error('page exception'); }, 0);
143
+ idle.request(2, () => { complete = true; }, 0);
144
+ clock.run();
145
+ expect(errors).toEqual(['page exception']);
146
+ expect(complete).toBe(true);
147
+ expect(tasks.size).toBe(0);
148
+ });