buninu-browser 0.2.2 → 0.2.4

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 (118) hide show
  1. package/lib/audio/playback-buffer.js +27 -0
  2. package/lib/audio/pulse-client.js +18 -8
  3. package/lib/cdp-server.js +53 -14
  4. package/lib/happy-dom/document-base.js +33 -0
  5. package/lib/happy-dom/parser.js +20 -6
  6. package/lib/happy-dom/selector-matcher.js +2 -2
  7. package/lib/happy-dom/selector-pseudo-adapter.js +33 -0
  8. package/lib/happy-dom/svg-attribute-adapter.js +50 -0
  9. package/lib/headless-shell.js +12 -6
  10. package/lib/input/hit-test.js +67 -19
  11. package/lib/layout/text-layout.js +289 -131
  12. package/lib/media/byte-pipe.js +2 -2
  13. package/lib/media/position-audio.js +10 -1
  14. package/lib/media/source-snapshots.js +126 -0
  15. package/lib/media/video-keyframe.js +42 -0
  16. package/lib/media/webm-stream.js +106 -0
  17. package/lib/network/network-service.js +268 -120
  18. package/lib/paint/display-list.js +22 -27
  19. package/lib/paint/frame-cache.js +25 -0
  20. package/lib/paint/frame.js +42 -13
  21. package/lib/paint/paragraph-text.js +24 -0
  22. package/lib/paint/raster-readback.js +41 -0
  23. package/lib/paint/rounded-rect.js +82 -0
  24. package/lib/paint/shared-frame-pool.js +105 -0
  25. package/lib/render-tree/index.js +40 -6
  26. package/lib/render-tree/svg.js +15 -7
  27. package/lib/renderer/bindings.js +244 -82
  28. package/lib/renderer/broadcast-channel.js +76 -0
  29. package/lib/renderer/canvas-2d.js +257 -14
  30. package/lib/renderer/canvas-text.js +120 -0
  31. package/lib/renderer/compositor.js +27 -10
  32. package/lib/renderer/custom-element-reactions.js +42 -0
  33. package/lib/renderer/font-loading.js +227 -0
  34. package/lib/renderer/history.js +42 -0
  35. package/lib/renderer/host.js +36 -5
  36. package/lib/renderer/image-loading.js +27 -0
  37. package/lib/renderer/image-resources.js +65 -0
  38. package/lib/renderer/interfaces.js +28 -0
  39. package/lib/renderer/keyboard-input.js +12 -0
  40. package/lib/renderer/layout-observers.js +236 -0
  41. package/lib/renderer/main.js +33 -5
  42. package/lib/renderer/media-playback-events.js +13 -0
  43. package/lib/renderer/media-source.js +126 -55
  44. package/lib/renderer/page-realm.js +278 -147
  45. package/lib/renderer/page-renderer.js +840 -301
  46. package/lib/renderer/piano-input.js +22 -0
  47. package/lib/renderer/pointer-move.js +29 -0
  48. package/lib/renderer/prepared-script-cache.js +50 -0
  49. package/lib/renderer/rendered-text.js +108 -0
  50. package/lib/renderer/rewrite-worker.js +4 -3
  51. package/lib/renderer/script-rewrite.js +13 -2
  52. package/lib/renderer/scroll-into-view.js +28 -0
  53. package/lib/renderer/slot-assignment.js +60 -0
  54. package/lib/renderer/video-presentation.js +145 -0
  55. package/lib/renderer/workers.js +2 -2
  56. package/lib/style/computed-style.js +632 -108
  57. package/lib/style/selector-traits.js +49 -5
  58. package/package.json +1 -1
  59. package/test/broadcast-channel.test.js +26 -0
  60. package/test/canvas-2d.test.js +83 -2
  61. package/test/canvas-paint.test.js +9 -0
  62. package/test/canvas-text.test.js +41 -0
  63. package/test/cdp-stream.test.js +56 -0
  64. package/test/compositor.test.js +38 -0
  65. package/test/computed-style-binding.test.js +151 -0
  66. package/test/computed-style.test.js +279 -1
  67. package/test/display-list.test.js +26 -0
  68. package/test/document-base.test.js +87 -0
  69. package/test/dom-platform.test.js +461 -0
  70. package/test/download.test.js +8 -0
  71. package/test/fetch-lifecycle.test.js +161 -0
  72. package/test/fixtures/canvas-paint.js +117 -0
  73. package/test/fixtures/canvas-text.js +42 -0
  74. package/test/fixtures/image-lifetime.js +90 -0
  75. package/test/fixtures/media-seek-snapshot.js +77 -0
  76. package/test/fixtures/mse-audio-chirp.webm +0 -0
  77. package/test/fixtures/mse-switch-large.mp4 +0 -0
  78. package/test/fixtures/mse-switch-small.mp4 +0 -0
  79. package/test/fixtures/renderer-memory.js +110 -0
  80. package/test/fixtures/summary-style.js +28 -0
  81. package/test/font-loading.test.js +97 -0
  82. package/test/grid.test.js +58 -0
  83. package/test/headless-shell.test.js +22 -0
  84. package/test/hit-test.test.js +21 -1
  85. package/test/image-lifetime.test.js +12 -0
  86. package/test/image-resources.test.js +94 -0
  87. package/test/local-media.test.js +84 -0
  88. package/test/media-playback-events.test.js +22 -0
  89. package/test/media-seek-snapshot.test.js +11 -0
  90. package/test/media-source-config.test.js +98 -0
  91. package/test/media-source-webm-accounting.test.js +98 -0
  92. package/test/media-source-webm.test.js +108 -0
  93. package/test/navigation.test.js +1 -0
  94. package/test/network-service.test.js +388 -0
  95. package/test/page-scripts.test.js +160 -2
  96. package/test/paint-frame.test.js +103 -0
  97. package/test/paint-paragraph-cache.test.js +87 -0
  98. package/test/piano-input.test.js +44 -0
  99. package/test/playback-buffer.test.js +31 -0
  100. package/test/position-audio.test.js +24 -0
  101. package/test/positioning.test.js +22 -0
  102. package/test/prepared-script-cache.test.js +45 -0
  103. package/test/pulse-client.test.js +27 -5
  104. package/test/raster-readback.test.js +40 -0
  105. package/test/render-tree.test.js +28 -0
  106. package/test/renderer-memory.test.js +11 -0
  107. package/test/selector-traits.test.js +24 -0
  108. package/test/shared-frame-pool.test.js +63 -0
  109. package/test/style-sheets.test.js +51 -0
  110. package/test/svg.test.js +29 -0
  111. package/test/text-layout.test.js +168 -0
  112. package/test/video-keyframe.test.js +61 -0
  113. package/test/video-presentation.test.js +136 -0
  114. package/tools/browser-lab.js +78 -0
  115. package/tools/browser-soak.js +75 -0
  116. package/tools/network-reference.js +49 -0
  117. package/tools/process-memory.js +38 -0
  118. package/tools/script-benchmark.js +25 -0
