buninu-browser 0.2.3 → 0.3.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.
- package/README.md +86 -1
- package/lib/cdp-server.js +47 -14
- package/lib/clipboard.js +22 -0
- package/lib/happy-dom/document-base.js +33 -0
- package/lib/happy-dom/parser.js +15 -6
- package/lib/happy-dom/selector-matcher.js +2 -2
- package/lib/happy-dom/selector-pseudo-adapter.js +19 -0
- package/lib/happy-dom/svg-attribute-adapter.js +50 -0
- package/lib/headless-shell.js +40 -11
- package/lib/input/hit-test.js +67 -19
- package/lib/layout/text-layout.js +213 -91
- package/lib/media/byte-pipe.js +2 -2
- package/lib/media/position-audio.js +10 -1
- package/lib/media/source-snapshots.js +126 -0
- package/lib/media/video-keyframe.js +17 -4
- package/lib/media/webm-stream.js +106 -0
- package/lib/network/network-service.js +268 -120
- package/lib/paint/display-list.js +8 -28
- package/lib/paint/frame-cache.js +25 -0
- package/lib/paint/frame.js +77 -16
- package/lib/paint/paragraph-text.js +24 -0
- package/lib/paint/raster-readback.js +41 -0
- package/lib/paint/raw-frames.js +47 -0
- package/lib/paint/rounded-rect.js +82 -0
- package/lib/paint/shared-frame-pool.js +105 -0
- package/lib/render-tree/index.js +38 -6
- package/lib/render-tree/svg.js +15 -7
- package/lib/renderer/bindings.js +233 -80
- package/lib/renderer/broadcast-channel.js +76 -0
- package/lib/renderer/canvas-2d.js +257 -14
- package/lib/renderer/canvas-text.js +120 -0
- package/lib/renderer/compositor-channel.js +103 -0
- package/lib/renderer/compositor.js +34 -40
- package/lib/renderer/custom-element-reactions.js +25 -1
- package/lib/renderer/flags.js +1 -1
- package/lib/renderer/font-loading.js +227 -0
- package/lib/renderer/host.js +133 -98
- package/lib/renderer/image-resources.js +65 -0
- package/lib/renderer/interfaces.js +28 -0
- package/lib/renderer/layout-observers.js +236 -0
- package/lib/renderer/main.js +52 -5
- package/lib/renderer/media-source.js +126 -55
- package/lib/renderer/page-realm.js +287 -99
- package/lib/renderer/page-renderer.js +663 -239
- package/lib/renderer/prepared-script-cache.js +50 -0
- package/lib/renderer/rendered-text.js +108 -0
- package/lib/renderer/rewrite-worker.js +4 -3
- package/lib/renderer/script-rewrite.js +13 -2
- package/lib/renderer/scroll-into-view.js +28 -0
- package/lib/renderer/slot-assignment.js +60 -0
- package/lib/renderer/video-presentation.js +23 -4
- package/lib/renderer/workers.js +2 -2
- package/lib/style/computed-style.js +611 -104
- package/lib/style/selector-traits.js +49 -5
- package/package.json +1 -1
- package/test/broadcast-channel.test.js +26 -0
- package/test/canvas-2d.test.js +83 -2
- package/test/canvas-paint.test.js +9 -0
- package/test/canvas-text.test.js +41 -0
- package/test/cdp-stream.test.js +56 -0
- package/test/clipboard.test.js +35 -0
- package/test/computed-style-binding.test.js +151 -0
- package/test/computed-style.test.js +258 -1
- package/test/display-list.test.js +12 -0
- package/test/document-base.test.js +87 -0
- package/test/dom-platform.test.js +461 -0
- package/test/fetch-lifecycle.test.js +161 -0
- package/test/fixtures/browser-raw-demo.html +36 -0
- package/test/fixtures/canvas-paint.js +117 -0
- package/test/fixtures/canvas-text.js +42 -0
- package/test/fixtures/image-lifetime.js +90 -0
- package/test/fixtures/media-seek-snapshot.js +77 -0
- package/test/fixtures/mse-audio-chirp.webm +0 -0
- package/test/fixtures/mse-switch-large.mp4 +0 -0
- package/test/fixtures/mse-switch-small.mp4 +0 -0
- package/test/fixtures/raw-capture-benchmark.json +76 -0
- package/test/fixtures/renderer-memory.js +110 -0
- package/test/fixtures/summary-style.js +28 -0
- package/test/font-loading.test.js +97 -0
- package/test/grid.test.js +58 -0
- package/test/hit-test.test.js +21 -1
- package/test/image-lifetime.test.js +12 -0
- package/test/image-resources.test.js +94 -0
- package/test/local-media.test.js +84 -0
- package/test/media-seek-snapshot.test.js +11 -0
- package/test/media-source-config.test.js +98 -0
- package/test/media-source-webm-accounting.test.js +98 -0
- package/test/media-source-webm.test.js +108 -0
- package/test/navigation.test.js +1 -0
- package/test/network-service.test.js +388 -0
- package/test/page-scripts.test.js +1 -1
- package/test/paint-frame.test.js +103 -0
- package/test/paint-paragraph-cache.test.js +87 -0
- package/test/position-audio.test.js +24 -0
- package/test/positioning.test.js +22 -0
- package/test/prepared-script-cache.test.js +45 -0
- package/test/pulse-client.test.js +10 -3
- package/test/raster-readback.test.js +40 -0
- package/test/raw-capture.test.js +152 -0
- package/test/render-tree.test.js +28 -0
- package/test/renderer-memory.test.js +11 -0
- package/test/selector-traits.test.js +24 -0
- package/test/shared-frame-pool.test.js +63 -0
- package/test/style-sheets.test.js +51 -0
- package/test/svg.test.js +29 -0
- package/test/text-layout.test.js +106 -0
- package/test/video-keyframe.test.js +61 -0
- package/test/video-presentation.test.js +69 -0
- package/test/window-resize.test.js +36 -0
- package/tools/bench-raw-capture.js +32 -0
- package/tools/browser-lab.js +78 -0
- package/tools/browser-soak.js +75 -0
- package/tools/network-reference.js +49 -0
- package/tools/process-memory.js +38 -0
- package/tools/script-benchmark.js +25 -0
- package/ai-debug.log +0 -5537
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { afterAll, beforeAll, expect, test } from 'bun:test';
|
|
2
|
+
import { RendererHost } from '../lib/renderer/host.js';
|
|
3
|
+
import { NetworkService } from '../lib/network/network-service.js';
|
|
4
|
+
|
|
5
|
+
let renderer;
|
|
6
|
+
const active = new Set();
|
|
7
|
+
const aborted = [];
|
|
8
|
+
const bodies = new Map();
|
|
9
|
+
const binaryBody = new Uint8Array([0, 255, 195, 40, 237, 160, 128, 240, 159, 152, 128]);
|
|
10
|
+
beforeAll(async () => {
|
|
11
|
+
const network = new NetworkService({ fetch: async (url, init) => {
|
|
12
|
+
if (url.endsWith('/complete')) return new Response('complete');
|
|
13
|
+
if (url.endsWith('/binary')) return new Response(binaryBody, { headers: { 'content-type': 'application/octet-stream' } });
|
|
14
|
+
active.add(url);
|
|
15
|
+
if (url.endsWith('/headers')) return new Promise((resolve, reject) => {
|
|
16
|
+
init.signal.addEventListener('abort', () => {
|
|
17
|
+
active.delete(url); aborted.push(url); reject(init.signal.reason);
|
|
18
|
+
}, { once: true });
|
|
19
|
+
});
|
|
20
|
+
return new Response(new ReadableStream({ start(controller) {
|
|
21
|
+
bodies.set(url, controller);
|
|
22
|
+
controller.enqueue(new TextEncoder().encode('first chunk'));
|
|
23
|
+
init.signal.addEventListener('abort', () => {
|
|
24
|
+
active.delete(url); aborted.push(url); controller.error(init.signal.reason);
|
|
25
|
+
}, { once: true });
|
|
26
|
+
} }));
|
|
27
|
+
} });
|
|
28
|
+
renderer = new RendererHost({ network, timeout: 20_000 });
|
|
29
|
+
await renderer.start();
|
|
30
|
+
});
|
|
31
|
+
afterAll(() => renderer?.close());
|
|
32
|
+
const load = () => renderer.call('loadDocument', {
|
|
33
|
+
url: 'https://page.test/', contentType: 'text/html', status: 200,
|
|
34
|
+
source: '<!doctype html><title>fetch lifecycle</title><p>Ready</p>',
|
|
35
|
+
});
|
|
36
|
+
const evaluate = source => renderer.call('evaluate', source);
|
|
37
|
+
async function until(predicate) {
|
|
38
|
+
for (let attempt = 0; attempt < 100; attempt++) {
|
|
39
|
+
if (await predicate()) return;
|
|
40
|
+
await Bun.sleep(10);
|
|
41
|
+
}
|
|
42
|
+
throw new Error('condition timed out');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
test('aborting after response headers errors the streaming body with the supplied reason', async () => {
|
|
46
|
+
await load();
|
|
47
|
+
await evaluate(`window.controller = new AbortController(); window.ready = false;
|
|
48
|
+
fetch('/stream', { signal: controller.signal }).then(response => {
|
|
49
|
+
window.result = 'pending'; window.ready = true;
|
|
50
|
+
response.text().then(text => result = text, error => result = error);
|
|
51
|
+
});`);
|
|
52
|
+
await until(async () => await evaluate('ready'));
|
|
53
|
+
await evaluate(`controller.abort('cancelled body')`);
|
|
54
|
+
await until(async () => await evaluate('result') === 'cancelled body');
|
|
55
|
+
await until(() => active.size === 0);
|
|
56
|
+
expect(aborted).toContain('https://page.test/stream');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('same-origin navigation cancels old document fetches before and after headers', async () => {
|
|
60
|
+
await load();
|
|
61
|
+
await evaluate(`fetch('/body').then(r => r.text()).catch(() => {});
|
|
62
|
+
fetch('/headers').catch(() => {}); undefined;`);
|
|
63
|
+
await until(() => active.size === 2);
|
|
64
|
+
await load();
|
|
65
|
+
await until(() => active.size === 0);
|
|
66
|
+
expect(aborted).toContain('https://page.test/body');
|
|
67
|
+
expect(aborted).toContain('https://page.test/headers');
|
|
68
|
+
expect(await evaluate(`fetch('/complete').then(r => r.text())`)).toBe('complete');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('aborting before headers preserves an arbitrary AbortSignal reason', async () => {
|
|
72
|
+
await load();
|
|
73
|
+
await evaluate(`window.controller = new AbortController(); window.result = 'pending';
|
|
74
|
+
fetch('/headers', {signal: controller.signal}).catch(error => result = error); undefined;`);
|
|
75
|
+
await until(() => active.size === 1);
|
|
76
|
+
await evaluate(`controller.abort('cancelled headers')`);
|
|
77
|
+
await until(async () => await evaluate('result') === 'cancelled headers');
|
|
78
|
+
await until(() => active.size === 0);
|
|
79
|
+
expect(await evaluate('result')).toBe('cancelled headers');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('navigation cancels unfinished image, script and stylesheet resource transfers', async () => {
|
|
83
|
+
await load();
|
|
84
|
+
await evaluate(`const image = document.createElement('img'); image.src = '/image/headers'; document.body.append(image);
|
|
85
|
+
const script = document.createElement('script'); script.src = '/script/headers'; document.head.append(script);
|
|
86
|
+
const style = document.createElement('link'); style.rel = 'stylesheet'; style.href = '/style/headers'; document.head.append(style);
|
|
87
|
+
undefined;`);
|
|
88
|
+
await until(() => active.size === 3);
|
|
89
|
+
await load();
|
|
90
|
+
await until(() => active.size === 0);
|
|
91
|
+
for (const kind of ['image', 'script', 'style']) expect(aborted).toContain(`https://page.test/${kind}/headers`);
|
|
92
|
+
expect(await evaluate('document.title')).toBe('fetch lifecycle');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
const startXhr = (path, timeout = 0) => evaluate(`
|
|
96
|
+
window.xhrEvents = [];
|
|
97
|
+
window.xhr = new XMLHttpRequest();
|
|
98
|
+
xhr.timeout = ${timeout};
|
|
99
|
+
for (const type of ['load', 'error', 'abort', 'timeout', 'loadend']) {
|
|
100
|
+
xhr.addEventListener(type, () => xhrEvents.push(type));
|
|
101
|
+
}
|
|
102
|
+
xhr.open('GET', ${JSON.stringify(path)});
|
|
103
|
+
xhr.send();
|
|
104
|
+
undefined;
|
|
105
|
+
`);
|
|
106
|
+
|
|
107
|
+
test('XMLHttpRequest reports body errors after headers and clears the failed response', async () => {
|
|
108
|
+
await load();
|
|
109
|
+
await startXhr('/xhr-body-error');
|
|
110
|
+
await until(async () => await evaluate('xhr.readyState') === 3);
|
|
111
|
+
bodies.get('https://page.test/xhr-body-error').error(new Error('body connection closed'));
|
|
112
|
+
active.delete('https://page.test/xhr-body-error');
|
|
113
|
+
await until(async () => (await evaluate('xhrEvents')).includes('loadend'));
|
|
114
|
+
expect(await evaluate('xhrEvents')).toEqual(['error', 'loadend']);
|
|
115
|
+
expect(await evaluate('[xhr.readyState, xhr.status, xhr.statusText, xhr.responseURL, xhr.response, xhr.responseText, xhr.getAllResponseHeaders()]'))
|
|
116
|
+
.toEqual([4, 0, '', '', null, '', '']);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test('XMLHttpRequest abort after headers reports abort even with a configured timeout', async () => {
|
|
120
|
+
await load();
|
|
121
|
+
await startXhr('/xhr-body-abort', 10_000);
|
|
122
|
+
await until(async () => await evaluate('xhr.readyState') === 3);
|
|
123
|
+
await evaluate('xhr.abort()');
|
|
124
|
+
await until(async () => (await evaluate('xhrEvents')).includes('loadend'));
|
|
125
|
+
expect(await evaluate('xhrEvents')).toEqual(['abort', 'loadend']);
|
|
126
|
+
expect(await evaluate('xhr.status')).toBe(0);
|
|
127
|
+
await until(() => active.size === 0);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('XMLHttpRequest timeout after headers reports timeout and releases the transfer', async () => {
|
|
131
|
+
await load();
|
|
132
|
+
await startXhr('/xhr-body-timeout', 100);
|
|
133
|
+
await until(async () => (await evaluate('xhrEvents')).includes('loadend'));
|
|
134
|
+
expect(await evaluate('xhrEvents')).toEqual(['timeout', 'loadend']);
|
|
135
|
+
expect(await evaluate('[xhr.readyState, xhr.status]')).toEqual([4, 0]);
|
|
136
|
+
await until(() => active.size === 0);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test('XMLHttpRequest binary response types preserve arbitrary bytes and return their platform types', async () => {
|
|
140
|
+
await load();
|
|
141
|
+
await evaluate(`Headers.prototype.get = function () { throw new Error('page Headers.get hook'); }; undefined;`);
|
|
142
|
+
for (const responseType of ['arraybuffer', 'blob']) {
|
|
143
|
+
const result = await evaluate(`new Promise((resolve, reject) => {
|
|
144
|
+
const request = new XMLHttpRequest();
|
|
145
|
+
request.open('GET', '/binary');
|
|
146
|
+
request.responseType = ${JSON.stringify(responseType)};
|
|
147
|
+
request.onload = async () => {
|
|
148
|
+
const value = request.response;
|
|
149
|
+
const bytes = request.responseType === 'blob' ? await value.arrayBuffer() : value;
|
|
150
|
+
resolve({ bytes: [...new Uint8Array(bytes)], isArrayBuffer: value instanceof ArrayBuffer,
|
|
151
|
+
isBlob: value instanceof Blob, type: value.type ?? null, responseText: request.responseText,
|
|
152
|
+
contentType: request.getResponseHeader('content-type') });
|
|
153
|
+
};
|
|
154
|
+
request.onerror = () => reject(new Error('binary request failed'));
|
|
155
|
+
request.send();
|
|
156
|
+
})`);
|
|
157
|
+
expect(result).toEqual({ bytes: [...binaryBody], isArrayBuffer: responseType === 'arraybuffer',
|
|
158
|
+
isBlob: responseType === 'blob', type: responseType === 'blob' ? 'application/octet-stream' : null,
|
|
159
|
+
responseText: '', contentType: 'application/octet-stream' });
|
|
160
|
+
}
|
|
161
|
+
});
|