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
@@ -0,0 +1,37 @@
1
+ import {test, expect} from 'bun:test';
2
+ import Window from '../vendor/happy-dom/packages/happy-dom/src/window/Window.ts';
3
+ import {createInertWindow} from '../lib/happy-dom/parser.js';
4
+
5
+ test('inert links leave existing and subsequent ordinary windows resource loading intact', async () => {
6
+ const requests=[];
7
+ const server=Bun.serve({port:0,fetch(req){requests.push(new URL(req.url).pathname);return new Response('body{color:red}',{headers:{'content-type':'text/css'}})}});
8
+ const windows=[];
9
+ const ordinary=()=>{const w=new Window({url:server.url.href});windows.push(w);return w};
10
+ const addLink=(w,path)=>{
11
+ const link=w.document.createElement('link'),events=[];
12
+ link.rel='stylesheet';link.href=path;
13
+ link.onload=()=>events.push('load');link.onerror=()=>events.push('error');
14
+ w.document.head.append(link);return {link,events};
15
+ };
16
+ try {
17
+ const before=ordinary();
18
+ const inert=createInertWindow(server.url.href);windows.push(inert);
19
+ const after=ordinary();
20
+ // Repeated setup must not replace the saved native hooks with wrappers.
21
+ windows.push(createInertWindow(server.url.href));
22
+ for(const [w,path] of [[before,'/before.css'],[after,'/after.css']]){
23
+ const {link,events}=addLink(w,path);
24
+ await w.happyDOM.waitUntilComplete();
25
+ expect(events).toEqual(['load']);expect(link.sheet).not.toBeNull();
26
+ link.href=path+'?changed';await w.happyDOM.waitUntilComplete();
27
+ expect(events).toEqual(['load','load']);
28
+ }
29
+ const {link,events}=addLink(inert,'/inert.css');
30
+ link.href='/inert-changed.css';
31
+ await inert.happyDOM.waitUntilComplete();
32
+ expect(events).toEqual([]);expect(link.sheet).toBeNull();
33
+ expect(requests).toEqual(['/before.css','/before.css','/after.css','/after.css']);
34
+ } finally {
35
+ await Promise.all(windows.map(w=>w.happyDOM.abort()));server.stop(true);
36
+ }
37
+ });
@@ -0,0 +1,63 @@
1
+ import { expect, test } from 'bun:test';
2
+ import { parseHTMLDocument } from '../lib/happy-dom/parser.js';
3
+ import { computeElementStyle, parseDeclarations, StyleEngine } from '../lib/style/computed-style.js';
4
+
5
+ test('inline importance and variables reach principal styles without leaking into pseudos', () => {
6
+ const { document, window } = parseHTMLDocument('<div id="target" style="--accent:blue!important;--accent:orange;color:var(--accent);margin:2px;margin-left:7px!important;content:\'principal\'">text</div>');
7
+ const sheets = [
8
+ '#target {--accent:red!important;color:purple!important;margin-left:9px!important;background-color:var(--accent)}',
9
+ '#target::before {--accent:gold;content:"before";color:var(--accent);margin-left:inherit}',
10
+ ];
11
+ try {
12
+ const target = document.getElementById('target');
13
+ const engine = new StyleEngine().compute(document, sheets);
14
+ const original = engine.get(target), originalCustom = engine.customPropertiesOf(target);
15
+ expect(original).toMatchObject({
16
+ color:'rgba(128, 0, 128, 1)', backgroundColor:'rgba(0, 0, 255, 1)', margin:[2,2,2,7], content:'principal',
17
+ });
18
+ expect(originalCustom.get('--accent')).toBe('blue');
19
+ expect(engine.getPseudo(target, 'before')).toMatchObject({ content:'before', color:'rgba(255, 215, 0, 1)', margin:[0,0,0,7] });
20
+
21
+ target.setAttribute('style', '--accent:lime!important;color:var(--accent)!important;margin:4px;margin-left:8px!important');
22
+ engine.compute(document, sheets, {}, new Set([target]));
23
+ expect(engine.get(target)).toMatchObject({
24
+ color:'rgba(0, 255, 0, 1)', backgroundColor:'rgba(0, 255, 0, 1)', margin:[4,4,4,8], content:null,
25
+ });
26
+ expect(engine.customPropertiesOf(target).get('--accent')).toBe('lime');
27
+ expect(engine.getPseudo(target, 'before')).toMatchObject({ content:'before', color:'rgba(255, 215, 0, 1)', margin:[0,0,0,8] });
28
+ expect(original.backgroundColor).toBe('rgba(0, 0, 255, 1)');
29
+ expect(originalCustom.get('--accent')).toBe('blue');
30
+ } finally { window.happyDOM.abort(); }
31
+ });
32
+
33
+ test('inline shorthands retain their priority over UA defaults and dimension hints', () => {
34
+ const { document, window } = parseHTMLDocument('<h1 id="heading" style="font:italic 20px serif;margin:3px">heading</h1><img id="picture" width="17" height="19" style="width:33px;height:44px">');
35
+ try {
36
+ const engine = new StyleEngine().compute(document, ['#picture {width:80px!important}']);
37
+ expect(engine.get(document.getElementById('heading'))).toMatchObject({
38
+ fontSize:20, fontWeight:400, fontStyle:'italic', fontFamily:['serif'], margin:[3,3,3,3],
39
+ });
40
+ expect(engine.get(document.getElementById('picture'))).toMatchObject({ width:80, height:44 });
41
+ } finally { window.happyDOM.abort(); }
42
+ });
43
+
44
+ test('direct computed-style calls still cascade inline values without mutating supplied declarations', () => {
45
+ const { document, window } = parseHTMLDocument('<div style="color:blue;width:31px;padding:2px">text</div>');
46
+ try {
47
+ const element = document.querySelector('div');
48
+ const declarations = parseDeclarations('color:purple!important;width:10px');
49
+ const originalWidth = declarations.get('width');
50
+ const first = computeElementStyle(element, null, declarations);
51
+ expect(first).toMatchObject({ color:'rgba(128, 0, 128, 1)', width:31, padding:[2,2,2,2] });
52
+ expect(declarations.size).toBe(2);
53
+ expect(declarations.get('width')).toBe(originalWidth);
54
+ expect(originalWidth.value).toBe('10px');
55
+
56
+ declarations.set('color', { ...declarations.get('color'), value:'green' });
57
+ element.setAttribute('style', 'color:blue;width:41px;padding:4px');
58
+ const second = computeElementStyle(element, null, declarations);
59
+ expect(second).toMatchObject({ color:'rgba(0, 128, 0, 1)', width:41, padding:[4,4,4,4] });
60
+ expect(first.width).toBe(31);
61
+ expect(first.color).toBe('rgba(128, 0, 128, 1)');
62
+ } finally { window.happyDOM.abort(); }
63
+ });
@@ -0,0 +1,9 @@
1
+ import { expect, test } from 'bun:test';
2
+
3
+ test('paint-only geometry reads skip layout and match full-layout pixels', async () => {
4
+ const child = Bun.spawn([process.execPath, new URL('./fixtures/layout-repaint.js', import.meta.url).pathname],
5
+ { stdout: 'pipe', stderr: 'pipe' });
6
+ const [exit, stderr] = await Promise.all([child.exited, new Response(child.stderr).text()]);
7
+ expect(stderr).toBe('');
8
+ expect(exit).toBe(0);
9
+ }, 15_000);
@@ -0,0 +1,222 @@
1
+ import {test, expect} from 'bun:test';
2
+ import {parseHTMLDocument} from '../lib/happy-dom/parser.js';
3
+ import {StyleEngine} from '../lib/style/computed-style.js';
4
+ import {RenderTreeBuilder} from '../lib/render-tree/index.js';
5
+ import {LayoutEngine} from '../lib/layout/text-layout.js';
6
+ import {buildDisplayList} from '../lib/paint/display-list.js';
7
+ import {repaintLayout} from '../lib/layout/repaint.js';
8
+
9
+ function fixture(source='<body><div id="a" style="width:100px;height:30px;transform:scaleX(0.3);background-image:linear-gradient(red, blue);background-size:20px 20px">hello <span>world</span></div><p>after</p></body>') {
10
+ const parsed=parseHTMLDocument(source);
11
+ const builder=new RenderTreeBuilder(),engine=new LayoutEngine();
12
+ const first=builder.build(parsed.document,new StyleEngine().compute(parsed.document));
13
+ const options={width:300,viewportHeight:200};
14
+ const layout=engine.layout(first,options);
15
+ return {...parsed,builder,engine,first,layout,options,element:parsed.document.getElementById('a')};
16
+ }
17
+ for (const [name,value] of [['transform','scaleX(0.8)'],['backgroundSize','40px 15px'],['transformOrigin','left top'],['opacity','0.4'],['backgroundColor','blue']]) {
18
+ test(`retained ${name} paint matches a complete layout, leaving prior geometry immutable`,()=>{
19
+ const f=fixture();
20
+ try {
21
+ const before=JSON.stringify(f.layout);
22
+ f.element.style[name]=value;
23
+ const next=f.builder.build(f.document,new StyleEngine().compute(f.document)),retained=repaintLayout(f.layout,f.first,next);
24
+ expect(retained).not.toBeNull();
25
+ expect(buildDisplayList(retained,next)).toEqual(buildDisplayList(f.engine.layout(next,f.options),next));
26
+ expect(JSON.stringify(f.layout)).toBe(before);
27
+ const unchanged=f.layout.fragments.findIndex(fragment=>fragment.runs.some(run=>run.text==='after'));
28
+ expect(unchanged).toBeGreaterThanOrEqual(0);
29
+ expect(retained.fragments[unchanged]).toBe(f.layout.fragments[unchanged]);
30
+ expect(retained.fragments[unchanged].runs).toBe(f.layout.fragments[unchanged].runs);
31
+ } finally {f.window.happyDOM.abort();}
32
+ });
33
+ }
34
+ for (const [name,value] of [['transform','translateX(10px) scaleX(0.8)'],['transform','none'],['width','120px'],['color','red'],['display','none']]) {
35
+ test(`changing ${name} outside the paint-only subset retains full layout`,()=>{
36
+ const f=fixture();try {f.element.style[name]=value;expect(repaintLayout(f.layout,f.first,f.builder.build(f.document,new StyleEngine().compute(f.document)))).toBeNull();}
37
+ finally{f.window.happyDOM.abort();}
38
+ });
39
+ }
40
+ test('text changes cannot reuse old line breaking',()=>{
41
+ const f=fixture();try{f.element.firstChild.data='a much longer paragraph';expect(repaintLayout(f.layout,f.first,f.builder.build(f.document,new StyleEngine().compute(f.document)))).toBeNull();}
42
+ finally{f.window.happyDOM.abort();}
43
+ });
44
+
45
+ function expectEquivalent(f, next, retained) {
46
+ expect(retained).not.toBeNull();
47
+ const full = new LayoutEngine().layout(next, f.options);
48
+ // Fragment IDs retain the generation in which their geometry was created.
49
+ const comparable = layout => ({ ...layout, fragments: layout.fragments.map(({ id, ...fragment }) => fragment) });
50
+ expect(comparable(retained)).toEqual(comparable(full));
51
+ const painted = buildDisplayList(retained, next), reference = buildDisplayList(full, next);
52
+ expect(painted).toEqual(reference);
53
+ // Hit testing, text selection, and scroll extents are non-enumerable.
54
+ expect(painted.hits).toEqual(reference.hits);
55
+ expect(painted.scrollers).toEqual(reference.scrollers);
56
+ expect(painted.extent).toEqual(reference.extent);
57
+ return painted;
58
+ }
59
+
60
+ test('ancestor opacity updates nested positioned content and survives zero opacity', () => {
61
+ const f = fixture(`<body><div id="a" style="position:relative;z-index:2;width:100px;height:70px;background:red">
62
+ text <span style="opacity:.5;background:yellow">faint</span>
63
+ <span style="position:absolute;z-index:-1;left:2px;top:4px;background:blue">behind</span>
64
+ <span style="position:absolute;z-index:3;left:8px;top:15px;background:green">above</span>
65
+ </div><div style="position:relative;z-index:1">outside</div></body>`);
66
+ try {
67
+ let previous = f.first, layout = f.layout;
68
+ for (const opacity of ['0.5', '0', '1']) {
69
+ f.element.style.opacity = opacity;
70
+ const next = f.builder.build(f.document, new StyleEngine().compute(f.document));
71
+ const retained = repaintLayout(layout, previous, next);
72
+ const list = expectEquivalent(f, next, retained);
73
+ const faint = list.items.find(item => item.text === 'faint');
74
+ if (opacity === '0') expect(faint).toBeUndefined();
75
+ else expect(faint.opacity).toBe(Number(opacity) * 0.5);
76
+ for (let index = 0; index < retained.boxes.length; index++) {
77
+ expect(retained.boxes[index].layer).toBe(layout.boxes[index].layer);
78
+ }
79
+ previous = next;
80
+ layout = retained;
81
+ }
82
+ } finally { f.window.happyDOM.abort(); }
83
+ });
84
+
85
+ for (const [description, content] of [
86
+ ['wrapped inline', '<div style="width:90px">before <span id="a" style="background:red">several wrapped words</span> after</div>'],
87
+ ['bordered inline', '<div><span id="a" style="border:1px solid currentColor">text</span></div>'],
88
+ ['bordered image', '<div><img id="a" width="30" height="20" style="border:1px solid currentColor"></div>'],
89
+ ['table cell with resolved padding', '<table style="width:140px"><tr><td id="a" style="padding:10%;background:red">cell</td><td>other</td></tr></table>'],
90
+ ['block with anonymous lines', '<div id="a" style="padding:5%">before<p>middle</p>after</div>'],
91
+ ]) {
92
+ test(`background color retains ${description} geometry and paint`, () => {
93
+ const f = fixture(`<body>${content}</body>`);
94
+ try {
95
+ f.element.style.backgroundColor = 'blue';
96
+ const next = f.builder.build(f.document, new StyleEngine().compute(f.document));
97
+ expectEquivalent(f, next, repaintLayout(f.layout, f.first, next));
98
+ } finally { f.window.happyDOM.abort(); }
99
+ });
100
+ }
101
+
102
+ for (const [description, content, color] of [
103
+ ['new inline background', '<span id="a">text</span>', 'red'],
104
+ ['removed inline background', '<span id="a" style="background:red">text</span>', 'transparent'],
105
+ ['new image background', '<img id="a" width="30" height="20">', 'red'],
106
+ ['removed image background', '<img id="a" width="30" height="20" style="background:red">', 'transparent'],
107
+ ]) {
108
+ test(`${description} regenerates inline decoration boxes`, () => {
109
+ const f = fixture(`<body><div>${content}</div></body>`);
110
+ try {
111
+ f.element.style.backgroundColor = color;
112
+ const next = f.builder.build(f.document, new StyleEngine().compute(f.document));
113
+ expect(repaintLayout(f.layout, f.first, next)).toBeNull();
114
+ } finally { f.window.happyDOM.abort(); }
115
+ });
116
+ }
117
+
118
+ test('retained styles preserve gradient text identity across repeated paint changes', () => {
119
+ const f = fixture('<body><div id="a" style="width:100px;background-image:linear-gradient(red,blue);background-clip:text">gradient text</div></body>');
120
+ try {
121
+ let previous = f.first, layout = f.layout;
122
+ for (const [property, value] of [['opacity', '.5'], ['background-size', '40px 20px'], ['background-color', 'green']]) {
123
+ f.element.setAttribute('style', `${f.element.getAttribute('style')};${property}:${value}`);
124
+ const next = f.builder.build(f.document, new StyleEngine().compute(f.document));
125
+ const retained = repaintLayout(layout, previous, next);
126
+ const list = expectEquivalent(f, next, retained);
127
+ expect(list.items.filter(item => item.op === 'drawText').every(item => item.gradient)).toBe(true);
128
+ previous = next;
129
+ layout = retained;
130
+ }
131
+ } finally { f.window.happyDOM.abort(); }
132
+ });
133
+
134
+ test('color changes with inherited currentColor borders, decorations and SVG keep full layout', () => {
135
+ const f = fixture(`<body><div id="a" style="color:red;text-decoration:underline">
136
+ <span style="border:1px solid currentColor">text</span>
137
+ <svg width="20" height="20"><rect width="20" height="20" fill="currentColor"/></svg>
138
+ </div></body>`);
139
+ try {
140
+ f.element.style.color = 'blue';
141
+ const next = f.builder.build(f.document, new StyleEngine().compute(f.document));
142
+ expect(repaintLayout(f.layout, f.first, next)).toBeNull();
143
+ } finally { f.window.happyDOM.abort(); }
144
+ });
145
+
146
+ for (const otherOpacity of [null, '0.7', '0.3']) {
147
+ test(`a shared old style cannot leak into an alias with opacity ${otherOpacity ?? 'unchanged'}`, () => {
148
+ const f = fixture('<body><div id="a" style="width:100px">first</div><div id="b" style="width:100px">second</div></body>');
149
+ try {
150
+ const a = [...f.first.nodesById.values()].find(node => node.domNode === f.element);
151
+ const other = f.document.getElementById('b');
152
+ const b = [...f.first.nodesById.values()].find(node => node.domNode === other);
153
+ expect(a.style).toEqual(b.style);
154
+ // Render trees may share immutable styles. Give both elements and their
155
+ // text nodes that same style, then split it in the next generation.
156
+ const nodesById = new Map();
157
+ const share = node => {
158
+ const shared = Object.freeze({ ...node,
159
+ style: node.style === b.style ? a.style : node.style,
160
+ children: Object.freeze(node.children.map(share)),
161
+ });
162
+ nodesById.set(shared.id, shared);
163
+ return shared;
164
+ };
165
+ const previous = { ...f.first, root: share(f.first.root), nodesById };
166
+ const layout = new LayoutEngine().layout(previous, f.options);
167
+ f.element.style.opacity = '0.3';
168
+ if (otherOpacity != null) other.style.opacity = otherOpacity;
169
+ const next = f.builder.build(f.document, new StyleEngine().compute(f.document));
170
+ const retained = repaintLayout(layout, previous, next);
171
+ if (otherOpacity === '0.3') expectEquivalent(f, next, retained);
172
+ else expect(retained).toBeNull();
173
+ } finally { f.window.happyDOM.abort(); }
174
+ });
175
+ }
176
+ test('a changed run only replaces its own immutable fragment and preserves sibling runs',()=>{
177
+ const f=fixture('<body><div style="width:280px">before <span id="a" style="background-size:20px 20px">target</span> after</div><p>outside</p></body>');
178
+ try {
179
+ const before=JSON.stringify(f.layout);
180
+ const index=f.layout.fragments.findIndex(fragment=>fragment.runs.some(run=>run.text==='target'));
181
+ const original=f.layout.fragments[index];
182
+ expect(original.runs.length).toBeGreaterThan(1);
183
+ f.element.style.backgroundSize='40px 15px';
184
+ const next=f.builder.build(f.document,new StyleEngine().compute(f.document)),retained=repaintLayout(f.layout,f.first,next);
185
+ expect(retained).not.toBeNull();
186
+ const updated=retained.fragments[index];
187
+ expect(updated).not.toBe(original);
188
+ expect(updated.runs).not.toBe(original.runs);
189
+ for(let runIndex=0;runIndex<original.runs.length;runIndex++) {
190
+ const run=original.runs[runIndex],replacement=updated.runs[runIndex];
191
+ if(run.text==='target') {
192
+ expect(replacement).not.toBe(run);
193
+ expect(Object.isFrozen(replacement)).toBe(true);
194
+ expect(Object.isFrozen(replacement.style)).toBe(true);
195
+ } else expect(replacement).toBe(run);
196
+ }
197
+ expect(Object.isFrozen(updated)).toBe(true);
198
+ expect(Object.isFrozen(updated.runs)).toBe(true);
199
+ expect(Object.isFrozen(retained.fragments)).toBe(true);
200
+ expect(buildDisplayList(retained,next)).toEqual(buildDisplayList(f.engine.layout(next,f.options),next));
201
+ expect(JSON.stringify(f.layout)).toBe(before);
202
+ } finally {f.window.happyDOM.abort();}
203
+ });
204
+ test('box-only and no-op paint updates keep unchanged fragment and record arrays',()=>{
205
+ const f=fixture('<body><div id="a" style="width:100px;height:30px;background-size:20px 20px"></div><p>after</p></body>');
206
+ try {
207
+ const before=JSON.stringify(f.layout);
208
+ f.element.style.backgroundSize='40px 15px';
209
+ const next=f.builder.build(f.document,new StyleEngine().compute(f.document)),retained=repaintLayout(f.layout,f.first,next);
210
+ expect(retained).not.toBeNull();
211
+ expect(retained.boxes).not.toBe(f.layout.boxes);
212
+ expect(Object.isFrozen(retained.boxes)).toBe(true);
213
+ expect(retained.fragments).toBe(f.layout.fragments);
214
+ expect(buildDisplayList(retained,next)).toEqual(buildDisplayList(f.engine.layout(next,f.options),next));
215
+ expect(JSON.stringify(f.layout)).toBe(before);
216
+ const unchanged=f.builder.build(f.document,new StyleEngine().compute(f.document));
217
+ const again=repaintLayout(retained,next,unchanged);
218
+ expect(again.boxes).toBe(retained.boxes);
219
+ expect(again.fragments).toBe(retained.fragments);
220
+ expect(again.generation).toBe(unchanged.generation);
221
+ } finally {f.window.happyDOM.abort();}
222
+ });
@@ -0,0 +1,14 @@
1
+ import { test, expect } from 'bun:test';
2
+
3
+ for (const scenario of ['success', 'pause', 'controls', 'seek', 'source', 'format', 'worker-failure', 'epoch', 'no-growth']) {
4
+ test(`presentation continuation: ${scenario}`, async () => {
5
+ const child = Bun.spawn([process.execPath,
6
+ new URL('./fixtures/media-presentation-continuation.js', import.meta.url).pathname, scenario],
7
+ { stdout: 'pipe', stderr: 'pipe' });
8
+ const [out, error, code] = await Promise.all([
9
+ new Response(child.stdout).text(), new Response(child.stderr).text(), child.exited,
10
+ ]);
11
+ expect({ code, error }).toEqual({ code: 0, error: '' });
12
+ expect(out).toContain(`continuation ${scenario} passed`);
13
+ }, 15000);
14
+ }
@@ -0,0 +1,8 @@
1
+ import {test,expect} from 'bun:test';
2
+
3
+ test('presentation refill avoids duplicate page decoding and retains local fallbacks',async()=>{
4
+ const child=Bun.spawn([process.execPath,new URL('./fixtures/media-presentation-refill.js',import.meta.url).pathname],{stdout:'pipe',stderr:'pipe'});
5
+ const [out,error,code]=await Promise.all([new Response(child.stdout).text(),new Response(child.stderr).text(),child.exited]);
6
+ expect({code,error}).toEqual({code:0,error:''});
7
+ expect(out).toContain('presentation refill avoids duplicate page decode and preserves fallbacks');
8
+ },15000);
@@ -0,0 +1,16 @@
1
+ import { expect, test } from 'bun:test';
2
+
3
+ for (const scenario of ['initial-shared', 'initial-growth', 'initial-source', 'initial-rejected', 'audio-only',
4
+ 'refresh-growth', 'pipe-change', 'failed-retry', 'mime-change', 'epoch', 'epoch-inflight', 'moving-snapshot']) {
5
+ test(`media metadata probe: ${scenario}`, async () => {
6
+ const child = Bun.spawn([process.execPath, new URL('./fixtures/media-probe.js', import.meta.url).pathname, scenario],
7
+ { stdout: 'pipe', stderr: 'pipe' });
8
+ try {
9
+ const [out, error, code] = await Promise.all([
10
+ new Response(child.stdout).text(), new Response(child.stderr).text(), child.exited,
11
+ ]);
12
+ expect({ code, error }).toEqual({ code: 0, error: '' });
13
+ expect(out).toContain(`media probe ${scenario} passed`);
14
+ } finally { child.kill(); }
15
+ }, 10000);
16
+ }
@@ -0,0 +1,8 @@
1
+ import {test,expect} from 'bun:test';
2
+
3
+ test('independent media coalesces page UI painting while pause, seek and native controls remain live',async()=>{
4
+ const child=Bun.spawn([process.execPath,new URL('./fixtures/media-repaint.js',import.meta.url).pathname],{stdout:'pipe',stderr:'pipe'});
5
+ const [out,error,code]=await Promise.all([new Response(child.stdout).text(),new Response(child.stderr).text(),child.exited]);
6
+ expect({code,error}).toEqual({code:0,error:''});
7
+ expect(out).toContain('media repaint integration passed');
8
+ },15000);
@@ -0,0 +1,34 @@
1
+ import {expect, test} from 'bun:test';
2
+
3
+ test('only the currently attached MediaSource can change media state across src, srcObject and documents', async () => {
4
+ const child=Bun.spawn([process.execPath,new URL('./fixtures/media-source-attachment.js',import.meta.url).pathname],{
5
+ stdout:'pipe',stderr:'pipe',
6
+ });
7
+ const [out,error,code]=await Promise.all([
8
+ new Response(child.stdout).text(),new Response(child.stderr).text(),child.exited,
9
+ ]);
10
+ expect({code,error}).toEqual({code:0,error:''});
11
+ expect(out).toContain('MediaSource attachment isolation passed');
12
+ },15000);
13
+
14
+ for(const action of ['src','attribute','load'])test(`explicit ${action} reselects media while passive base changes preserve it`,async()=>{
15
+ const child=Bun.spawn([process.execPath,new URL('./fixtures/media-resource-reselection.js',import.meta.url).pathname,action],{
16
+ stdout:'pipe',stderr:'pipe',
17
+ });
18
+ const [out,error,code]=await Promise.all([
19
+ new Response(child.stdout).text(),new Response(child.stderr).text(),child.exited,
20
+ ]);
21
+ expect({code,error}).toEqual({code:0,error:''});
22
+ expect(out).toContain(`Media resource reselection ${action} passed`);
23
+ },15000);
24
+
25
+ test('MediaSource attachment queues sourceopen without a playback decoder', async () => {
26
+ const child=Bun.spawn([process.execPath,new URL('./fixtures/media-source-no-decoder.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('Decoder-less MediaSource attachment passed');
34
+ },15000);
@@ -0,0 +1,26 @@
1
+ import {expect, test} from 'bun:test';
2
+
3
+ test('video-only refills retain audio until its own EOF or epoch boundary, including pending output requests', async () => {
4
+ const child = Bun.spawn([process.execPath, new URL('./fixtures/media-track-refill.js', import.meta.url).pathname], {
5
+ stdout:'pipe', stderr:'pipe',
6
+ });
7
+ const [out, error, code] = await Promise.all([
8
+ new Response(child.stdout).text(), new Response(child.stderr).text(), child.exited,
9
+ ]);
10
+ expect({code, error}).toEqual({code:0, error:''});
11
+ expect(out).toContain('track-independent refill integration passed');
12
+ }, 15_000);
13
+
14
+ for (const action of ['seek', 'pause', 'navigate']) {
15
+ test(`a failed audio refill cancelled by ${action} does not fail the current playback`, async () => {
16
+ const child = Bun.spawn([process.execPath,
17
+ new URL('./fixtures/media-refill-cancel.js', import.meta.url).pathname, action], {
18
+ stdout:'pipe', stderr:'pipe',
19
+ });
20
+ const [out, error, code] = await Promise.all([
21
+ new Response(child.stdout).text(), new Response(child.stderr).text(), child.exited,
22
+ ]);
23
+ expect({code, error}).toEqual({code:0, error:''});
24
+ expect(out).toContain(`${action} ignores an obsolete refill rejection`);
25
+ }, 15_000);
26
+ }
@@ -0,0 +1,11 @@
1
+ import {expect,test} from 'bun:test';
2
+
3
+ test('renderer resumes audio output after seeking across a missing WebM range',async()=>{
4
+ const child=Bun.spawn([process.execPath,new URL('./fixtures/media-webm-seek.js',import.meta.url).pathname],{stdout:'pipe',stderr:'pipe'});
5
+ const timeout=setTimeout(()=>child.kill(),15_000);
6
+ try{
7
+ const [code,stdout,stderr]=await Promise.all([child.exited,new Response(child.stdout).text(),new Response(child.stderr).text()]);
8
+ expect({code,stderr}).toEqual({code:0,stderr:''});
9
+ expect(stdout).toContain('sparse WebM seek and playback passed');
10
+ }finally{clearTimeout(timeout);}
11
+ },20_000);
@@ -0,0 +1,36 @@
1
+ import {expect,test} from 'bun:test';
2
+ import {mp4KeyframeBefore} from '../lib/media/mp4-keyframes.js';
3
+
4
+ async function fixture(){return new Uint8Array(await Bun.file(new URL('./fixtures/mse-switch-large.mp4',import.meta.url)).arrayBuffer());}
5
+ function findBox(bytes,type){
6
+ for(let i=4;i+4<=bytes.length;i++)if(String.fromCharCode(...bytes.subarray(i,i+4))===type)return i+4;
7
+ throw Error(`missing ${type}`);
8
+ }
9
+
10
+ test('fMP4 sync-sample candidates retain absolute decode times',async()=>{
11
+ const bytes=await fixture();
12
+ expect([0,1.9,2,3.9,4,20].map(time=>mp4KeyframeBefore(bytes,time))).toEqual([0,0,2,2,4,4]);
13
+ expect(mp4KeyframeBefore(bytes,-1)).toBeNull();
14
+ expect(mp4KeyframeBefore(bytes,Infinity)).toBeNull();
15
+ });
16
+
17
+ test('invalid and unsupported MP4 layouts decline indexing without throwing',async()=>{
18
+ const source=await fixture();
19
+ for(const change of [
20
+ (b,v)=>v.setUint32(0,4), // invalid root box
21
+ (b,v)=>v.setUint32(0,0xffffffff), // truncated root box
22
+ (b,v)=>v.setUint32(findBox(b,'trun')+4,0xffffffff), // bounded sample work
23
+ (b,v)=>v.setUint32(findBox(b,'trun'),0x00000404), // conflicting flag tables
24
+ (b,v)=>v.setUint32(findBox(b,'tfhd'),0x00040000), // unknown fragment flags
25
+ (b,v)=>v.setUint32(findBox(b,'mdhd')+12,0), // no timescale
26
+ (b,v)=>{const at=findBox(b,'tfdt');b[at]=2;}, // unknown timing version
27
+ (b,v)=>{const at=findBox(b,'trex');v.setUint32(at+4,999);}, // unmatched track
28
+ ]){
29
+ const bytes=source.slice();change(bytes,new DataView(bytes.buffer));
30
+ expect(mp4KeyframeBefore(bytes,3)).toBeNull();
31
+ expect(mp4KeyframeBefore(bytes,3)).toBeNull(); // a declined index is cached too
32
+ }
33
+ for(const length of [0,1,7,16,source.length-1])expect(mp4KeyframeBefore(source.slice(0,length),3)).toBeNull();
34
+ const ordinary=new Uint8Array(await Bun.file(new URL('./fixtures/video.mp4',import.meta.url)).arrayBuffer());
35
+ expect(mp4KeyframeBefore(ordinary,2)).toBeNull();
36
+ });
@@ -0,0 +1,91 @@
1
+ import { expect, test } from 'bun:test';
2
+ import { parseHTMLDocument } from '../lib/happy-dom/parser.js';
3
+ import * as symbols from '../vendor/happy-dom/packages/happy-dom/src/PropertySymbol.ts';
4
+
5
+ const tick = () => new Promise(resolve => queueMicrotask(resolve));
6
+
7
+ test('multiple windows share notification ordering and one closing window cannot cancel another', async () => {
8
+ const first = parseHTMLDocument('<div></div>'), second = parseHTMLDocument('<div></div>');
9
+ const events = [];
10
+ try {
11
+ const older = new first.window.MutationObserver(() => events.push('older'));
12
+ const newer = new second.window.MutationObserver(() => events.push('newer'));
13
+ older.observe(first.document.body, { attributes: true });
14
+ newer.observe(second.document.body, { attributes: true });
15
+ second.document.body.setAttribute('x', '1');
16
+ queueMicrotask(() => events.push('promise'));
17
+ first.document.body.setAttribute('x', '1');
18
+ await tick();
19
+ expect(events).toEqual(['older', 'newer', 'promise']);
20
+ events.length = 0;
21
+ first.document.body.setAttribute('x', '2');
22
+ second.document.body.setAttribute('x', '2');
23
+ const closing = first.window.happyDOM.close();
24
+ await tick(); await closing;
25
+ expect(events).toEqual(['newer']);
26
+ second.document.body.setAttribute('x', '3'); await tick();
27
+ expect(events).toEqual(['newer', 'newer']);
28
+ } finally { await first.window.happyDOM.close(); await second.window.happyDOM.close(); }
29
+ });
30
+
31
+ test('window teardown disconnects every observer including cross-document and detached targets', async () => {
32
+ const first = parseHTMLDocument('<div><span></span></div>'), second = parseHTMLDocument('<div></div>');
33
+ const target = first.document.querySelector('div'), child = target.firstChild, other = second.document.body;
34
+ let calls = 0;
35
+ try {
36
+ const observers = Array.from({ length: 3 }, () => new first.window.MutationObserver(() => calls++));
37
+ for (const observer of observers) {
38
+ observer.observe(first.document.body, { attributes: true, subtree: true });
39
+ observer.observe(other, { attributes: true });
40
+ }
41
+ target.remove(); child.setAttribute('x', '1'); other.setAttribute('x', '1');
42
+ await first.window.happyDOM.close(); await tick();
43
+ expect(calls).toBe(0);
44
+ for (const node of [target, child, other]) expect(node[symbols.mutationListeners]).toHaveLength(0);
45
+ for (const observer of observers) expect(observer.takeRecords()).toEqual([]);
46
+ child.setAttribute('x', '2'); other.setAttribute('x', '2'); await tick();
47
+ expect(calls).toBe(0);
48
+ } finally { await first.window.happyDOM.close(); await second.window.happyDOM.close(); }
49
+ });
50
+
51
+ test('repeated subtree moves retain only live registrations and disconnect releases every listener', async () => {
52
+ const { window, document } = parseHTMLDocument('<section><div><span></span></div><aside></aside></section>');
53
+ const root = document.querySelector('section'), parent = root.firstChild, child = parent.firstChild, other = root.lastChild;
54
+ const nodes = [root, parent, child, other]; const seen = [];
55
+ try {
56
+ const observer = new window.MutationObserver(records => seen.push(records.length));
57
+ observer.observe(root, { attributes: true, childList: true, subtree: true });
58
+ observer.observe(parent, { attributes: true, subtree: true });
59
+ for (let round = 0; round < 10; round++) {
60
+ parent.remove(); child.remove(); other.appendChild(child); root.appendChild(parent);
61
+ parent.appendChild(child); parent.remove(); root.appendChild(parent);
62
+ await tick();
63
+ expect(nodes.map(node => node[symbols.mutationListeners].length)).toEqual([1, 2, 2, 1]);
64
+ }
65
+ parent.remove(); child.remove(); observer.disconnect();
66
+ for (const node of nodes) expect(node[symbols.mutationListeners]).toHaveLength(0);
67
+ const count = seen.length; child.setAttribute('after', '1'); await tick();
68
+ expect(seen).toHaveLength(count);
69
+ } finally { await window.happyDOM.close(); }
70
+ });
71
+
72
+ test('callback exceptions are reported without aborting later observers', async () => {
73
+ const { window, document } = parseHTMLDocument('<div></div>');
74
+ const events = [];
75
+ try {
76
+ window.addEventListener('error', event => { events.push(event.message); event.preventDefault(); });
77
+ const first = new window.MutationObserver(() => { events.push('first'); throw new Error('expected-observer-error'); });
78
+ const second = new window.MutationObserver(() => events.push('second'));
79
+ for (const observer of [first, second]) observer.observe(document.body, { attributes: true });
80
+ document.body.setAttribute('x', '1'); await tick();
81
+ expect(events).toEqual(['first', 'expected-observer-error', 'second']);
82
+ } finally { await window.happyDOM.close(); }
83
+ });
84
+
85
+ test('SES observer delivery agrees with Chromium for batching, options and callback mutations', async () => {
86
+ const child = Bun.spawn([process.execPath, new URL('./fixtures/mutation-observer.js', import.meta.url).pathname],
87
+ { stdout: 'pipe', stderr: 'pipe' });
88
+ const [exit, stderr] = await Promise.all([child.exited, new Response(child.stderr).text()]);
89
+ expect(stderr).toBe('');
90
+ expect(exit).toBe(0);
91
+ }, 15_000);