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,225 @@
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 = []) {
6
+ const parsed = parseHTMLDocument(source);
7
+ const engine = new StyleEngine().compute(parsed.document, sheets);
8
+ return { ...parsed, engine, sheets, get: id => parsed.document.getElementById(id) };
9
+ }
10
+ function self(f, element = f.get('parent')) {
11
+ f.engine.compute(f.document, f.sheets, {}, new Set(), new Set([element]));
12
+ }
13
+ function fullOracle(f) {
14
+ const full = new StyleEngine().compute(f.document, f.sheets);
15
+ for (const element of f.document.querySelectorAll('*')) {
16
+ expect(f.engine.get(element)).toEqual(full.get(element));
17
+ expect(f.engine.customPropertiesOf(element)).toEqual(full.customPropertiesOf(element));
18
+ for (const pseudo of ['before', 'after']) expect(f.engine.getPseudo(element, pseudo)).toEqual(full.getPseudo(element, pseudo));
19
+ }
20
+ }
21
+
22
+ test('self-only width, padding and opacity updates preserve unaffected descendant styles', () => {
23
+ const f = fixture('<div id="parent"><span id="child"><b id="grandchild">text</b></span></div>');
24
+ try {
25
+ const before = [f.get('child'), f.get('grandchild')].map(element => f.engine.get(element));
26
+ for (const [property, value] of [['width', '180px'], ['padding', '5px'], ['opacity', '.4']]) {
27
+ f.get('parent').style[property] = value;
28
+ self(f);
29
+ expect(f.engine.restyled).toBe(1);
30
+ expect(f.engine.get(f.get('child'))).toBe(before[0]);
31
+ expect(f.engine.get(f.get('grandchild'))).toBe(before[1]);
32
+ fullOracle(f);
33
+ }
34
+ } finally { f.window.happyDOM.abort(); }
35
+ });
36
+
37
+ test('explicit inheritance propagates through dependent children and stops at unchanged ones', () => {
38
+ const f = fixture(`<div id="parent" style="width:100px">
39
+ <div id="child" style="width:inherit"><span id="chain" style="width:inherit">chain</span></div>
40
+ <div id="barrier" style="width:80px"><span id="shielded" style="width:inherit">shielded</span></div></div>`);
41
+ try {
42
+ const shielded = f.engine.get(f.get('shielded'));
43
+ f.get('parent').style.width = '200px';
44
+ self(f);
45
+ expect(f.engine.restyled).toBe(3);
46
+ expect(f.engine.get(f.get('chain')).width).toBe(200);
47
+ expect(f.engine.get(f.get('shielded'))).toBe(shielded);
48
+ fullOracle(f);
49
+ } finally { f.window.happyDOM.abort(); }
50
+ });
51
+
52
+ test('dependency metadata changes even when the computed-style identity is retained', () => {
53
+ const f = fixture('<div id="parent" style="width:100px"><span id="child" style="width:100px">text</span></div>');
54
+ try {
55
+ const before = f.engine.get(f.get('child'));
56
+ f.get('child').style.width = 'inherit';
57
+ self(f, f.get('child'));
58
+ expect(f.engine.get(f.get('child'))).toBe(before);
59
+ f.get('parent').style.width = '200px';
60
+ self(f);
61
+ expect(f.engine.restyled).toBe(2);
62
+ expect(f.engine.get(f.get('child')).width).toBe(200);
63
+ const inherited = f.engine.get(f.get('child'));
64
+ f.get('child').style.width = '200px';
65
+ self(f, f.get('child'));
66
+ expect(f.engine.get(f.get('child'))).toBe(inherited);
67
+ f.get('parent').style.width = '300px';
68
+ self(f);
69
+ expect(f.engine.restyled).toBe(1);
70
+ expect(f.engine.get(f.get('child')).width).toBe(200);
71
+ fullOracle(f);
72
+ } finally { f.window.happyDOM.abort(); }
73
+ });
74
+
75
+ for (const [name, parentStyle, childStyle, property, value, field, expected] of [
76
+ ['resolved variable', 'width:100px;--choice:inherit', 'width:var(--choice)', 'width', '200px', 'width', 200],
77
+ ['variable fallback', 'width:100px', 'width:var(--missing,inherit)', 'width', '200px', 'width', 200],
78
+ ['all shorthand', 'width:100px', 'all:inherit', 'width', '200px', 'width', 200],
79
+ ['variable all shorthand', 'width:100px;--choice:inherit', 'all:var(--choice)', 'width', '200px', 'width', 200],
80
+ ['logical size', 'width:100px', 'inline-size:inherit', 'width', '200px', 'width', 200],
81
+ ['margin shorthand', 'margin:1px', 'margin:inherit', 'margin', '8px', 'margin', [8,8,8,8]],
82
+ ['logical padding shorthand', 'padding:1px', 'padding-inline:inherit', 'padding', '8px', 'padding', [0,8,0,8]],
83
+ ['border shorthand', 'border:1px solid red', 'border:inherit', 'borderWidth', '3px', 'borderWidths', [3,3,3,3]],
84
+ ]) {
85
+ test(`${name} records explicit parent dependencies`, () => {
86
+ const f = fixture(`<div id="parent" style="${parentStyle}"><span id="child" style="${childStyle}">text</span></div>`);
87
+ try {
88
+ f.get('parent').style[property] = value;
89
+ self(f);
90
+ expect(f.engine.get(f.get('child'))[field]).toEqual(expected);
91
+ fullOracle(f);
92
+ } finally { f.window.happyDOM.abort(); }
93
+ });
94
+ }
95
+
96
+ test('UA table inheritance propagates vertical alignment through rows and cells', () => {
97
+ const f = fixture('<table><tbody id="parent" style="vertical-align:top"><tr id="row"><td id="child">cell</td></tr></tbody></table>');
98
+ try {
99
+ f.get('parent').style.verticalAlign = 'bottom';
100
+ self(f);
101
+ expect(f.engine.restyled).toBe(3);
102
+ expect(f.engine.get(f.get('row')).verticalAlign).toBe('bottom');
103
+ expect(f.engine.get(f.get('child')).verticalAlign).toBe('bottom');
104
+ fullOracle(f);
105
+ } finally { f.window.happyDOM.abort(); }
106
+ });
107
+
108
+ test('stylesheet and pseudo inheritance follow the originating element', () => {
109
+ const f = fixture('<div id="parent" style="width:100px"><span id="child">text</span></div>', [
110
+ '#child { width:inherit } #child::before { content:"x";width:var(--missing,inherit) } #parent[data-ready]::after { content:"ready" }',
111
+ ]);
112
+ try {
113
+ f.get('parent').style.width = '200px';
114
+ self(f);
115
+ expect(f.engine.getPseudo(f.get('child'), 'before').width).toBe(200);
116
+ fullOracle(f);
117
+ const child = f.engine.get(f.get('child'));
118
+ f.get('parent').setAttribute('data-ready', '');
119
+ self(f);
120
+ expect(f.engine.restyled).toBe(1);
121
+ expect(f.engine.get(f.get('child'))).toBe(child);
122
+ fullOracle(f);
123
+ } finally { f.window.happyDOM.abort(); }
124
+ });
125
+
126
+ for (const [name, parentStyle, childStyle, property, value, field, expected] of [
127
+ ['color scheme', 'color-scheme:light', 'color:light-dark(red,blue)', 'colorScheme', 'dark', 'color', 'rgba(0, 0, 255, 1)'],
128
+ ['line-height factor at equal parent height', 'font-size:10px;line-height:2', 'font-size:20px', 'lineHeight', '20px', 'lineHeight', 20],
129
+ ['parent display and float suppression', 'display:block', 'float:left', 'display', 'flex', 'float', 'none'],
130
+ ['font shorthand', 'font:16px serif', 'font:inherit', 'font', 'italic 20px monospace', 'fontSize', 20],
131
+ ]) {
132
+ test(`${name} keeps full inherited-input propagation`, () => {
133
+ const f = fixture(`<div id="parent" style="${parentStyle}"><div id="child" style="${childStyle}"><span>descendant</span></div></div>`);
134
+ try {
135
+ f.get('parent').style[property] = value;
136
+ self(f);
137
+ expect(f.engine.restyled).toBe(3);
138
+ expect(f.engine.get(f.get('child'))[field]).toEqual(expected);
139
+ fullOracle(f);
140
+ } finally { f.window.happyDOM.abort(); }
141
+ });
142
+ }
143
+
144
+ test('font inheritance is recorded even when a non-font parent property changes', () => {
145
+ const f = fixture('<div id="parent"><span id="child" style="font:inherit">text</span></div>');
146
+ try {
147
+ f.get('parent').style.width = '200px';
148
+ self(f);
149
+ expect(f.engine.restyled).toBe(2);
150
+ fullOracle(f);
151
+ } finally { f.window.happyDOM.abort(); }
152
+ });
153
+
154
+ test('custom properties and propagated decorations remain descendant inputs', () => {
155
+ const f = fixture('<div id="parent" style="--tone:red"><span id="child" style="color:var(--tone)"><i>descendant</i></span></div>');
156
+ try {
157
+ const parent = f.engine.get(f.get('parent'));
158
+ f.get('parent').style.setProperty('--tone', 'blue');
159
+ self(f);
160
+ expect(f.engine.get(f.get('parent'))).toBe(parent);
161
+ expect(f.engine.restyled).toBe(3);
162
+ expect(f.engine.get(f.get('child')).color).toBe('rgba(0, 0, 255, 1)');
163
+ fullOracle(f);
164
+ f.get('parent').style.textDecoration = 'underline';
165
+ self(f);
166
+ expect(f.engine.restyled).toBe(3);
167
+ expect(f.engine.get(f.get('child')).textDecorations.lines).toContain('underline');
168
+ fullOracle(f);
169
+ } finally { f.window.happyDOM.abort(); }
170
+ });
171
+
172
+ test('root font changes reach rem descendants behind a fixed-font child', () => {
173
+ const f = fixture('<html style="font-size:16px"><body style="font-size:8px"><div id="child" style="width:2rem">text</div></body></html>');
174
+ try {
175
+ f.document.documentElement.style.fontSize = '20px';
176
+ self(f, f.document.documentElement);
177
+ expect(f.engine.get(f.get('child')).width).toBe(40);
178
+ expect(f.engine.get(f.get('child')).fontSize).toBe(8);
179
+ fullOracle(f);
180
+ } finally { f.window.happyDOM.abort(); }
181
+ });
182
+
183
+ test('partial root propagation keeps independently dirty roots and self-only descendants', () => {
184
+ const f = fixture('<html style="width:100px"><body style="width:inherit"><div id="subtree"><span id="child">text</span></div><p id="other">other</p></body></html>');
185
+ try {
186
+ const root = f.document.documentElement;
187
+ root.style.width = '200px';
188
+ f.get('subtree').style.color = 'red';
189
+ f.get('other').style.color = 'blue';
190
+ f.engine.compute(f.document, f.sheets, {}, new Set([f.get('subtree')]), new Set([root, f.get('other')]));
191
+ expect(f.engine.get(f.document.body).width).toBe(200);
192
+ expect(f.engine.get(f.get('child')).color).toBe('rgba(255, 0, 0, 1)');
193
+ expect(f.engine.get(f.get('other')).color).toBe('rgba(0, 0, 255, 1)');
194
+ fullOracle(f);
195
+ } finally { f.window.happyDOM.abort(); }
196
+ });
197
+
198
+ test('unknown children receive a full first style pass during partial propagation', () => {
199
+ const f = fixture('<div id="parent"><span>old</span></div>');
200
+ try {
201
+ f.get('parent').insertAdjacentHTML('beforeend', '<span id="new" style="width:inherit"><b id="nested">new</b></span>');
202
+ f.get('parent').style.width = '200px';
203
+ self(f);
204
+ expect(f.engine.restyled).toBe(3);
205
+ expect(f.engine.get(f.get('new')).width).toBe(200);
206
+ fullOracle(f);
207
+ } finally { f.window.happyDOM.abort(); }
208
+ });
209
+
210
+ test('an explicitly dirty selector subtree still receives the complete traversal', () => {
211
+ const f = fixture('<div id="parent"><span id="child">text</span></div>', ['.active span { color:red }']);
212
+ try {
213
+ f.get('parent').className = 'active';
214
+ f.engine.compute(f.document, f.sheets, {}, new Set([f.get('parent')]));
215
+ expect(f.engine.restyled).toBe(2);
216
+ expect(f.engine.get(f.get('child')).color).toBe('rgba(255, 0, 0, 1)');
217
+ fullOracle(f);
218
+ } finally { f.window.happyDOM.abort(); }
219
+ });
220
+
221
+ test('a 600-child style update prunes unchanged descendants under SES', async () => {
222
+ const child = Bun.spawn([process.execPath, new URL('./fixtures/style-propagation.js', import.meta.url).pathname], { stdout:'pipe', stderr:'pipe' });
223
+ const [exit, stdout, stderr] = await Promise.all([child.exited, new Response(child.stdout).text(), new Response(child.stderr).text()]);
224
+ expect({exit,stdout,stderr}).toEqual({exit:0,stdout:'',stderr:''});
225
+ }, 15_000);
@@ -49,3 +49,22 @@ test("linked sheet media can change without refetching and disabled sheets fetch
49
49
  expect(await read()).toBe("rgba(0, 0, 255, 1)");
50
50
  expect(requests).toEqual(["https://style.test/linked.css", "https://style.test/disabled.css"]);
51
51
  });