@@ -0,0 +1,461 @@
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("resize", 400, 300, 1);
9
+ });
10
+ afterAll(() => renderer?.close());
11
+
12
+ const load = async (body) => {
13
+ await renderer.call("loadDocument", {
14
+ url: "https://page.test/",
15
+ source: `<!doctype html><html><head></head><body>${body}</body></html>`,
16
+ contentType: "text/html", status: 200,
17
+ });
18
+ await renderer.call("whenLoaded");
19
+ };
20
+ const evaluate = (source) => renderer.call("evaluate", source);
21
+ const eventually = async (expression, expected) => {
22
+ let value;
23
+ for (let attempt = 0; attempt < 100; attempt++) {
24
+ value = await evaluate(expression);
25
+ if (value === expected) return;
26
+ await Bun.sleep(20);
27
+ }
28
+ expect(value).toBe(expected);
29
+ };
30
+
31
+ test("dataset is a DOMStringMap with live named properties and a mutable page prototype", async () => {
32
+ await load('<div id="test" data-async-css="1" data-name="before"></div>');
33
+ expect(await evaluate(`JSON.stringify((() => {
34
+ const element = document.getElementById('test');
35
+ const data = element.dataset;
36
+ const result = [data instanceof DOMStringMap, Object.prototype.toString.call(data), data === element.dataset,
37
+ Object.getPrototypeOf(data) === DOMStringMap.prototype, data.asyncCss, data['async-css'], Object.keys(data)];
38
+ data.name = 'after';
39
+ result.push(element.getAttribute('data-name'));
40
+ element.setAttribute('data-new-value', 'live');
41
+ result.push(data.newValue);
42
+ delete data.name;
43
+ result.push(element.hasAttribute('data-name'));
44
+ try { data['bad-name'] = 'x'; } catch (error) { result.push(error.name); }
45
+ try { new DOMStringMap(); } catch (error) { result.push(error.name); }
46
+ DOMStringMap.prototype.extension = 7;
47
+ result.push(data.extension, 'extension' in data, Object.hasOwn(data, 'extension'));
48
+ data.constructor = 'data attribute';
49
+ result.push(data.constructor, element.getAttribute('data-constructor'));
50
+ delete data.constructor;
51
+ result.push(data.constructor === DOMStringMap);
52
+ return result;
53
+ })())`)).toBe(JSON.stringify([true, "[object DOMStringMap]", true, true, "1", undefined, ["asyncCss", "name"],
54
+ "after", "live", false, "SyntaxError", "TypeError", 7, true, false, "data attribute", "data attribute", true]));
55
+ });
56
+
57
+ test("IntersectionObserver batches targets and updates as scrolling crosses thresholds", async () => {
58
+ await load(`<div id="first" style="position:absolute;left:0;top:400px;width:100px;height:100px"></div>
59
+ <div id="second" style="position:absolute;left:120px;top:400px;width:100px;height:100px"></div>
60
+ <div style="height:1200px"></div>`);
61
+ await evaluate(`window.observations = []; window.observer = new IntersectionObserver(entries => {
62
+ observations.push(entries.map(e => [e.target.id, e.isIntersecting, e.intersectionRatio]));
63
+ }, {threshold: [0, 0.5, 1]});
64
+ observer.observe(document.getElementById('first')); observer.observe(document.getElementById('second'));`);
65
+ await eventually("observations.length", 1);
66
+ expect(await evaluate("JSON.stringify(observations[0])")).toBe(JSON.stringify([["first", false, 0], ["second", false, 0]]));
67
+ await renderer.call("scrollTo", 0, 150);
68
+ await eventually("observations.length", 2);
69
+ expect(await evaluate("JSON.stringify(observations[1])")).toBe(JSON.stringify([["first", true, 0.5], ["second", true, 0.5]]));
70
+ await renderer.call("scrollTo", 0, 200);
71
+ await eventually("observations.length", 3);
72
+ expect(await evaluate("JSON.stringify(observations[2])")).toBe(JSON.stringify([["first", true, 1], ["second", true, 1]]));
73
+ await renderer.call("scrollTo", 0, 220);
74
+ await Bun.sleep(60);
75
+ expect(await evaluate("observations.length")).toBe(3);
76
+ await evaluate("observer.disconnect()");
77
+ await renderer.call("scrollTo", 0, 0);
78
+ await Bun.sleep(60);
79
+ expect(await evaluate("observations.length")).toBe(3);
80
+ });
81
+
82
+ test("ResizeObserver reports content and border boxes again after size changes", async () => {
83
+ await load('<div id="box" style="width:100px;height:50px;padding:10px;border:2px solid"></div>');
84
+ await evaluate(`window.sizes = []; window.observer = new ResizeObserver(entries => {
85
+ sizes.push(entries.map(e => [e.contentRect.width,e.contentRect.height,e.borderBoxSize[0].inlineSize,e.borderBoxSize[0].blockSize]));
86
+ }); observer.observe(document.getElementById('box'));`);
87
+ await eventually("sizes.length", 1);
88
+ expect(await evaluate("JSON.stringify(sizes[0])")).toBe("[[100,50,124,74]]");
89
+ await evaluate("document.getElementById('box').style.width='150px'");
90
+ await eventually("sizes.length", 2);
91
+ expect(await evaluate("JSON.stringify(sizes[1])")).toBe("[[150,50,174,74]]");
92
+ await evaluate("document.getElementById('box').style.color='red'");
93
+ await Bun.sleep(60);
94
+ expect(await evaluate("sizes.length")).toBe(2);
95
+ await evaluate("document.getElementById('box').style.display='none'");
96
+ await eventually("sizes.length", 3);
97
+ expect(await evaluate("JSON.stringify(sizes[2])")).toBe("[[0,0,0,0]]");
98
+ });
99
+
100
+ test("IntersectionObserver validates options and applies explicit root margins", async () => {
101
+ await load(`<div id="root" style="position:absolute;top:20px;left:20px;width:100px;height:100px;overflow:hidden">
102
+ <div id="target" style="position:absolute;top:150px;left:0;width:100px;height:100px"></div></div>`);
103
+ expect(await evaluate(`JSON.stringify((() => {
104
+ const result=[];
105
+ for(const options of [{threshold:2},{threshold:NaN},{rootMargin:'1em'},{root:{} }]) {
106
+ try { new IntersectionObserver(()=>{},options); } catch(e) { result.push(e.name); }
107
+ }
108
+ window.observations=[];
109
+ window.observer=new IntersectionObserver(e=>observations.push(e[0].intersectionRatio),{
110
+ root:document.getElementById('root'),rootMargin:'0px 0px 100%',threshold:[1,0.5,0]});
111
+ observer.observe(document.getElementById('target'));
112
+ result.push(observer instanceof IntersectionObserver,observer.root.id,observer.rootMargin,observer.thresholds.join(','));
113
+ return result;
114
+ })())`)).toBe(JSON.stringify(["RangeError", "RangeError", "SyntaxError", "TypeError", true, "root", "0px 0px 100% 0px", "0,0.5,1"]));
115
+ await eventually("observations.length", 1);
116
+ expect(await evaluate("observations[0]")).toBe(0.5);
117
+ });
118
+
119
+ test("innerText collects visible text and line boundaries instead of scripts and hidden content", async () => {
120
+ await load(`<div id="text"> hello <span> world </span> !<script type="application/json">secret</script>
121
+ <style>#text b { display:none }</style><b>hidden</b><div>block <i> words </i></div><p>paragraph</p>
122
+ <span style="visibility:hidden">invisible<span style="visibility:visible">visible</span></span><br>end<br></div>
123
+ <div id="hidden" style="display:none">raw <b>hidden</b><script type="application/json">data</script></div>
124
+ <pre id="pre"> pre\n text </pre>`);
125
+ expect(await evaluate("document.getElementById('text').innerText")).toBe("hello world !\nblock words\n\nparagraph\n\nvisible\nend\n");
126
+ expect(await evaluate("document.getElementById('hidden').innerText")).toBe("raw hiddendata");
127
+ expect(await evaluate("document.getElementById('pre').innerText")).toBe(" pre\n text ");
128
+ expect(await evaluate(`(()=>{const e=document.createElement('div');e.innerHTML='<b>a</b><br><i>b</i>';return e.innerText})()`)).toBe("ab");
129
+ expect(await evaluate(String.raw`(()=>{const e=document.getElementById('text');e.innerText='a\r\nb\rc\n';return e.innerHTML})()`)).toBe("a<br>b<br>c<br>");
130
+ expect(await evaluate(`(()=>{const e=document.getElementById('text');e.innerText=null;return e.innerHTML})()`)).toBe("");
131
+ });
132
+
133
+ test("Headers instrumentation is page-local and reaches Request and Response headers", async () => {
134
+ await load('<p>fetch interfaces</p>');
135
+ expect(await evaluate(`JSON.stringify((() => {
136
+ const original=Headers.prototype.set;
137
+ window.calls=[];
138
+ Headers.prototype.set=function(name,value){ calls.push(name); return original.call(this,name,value); };
139
+ const headers=new Headers(); headers.set('x-direct','1');
140
+ const request=new Request('/api'); request.headers.set('x-request','2');
141
+ const response=new Response('body'); response.headers.set('x-response','3');
142
+ return [calls,headers instanceof Headers,request.headers instanceof Headers,response.headers instanceof Headers,
143
+ request.clone() instanceof Request,response.clone() instanceof Response,
144
+ Response.error() instanceof Response,Response.json({ok:true}).headers instanceof Headers,
145
+ new Request('/post',{method:'POST',body:'hello'}).headers.get('content-type')];
146
+ })())`)).toBe(JSON.stringify([["x-direct", "x-request", "x-response"], true, true, true, true, true, true, true, "text/plain;charset=UTF-8"]));
147
+ await load('<p>new realm</p>');
148
+ expect(await evaluate("Headers.prototype.set.toString().includes('[native code]')")).toBe(true);
149
+ await evaluate("window.fetchHeaders = null; fetch('data:text/plain,hello').then(response => fetchHeaders = response.headers instanceof Headers)");
150
+ await eventually("fetchHeaders", true);
151
+ });
152
+
153
+ test("fetch and response cloning read internal headers without invoking page instrumentation", async () => {
154
+ await load('<p>internal fetch headers</p>');
155
+ expect(await evaluate(`(async () => {
156
+ const original = Headers.prototype.get;
157
+ let calls = 0;
158
+ Headers.prototype.get = function () { calls++; throw new Error('page header hook'); };
159
+ try {
160
+ const response = await fetch('data:application/octet-stream,media-chunk');
161
+ const copy = response.clone();
162
+ const [blob, copiedText] = await Promise.all([response.blob(), copy.text()]);
163
+ const result = [calls, response.headers instanceof Headers, blob.type, await blob.text(), copiedText];
164
+ try { response.headers.get('content-type'); } catch {}
165
+ result.push(calls);
166
+ return result;
167
+ } finally {
168
+ Headers.prototype.get = original;
169
+ }
170
+ })()`)).toEqual([0, true, 'application/octet-stream', 'media-chunk', 'media-chunk', 1]);
171
+ });
172
+
173
+ test("DOMParser returns separate inert documents with page interfaces and XML namespaces", async () => {
174
+ await load('<main id="main">active page</main>');
175
+ expect(await evaluate(`JSON.stringify((() => {
176
+ window.parsedRan=false; window.customRan=0;
177
+ customElements.define('parser-test',class extends HTMLElement {constructor(){super();customRan++;}});
178
+ const parser=new DOMParser();
179
+ const parsed=parser.parseFromString('<!doctype html><html><head><title>parsed</title></head><body><p id="copy">copy</p><parser-test></parser-test><script>parsedRan=true<\/script></body></html>','text/html');
180
+ const xml=parser.parseFromString('<root xmlns="urn:test"><child data-key="value"/></root>','application/xml');
181
+ const other=document.implementation.createHTMLDocument('other');
182
+ const result=[parsed instanceof Document,parsed.body!==document.body,parsed.body.ownerDocument===parsed,
183
+ parsed.title,parsed.defaultView,parsed.location,parsed.currentScript,parsed.readyState,parsed.contentType,
184
+ document.getElementById('main').textContent,parsedRan,customRan,other.body!==document.body,other.title,
185
+ xml.contentType,xml.documentElement.namespaceURI,xml.documentElement.localName,
186
+ new XMLSerializer().serializeToString(xml)];
187
+ document.body.appendChild(document.importNode(parsed.getElementById('copy'),true));
188
+ result.push(document.getElementById('copy').ownerDocument===document);
189
+ try {parser.parseFromString('x','text/plain');} catch(e){result.push(e.name);}
190
+ return result;
191
+ })())`)).toBe(JSON.stringify([true,true,true,"parsed",null,null,null,"complete","text/html","active page",false,0,true,"other",
192
+ "application/xml","urn:test","root",'<root xmlns="urn:test"><child data-key="value"/></root>',true,"TypeError"]));
193
+ });
194
+
195
+ test("details disclosure is clickable and keyboard accessible, including the generated label", async () => {
196
+ await load('<details id="explicit"><summary id="summary">More options</summary><button id="inside">Inside</button></details><details id="implicit">Hidden content</details><div style="height:1200px"></div>');
197
+ const click = async (id) => {
198
+ const [x,y] = JSON.parse(await evaluate(`JSON.stringify((()=>{const r=document.getElementById('${id}').getBoundingClientRect();return [r.left+10,r.top+5]})())`));
199
+ await renderer.call("click",x,y);
200
+ };
201
+ expect(await evaluate("document.getElementById('inside').getBoundingClientRect().height")).toBe(0);
202
+ await click('summary');
203
+ expect(await evaluate("document.getElementById('explicit').open && document.activeElement.id === 'summary'")).toBe(true);
204
+ await renderer.call('press','Enter');
205
+ expect(await evaluate("document.getElementById('explicit').open")).toBe(false);
206
+ await renderer.call('press',' ');
207
+ expect(await evaluate("document.getElementById('explicit').open")).toBe(true);
208
+ expect(await evaluate('window.scrollY')).toBe(0);
209
+ await evaluate("document.getElementById('summary').addEventListener('click',e=>e.preventDefault(),{once:true})");
210
+ await renderer.call('press','Enter');
211
+ expect(await evaluate("document.getElementById('explicit').open")).toBe(true);
212
+ await click('implicit');
213
+ expect(await evaluate("document.getElementById('implicit').open && document.activeElement.id === 'implicit'")).toBe(true);
214
+ await renderer.call('press',' ');
215
+ expect(await evaluate("document.getElementById('implicit').open")).toBe(false);
216
+ });
217
+
218
+ test("shadow slots assign live named children, flatten fallback content and replace manual assignments", async () => {
219
+ await load('<div id="host">text<!--ignore--><b id="named" slot="name">named</b><i id="default">default</i></div><div id="manual"><b id="one">one</b><i id="two">two</i></div>');
220
+ expect(await evaluate(`JSON.stringify((() => {
221
+ const host=document.getElementById('host');
222
+ const root=host.attachShadow({mode:'open'});
223
+ root.innerHTML='<slot id="default"></slot><slot name="name" id="named"></slot><slot name="name" id="duplicate"></slot><slot name="absent" id="fallback"><em>fallback</em></slot>';
224
+ const slots=[...root.querySelectorAll('slot')];
225
+ const result=[slots[0] instanceof HTMLSlotElement,typeof document.body.assignedNodes,
226
+ slots[0].assignedNodes().map(n=>n.nodeType===3?n.data:n.id),slots[0].assignedElements().map(n=>n.id),
227
+ slots[1].assignedNodes().map(n=>n.id),slots[2].assignedNodes().length,
228
+ slots[3].assignedNodes().length,slots[3].assignedElements({flatten:true}).map(n=>n.textContent),
229
+ host.firstChild.assignedSlot===slots[0],document.getElementById('named').assignedSlot===slots[1]];
230
+ document.getElementById('named').slot='';
231
+ result.push(slots[1].assignedNodes().length,slots[0].assignedElements().map(n=>n.id));
232
+ const manual=document.getElementById('manual'),one=document.getElementById('one'),two=document.getElementById('two');
233
+ const manualRoot=manual.attachShadow({mode:'open',slotAssignment:'manual',delegatesFocus:true});
234
+ manualRoot.innerHTML='<slot></slot><slot></slot>';
235
+ const [a,b]=manualRoot.querySelectorAll('slot');
236
+ a.assign(one,two,one); result.push(a.assignedNodes().map(n=>n.id),one.assignedSlot===a);
237
+ b.assign(one); result.push(a.assignedNodes().map(n=>n.id),b.assignedNodes().map(n=>n.id),one.assignedSlot===b);
238
+ a.assign(); result.push(a.assignedNodes().length,two.assignedSlot,manualRoot.slotAssignment,manualRoot.delegatesFocus);
239
+ try {a.assign(document.createComment('bad'));} catch(e) {result.push(e.name);}
240
+ return result;
241
+ })())`)).toBe(JSON.stringify([true,'undefined',['text','default'],['default'],['named'],0,0,['fallback'],true,true,
242
+ 0,['named','default'],['one','two'],true,['two'],['one'],true,0,null,'manual',true,'TypeError']));
243
+ });
244
+
245
+ test("HTML parsing adjusts SVG attribute names while XML and DOM setters preserve case", async () => {
246
+ await load('<svg id="svg" viewbox="0 0 10 10" data-Camel="Keep VALUE" DATA-CAMEL="duplicate" preserveaspectratio="none"></svg>');
247
+ expect(await evaluate(`JSON.stringify((() => {
248
+ const svg=document.getElementById('svg');
249
+ const template=document.createElement('template');
250
+ template.innerHTML='<svg data-nextAction$lit$="Lit VALUE" viewBox$lit$="Marker" viewbox="0 0 2 2"></svg>';
251
+ const inner=template.content.firstChild;
252
+ const xml=new DOMParser().parseFromString('<svg xmlns="http://www.w3.org/2000/svg" data-Camel="XML"/>','image/svg+xml');
253
+ svg.setAttribute('data-Other','DOM');
254
+ return [svg.getAttributeNames(),svg.getAttribute('data-camel'),svg.getAttribute('viewBox'),
255
+ inner.getAttributeNames(),inner.getAttribute('data-nextaction$lit$'),inner.getAttribute('viewbox$lit$'),
256
+ xml.documentElement.getAttribute('data-Camel'),xml.documentElement.getAttribute('data-camel'),
257
+ svg.getAttribute('data-Other'),svg.getAttribute('data-other')];
258
+ })())`)).toBe(JSON.stringify([['id','viewBox','data-camel','preserveAspectRatio','data-Other'],'Keep VALUE','0 0 10 10',
259
+ ['data-nextaction$lit$','viewbox$lit$','viewBox'],'Lit VALUE','Marker','XML',null,'DOM',null]));
260
+ });
261
+
262
+ test("constraint validation exposes live state and honors canceled submissions and novalidate", async () => {
263
+ await load('<form id="form"><input id="input" required><button id="submit">Send</button><button id="plain" type="button">Plain</button></form>');
264
+ expect(await evaluate(`JSON.stringify((()=>{
265
+ const input=document.getElementById('input'),form=document.getElementById('form');
266
+ const state=input.validity;
267
+ const result=[state instanceof ValidityState,state===input.validity,state.valueMissing,state.valid,input.willValidate,form.checkValidity()];
268
+ input.value='ready';result.push(state.valid,input.checkValidity());
269
+ input.setCustomValidity('Please correct');result.push(state.customError,state.valid,input.validationMessage,input.reportValidity());
270
+ input.disabled=true;result.push(input.checkValidity(),input.willValidate);input.disabled=false;
271
+ let submissions=[];form.addEventListener('submit',event=>{submissions.push(event.submitter?.id??null);event.preventDefault()});
272
+ form.requestSubmit();result.push(submissions.length);
273
+ form.noValidate=true;form.requestSubmit();form.requestSubmit(document.getElementById('submit'));
274
+ result.push(submissions);
275
+ try{form.requestSubmit(document.getElementById('plain'));}catch(error){result.push(error.name)}
276
+ input.setCustomValidity('');result.push(state.valid,input.validationMessage);
277
+ return result;
278
+ })())`)).toBe(JSON.stringify([true,true,true,false,true,false,true,true,true,false,'Please correct',false,true,false,0,[null,'submit'],'TypeError',true,'']));
279
+ });
280
+
281
+ test("Error keeps native branding and a page-local extensible constructor after SES lockdown", async () => {
282
+ await load('<p>errors</p>');
283
+ expect(await evaluate(`JSON.stringify((()=>{
284
+ const error=Error('message',{cause:'reason'});
285
+ class CustomError extends Error {}
286
+ const custom=new CustomError('custom');
287
+ let native;try{null.missing}catch(e){native=e}
288
+ Object.defineProperty(Error,'extension',{value:1,configurable:true});
289
+ return [typeof Error.isError,error instanceof Error,error.constructor===Error,error.cause,
290
+ custom instanceof CustomError,custom instanceof Error,native instanceof Error,Error.isError(native),
291
+ Error.isError(error),Error.isError(new Proxy(error,{})),Error.isError({[Symbol.toStringTag]:'Error'}),
292
+ Error.isError(Object.create(Error.prototype)),Error.extension];
293
+ })())`)).toBe(JSON.stringify(['function',true,true,'reason',true,true,true,true,true,false,false,false,1]));
294
+ await load('<p>new realm</p>');
295
+ expect(await evaluate('Error.extension')).toBeUndefined();
296
+ });
297
+
298
+ test("BroadcastChannel delivers cloned asynchronous messages and closes without echoing to itself", async () => {
299
+ await load('<p>broadcast</p>');
300
+ await evaluate(`window.received=[];window.echo=0;window.sender=new BroadcastChannel('platform-test');window.receiver=new BroadcastChannel('platform-test');
301
+ sender.onmessage=()=>echo++;
302
+ receiver.onmessage=e=>received.push([e.data.value,e.origin,e.target===receiver,e.isTrusted,e instanceof MessageEvent]);
303
+ const message={value:'before'};sender.postMessage(message);message.value='after';`);
304
+ await eventually('received.length',1);
305
+ expect(await evaluate('JSON.stringify([received,echo,receiver instanceof BroadcastChannel,receiver instanceof EventTarget,receiver.name])'))
306
+ .toBe(JSON.stringify([[['before','https://page.test',true,true,true]],0,true,true,'platform-test']));
307
+ expect(await evaluate(`JSON.stringify((()=>{const errors=[];try{sender.postMessage(()=>{})}catch(e){errors.push(e.name)}receiver.close();try{receiver.postMessage('closed')}catch(e){errors.push(e.name)}sender.postMessage('ignored');sender.close();return errors})())`))
308
+ .toBe(JSON.stringify(['DataCloneError','InvalidStateError']));
309
+ await Bun.sleep(30);
310
+ expect(await evaluate('received.length')).toBe(1);
311
+ });
312
+
313
+ test("developer diagnostics retain error stacks without exposing privileged stack access to pages", async () => {
314
+ await load('<p>diagnostics</p>');
315
+ expect(await evaluate(`JSON.stringify((()=>{
316
+ function diagnosticMarker(){try{null.missing}catch(error){console.error(error);return error.stack}}
317
+ return [typeof getStackString,diagnosticMarker()];
318
+ })())`)).toBe(JSON.stringify(['undefined','']));
319
+ const entries = await renderer.call('consoleMessages');
320
+ expect(entries.some(entry => entry.text.includes('TypeError') && entry.text.includes('diagnosticMarker'))).toBe(true);
321
+ });
322
+
323
+ test("plain Events allow UI-named expandos without inheriting readonly subclass attributes", async () => {
324
+ await load('<button id="button">Focus</button>');
325
+ expect(await evaluate(`JSON.stringify((()=>{'use strict';
326
+ const event=document.createEvent('Event'),target=document.getElementById('button');
327
+ event.initEvent('afterblur',true,false);
328
+ const result=['relatedTarget' in event,'clientX' in event,Object.getPrototypeOf(Event.prototype)===Object.prototype];
329
+ event.relatedTarget=target;event.clientX=25;event.detail='custom';
330
+ document.addEventListener('afterblur',e=>result.push(e.relatedTarget===target,e.clientX,e.detail));
331
+ document.dispatchEvent(event);
332
+ const mouse=new MouseEvent('click');
333
+ result.push(mouse.relatedTarget,mouse.clientX,Object.hasOwn(MouseEvent.prototype,'relatedTarget'));
334
+ const custom=new CustomEvent('example',{detail:7});
335
+ try{custom.detail=8}catch(error){result.push(error.name)}
336
+ result.push(custom.detail);
337
+ return result;
338
+ })())`)).toBe(JSON.stringify([false,false,true,true,25,'custom',null,0,true,'TypeError',7]));
339
+ });
340
+
341
+ test("innerText excludes SVG accessible descriptions and unpainted resource subtrees", async () => {
342
+ await load('<div id="text">Before<svg><title>Label</title><desc>Description</desc><defs><text>Definition</text></defs><path d="M0 0L10 10"/></svg>After</div>');
343
+ expect(await evaluate("document.getElementById('text').innerText")).toBe('BeforeAfter');
344
+ expect(await evaluate("document.getElementById('text').textContent")).toBe('BeforeLabelDescriptionDefinitionAfter');
345
+ });
346
+
347
+ test("innerText and client rectangles exclude closed disclosure content and elements without boxes", async () => {
348
+ await load('<div id="root"><details id="disclosure"><summary>Title</summary><p id="content">Hidden</p><summary>Other</summary></details><details id="bare"><p>Secret</p></details></div><div id="none" style="display:none">Hidden</div><div id="contents" style="display:contents"><span>Visible</span></div><div id="zero" style="width:0;height:0"></div>');
349
+ expect(await evaluate(`JSON.stringify((()=>{
350
+ const ids=['root','disclosure','content','bare','none','contents','zero'],elements=ids.map(id=>document.getElementById(id));
351
+ const result=elements.map(e=>[e.innerText,e.getClientRects().length]);
352
+ result.push(document.createElement('div').getClientRects().length);
353
+ document.getElementById('disclosure').open=true;
354
+ result.push(document.getElementById('disclosure').innerText,document.getElementById('content').getClientRects().length);
355
+ return result;
356
+ })())`)).toBe(JSON.stringify([['Title',1],['Title',1],['',0],['',1],['Hidden',0],['Visible',0],['',1],0,'Title\n\nHidden\n\nOther',1]));
357
+ });
358
+
359
+ test("scrollIntoView honors nearest, center, end, boolean overloads and fixed-position targets", async () => {
360
+ await load(`<style>body{margin:0}</style><div style="position:relative;width:1000px;height:1000px">
361
+ <div id="visible" style="position:absolute;left:70px;top:60px;width:80px;height:30px"></div>
362
+ <div id="target" style="position:absolute;left:480px;top:420px;width:50px;height:40px"></div>
363
+ <div id="large" style="position:absolute;left:50px;top:40px;width:500px;height:400px"></div>
364
+ <div id="fixed" style="position:fixed;left:10px;top:10px;width:20px;height:20px"></div></div>`);
365
+ expect(await evaluate(`JSON.stringify((()=>{
366
+ const position=()=>[scrollX,scrollY],result=[],target=document.getElementById('target');
367
+ document.getElementById('visible').scrollIntoView({block:'nearest',inline:'nearest'});result.push(position());
368
+ target.scrollIntoView({block:'nearest',inline:'nearest'});result.push(position());
369
+ target.scrollIntoView({block:'nearest',inline:'nearest'});result.push(position());
370
+ target.scrollIntoView({block:'center',inline:'center'});result.push(position());
371
+ scrollTo(0,0);target.scrollIntoView(false);result.push(position());
372
+ scrollTo(0,0);target.scrollIntoView();result.push(position());
373
+ scrollTo(100,100);document.getElementById('large').scrollIntoView({block:'nearest',inline:'nearest'});result.push(position());
374
+ document.getElementById('fixed').scrollIntoView();result.push(position());
375
+ try{target.scrollIntoView({block:'invalid'})}catch(error){result.push(error.name)}
376
+ try{target.scrollIntoView({inline:null})}catch(error){result.push(error.name)}
377
+ return result;
378
+ })())`)).toBe(JSON.stringify([[0,0],[130,160],[130,160],[305,290],[130,160],[130,420],[100,100],[100,100],'TypeError','TypeError']));
379
+ });
380
+
381
+ test("link activation retains its target when click handlers replace the clicked descendants", async () => {
382
+ await load('<a id="link" href="/before"><span id="label" style="display:block;width:120px;height:40px">Open</span></a>');
383
+ await evaluate(`document.getElementById('link').addEventListener('click',event=>{event.currentTarget.href='/after';event.currentTarget.textContent='Changed'})`);
384
+ expect(await renderer.call('click', 25, 25)).toBe('https://page.test/after');
385
+ await load('<a id="link" href="/before"><span style="display:block;width:120px;height:40px">Open</span></a>');
386
+ await evaluate(`document.getElementById('link').addEventListener('click',event=>{event.currentTarget.textContent='Changed';event.preventDefault()})`);
387
+ expect(await renderer.call('click', 25, 25)).toBe(null);
388
+ });
389
+
390
+ test("scripted click is composed, suppresses recursive activation and keeps its original link", async () => {
391
+ await load('<div id="host"></div><button id="disabled" disabled>Disabled</button><a id="link" href="#after"><span id="label">Open</span></a>');
392
+ expect(await evaluate(`JSON.stringify((()=>{
393
+ const result=[],host=document.getElementById('host'),root=host.attachShadow({mode:'open'});
394
+ const button=document.createElement('button');root.append(button);
395
+ button.addEventListener('click',event=>{result.push([event.composed,event.isTrusted]);button.click()});
396
+ host.addEventListener('click',()=>result.push('host'));
397
+ button.click();
398
+ document.getElementById('disabled').addEventListener('click',()=>result.push('disabled'));
399
+ document.getElementById('disabled').click();
400
+ document.getElementById('link').addEventListener('click',event=>event.currentTarget.textContent='Changed');
401
+ document.getElementById('label').click();
402
+ result.push(location.hash);
403
+ return result;
404
+ })())`)).toBe(JSON.stringify([[true,false],'host','#after']));
405
+ });
406
+
407
+ test("upgrading declarative custom elements initializes observed properties before connecting", async () => {
408
+ await load('<date-label ts="2026-10-16" ignored="x"></date-label>');
409
+ expect(await evaluate(`JSON.stringify((()=>{
410
+ const reactions=[],original=document.querySelector('date-label');
411
+ let observedReads=0;
412
+ class DateLabel extends HTMLElement {
413
+ static get observedAttributes(){observedReads++;return ['ts']}
414
+ constructor(){super();this.date='';reactions.push(['constructor',this.getAttribute('ts'),customElements.get('date-label')===DateLabel])}
415
+ attributeChangedCallback(name,before,after,namespace){this.date=after;reactions.push(['attribute',name,before,after,namespace])}
416
+ connectedCallback(){reactions.push(['connected',this.date]);this.textContent=this.date}
417
+ }
418
+ customElements.define('date-label',DateLabel);
419
+ reactions.push(['identity',original===document.querySelector('date-label'),original instanceof DateLabel,observedReads]);
420
+ const created=document.createElement('date-label');created.setAttribute('ts','later');document.body.append(created);
421
+ return reactions;
422
+ })())`)).toBe(JSON.stringify([
423
+ ['constructor','2026-10-16',true],['attribute','ts',null,'2026-10-16',null],['connected','2026-10-16'],['identity',true,true,1],
424
+ ['constructor',null,true],['attribute','ts',null,'later',null],['connected','later'],
425
+ ]));
426
+ });
427
+
428
+ test("upgrade reactions keep initial values and constructor mutations in callback order", async () => {
429
+ await load('<mutable-label count="1"></mutable-label><failed-label count="1"></failed-label>');
430
+ expect(await evaluate(`JSON.stringify((()=>{
431
+ const reactions=[];
432
+ class MutableLabel extends HTMLElement {
433
+ static observedAttributes=['count'];
434
+ constructor(){super();this.setAttribute('count','2');reactions.push('constructor')}
435
+ attributeChangedCallback(name,before,after){reactions.push([before,after]);if(after==='1')this.setAttribute('count','3')}
436
+ connectedCallback(){reactions.push('connected')}
437
+ }
438
+ customElements.define('mutable-label',MutableLabel);
439
+ class FailedLabel extends HTMLElement {
440
+ static observedAttributes=['count'];
441
+ constructor(){super();this.setAttribute('count','2');throw Error('failed constructor')}
442
+ attributeChangedCallback(){reactions.push('failed attribute')}
443
+ connectedCallback(){reactions.push('failed connected')}
444
+ }
445
+ try{customElements.define('failed-label',FailedLabel)}catch{}
446
+ return reactions;
447
+ })())`)).toBe(JSON.stringify(['constructor',[null,'1'],['1','2'],['2','3'],'connected']));
448
+ });
449
+
450
+ test("scrollIntoView aligns nested scrollports and container nearest leaves outer scrolling untouched", async () => {
451
+ await load(`<style>body{margin:0}</style><div style="height:1100px;position:relative">
452
+ <div id="scroll" style="position:absolute;left:20px;top:600px;width:200px;height:100px;overflow:auto;border:2px solid">
453
+ <div style="height:400px;position:relative"><div id="target" style="position:absolute;top:160px;left:0;width:30px;height:20px"></div></div>
454
+ </div></div>`);
455
+ expect(await evaluate(`JSON.stringify((()=>{
456
+ const scroller=document.getElementById('scroll'),target=document.getElementById('target'),result=[];
457
+ target.scrollIntoView({block:'nearest',container:'nearest'});result.push([scroller.scrollTop,scrollY]);
458
+ scroller.scrollTop=0;target.scrollIntoView({block:'start'});result.push([scroller.scrollTop,scrollY,target.getBoundingClientRect().top]);
459
+ return result;
460
+ })())`)).toBe(JSON.stringify([[80,0],[160,602,0]]));
461
+ });
@@ -19,6 +19,8 @@ beforeAll(() => {
19
19
  + '<a id="attachment" href="/attachment">attachment</a>'
20
20
  + '<a id="data" href="data:text/plain,hello%20data" download="data.txt">data</a>'
21
21
  + '<a id="blob" download="blob.txt">blob</a>'
22
+ + '<a id="toc" href="#contents">contents</a><h2 id="contents">Contents</h2>'
23
+ + '<script>document.getElementById("toc").addEventListener("click", event => { event.preventDefault(); location.href = event.currentTarget.href; });</script>'
22
24
  + '<script>document.getElementById("blob").href = URL.createObjectURL(new Blob(["blob bytes"]));</script>',
23
25
  { headers: { "content-type": "text/html" } });
24
26
  if (path === "/plain") return new Response("original bytes", { headers: { "content-type": "text/plain" } });
@@ -74,6 +76,12 @@ test("clicked downloads save files and leave the current page in place", async (
74
76
  await click("#attachment");
75
77
  expect(readFileSync(join(dir, "photo.jpg"), "utf8")).toBe("photo bytes");
76
78
  expect(browser.context.resourceContent()?.url).toBe(`${origin}/links`);
79
+ expect(await browser.context.evaluate("[location.href, document.URL, document.baseURI, document.getElementById('toc').href]"))
80
+ .toEqual([`${origin}/links`, `${origin}/links`, `${origin}/links`, `${origin}/links#contents`]);
81
+ await click("#toc");
82
+ expect(await browser.context.evaluate("[location.href, document.URL]"))
83
+ .toEqual([`${origin}/links#contents`, `${origin}/links#contents`]);
84
+ expect(events.filter((item) => item.event === "willBegin")).toHaveLength(4);
77
85
  await click("#data");
78
86
  expect(readFileSync(join(dir, "data.txt"), "utf8")).toBe("hello data");
79
87
  await browser.context.evaluate("document.getElementById('blob').click()");