52
+
53
+ test("cached discovery observes rel, sheet text, base URL and detached/reinserted sheets", async () => {
54
+ requests.length = 0;
55
+ await load(`<!doctype html><base href="/first/"><style id="inline">#target{color:red}</style>
56
+ <link id="linked" rel="preload" href="dynamic.css"><div id="target">Text</div>`);
57
+ expect(await read()).toBe("rgba(255, 0, 0, 1)");
58
+ expect(requests).toEqual([]);
59
+ await read("document.querySelector('base').href='/second/';document.getElementById('linked').rel='stylesheet'");
60
+ const deadline = Date.now() + 3000;
61
+ while (await read() !== "rgba(0, 0, 255, 1)" && Date.now() < deadline) await Bun.sleep(10);
62
+ expect(await read()).toBe("rgba(0, 0, 255, 1)");
63
+ expect(requests).toEqual(["https://style.test/second/dynamic.css"]);
64
+ expect(await read("document.getElementById('linked').rel='preload'")).toBe("rgba(255, 0, 0, 1)");
65
+ expect(await read("document.getElementById('inline').firstChild.data='#target{color:green}'")).toBe("rgba(0, 128, 0, 1)");
66
+ expect(await read("window.saved=document.getElementById('linked');saved.rel='stylesheet';saved.remove()"))
67
+ .toBe("rgba(0, 128, 0, 1)");
68
+ expect(await read("document.head.append(saved)")).toBe("rgba(0, 0, 255, 1)");
69
+ expect(requests).toHaveLength(1);
70
+ });
package/test/svg.test.js CHANGED
@@ -88,3 +88,48 @@ test("inline SVG is a replaced box with margins and vertical-align in the line",
88
88
  expect(items.find((item) => item.op === "drawSvg")).toMatchObject({ rect: { x: 0, width: 16, height: 16 } });
89
89
  window.happyDOM.abort();
90
90
  });
91
+
92
+ test("unchanged SVG nodes and their containers survive unrelated content edits", () => {
93
+ const { document, window } = parseHTMLDocument(`<main><section><svg viewBox="0 0 24 24" width="24" height="24">
94
+ <g transform="translate(2 3)"><path d="M0 0h4v4z" fill="currentColor"/></g>
95
+ </svg></section><p>before</p></main>`);
96
+ try {
97
+ const styles = new StyleEngine().compute(document), builder = new RenderTreeBuilder();
98
+ const before = builder.build(document, styles);
99
+ expect(builder.build(document, styles).root).toBe(before.root);
100
+ document.querySelector("p").firstChild.data = "after";
101
+ const after = builder.build(document, styles);
102
+ for (const node of before.nodesById.values()) if (["SVG", "SECTION"].includes(node.tagName)) {
103
+ expect(after.nodesById.get(node.id)).toBe(node);
104
+ }
105
+ expect(after.root).not.toBe(before.root);
106
+ } finally { window.happyDOM.abort(); }
107
+ });
108
+
109
+ test("SVG reuse compares transform numbers exactly, including overflow", () => {
110
+ const { document, window } = parseHTMLDocument(`<svg width="24" height="24">
111
+ <g transform="scale(1e308) scale(1e308)"><path d="M0 0h4v4z"/></g></svg>`);
112
+ try {
113
+ const styles = new StyleEngine().compute(document), builder = new RenderTreeBuilder();
114
+ const icon = tree => [...tree.nodesById.values()].find(node => node.tagName === "SVG");
115
+ const before = icon(builder.build(document, styles));
116
+ document.querySelector("g").setAttribute("transform", "scale(1e308) scale(-1e308)");
117
+ const after = icon(builder.build(document, styles));
118
+ // JSON turns both infinities and NaN into null; those are not equal
119
+ // extracted matrices, even though their JSON representations coincide.
120
+ expect(JSON.stringify(after.svg)).toBe(JSON.stringify(before.svg));
121
+ expect(after).not.toBe(before);
122
+ expect(before.svg.shapes[0].matrix[0]).toBe(Infinity);
123
+ expect(after.svg.shapes[0].matrix[0]).toBe(-Infinity);
124
+ expect(icon(builder.build(document, styles))).toBe(after);
125
+ } finally { window.happyDOM.abort(); }
126
+ });
127
+
128
+ test("retained SVG layout and edited SVG paint match fresh renderer pixels", async () => {
129
+ const result = Bun.spawnSync([process.execPath, new URL("fixtures/svg-node-reuse.js", import.meta.url).pathname], {
130
+ stdout: "pipe", stderr: "pipe",
131
+ });
132
+ expect(new TextDecoder().decode(result.stderr)).toBe("");
133
+ expect(result.exitCode).toBe(0);
134
+ expect(new TextDecoder().decode(result.stdout)).toContain("SVG node reuse renderer oracle passed");
135
+ }, 60_000);
@@ -0,0 +1,58 @@
1
+ import { expect, test } from 'bun:test';
2
+ import { createTimers } from '../lib/renderer/timers.js';
3
+ function fixture() {
4
+ let id=0, handle=0;
5
+ const pending=new Map(), tasks=new Map(), delays=[], errors=[];
6
+ const timers=createTimers({nextId:()=>++id, invoke(fn){try{fn();}catch(error){errors.push(error.message);}},
7
+ setTimer(callback,delay){delays.push(delay);pending.set(++handle,callback);return handle;},
8
+ clearTimer(handle){pending.delete(handle);},
9
+ queueTask(callback){tasks.set(++handle,callback);return handle;},clearTask(handle){tasks.delete(handle);}});
10
+ const fire=()=>{const [handle,callback]=pending.entries().next().value;pending.delete(handle);callback();};
11
+ const checkpoint=()=>{for(const [handle,callback] of [...tasks]){tasks.delete(handle);callback();}};
12
+ return {timers,pending,tasks,delays,errors,fire,checkpoint};
13
+ }
14
+ test('timer delay is converted once to a Web IDL long before repeats',()=>{
15
+ const f=fixture();let conversions=0,calls=0,id;
16
+ id=f.timers.schedule(()=>{if(++calls===9)f.timers.cancel(id);},{valueOf(){conversions++;return 0;}},true);
17
+ for(let i=0;i<9;i++){f.fire();f.checkpoint();}
18
+ expect(conversions).toBe(1);expect(calls).toBe(9);
19
+ expect(f.delays).toEqual([0,0,0,0,0,0,4,4,4,4]);
20
+ expect(f.pending.size).toBe(0);f.timers.dispose();expect(f.tasks.size).toBe(0);
21
+ });
22
+ test('nested timeout, Promise chains and queueMicrotask share timer nesting',async()=>{
23
+ for(const defer of [fn=>fn(),fn=>Promise.resolve().then(fn),fn=>Promise.resolve().then(()=>{}).then(()=>{}).then(fn),fn=>queueMicrotask(fn)]){
24
+ const f=fixture();let count=0;
25
+ const step=()=>{if(++count<9)defer(()=>f.timers.schedule(step,0));};
26
+ f.timers.schedule(step,0);
27
+ for(let i=0;i<9;i++){f.fire();for(let n=0;n<5;n++)await Promise.resolve();f.checkpoint();}
28
+ expect(f.delays).toEqual([0,0,0,0,0,0,4,4,4]);f.timers.dispose();
29
+ }
30
+ });
31
+ test('a later task or deferred promise cannot inherit a finished timer task',async()=>{
32
+ const f=fixture();let count=0,resolveLate;
33
+ const step=()=>{if(++count<7)f.timers.schedule(step,0);else{
34
+ Promise.resolve().then(()=>f.timers.schedule(()=>{},0));
35
+ new Promise(resolve=>resolveLate=resolve).then(()=>f.timers.schedule(()=>{},0));
36
+ }};
37
+ f.timers.schedule(step,0);
38
+ for(let i=0;i<7;i++){f.fire();await Promise.resolve();f.checkpoint();}
39
+ expect(f.delays.at(-1)).toBe(4);
40
+ resolveLate();await Promise.resolve();expect(f.delays.at(-1)).toBe(0);
41
+ f.timers.schedule(()=>{},0);expect(f.delays.at(-1)).toBe(0);f.timers.dispose();
42
+ });
43
+ test('signed delay conversion rejects invalid conversions synchronously without queuing',()=>{
44
+ const f=fixture();for(const value of [undefined,-1,Infinity,NaN,2147483648,4294967296])f.timers.schedule(()=>{},value);
45
+ f.timers.schedule(()=>{},4294967301);f.timers.schedule(()=>{},2.9);
46
+ expect(f.delays).toEqual([0,0,0,0,0,0,5,2]);
47
+ expect(()=>f.timers.schedule(()=>{},1n)).toThrow(TypeError);
48
+ expect(()=>f.timers.schedule(()=>{},Symbol())).toThrow(TypeError);
49
+ expect(()=>f.timers.schedule(()=>{},{valueOf(){throw Error('delay failure');}})).toThrow('delay failure');
50
+ expect(f.delays).toHaveLength(8);f.timers.dispose();
51
+ });
52
+ test('cancel inside callback, exceptions and disposal keep one live repeat and no late tasks',()=>{
53
+ const f=fixture();let calls=0,id=f.timers.schedule(()=>{calls++;if(calls===1)throw Error('tick');else f.timers.cancel(id);},1,true);
54
+ f.fire();expect(f.pending.size).toBe(1);f.checkpoint();f.fire();expect(f.pending.size).toBe(0);expect(f.errors).toEqual(['tick']);
55
+ const never=f.timers.schedule(()=>{throw Error('canceled');},0);f.timers.cancel(never);expect(f.pending.size).toBe(0);
56
+ f.timers.schedule(()=>f.timers.dispose(),0,true);f.fire();expect(f.pending.size).toBe(0);expect(f.tasks.size).toBe(0);
57
+ f.timers.schedule(()=>{throw Error('disposed');},0);expect(f.pending.size).toBe(0);
58
+ });
@@ -0,0 +1,34 @@
1
+ import {expect, test} from 'bun:test';
2
+ import {videoLayerGeometry} from '../lib/renderer/video-mask.js';
3
+
4
+ test('video replay rejects nonfinite painter and final compositor coordinates', () => {
5
+ const item = {rect:{x:12,y:14,width:640,height:360}};
6
+ expect(videoLayerGeometry(item,{x:0,y:0},1.25)).toEqual({
7
+ rect:{x:15,y:18,width:800,height:450},replayable:true,
8
+ });
9
+ for (const changed of [
10
+ {...item,fixed:true}, {...item,rect:{...item.rect,width:0}},
11
+ {...item,rect:{...item.rect,x:NaN}}, {...item,rect:{...item.rect,height:Infinity}},
12
+ {...item,transform:{a:1,b:.1,c:0,d:1,e:0,f:0}},
13
+ {...item,transform:{a:-1,b:0,c:0,d:1,e:0,f:0}},
14
+ {...item,transform:{a:1e308,b:0,c:0,d:1,e:0,f:0}},
15
+ ]) expect(videoLayerGeometry(changed,{x:0,y:0},1.25).replayable).toBe(false);
16
+ expect(videoLayerGeometry(item,{x:0,y:0},1e308).replayable).toBe(false);
17
+ // The painter's reordered expression remains finite here; the compositor's
18
+ // actual subtraction overflows before its tiny scale can reduce the value.
19
+ const overflow = videoLayerGeometry({rect:{x:0,y:0,width:1e308,height:1e308},
20
+ transform:{a:1,b:0,c:0,d:1,e:-1e308,f:0}}, {x:1e308,y:0},1e-308);
21
+ expect(overflow.rect.x).toBe(-Infinity);
22
+ expect(overflow.replayable).toBe(false);
23
+ });
24
+
25
+ test('opaque shared video reuses black raster with compositor pixels and conservative fallbacks', async () => {
26
+ const child = Bun.spawn([process.execPath,new URL('./fixtures/video-black-replay.js',import.meta.url).pathname],{
27
+ stdout:'pipe',stderr:'pipe',
28
+ });
29
+ const [out,error,code] = await Promise.all([
30
+ new Response(child.stdout).text(),new Response(child.stderr).text(),child.exited,
31
+ ]);
32
+ expect({code,error}).toEqual({code:0,error:''});
33
+ expect(out).toContain('video black replay integration passed');
34
+ },30_000);
@@ -0,0 +1,170 @@
1
+ import {expect, test} from 'bun:test';
2
+ import {captureViewport, rawFrame} from '../lib/paint/frame.js';
3
+ import {FrameLeaseTracker} from '../lib/paint/shared-frame-pool.js';
4
+ import {captureVideoViewport, compositeVideo} from '../lib/renderer/video-presentation.js';
5
+
6
+ function fixture(width = 19, height = 31, sourceOffset = 0, shared = false) {
7
+ const base = new Uint8Array(shared ? new SharedArrayBuffer(width * height * 4) : new ArrayBuffer(width * height * 4));
8
+ const mask = new Uint8Array(base.length);
9
+ for (let i = 0; i < width * height; i++) {
10
+ const at = i * 4, kind = i < width * 4 ? 0 : i % 4;
11
+ base.set(kind === 2 ? [20, 30, 40, 200] : [0, 0, 0, kind === 1 ? 93 : 255], at);
12
+ if (kind !== 3) mask.set(kind === 2 ? [127, 80, 190, 0] : [255, 255, 255, 0], at);
13
+ }
14
+ const pixels = new Uint8Array(new ArrayBuffer(5 * 7 * 4 + sourceOffset), sourceOffset);
15
+ for (let i = 0; i < pixels.length; i++) pixels[i] = i * 71 % 256;
16
+ return {
17
+ tile: {x:3.2, y:8.7, width:width/1.25, height:height/1.25, scale:1.25,
18
+ pixelWidth:width, pixelHeight:height, pixels:base, background:[12, 34, 56, 128],
19
+ videoLayer:{base, mask, rect:{x:-1.25, y:0.6, width:22.5, height:30.2}}},
20
+ frame: {width:5, height:7, pixels},
21
+ };
22
+ }
23
+
24
+ function compare(tile, frame, viewport, clip, format) {
25
+ const whole = compositeVideo(tile, frame, format);
26
+ const expected = captureViewport(whole, ...viewport, clip);
27
+ const actual = captureVideoViewport(tile, frame, ...viewport, clip, format);
28
+ expect(actual).not.toBeNull();
29
+ expect([actual.width, actual.height]).toEqual([expected.width, expected.height]);
30
+ expect(actual.pixels).toEqual(expected.pixels);
31
+ expect(actual.pixels.buffer).not.toBe(tile.pixels.buffer);
32
+ expect(actual.pixels.buffer).not.toBe(tile.videoLayer.base.buffer);
33
+ expect(actual.pixels.buffer.byteLength).toBe(actual.width * actual.height * 4);
34
+ expect(actual.pixelFormat).toBe(format);
35
+ whole.pixels.buffer.transfer(0);
36
+ actual.pixels.buffer.transfer(0);
37
+ }
38
+
39
+ test('viewport-first video preserves fractional clip phase, clipped masks, alpha and outside background', () => {
40
+ for (const offset of [0, 1]) {
41
+ const {tile, frame} = fixture(19, 31, offset);
42
+ const original = new Uint8Array(tile.pixels), mask = new Uint8Array(tile.videoLayer.mask);
43
+ for (const format of ['rgba', 'bgra']) {
44
+ // Full-width zero-copy base crop, then partial and completely outside
45
+ // crops. A fractional viewport without clip keeps its floor semantics.
46
+ for (const viewport of [[3.2, 13.1, 15.2, 6.3], [4.13, 17.91, 7.3, 9.8], [-5, -4, 12, 15], [90, 90, 8, 5]]) {
47
+ compare(tile, frame, viewport, undefined, format);
48
+ compare(tile, frame, viewport, {x:viewport[0], y:viewport[1], width:viewport[2], height:viewport[3], scale:1}, format);
49
+ }
50
+ frame.pixels[0] ^= 255;
51
+ compare(tile, frame, [3.2, 13.1, 15.2, 6.3], undefined, format);
52
+ }
53
+ expect(tile.pixels).toEqual(original);
54
+ expect(tile.videoLayer.mask).toEqual(mask);
55
+ }
56
+ });
57
+
58
+ test('original tile coordinates remain exact over changing DPR, crop and decoded dimensions', () => {
59
+ let seed = 0x831d24;
60
+ const random = () => ((seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0) / 0x100000000);
61
+ for (let sample = 0; sample < 150; sample++) {
62
+ const {tile, frame} = fixture(1 + Math.floor(random() * 29), 1 + Math.floor(random() * 37), sample % 2);
63
+ tile.scale = [0.75, 1, 1.25, 1.5, 2.25][sample % 5];
64
+ tile.width = tile.pixelWidth / tile.scale;
65
+ tile.height = tile.pixelHeight / tile.scale;
66
+ tile.videoLayer.rect = {x:random() * 12 - 6, y:random() * 12 - 6,
67
+ width:0.2 + random() * 34, height:0.2 + random() * 43};
68
+ const viewport = [random() * 24 - 8, random() * 35 - 8, 0.2 + random() * 26, 0.2 + random() * 22];
69
+ const clip = sample % 3 ? {x:viewport[0], y:viewport[1], width:viewport[2], height:viewport[3], scale:1} : undefined;
70
+ compare(tile, frame, viewport, clip, sample % 2 ? 'bgra' : 'rgba');
71
+ // Reusing this layer with another crop/decoded size must rebuild only the
72
+ // geometry plan, never retain pixels from an earlier decoded frame.
73
+ frame.width = 7; frame.height = 5;
74
+ frame.pixels[0] ^= 127;
75
+ viewport[1] += 0.49 / tile.scale;
76
+ compare(tile, frame, viewport, clip && {...clip, y:viewport[1]}, sample % 2 ? 'rgba' : 'bgra');
77
+ }
78
+ });
79
+
80
+ test('unsupported scaled clips keep the original path and invalid unit-scale clips still reject', () => {
81
+ const {tile, frame} = fixture();
82
+ for (const scale of [0, 0.5, 2, NaN]) expect(captureVideoViewport(tile, frame, 0, 0, 5, 5,
83
+ {x:0, y:0, width:5, height:5, scale})).toBeNull();
84
+ expect(captureVideoViewport(tile, null, 0, 0, 5, 5)).toBeNull();
85
+ expect(captureVideoViewport({...tile, videoLayer:null}, frame, 0, 0, 5, 5)).toBeNull();
86
+ for (const clip of [{x:NaN, y:0, width:5, height:5, scale:1}, {x:0, y:0, width:0, height:5, scale:1},
87
+ {x:0, y:0, width:9000, height:5, scale:1}]) expect(() => captureVideoViewport(tile, frame, 0, 0, 5, 5, clip)).toThrow();
88
+ });
89
+
90
+ test('raw cropped captures own their buffers independently of recyclable frame leases', async () => {
91
+ const {tile, frame} = fixture(20, 40, 0, true);
92
+ const released = [], leases = new FrameLeaseTracker(serial => released.push(serial));
93
+ leases.replace(1, tile);
94
+ const result = await leases.capture(retained => {
95
+ const capture = captureVideoViewport(retained, frame, tile.x, tile.y + 4, 16, 8, undefined, 'bgra');
96
+ return rawFrame(capture.pixels, capture.width, capture.height, 'bgra', {takeOwnership:true, inputFormat:capture.pixelFormat});
97
+ });
98
+ const expected = new Uint8Array(result.pixels);
99
+ leases.replace(2, {...tile});
100
+ expect(released).toEqual([1]);
101
+ tile.videoLayer.base.fill(42);
102
+ expect(result.pixels).toEqual(expected);
103
+ result.pixels.buffer.transfer(0);
104
+ expect(tile.pixels.byteLength).toBe(20 * 40 * 4);
105
+ expect(frame.pixels.byteLength).toBe(5 * 7 * 4);
106
+ });
107
+
108
+ test('opaque-run skipping initializes dirty allocations, including unaligned sources and preserved alpha', () => {
109
+ const allocate = Bun.allocUnsafe;
110
+ let allocations = 0;
111
+ Bun.allocUnsafe = size => { allocations++; return new Uint8Array(size).fill(0xa5); };
112
+ try {
113
+ for (const offset of [0, 1]) for (const rectHeight of [4, 30.2]) {
114
+ const {tile, frame} = fixture(20, 40, offset);
115
+ tile.videoLayer.rect.height = rectHeight;
116
+ const base = new Uint8Array(tile.videoLayer.base);
117
+ // A full-width crop borrows the base. Alternate channel order and crop
118
+ // origins to invalidate cached source views, while reusing the layer.
119
+ for (const format of ['rgba', 'bgra', 'rgba']) for (const y of [tile.y, tile.y + 4, tile.y]) {
120
+ compare(tile, frame, [tile.x, y, 16, 8], undefined, format);
121
+ frame.pixels[0] ^= 127;
122
+ }
123
+ expect(tile.videoLayer.base).toEqual(base);
124
+ }
125
+ expect(allocations).toBe(36);
126
+ } finally { Bun.allocUnsafe = allocate; }
127
+ });
128
+
129
+ test('an entirely opaque capture writes every channel while a capture without opaque runs copies every byte', () => {
130
+ const allocate = Bun.allocUnsafe;
131
+ Bun.allocUnsafe = size => new Uint8Array(size).fill(0xa5);
132
+ try {
133
+ for (const offset of [0, 1]) for (const opaque of [false, true]) {
134
+ const {tile, frame} = fixture(20, 40, offset);
135
+ tile.videoLayer.rect = {x:0, y:0, width:20, height:40};
136
+ tile.videoLayer.base.fill(0);
137
+ for (let i=3;i<tile.videoLayer.base.length;i+=4) tile.videoLayer.base[i] = 255;
138
+ tile.videoLayer.mask.fill(opaque ? 255 : 0);
139
+ for (const format of ['rgba', 'bgra']) compare(tile, frame, [tile.x, tile.y, 16, 8], undefined, format);
140
+ }
141
+ } finally { Bun.allocUnsafe = allocate; }
142
+ });
143
+
144
+ test('unexpected pooled allocations fall back to independently owned aligned capture bytes', () => {
145
+ const allocate = Bun.allocUnsafe, borrowed = [];
146
+ Bun.allocUnsafe = size => {
147
+ const pixels = new Uint8Array(size + 8).subarray(4, size + 4);
148
+ borrowed.push(pixels);
149
+ return pixels;
150
+ };
151
+ try {
152
+ const {tile, frame} = fixture(20, 40);
153
+ for (const format of ['rgba', 'bgra']) compare(tile, frame, [tile.x, tile.y, 16, 8], undefined, format);
154
+ expect(borrowed).toHaveLength(2);
155
+ // compare() detached the returned captures; the unexpected pool is intact.
156
+ expect(borrowed.every(pixels => pixels.byteLength === 20 * 10 * 4)).toBe(true);
157
+ } finally { Bun.allocUnsafe = allocate; }
158
+ });
159
+
160
+ test('partial-width and out-of-tile crops preserve the initialized crop fallback', () => {
161
+ const allocate = Bun.allocUnsafe;
162
+ Bun.allocUnsafe = () => { throw Error('noncontiguous crops already own their initialized pixels'); };
163
+ try {
164
+ const {tile, frame} = fixture(20, 40, 1);
165
+ for (const format of ['rgba', 'bgra']) {
166
+ compare(tile, frame, [tile.x + 1, tile.y, 12, 8], undefined, format);
167
+ compare(tile, frame, [tile.x - 4, tile.y - 3, 16, 8], undefined, format);
168
+ }
169
+ } finally { Bun.allocUnsafe = allocate; }
170
+ });