buninu-browser 0.2.1 → 0.2.2
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/AGENTS.md +4 -0
- package/README.md +74 -9
- package/bunfig.toml +1 -0
- package/buninu-browser.js +4 -0
- package/index.js +5 -0
- package/lib/audio/piano.js +24 -1
- package/lib/audio/pulse-client.js +120 -12
- package/lib/cdp-server.js +16 -11
- package/lib/happy-dom/parser.js +24 -4
- package/lib/headless-shell.js +66 -19
- package/lib/input/hit-test.js +25 -7
- package/lib/layout/text-layout.js +196 -49
- package/lib/log.js +5 -2
- package/lib/media/byte-pipe.js +82 -0
- package/lib/media/position-audio.js +42 -0
- package/lib/network/network-service.js +161 -14
- package/lib/paint/display-list.js +300 -14
- package/lib/paint/gradient.js +11 -5
- package/lib/render-tree/index.js +17 -2
- package/lib/render-tree/svg.js +2 -1
- package/lib/renderer/bindings.js +985 -68
- package/lib/renderer/canvas-2d.js +92 -0
- package/lib/renderer/host.js +158 -8
- package/lib/renderer/interfaces.js +215 -0
- package/lib/renderer/main.js +160 -12
- package/lib/renderer/media-source.js +819 -0
- package/lib/renderer/messaging.js +411 -0
- package/lib/renderer/native-functions.js +113 -0
- package/lib/renderer/page-realm.js +1405 -198
- package/lib/renderer/page-renderer.js +1736 -193
- package/lib/renderer/rejections.js +31 -0
- package/lib/renderer/rewrite-worker.js +22 -0
- package/lib/renderer/script-rewrite.js +805 -57
- package/lib/renderer/trusted-types.js +129 -0
- package/lib/renderer/workers.js +327 -0
- package/lib/screenshot-cli.js +2 -0
- package/lib/style/computed-style.js +162 -22
- package/lib/user-agent.js +14 -2
- package/package.json +4 -1
- package/patches/happy-dom/0002-use-bun-platform-adapters.patch +139 -19
- package/patches/happy-dom/0006-parse-css-escapes-in-selectors.patch +115 -0
- package/patches/happy-dom/README.md +26 -0
- package/patches/ses/0002-embedder-native-functions-source-text-sloppy-code.patch +106 -0
- package/patches/ses/0003-cache-sloppy-evaluators.patch +47 -0
- package/patches/ses/README.md +4 -0
- package/test/canvas-2d.test.js +55 -0
- package/test/compositor.test.js +3 -1
- package/test/computed-style.test.js +38 -1
- package/test/display-list.test.js +74 -1
- package/test/download.test.js +35 -0
- package/test/fixtures/long-video.mp4 +0 -0
- package/test/fixtures/media-sync.js +57 -0
- package/test/happy-dom-platform-adapters.test.js +25 -0
- package/test/headless-shell.test.js +1 -1
- package/test/hit-test.test.js +25 -1
- package/test/media-source.test.js +156 -0
- package/test/media-sync.test.js +12 -0
- package/test/mobile.test.js +2 -2
- package/test/navigation.test.js +61 -0
- package/test/network-service.test.js +14 -0
- package/test/page-scripts.test.js +1045 -7
- package/test/position-audio.test.js +67 -0
- package/test/positioning.test.js +95 -1
- package/test/pulse-client.test.js +69 -0
- package/test/renderer-host.test.js +5 -2
- package/test/screenshot-cli.test.js +25 -1
- package/test/script-rewrite.test.js +111 -6
- package/test/setup.js +5 -0
- package/test/user-agent.test.js +60 -4
- package/vendor/happy-dom/packages/happy-dom/src/nodes/html-image-element/HTMLImageElement.ts +52 -22
- package/vendor/happy-dom/packages/happy-dom/src/query-selector/SelectorParser.ts +18 -17
- package/vendor/ses/dist/ses.mjs +52 -6
|
@@ -6,10 +6,20 @@ import { RendererHost } from "../lib/renderer/host.js";
|
|
|
6
6
|
* because lockdown() would freeze the test runner's own realm.
|
|
7
7
|
*/
|
|
8
8
|
const scripts = new Map();
|
|
9
|
+
// A 2x3 red PNG.
|
|
10
|
+
const RED_2X3_PNG = Buffer.from(
|
|
11
|
+
"iVBORw0KGgoAAAANSUhEUgAAAAIAAAADCAIAAAA2iEnWAAAAEElEQVR4nGP4z8AARAwoFABE0AX7pM/egAAAAABJRU5ErkJggg==",
|
|
12
|
+
"base64",
|
|
13
|
+
);
|
|
14
|
+
// HTML documents (iframes), by URL.
|
|
15
|
+
const pages = new Map();
|
|
9
16
|
const navigations = [];
|
|
10
17
|
// Every resource request with its time, for the preload test.
|
|
11
18
|
const requests = [];
|
|
12
19
|
let renderer;
|
|
20
|
+
let dripFinished = false;
|
|
21
|
+
let dripAudioFinished = false;
|
|
22
|
+
let pageFetchAborted = false;
|
|
13
23
|
|
|
14
24
|
beforeAll(async () => {
|
|
15
25
|
renderer = new RendererHost({
|
|
@@ -18,6 +28,16 @@ beforeAll(async () => {
|
|
|
18
28
|
fetch: async (url, init = {}) => {
|
|
19
29
|
const target = new URL(String(url));
|
|
20
30
|
requests.push({ url: target.href, at: performance.now() });
|
|
31
|
+
if (target.pathname === "/slow-abort") {
|
|
32
|
+
return await new Promise((resolve, reject) => {
|
|
33
|
+
const aborted = () => {
|
|
34
|
+
pageFetchAborted = true;
|
|
35
|
+
reject(new DOMException("aborted", "AbortError"));
|
|
36
|
+
};
|
|
37
|
+
if (init.signal?.aborted) aborted();
|
|
38
|
+
else init.signal?.addEventListener("abort", aborted, { once: true });
|
|
39
|
+
});
|
|
40
|
+
}
|
|
21
41
|
if (target.pathname === "/slow.css") {
|
|
22
42
|
await Bun.sleep(400);
|
|
23
43
|
return new Response("body { color: red }", { headers: { "content-type": "text/css" } });
|
|
@@ -39,8 +59,73 @@ beforeAll(async () => {
|
|
|
39
59
|
}
|
|
40
60
|
if (target.pathname === "/video.mp4") return new Response(Bun.file(new URL("./fixtures/video.mp4", import.meta.url)),
|
|
41
61
|
{ headers: { "content-type": "video/mp4" } });
|
|
62
|
+
if (target.pathname === "/red-2x3.png") return new Response(RED_2X3_PNG, { headers: { "content-type": "image/png" } });
|
|
63
|
+
// 30s: it cannot reach its end while a test clicks it, however slow the run.
|
|
64
|
+
if (target.pathname === "/long-video.mp4") return new Response(Bun.file(new URL("./fixtures/long-video.mp4", import.meta.url)),
|
|
65
|
+
{ headers: { "content-type": "video/mp4" } });
|
|
42
66
|
if (target.pathname === "/tone.mp3") return new Response(Bun.file(new URL("./fixtures/tone.mp3", import.meta.url)),
|
|
43
67
|
{ headers: { "content-type": "audio/mpeg" } });
|
|
68
|
+
if (target.pathname === "/drip-tone.mp3") {
|
|
69
|
+
const tone = new Uint8Array(await Bun.file(new URL("./fixtures/tone.mp3", import.meta.url)).bytes());
|
|
70
|
+
const bytes = new Uint8Array(tone.byteLength * 8);
|
|
71
|
+
for (let index = 0; index < 8; index++) bytes.set(tone, index * tone.byteLength);
|
|
72
|
+
let offset = 0;
|
|
73
|
+
const stream = new ReadableStream({
|
|
74
|
+
async pull(controller) {
|
|
75
|
+
if (!offset) {
|
|
76
|
+
controller.enqueue(bytes.subarray(0, tone.byteLength));
|
|
77
|
+
offset = tone.byteLength;
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
await Bun.sleep(300);
|
|
81
|
+
controller.enqueue(bytes.subarray(offset));
|
|
82
|
+
controller.close();
|
|
83
|
+
dripAudioFinished = true;
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
return new Response(stream, { headers: { "content-type": "audio/mpeg" } });
|
|
87
|
+
}
|
|
88
|
+
// First 8 KiB immediately, the rest after a pause, so a test can see
|
|
89
|
+
// playback begin before the body has finished.
|
|
90
|
+
if (target.pathname === "/drip-video.mp4") {
|
|
91
|
+
const bytes = new Uint8Array(await Bun.file(new URL("./fixtures/long-video.mp4", import.meta.url)).bytes());
|
|
92
|
+
const head = bytes.subarray(0, 8192);
|
|
93
|
+
const rest = bytes.subarray(8192);
|
|
94
|
+
let sentHead = false;
|
|
95
|
+
const stream = new ReadableStream({
|
|
96
|
+
async pull(controller) {
|
|
97
|
+
if (!sentHead) {
|
|
98
|
+
sentHead = true;
|
|
99
|
+
controller.enqueue(head);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
await Bun.sleep(1500);
|
|
103
|
+
controller.enqueue(rest);
|
|
104
|
+
controller.close();
|
|
105
|
+
dripFinished = true;
|
|
106
|
+
},
|
|
107
|
+
});
|
|
108
|
+
return new Response(stream, { headers: { "content-type": "video/mp4" } });
|
|
109
|
+
}
|
|
110
|
+
// More than forty small chunks before a decodable prefix. This catches
|
|
111
|
+
// arbitrary probe-attempt limits without delaying the test.
|
|
112
|
+
if (target.pathname === "/many-chunk-video.mp4") {
|
|
113
|
+
const bytes = new Uint8Array(await Bun.file(new URL("./fixtures/video.mp4", import.meta.url)).bytes());
|
|
114
|
+
let offset = 0;
|
|
115
|
+
const stream = new ReadableStream({
|
|
116
|
+
pull(controller) {
|
|
117
|
+
const length = offset < 128 ? 1 : bytes.byteLength - offset;
|
|
118
|
+
controller.enqueue(bytes.subarray(offset, offset + length));
|
|
119
|
+
offset += length;
|
|
120
|
+
if (offset >= bytes.byteLength) controller.close();
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
return new Response(stream, { headers: { "content-type": "video/mp4" } });
|
|
124
|
+
}
|
|
125
|
+
if (target.pathname === "/invalid-video.mp4") {
|
|
126
|
+
return new Response("not a media container", { headers: { "content-type": "video/mp4" } });
|
|
127
|
+
}
|
|
128
|
+
if (pages.has(target.href)) return new Response(pages.get(target.href), { headers: { "content-type": "text/html" } });
|
|
44
129
|
const body = scripts.get(target.href);
|
|
45
130
|
return body === undefined
|
|
46
131
|
? new Response("missing", { status: 404 })
|
|
@@ -86,6 +171,161 @@ test("classic scripts share globals, load in order and fire DOMContentLoaded the
|
|
|
86
171
|
expect(await evaluate("document.readyState")).toBe("complete");
|
|
87
172
|
});
|
|
88
173
|
|
|
174
|
+
test("large classic scripts prepared on the rewrite thread run in document order with the others", async () => {
|
|
175
|
+
// Past the size the renderer prepares off its main thread (see prepareOffThread).
|
|
176
|
+
const padding = `/* ${"x".repeat(40 * 1024)} */`;
|
|
177
|
+
scripts.set("https://page.test/big.js", `${padding}\nvar bigVar = 'big'; function bigFunction() { return this === window; } order.push('big:' + bigFunction());`);
|
|
178
|
+
scripts.set("https://page.test/small.js", "order.push('small:' + bigVar);");
|
|
179
|
+
await load(`
|
|
180
|
+
<script>var order = ['first'];</script>
|
|
181
|
+
<script src="big.js"></script>
|
|
182
|
+
<script src="small.js"></script>
|
|
183
|
+
<script>${padding}
|
|
184
|
+
order.push('inline-big:' + (typeof bigFunction) + ':' + (function () { return this === window; })());</script>
|
|
185
|
+
<script>order.push('last');</script>`);
|
|
186
|
+
expect(await evaluate("order.join(',')")).toBe("first,big:true,small:big,inline-big:function:true,last");
|
|
187
|
+
expect(await evaluate("bigFunction.toString().includes('$buninu$')")).toBe(false);
|
|
188
|
+
scripts.set("https://page.test/dynamic-big.js", `${padding}\nwindow.dynamicBig = (function () { return this === window; })();`);
|
|
189
|
+
await evaluate("document.body.appendChild(Object.assign(document.createElement('script'), { src: 'dynamic-big.js' })), 0");
|
|
190
|
+
for (let i = 0; i < 50 && await evaluate("window.dynamicBig") !== true; i++) await Bun.sleep(50);
|
|
191
|
+
expect(await evaluate("window.dynamicBig")).toBe(true);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test("functions a direct eval defines keep live access to the caller's variables", async () => {
|
|
195
|
+
await load(`<script>
|
|
196
|
+
window.evalScope = (function () {
|
|
197
|
+
var count = 0, label = "a";
|
|
198
|
+
const bump = eval("0,function (step) { count += step; return { count, label }; }");
|
|
199
|
+
const relabel = eval("0,function (next) { label = next; }");
|
|
200
|
+
const first = bump(2);
|
|
201
|
+
relabel("b");
|
|
202
|
+
count = 10;
|
|
203
|
+
const second = bump(1);
|
|
204
|
+
return [first.count, first.label, second.count, second.label, count, label, String(bump)];
|
|
205
|
+
})();
|
|
206
|
+
</script>`);
|
|
207
|
+
expect(JSON.parse(await evaluate("JSON.stringify(evalScope)"))).toEqual([
|
|
208
|
+
2, "a", 11, "b", 11, "b", "function (step) { count += step; return { count, label }; }",
|
|
209
|
+
]);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test("direct eval preserves receivers of lexical calls and tagged templates", async () => {
|
|
213
|
+
await load(`<script>
|
|
214
|
+
window.evalReceivers = (function () {
|
|
215
|
+
function strict() { "use strict"; return this === undefined; }
|
|
216
|
+
function sloppy() { return this === window; }
|
|
217
|
+
const object = { method() { return this === object; } };
|
|
218
|
+
const missing = null;
|
|
219
|
+
const source = "(function () { return [strict(), strict?.(), strict\u0060tag\u0060, sloppy(), object.method(), missing?.()]; })";
|
|
220
|
+
const made = eval(source);
|
|
221
|
+
return [made(), made.toString() === source.slice(1, -1)];
|
|
222
|
+
})();
|
|
223
|
+
</script>`);
|
|
224
|
+
expect(JSON.parse(await evaluate("JSON.stringify(evalReceivers)"))).toEqual([
|
|
225
|
+
[true, true, true, true, true, null], true,
|
|
226
|
+
]);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test("documents and shadow roots have adoptedStyleSheets, which page patches of Element.prototype may extend", async () => {
|
|
230
|
+
await load(`<div id="host"></div><script>
|
|
231
|
+
// As a popover polyfill does: every new shadow root adopts its sheet.
|
|
232
|
+
const sheet = new CSSStyleSheet();
|
|
233
|
+
sheet.replaceSync(":host { color: red }");
|
|
234
|
+
const attach = Element.prototype.attachShadow;
|
|
235
|
+
Element.prototype.attachShadow = function (init) {
|
|
236
|
+
const root = attach.call(this, init);
|
|
237
|
+
root.adoptedStyleSheets = [...root.adoptedStyleSheets, sheet];
|
|
238
|
+
return root;
|
|
239
|
+
};
|
|
240
|
+
const root = document.getElementById("host").attachShadow({ mode: "open" });
|
|
241
|
+
document.adoptedStyleSheets.push(sheet);
|
|
242
|
+
window.adopted = [
|
|
243
|
+
Array.isArray(document.adoptedStyleSheets), document.adoptedStyleSheets.length, root.adoptedStyleSheets[0] === sheet,
|
|
244
|
+
(() => { try { document.adoptedStyleSheets = [{}]; return "accepted"; } catch (error) { return error.name; } })(),
|
|
245
|
+
(() => { try { root.adoptedStyleSheets = null; return "accepted"; } catch (error) { return error.name; } })(),
|
|
246
|
+
];
|
|
247
|
+
</script>`);
|
|
248
|
+
expect(JSON.parse(await evaluate("JSON.stringify(adopted)"))).toEqual([true, 1, true, "TypeError", "TypeError"]);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test("Web APIs have their Web IDL property shapes", async () => {
|
|
252
|
+
await load(`<p>shape</p>`);
|
|
253
|
+
const shape = JSON.parse(await evaluate(`JSON.stringify((() => {
|
|
254
|
+
const d = (o, k) => Object.getOwnPropertyDescriptor(o, k);
|
|
255
|
+
const title = d(HTMLElement.prototype, "title");
|
|
256
|
+
const constant = d(Node, "ELEMENT_NODE");
|
|
257
|
+
const doc = d(window, "document");
|
|
258
|
+
const self = d(window, "self");
|
|
259
|
+
return [
|
|
260
|
+
title.get.name, title.set.name, d(HTMLElement.prototype, "onclick").get.name,
|
|
261
|
+
clearTimeout.name, clearInterval.name, cancelAnimationFrame.name, clearTimeout === clearInterval,
|
|
262
|
+
clearTimeout.length, cancelAnimationFrame.length, scrollTo.length, EventTarget.prototype.addEventListener.length,
|
|
263
|
+
Element.prototype.toggleAttribute.length, Promise.length, Function.length,
|
|
264
|
+
constant.writable, constant.configurable, d(EventTarget.prototype, "constructor").enumerable,
|
|
265
|
+
typeof doc.get, doc.configurable, doc.enumerable, typeof self.set, d(window, "console").enumerable,
|
|
266
|
+
(() => { window.innerWidth = 5; return window.innerWidth; })(),
|
|
267
|
+
];
|
|
268
|
+
})())`));
|
|
269
|
+
expect(shape).toEqual([
|
|
270
|
+
"get title", "set title", "get onclick",
|
|
271
|
+
"clearTimeout", "clearInterval", "cancelAnimationFrame", false,
|
|
272
|
+
0, 1, 0, 2,
|
|
273
|
+
1, 1, 1,
|
|
274
|
+
false, false, false,
|
|
275
|
+
"function", false, true, "function", false,
|
|
276
|
+
5,
|
|
277
|
+
]);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
test("each HTML element has its interface, and unknown names are HTMLUnknownElement", async () => {
|
|
281
|
+
await load(`<p>tags</p>`);
|
|
282
|
+
const names = await evaluate(`["ins", "col", "caption", "data", "xmp", "article", "lh", "blink", "my-widget", "audio"]
|
|
283
|
+
.map((tag) => Object.prototype.toString.call(document.createElement(tag)).slice(8, -1)
|
|
284
|
+
+ (document.createElement(tag) instanceof HTMLUnknownElement ? "?" : "")).join()`);
|
|
285
|
+
expect(names).toBe("HTMLModElement,HTMLTableColElement,HTMLTableCaptionElement,HTMLDataElement,HTMLPreElement,"
|
|
286
|
+
+ "HTMLElement,HTMLUnknownElement?,HTMLUnknownElement?,HTMLElement,HTMLAudioElement");
|
|
287
|
+
expect(await evaluate("Object.hasOwn(HTMLMediaElement.prototype, 'currentTime') && !Object.hasOwn(HTMLAudioElement.prototype, 'currentTime')")).toBe(true);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
test("unhandled rejections fire unhandledrejection, then rejectionhandled when caught later", async () => {
|
|
291
|
+
await load(`<script>
|
|
292
|
+
window.log = [];
|
|
293
|
+
addEventListener("unhandledrejection", (event) => {
|
|
294
|
+
log.push(event.type, event instanceof PromiseRejectionEvent, event.reason, event.promise === late, event.cancelable);
|
|
295
|
+
event.preventDefault();
|
|
296
|
+
});
|
|
297
|
+
onrejectionhandled = (event) => log.push(event.type, event.reason, event.promise === late);
|
|
298
|
+
var late = Promise.reject("why");
|
|
299
|
+
setTimeout(() => late.catch(() => {}), 50);
|
|
300
|
+
</script>`);
|
|
301
|
+
expect(await waitFor("log.length === 8", 3000)).toBe(true);
|
|
302
|
+
expect(await evaluate("JSON.stringify(log)")).toBe(JSON.stringify([
|
|
303
|
+
"unhandledrejection", true, "why", true, true, "rejectionhandled", "why", true,
|
|
304
|
+
]));
|
|
305
|
+
expect(await evaluate(`(() => { const p = Promise.resolve(); const e = new PromiseRejectionEvent("x", { promise: p, reason: 1 });
|
|
306
|
+
return [e.promise === p, e.reason, String(e), PromiseRejectionEvent.length,
|
|
307
|
+
(() => { try { new PromiseRejectionEvent("x", {}); return "ok"; } catch (error) { return error.name; } })()].join(); })()`))
|
|
308
|
+
.toBe("true,1,[object PromiseRejectionEvent],2,TypeError");
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
test("navigator reports cookies and User-Agent Client Hints consistently", async () => {
|
|
312
|
+
await load(`<script>
|
|
313
|
+
navigator.userAgentData.getHighEntropyValues(['uaFullVersion']).then(value => {
|
|
314
|
+
document.title = [navigator.cookieEnabled, navigator.userAgentData.platform,
|
|
315
|
+
navigator.userAgentData.brands[1].brand, value.uaFullVersion].join(',');
|
|
316
|
+
});
|
|
317
|
+
</script>`);
|
|
318
|
+
expect(await waitFor("document.title.startsWith('true,Linux,Chromium,')")).toBe(true);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
test("events generated by browser input are trusted, script events are not", async () => {
|
|
322
|
+
await load(`<button style="width:100px;height:40px" onclick="window.trust = event.isTrusted">go</button>`);
|
|
323
|
+
expect(await evaluate("new Event('click').isTrusted")).toBe(false);
|
|
324
|
+
const rect = await evaluate(`(() => { const r = document.querySelector('button').getBoundingClientRect(); return { x:r.x, y:r.y, width:r.width, height:r.height } })()`);
|
|
325
|
+
await renderer.call("click", rect.x + rect.width / 2, rect.y + rect.height / 2);
|
|
326
|
+
expect(await evaluate("window.trust")).toBe(true);
|
|
327
|
+
});
|
|
328
|
+
|
|
89
329
|
test("parser-blocking head scripts run before document.body exists", async () => {
|
|
90
330
|
await load(`<head><script>window.bodyDuringHead = document.body</script></head><body><p>ready</p></body>`);
|
|
91
331
|
expect(await evaluate("bodyDuringHead === null")).toBe(true);
|
|
@@ -104,6 +344,28 @@ test("anchors expose parsed hyperlink URL components", async () => {
|
|
|
104
344
|
expect(await evaluate(`document.getElementById("link").href`)).toBe("https://other.test/path?q=one#part");
|
|
105
345
|
});
|
|
106
346
|
|
|
347
|
+
test("form.elements is a live named collection of form controls", async () => {
|
|
348
|
+
await load(`<form id="form"><input name="query"><button id="send">go</button></form>`);
|
|
349
|
+
expect(await evaluate(`(() => { const form = document.getElementById('form'); return [
|
|
350
|
+
form.elements.length, form.elements.namedItem('query') === form.elements[0],
|
|
351
|
+
form.elements.namedItem('send') === document.getElementById('send')
|
|
352
|
+
]; })()`)).toEqual([2, true, true]);
|
|
353
|
+
await evaluate(`document.getElementById('form').appendChild(document.createElement('select'))`);
|
|
354
|
+
expect(await evaluate("document.getElementById('form').elements.length")).toBe(3);
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
test("constructable CSSStyleSheet supports feature checks and basic rule mutation", async () => {
|
|
358
|
+
await load(`<p>cssom</p>`);
|
|
359
|
+
expect(await evaluate(`(async () => {
|
|
360
|
+
const sheet = new CSSStyleSheet();
|
|
361
|
+
sheet.replaceSync('p { color: red }');
|
|
362
|
+
const inserted = sheet.insertRule('b { display: block }');
|
|
363
|
+
const same = await sheet.replace('i { color: blue }');
|
|
364
|
+
return [sheet instanceof CSSStyleSheet, sheet.cssRules[0] instanceof CSSStyleRule,
|
|
365
|
+
inserted, same === sheet, sheet.cssRules[0].cssText, typeof CSSGroupingRule];
|
|
366
|
+
})()`)).toEqual([true, true, 1, true, "i { color: blue }", "function"]);
|
|
367
|
+
});
|
|
368
|
+
|
|
107
369
|
test("getAttributeNode exposes a live Attr view", async () => {
|
|
108
370
|
await load(`<form id="search" onsubmit="return false"></form>`);
|
|
109
371
|
expect(await evaluate(`(() => { const form = document.getElementById("search"); const attr = form.getAttributeNode("onsubmit");
|
|
@@ -112,6 +374,23 @@ test("getAttributeNode exposes a live Attr view", async () => {
|
|
|
112
374
|
.toEqual(["onsubmit", "return false", 2, true, true, "return true", null]);
|
|
113
375
|
});
|
|
114
376
|
|
|
377
|
+
test("element.attributes is a live NamedNodeMap by index and by name", async () => {
|
|
378
|
+
await load(`<img id="pic" src="a.png" alt="x">`);
|
|
379
|
+
expect(await evaluate(`(() => { const img = document.getElementById("pic"); const map = img.attributes;
|
|
380
|
+
const before = [map.length, map[0].name, map.src.value, map.getNamedItem("alt").value, map.item(9), map.missing === undefined,
|
|
381
|
+
[...map].map((attribute) => attribute.name).join(), map === img.attributes, "src" in map];
|
|
382
|
+
img.setAttribute("title", "t"); map.removeNamedItem("alt");
|
|
383
|
+
return [...before, map.length, Array.from(map, (attribute) => attribute.name + "=" + attribute.value).join()]; })()`))
|
|
384
|
+
.toEqual([3, "id", "a.png", "x", null, true, "id,src,alt", true, true, 3, "id=pic,src=a.png,title=t"]);
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
test("document.elementFromPoint finds the topmost element painted there", async () => {
|
|
388
|
+
await load(`<div id="under" style="height:60px"><span id="text">text</span></div>
|
|
389
|
+
<div id="over" style="position:absolute;top:0;left:100px;width:50px;height:50px"></div>`);
|
|
390
|
+
expect(await evaluate(`[document.elementFromPoint(5, 5)?.id, document.elementFromPoint(110, 10)?.id,
|
|
391
|
+
document.elementFromPoint(300, 30)?.id, document.elementFromPoint(-1, 5)]`)).toEqual(["text", "over", "under", null]);
|
|
392
|
+
});
|
|
393
|
+
|
|
115
394
|
test("scripts change the DOM and the next screenshot shows it", async () => {
|
|
116
395
|
await load(`<div id="box" style="height:20px"></div>
|
|
117
396
|
<script>document.getElementById('box').style.background = 'rgb(255, 0, 0)';
|
|
@@ -299,7 +578,7 @@ test("CSS.escape produces selectors for leading digits and punctuation", async (
|
|
|
299
578
|
});
|
|
300
579
|
|
|
301
580
|
test("a video plays frames and pauses when clicked", async () => {
|
|
302
|
-
await load(`<video src="/video.mp4" controls width="160" height="120"></video>`);
|
|
581
|
+
await load(`<video src="/long-video.mp4" controls width="160" height="120"></video>`);
|
|
303
582
|
const bounds = await evaluate("document.querySelector('video').getBoundingClientRect().toJSON()");
|
|
304
583
|
const x = bounds.x + bounds.width / 2;
|
|
305
584
|
const y = bounds.y + bounds.height / 2;
|
|
@@ -308,8 +587,12 @@ test("a video plays frames and pauses when clicked", async () => {
|
|
|
308
587
|
await renderer.call("click", x, y);
|
|
309
588
|
expect(await evaluate("document.querySelector('video').paused")).toBeFalse();
|
|
310
589
|
const started = await renderer.call("screenshot");
|
|
311
|
-
|
|
312
|
-
|
|
590
|
+
// A later frame, however slowly frames come under load.
|
|
591
|
+
let during = started;
|
|
592
|
+
for (const deadline = Date.now() + 3000; during === started && Date.now() < deadline;) {
|
|
593
|
+
await Bun.sleep(100);
|
|
594
|
+
during = await renderer.call("screenshot");
|
|
595
|
+
}
|
|
313
596
|
expect(during).not.toBe(before);
|
|
314
597
|
expect(during).not.toBe(started);
|
|
315
598
|
expect(await evaluate("document.querySelector('video').currentTime > 0")).toBeTrue();
|
|
@@ -460,6 +743,17 @@ test("performance.timing follows the document lifecycle", async () => {
|
|
|
460
743
|
.toBe(JSON.stringify([[true, 0], true, true, 0, 21]));
|
|
461
744
|
});
|
|
462
745
|
|
|
746
|
+
test("each document starts a new performance timeline", async () => {
|
|
747
|
+
await load('<p>first</p>');
|
|
748
|
+
const previousOrigin = await evaluate('performance.timeOrigin');
|
|
749
|
+
await Bun.sleep(100);
|
|
750
|
+
await load('<script>var initialNow = performance.now(); var frameTime; requestAnimationFrame(t => frameTime = t);</script>');
|
|
751
|
+
expect(await evaluate('performance.timeOrigin')).toBeGreaterThan(previousOrigin);
|
|
752
|
+
expect(await evaluate('initialNow')).toBeLessThan(100);
|
|
753
|
+
expect(await waitFor('typeof frameTime === "number"')).toBe(true);
|
|
754
|
+
expect(await evaluate('frameTime >= initialNow && frameTime <= performance.now()')).toBe(true);
|
|
755
|
+
});
|
|
756
|
+
|
|
463
757
|
test("location assignment asks the controller to navigate", async () => {
|
|
464
758
|
navigations.length = 0;
|
|
465
759
|
await load(`<script>setTimeout(() => { location.href = '/elsewhere?x=1' }, 0)</script>`);
|
|
@@ -474,6 +768,124 @@ test("geometry APIs read the real layout", async () => {
|
|
|
474
768
|
expect(await evaluate("document.getElementById('box').offsetWidth + 'x' + innerWidth")).toBe("120x400");
|
|
475
769
|
});
|
|
476
770
|
|
|
771
|
+
test("DOM objects have their interfaces' shape: events, element.style, image attributes and Node methods", async () => {
|
|
772
|
+
await load(`<div id="host">a<b>c</b></div><script>
|
|
773
|
+
const event = document.createEvent("MouseEvents");
|
|
774
|
+
const enumerated = []; for (const key in event) enumerated.push(key);
|
|
775
|
+
const image = document.createElement("img");
|
|
776
|
+
const style = document.body.style;
|
|
777
|
+
style.setProperty("margin-top", "3px");
|
|
778
|
+
const frame = document.createElement("iframe");
|
|
779
|
+
document.body.appendChild(frame);
|
|
780
|
+
const host = document.getElementById("host");
|
|
781
|
+
host.appendChild(document.createTextNode("x"));
|
|
782
|
+
host.appendChild(document.createTextNode("y"));
|
|
783
|
+
host.normalize();
|
|
784
|
+
window.shapes = {
|
|
785
|
+
event: [Object.prototype.toString.call(event), event instanceof MouseEvent, event instanceof UIEvent,
|
|
786
|
+
Object.getOwnPropertyNames(event).join(), enumerated.includes("clientX"), enumerated.includes("preventDefault"),
|
|
787
|
+
typeof event.initMouseEvent, event.view, MouseEvent.prototype.hasOwnProperty("clientX"), Event.prototype.hasOwnProperty("clientX")],
|
|
788
|
+
unsupportedEvent: (() => { try { document.createEvent("NoSuchEvents"); return "created"; } catch (error) { return error.name; } })(),
|
|
789
|
+
style: [Object.prototype.toString.call(style), style instanceof CSSStyleDeclaration, style.setProperty.toString(),
|
|
790
|
+
style.getPropertyValue("margin-top"), style.marginTop],
|
|
791
|
+
image: [image.useMap, image.name, image.complete, image.decoding, image.crossOrigin, image.hspace],
|
|
792
|
+
frame: frame.contentWindow !== null,
|
|
793
|
+
node: [host.childNodes.length, host.isSameNode(host), host.isEqualNode(host.cloneNode(true)),
|
|
794
|
+
host.lookupNamespaceURI(null), host.lookupPrefix("http://www.w3.org/1999/xhtml"), host.getRootNode() === document, Node.NOTATION_NODE],
|
|
795
|
+
};
|
|
796
|
+
</script>`);
|
|
797
|
+
expect(JSON.parse(await evaluate("JSON.stringify(shapes)"))).toEqual({
|
|
798
|
+
event: ["[object MouseEvent]", true, true, "isTrusted", true, true, "function", null, true, false],
|
|
799
|
+
unsupportedEvent: "NotSupportedError",
|
|
800
|
+
style: ["[object CSSStyleDeclaration]", true, "function setProperty() { [native code] }", "3px", "3px"],
|
|
801
|
+
image: ["", "", true, "auto", null, 0],
|
|
802
|
+
frame: true,
|
|
803
|
+
node: [3, true, true, "http://www.w3.org/1999/xhtml", null, true, 12],
|
|
804
|
+
});
|
|
805
|
+
});
|
|
806
|
+
|
|
807
|
+
test("HTMLElement.prototype inherits Element's members, so patches to Element.prototype reach HTML elements", async () => {
|
|
808
|
+
await load(`<div id="host"></div><script>
|
|
809
|
+
const original = Element.prototype.setAttribute;
|
|
810
|
+
Element.prototype.setAttribute = function (name, value) { window.patched = name; return original.call(this, name, value); };
|
|
811
|
+
document.getElementById("host").setAttribute("data-x", "1");
|
|
812
|
+
window.shape = [Object.hasOwn(HTMLElement.prototype, "setAttribute"), Object.hasOwn(HTMLElement.prototype, "focus"),
|
|
813
|
+
Object.hasOwn(HTMLElement.prototype, "onclick"), Object.hasOwn(Element.prototype, "focus"), Object.hasOwn(Element.prototype, "id")];
|
|
814
|
+
</script>`);
|
|
815
|
+
expect(await evaluate("window.patched")).toBe("data-x");
|
|
816
|
+
expect(JSON.parse(await evaluate("JSON.stringify(shape)"))).toEqual([false, true, true, false, true]);
|
|
817
|
+
});
|
|
818
|
+
|
|
819
|
+
test("Trusted Types: policies make trusted values, and eval runs a TrustedScript", async () => {
|
|
820
|
+
await load(`<div id="host"></div><script>
|
|
821
|
+
const policy = trustedTypes.createPolicy("page", { createHTML: (s) => s + "!", createScript: (s) => s, createScriptURL: (s) => s });
|
|
822
|
+
const html = policy.createHTML("<b>x</b>");
|
|
823
|
+
document.getElementById("host").innerHTML = html;
|
|
824
|
+
const script = policy.createScript("1 + 2");
|
|
825
|
+
window.trusted = [
|
|
826
|
+
Object.prototype.toString.call(trustedTypes), html instanceof TrustedHTML, String(html), JSON.stringify(html),
|
|
827
|
+
trustedTypes.isHTML(html), trustedTypes.isHTML("x"), trustedTypes.isScript(script), policy.name,
|
|
828
|
+
eval(script), (0, eval)(script), eval(7), trustedTypes.emptyHTML.toString(),
|
|
829
|
+
trustedTypes.getPropertyType("div", "innerHTML"), trustedTypes.getAttributeType("script", "src"), trustedTypes.defaultPolicy,
|
|
830
|
+
(() => { try { trustedTypes.createPolicy("bare").createHTML("x"); return "created"; } catch (error) { return error.name; } })(),
|
|
831
|
+
(() => { try { new TrustedHTML(); return "constructed"; } catch (error) { return error.name; } })(),
|
|
832
|
+
document.getElementById("host").innerHTML,
|
|
833
|
+
String(trustedTypes.createPolicy),
|
|
834
|
+
];
|
|
835
|
+
</script>`);
|
|
836
|
+
expect(JSON.parse(await evaluate("JSON.stringify(trusted)"))).toEqual([
|
|
837
|
+
"[object TrustedTypePolicyFactory]", true, "<b>x</b>!", '"<b>x</b>!"',
|
|
838
|
+
true, false, true, "page",
|
|
839
|
+
3, 3, 7, "",
|
|
840
|
+
"TrustedHTML", "TrustedScriptURL", null,
|
|
841
|
+
"TypeError", "TypeError",
|
|
842
|
+
"<b>x</b>!",
|
|
843
|
+
"function createPolicy() { [native code] }",
|
|
844
|
+
]);
|
|
845
|
+
});
|
|
846
|
+
|
|
847
|
+
test("addEventListener reads capture, once, passive and signal, and an aborted signal removes the listener", async () => {
|
|
848
|
+
await load(`<div id="host"></div><script>
|
|
849
|
+
const read = [];
|
|
850
|
+
const options = {};
|
|
851
|
+
for (const name of ["passive", "signal", "once", "capture"]) {
|
|
852
|
+
Object.defineProperty(options, name, { get() { read.push(name); return undefined; } });
|
|
853
|
+
}
|
|
854
|
+
const host = document.getElementById("host");
|
|
855
|
+
host.addEventListener("ping", () => {}, options);
|
|
856
|
+
const controller = new AbortController();
|
|
857
|
+
window.calls = 0;
|
|
858
|
+
host.addEventListener("ping", () => window.calls++, { signal: controller.signal });
|
|
859
|
+
host.dispatchEvent(new Event("ping"));
|
|
860
|
+
controller.abort();
|
|
861
|
+
host.dispatchEvent(new Event("ping"));
|
|
862
|
+
host.addEventListener("ping", () => window.calls++, { signal: controller.signal });
|
|
863
|
+
host.dispatchEvent(new Event("ping"));
|
|
864
|
+
window.optionReads = read.join();
|
|
865
|
+
window.badSignal = (() => { try { host.addEventListener("ping", () => {}, { signal: {} }); return "added"; } catch (error) { return error.name; } })();
|
|
866
|
+
</script>`);
|
|
867
|
+
expect(await evaluate("optionReads")).toBe("capture,once,passive,signal");
|
|
868
|
+
expect(await evaluate("calls")).toBe(1);
|
|
869
|
+
expect(await evaluate("badSignal")).toBe("TypeError");
|
|
870
|
+
});
|
|
871
|
+
|
|
872
|
+
test("offsetParent is the nearest positioned ancestor, and offsets are measured from its padding edge", async () => {
|
|
873
|
+
await load(`<div id="outer" style="margin-left:20px;padding:5px">
|
|
874
|
+
<div id="toggle" style="position:relative;border:3px solid;padding:8px;width:200px;overflow:hidden">
|
|
875
|
+
<div id="tab" style="width:50px;height:10px"></div>
|
|
876
|
+
<div id="selection" style="position:absolute;left:0;top:0;width:100%;height:4px"></div>
|
|
877
|
+
</div></div>`);
|
|
878
|
+
expect(JSON.parse(await evaluate(`JSON.stringify((() => {
|
|
879
|
+
const tab = document.getElementById("tab"), toggle = document.getElementById("toggle");
|
|
880
|
+
return [tab.offsetParent === toggle, tab.offsetLeft, tab.offsetTop, toggle.offsetParent === document.body,
|
|
881
|
+
document.body.offsetParent, toggle.scrollWidth, toggle.clientWidth, document.body.offsetLeft, document.body.offsetTop];
|
|
882
|
+
})())`))).toEqual([
|
|
883
|
+
// An absolutely positioned child as wide as the padding box does not
|
|
884
|
+
// add the end padding again: nothing overflows. The body's offsets are 0.
|
|
885
|
+
true, 8, 8, true, null, 216, 216, 0, 0,
|
|
886
|
+
]);
|
|
887
|
+
});
|
|
888
|
+
|
|
477
889
|
test("page scripts cannot escape the sandbox or reach engine state", async () => {
|
|
478
890
|
await load(`<p id="p">x</p><script>
|
|
479
891
|
var probes = {};
|
|
@@ -484,8 +896,10 @@ test("page scripts cannot escape the sandbox or reach engine state", async () =>
|
|
|
484
896
|
attempt('privateState', () => Object.getOwnPropertySymbols(document).length + '/' + Object.getOwnPropertyNames(document).length);
|
|
485
897
|
attempt('newBinding', () => new document.constructor());
|
|
486
898
|
attempt('patchPrototype', () => { Object.getPrototypeOf(document.body).appendChild = () => 'pwned'; return 'patched'; });
|
|
487
|
-
|
|
488
|
-
|
|
899
|
+
// Classic scripts are sloppy-mode code: assigning to a frozen property
|
|
900
|
+
// fails silently, as in browsers. What matters is that nothing changed.
|
|
901
|
+
attempt('patchIntrinsic', () => { Array.prototype.includes = () => true; return [].includes(1) ? 'patched' : 'blocked'; });
|
|
902
|
+
attempt('patchUrl', () => { URL.prototype.toString = () => 'pwned'; return String(new URL('https://a.test/')) === 'pwned' ? 'patched' : 'blocked'; });
|
|
489
903
|
attempt('dynamicImport', () => { const pending = eval('import("node:fs")'); pending.catch(() => {}); return pending instanceof Promise ? 'promise' : 'wrong'; });
|
|
490
904
|
</script>`);
|
|
491
905
|
const probes = await evaluate("JSON.stringify(probes)");
|
|
@@ -610,6 +1024,27 @@ test("styles inside noscript do not apply while page scripting is enabled", asyn
|
|
|
610
1024
|
expect(await evaluate(`getComputedStyle(document.getElementById("visible")).display`)).toBe("block");
|
|
611
1025
|
});
|
|
612
1026
|
|
|
1027
|
+
test("pages can wrap Promise methods without modifying another realm", async () => {
|
|
1028
|
+
await load(`<script>
|
|
1029
|
+
window.promiseCalls = 0;
|
|
1030
|
+
const original = Promise.prototype.then;
|
|
1031
|
+
Promise.prototype.then = function (...args) { promiseCalls++; return original.apply(this, args); };
|
|
1032
|
+
Promise.prototype.catch = function (reject) { return this.then(undefined, reject); };
|
|
1033
|
+
Promise.prototype.finally = function (done) { return this.then(value => { done(); return value; }); };
|
|
1034
|
+
window.promiseResult = null;
|
|
1035
|
+
Promise.resolve(42).finally(() => {}).then(value => { promiseResult = value; });
|
|
1036
|
+
</script>`);
|
|
1037
|
+
await waitFor("promiseResult === 42");
|
|
1038
|
+
expect(await evaluate("promiseCalls")).toBeGreaterThanOrEqual(2);
|
|
1039
|
+
expect(await evaluate("Promise.resolve(0) instanceof Promise")).toBe(true);
|
|
1040
|
+
expect(await evaluate("(async () => 1)() instanceof Promise")).toBe(true);
|
|
1041
|
+
expect(await evaluate("(() => { class Child extends Promise {} return [new Child(r => r()) instanceof Child, Promise.resolve() instanceof Child]; })()")).toEqual([true, false]);
|
|
1042
|
+
expect(await evaluate("['all', 'allSettled', 'any', 'race', 'reject', 'resolve', 'withResolvers'].every((name) => Object.hasOwn(Promise, name)) && Object.prototype.toString.call(Promise.resolve())")).toBe("[object Promise]");
|
|
1043
|
+
await load(`<script>window.promiseResult = null; Promise.all([Promise.resolve(7)]).then(v => promiseResult = v[0]);</script>`);
|
|
1044
|
+
await waitFor("promiseResult === 7");
|
|
1045
|
+
expect(await evaluate("typeof promiseCalls")).toBe("undefined");
|
|
1046
|
+
});
|
|
1047
|
+
|
|
613
1048
|
test("page RegExp instances can override their own toString without changing the intrinsic", async () => {
|
|
614
1049
|
await load(`<script>
|
|
615
1050
|
const pattern = /demo/;
|
|
@@ -710,6 +1145,130 @@ test("fetch and XMLHttpRequest go through the controller with cookies and CORS",
|
|
|
710
1145
|
});
|
|
711
1146
|
});
|
|
712
1147
|
|
|
1148
|
+
test("fetch(request) uses the Request's internal state, not properties script defined on it", async () => {
|
|
1149
|
+
await load(`<script>
|
|
1150
|
+
window.network = {};
|
|
1151
|
+
(async () => {
|
|
1152
|
+
// Players check that fetch is not patched this way: the request still
|
|
1153
|
+
// fetches its own data: URL whatever its own properties now say.
|
|
1154
|
+
const request = new Request('data:application/json;base64,' + btoa(JSON.stringify({ ok: 42 })));
|
|
1155
|
+
Object.defineProperty(request, 'url', { get: () => 'https://page.test/api/echo' });
|
|
1156
|
+
Object.defineProperty(request, 'method', { get: () => 'POST' });
|
|
1157
|
+
Object.defineProperty(request, 'bodyUsed', { get: () => true });
|
|
1158
|
+
Object.defineProperty(request, 'mode', { get: () => 'same-origin' });
|
|
1159
|
+
network.shadowed = (await (await fetch(request)).json()).ok;
|
|
1160
|
+
const used = new Request('/api/echo', { method: 'POST', body: 'abc' });
|
|
1161
|
+
await used.text();
|
|
1162
|
+
network.used = await fetch(used).then(() => 'fetched', (error) => error.name);
|
|
1163
|
+
network.stringType = (await (await fetch(new Request('/api/echo', { method: 'POST', body: 'abc' }))).json()).contentType;
|
|
1164
|
+
network.done = true;
|
|
1165
|
+
})();
|
|
1166
|
+
</script>`);
|
|
1167
|
+
for (let attempt = 0; attempt < 50 && !(await evaluate("network.done === true")); attempt++) await Bun.sleep(20);
|
|
1168
|
+
expect(JSON.parse(await evaluate("JSON.stringify(network)"))).toEqual({
|
|
1169
|
+
shadowed: 42,
|
|
1170
|
+
used: "TypeError",
|
|
1171
|
+
stringType: "text/plain;charset=UTF-8",
|
|
1172
|
+
done: true,
|
|
1173
|
+
});
|
|
1174
|
+
});
|
|
1175
|
+
|
|
1176
|
+
test("classic scripts are sloppy-mode code whose missing receiver is the page global, never the engine's", async () => {
|
|
1177
|
+
await load(`<script>
|
|
1178
|
+
const page = (value) => value === window ? "page" : value && typeof value === "object" && ("Bun" in value || "process" in value) ? "ENGINE" : typeof value;
|
|
1179
|
+
window.sloppy = {
|
|
1180
|
+
call: page((function () { return this; })()),
|
|
1181
|
+
nullCall: page((function () { return this; }).call(null)),
|
|
1182
|
+
apply: page(Reflect.apply(function () { return this; }, undefined, [])),
|
|
1183
|
+
fn: page(Function("return this")()),
|
|
1184
|
+
evaluated: page(eval("(function () { return this; })")()),
|
|
1185
|
+
indirect: page((0, eval)("(function () { return this; })")()),
|
|
1186
|
+
topLevel: page(this),
|
|
1187
|
+
callee: (function () { return typeof arguments.callee; })(),
|
|
1188
|
+
withStatement: Function("with ({ a: 7 }) return a")(),
|
|
1189
|
+
octal: Function("return 010")(),
|
|
1190
|
+
implicitGlobal: (Function("implicitGlobal = 3")(), window.implicitGlobal),
|
|
1191
|
+
strictStaysStrict: (function () { "use strict"; return this; })(),
|
|
1192
|
+
engine: typeof Bun + " " + typeof process,
|
|
1193
|
+
};
|
|
1194
|
+
</script>`);
|
|
1195
|
+
expect(JSON.parse(await evaluate("JSON.stringify(sloppy)"))).toEqual({
|
|
1196
|
+
call: "page", nullCall: "page", apply: "page", fn: "page", evaluated: "page", indirect: "page", topLevel: "page",
|
|
1197
|
+
callee: "function", withStatement: 7, octal: 8, implicitGlobal: 3, strictStaysStrict: undefined,
|
|
1198
|
+
engine: "undefined undefined",
|
|
1199
|
+
});
|
|
1200
|
+
});
|
|
1201
|
+
|
|
1202
|
+
test("direct eval sees the caller's variables, and functions print the source the page wrote", async () => {
|
|
1203
|
+
await load(`<script>
|
|
1204
|
+
function counter(n) {
|
|
1205
|
+
const step = 2;
|
|
1206
|
+
eval("n += step");
|
|
1207
|
+
return [n, eval("typeof step"), eval("0,function () { return n * step; }")()];
|
|
1208
|
+
}
|
|
1209
|
+
function rewritten(n) { for (; n-- > 0;) eval("n"); return [this, (async () => {}).constructor, "<!-- -->"]; }
|
|
1210
|
+
window.evalResults = {
|
|
1211
|
+
counter: counter(1),
|
|
1212
|
+
declaresEval: eval('var eval = "shadowed"; eval'),
|
|
1213
|
+
source: rewritten.toString(),
|
|
1214
|
+
made: Function("a", "return this.a + a").toString(),
|
|
1215
|
+
};
|
|
1216
|
+
</script>`);
|
|
1217
|
+
expect(JSON.parse(await evaluate("JSON.stringify(evalResults)"))).toEqual({
|
|
1218
|
+
counter: [3, "number", 6],
|
|
1219
|
+
declaresEval: "shadowed",
|
|
1220
|
+
source: 'function rewritten(n) { for (; n-- > 0;) eval("n"); return [this, (async () => {}).constructor, "<!-- -->"]; }',
|
|
1221
|
+
made: "function anonymous(a\n) {\nreturn this.a + a\n}",
|
|
1222
|
+
});
|
|
1223
|
+
});
|
|
1224
|
+
|
|
1225
|
+
test("Web APIs look like a browser's built-ins: native functions, interface prototypes and tags", async () => {
|
|
1226
|
+
await load(`<script>
|
|
1227
|
+
const descriptor = Object.getOwnPropertyDescriptor(Navigator.prototype, "userAgent");
|
|
1228
|
+
window.shape = {
|
|
1229
|
+
fetch: fetch.toString(),
|
|
1230
|
+
fetchTag: Object.prototype.toString.call(fetch),
|
|
1231
|
+
getter: descriptor.get.toString(),
|
|
1232
|
+
illegalInvocation: (() => { try { descriptor.get.call({}); return "no error"; } catch (error) { return error.message; } })(),
|
|
1233
|
+
illegalConstructor: (() => { try { new Navigator(); return "no error"; } catch (error) { return error.message; } })(),
|
|
1234
|
+
navigatorOwn: Object.getOwnPropertyNames(navigator).length,
|
|
1235
|
+
tags: [window, navigator, screen, document.body, localStorage, performance].map((value) => Object.prototype.toString.call(value)),
|
|
1236
|
+
plugins: [navigator.plugins.length, navigator.mimeTypes.length, navigator.plugins[0].name, navigator.vendor],
|
|
1237
|
+
storage: (localStorage.theme = "dark", [localStorage.getItem("theme"), Object.keys(localStorage)]),
|
|
1238
|
+
enumerable: ["Array", "Navigator", "document", "setTimeout"].map((name) => Object.keys(window).includes(name)),
|
|
1239
|
+
sesGlobals: [typeof Compartment, typeof harden, typeof lockdown],
|
|
1240
|
+
};
|
|
1241
|
+
localStorage.clear();
|
|
1242
|
+
</script>`);
|
|
1243
|
+
expect(JSON.parse(await evaluate("JSON.stringify(shape)"))).toEqual({
|
|
1244
|
+
fetch: "function fetch() { [native code] }",
|
|
1245
|
+
fetchTag: "[object Function]",
|
|
1246
|
+
getter: "function get userAgent() { [native code] }",
|
|
1247
|
+
illegalInvocation: "Illegal invocation",
|
|
1248
|
+
illegalConstructor: "Illegal constructor",
|
|
1249
|
+
navigatorOwn: 0,
|
|
1250
|
+
tags: ["[object Window]", "[object Navigator]", "[object Screen]", "[object HTMLBodyElement]", "[object Storage]", "[object Performance]"],
|
|
1251
|
+
plugins: [5, 2, "PDF Viewer", "Google Inc."],
|
|
1252
|
+
storage: ["dark", ["theme"]],
|
|
1253
|
+
enumerable: [false, false, true, true],
|
|
1254
|
+
sesGlobals: ["undefined", "undefined", "undefined"],
|
|
1255
|
+
});
|
|
1256
|
+
});
|
|
1257
|
+
|
|
1258
|
+
test("aborting fetch cancels the controller's HTTP request", async () => {
|
|
1259
|
+
pageFetchAborted = false;
|
|
1260
|
+
await load(`<script>
|
|
1261
|
+
const controller = new AbortController();
|
|
1262
|
+
fetch('/slow-abort', { signal: controller.signal }).then(
|
|
1263
|
+
() => { window.abortResult = 'resolved'; },
|
|
1264
|
+
(error) => { window.abortResult = error.name; },
|
|
1265
|
+
);
|
|
1266
|
+
setTimeout(() => controller.abort(), 20);
|
|
1267
|
+
</script>`);
|
|
1268
|
+
expect(await waitFor("window.abortResult === 'AbortError'", 2000)).toBe(true);
|
|
1269
|
+
expect(pageFetchAborted).toBe(true);
|
|
1270
|
+
});
|
|
1271
|
+
|
|
713
1272
|
test("host mode: page globals that wrap host APIs do not call themselves, and rejections are not fatal", async () => {
|
|
714
1273
|
const host = new RendererHost({ timeout: 20_000, args: ["--dangerously-allow-host-js"] });
|
|
715
1274
|
try {
|
|
@@ -844,10 +1403,13 @@ test("Function constructors reached through prototypes work under SES", async ()
|
|
|
844
1403
|
var GeneratorFunction = (function* () {}).constructor;
|
|
845
1404
|
var plain = (function () {}).constructor;
|
|
846
1405
|
var classic = [[...new GeneratorFunction('yield 1; yield 2')()].join(), plain('return typeof window')(),
|
|
847
|
-
typeof Object.getPrototypeOf(async function* () {}).constructor('yield 1')
|
|
1406
|
+
typeof Object.getPrototypeOf(async function* () {}).constructor('yield 1'),
|
|
1407
|
+
({}).constructor === Object, typeof ({}).constructor.create];
|
|
848
1408
|
</script>`);
|
|
849
1409
|
for (let i = 0; i < 20 && !(await evaluate("typeof moduleResult")).startsWith("object"); i++) await Bun.sleep(25);
|
|
850
|
-
expect(await evaluate("JSON.stringify([moduleResult, classic])")).toBe(JSON.stringify([
|
|
1410
|
+
expect(await evaluate("JSON.stringify([moduleResult, classic])")).toBe(JSON.stringify([
|
|
1411
|
+
[3, true, "AsyncFunction"], ["1,2", "object", "function", true, "function"],
|
|
1412
|
+
]));
|
|
851
1413
|
});
|
|
852
1414
|
|
|
853
1415
|
test("UI event constructors, table rows/cells and select options are available", async () => {
|
|
@@ -958,3 +1520,479 @@ test("scripts and module preloads are fetched while the stylesheets load, each o
|
|
|
958
1520
|
expect(times("/preload/skipped.js")).toEqual([]);
|
|
959
1521
|
});
|
|
960
1522
|
|
|
1523
|
+
|
|
1524
|
+
test("an error status shows the page the server sent, scripts included; an empty one gets a note", async () => {
|
|
1525
|
+
await renderer.call("loadDocument", {
|
|
1526
|
+
url: "https://page.test/limited",
|
|
1527
|
+
source: `<!doctype html><title>check</title><body><form id="f"><p>unusual traffic</p></form><script>document.title = "ran";</script></body>`,
|
|
1528
|
+
contentType: "text/html",
|
|
1529
|
+
status: 429,
|
|
1530
|
+
statusText: "Too Many Requests",
|
|
1531
|
+
});
|
|
1532
|
+
await renderer.call("whenLoaded");
|
|
1533
|
+
expect(await evaluate("[document.title, !!document.getElementById('f'), document.body.innerText.includes('HTTP 429')].join()"))
|
|
1534
|
+
.toBe("ran,true,false");
|
|
1535
|
+
await renderer.call("loadDocument", { url: "https://page.test/gone", source: "", contentType: "text/html", status: 404, statusText: "Not Found" });
|
|
1536
|
+
await renderer.call("whenLoaded");
|
|
1537
|
+
expect(await evaluate("document.body.textContent")).toBe("HTTP 404 Not Found");
|
|
1538
|
+
});
|
|
1539
|
+
|
|
1540
|
+
test("meta.content is the reflected attribute; template.content stays the fragment", async () => {
|
|
1541
|
+
await load(`<template id="t"><b>x</b></template><script>
|
|
1542
|
+
"use strict";
|
|
1543
|
+
const meta = document.createElement("meta");
|
|
1544
|
+
meta.httpEquiv = "origin-trial";
|
|
1545
|
+
meta.content = "token";
|
|
1546
|
+
const other = document.createElement("div");
|
|
1547
|
+
other.content = 5;
|
|
1548
|
+
window.metaResult = [meta.getAttribute("content"), meta.content, other.content,
|
|
1549
|
+
document.getElementById("t").content.firstChild.nodeName].join();
|
|
1550
|
+
</script>`);
|
|
1551
|
+
expect(await evaluate("metaResult")).toBe("token,token,5,B");
|
|
1552
|
+
});
|
|
1553
|
+
|
|
1554
|
+
test("a video starts from a prefix while the rest of the file is still downloading", async () => {
|
|
1555
|
+
dripFinished = false;
|
|
1556
|
+
await load(`<video id="v" src="/drip-video.mp4" width="160" height="120"></video>`);
|
|
1557
|
+
await evaluate("document.getElementById('v').play()");
|
|
1558
|
+
expect(dripFinished).toBe(false);
|
|
1559
|
+
expect(await evaluate(`(() => { const v = document.getElementById('v');
|
|
1560
|
+
return !v.paused && v.readyState >= 1 && v.currentTime > 0 && v.networkState === 2; })()`)).toBe(true);
|
|
1561
|
+
const deadline = Date.now() + 4000;
|
|
1562
|
+
while (!dripFinished && Date.now() < deadline) await Bun.sleep(50);
|
|
1563
|
+
expect(dripFinished).toBe(true);
|
|
1564
|
+
});
|
|
1565
|
+
|
|
1566
|
+
test("a video probe is not limited by the number of network chunks", async () => {
|
|
1567
|
+
await load(`<video id="v" src="/many-chunk-video.mp4" width="160" height="120"></video>`);
|
|
1568
|
+
await evaluate("document.getElementById('v').play()");
|
|
1569
|
+
expect(await waitFor(`(() => { const v = document.getElementById('v');
|
|
1570
|
+
return !v.paused && v.error === null && v.readyState >= 1 && v.currentTime > 0; })()`)).toBe(true);
|
|
1571
|
+
});
|
|
1572
|
+
|
|
1573
|
+
test("a progressive MP3 updates its duration when the rest of the body arrives", async () => {
|
|
1574
|
+
dripAudioFinished = false;
|
|
1575
|
+
await load(`<audio id="a" src="/drip-tone.mp3"></audio>`);
|
|
1576
|
+
await evaluate("document.getElementById('a').play()");
|
|
1577
|
+
const initial = await evaluate("document.getElementById('a').duration");
|
|
1578
|
+
expect(dripAudioFinished).toBe(false);
|
|
1579
|
+
expect(await waitFor("document.getElementById('a').duration > " + initial, 3000)).toBe(true);
|
|
1580
|
+
expect(dripAudioFinished).toBe(true);
|
|
1581
|
+
});
|
|
1582
|
+
|
|
1583
|
+
test("an unsupported video exposes an error instead of silently stopping", async () => {
|
|
1584
|
+
await load(`<video id="v" src="/invalid-video.mp4"></video>`);
|
|
1585
|
+
await evaluate(`(() => { const v = document.getElementById("v");
|
|
1586
|
+
window.mediaFailed = false;
|
|
1587
|
+
v.addEventListener("error", () => { window.mediaFailed = true; });
|
|
1588
|
+
v.play();
|
|
1589
|
+
})()`);
|
|
1590
|
+
expect(await waitFor("window.mediaFailed === true")).toBe(true);
|
|
1591
|
+
expect(await evaluate(`(() => { const v = document.getElementById("v");
|
|
1592
|
+
return v.paused && v.error?.code === 4 && v.networkState === 3; })()`)).toBe(true);
|
|
1593
|
+
});
|
|
1594
|
+
|
|
1595
|
+
test("MediaSource appends a file in parts and the element can play it", async () => {
|
|
1596
|
+
await load(`<video id="v" width="160" height="120"></video>`);
|
|
1597
|
+
expect(await evaluate(`(() => {
|
|
1598
|
+
try {
|
|
1599
|
+
new MediaSource().addSourceBuffer('video/mp4; codecs="avc1.4d401e"');
|
|
1600
|
+
return "no-throw";
|
|
1601
|
+
} catch (error) {
|
|
1602
|
+
return error.name;
|
|
1603
|
+
}
|
|
1604
|
+
})()`)).toBe("InvalidStateError");
|
|
1605
|
+
expect(await evaluate(`[
|
|
1606
|
+
MediaSource.isTypeSupported('video/mp4; codecs="avc1.4d401e"'),
|
|
1607
|
+
MediaSource.isTypeSupported('audio/mp4; codecs="mp4a.40.2"'),
|
|
1608
|
+
MediaSource.isTypeSupported('video/mp4; codecs="avc1.4d401e, mp4a.40.2"'),
|
|
1609
|
+
MediaSource.isTypeSupported("video/mp4"),
|
|
1610
|
+
MediaSource.isTypeSupported("video/x-unknown"),
|
|
1611
|
+
HTMLMediaElement.HAVE_METADATA,
|
|
1612
|
+
HTMLMediaElement.HAVE_NOTHING
|
|
1613
|
+
].join()`)).toBe("true,true,true,false,false,1,0");
|
|
1614
|
+
await evaluate(`(() => {
|
|
1615
|
+
const video = document.getElementById("v");
|
|
1616
|
+
const source = new MediaSource();
|
|
1617
|
+
window.ms = source;
|
|
1618
|
+
window.mse = {};
|
|
1619
|
+
video.src = URL.createObjectURL(source);
|
|
1620
|
+
source.addEventListener("sourceopen", () => { window.mse.open = source.readyState; });
|
|
1621
|
+
})()`);
|
|
1622
|
+
await waitFor("window.mse.open === 'open'");
|
|
1623
|
+
await evaluate(`(async () => {
|
|
1624
|
+
const bytes = new Uint8Array(await (await fetch("/video.mp4")).arrayBuffer());
|
|
1625
|
+
const buffer = window.ms.addSourceBuffer('video/mp4; codecs="avc1.4d401e, mp4a.40.2"');
|
|
1626
|
+
const middle = bytes.length >> 1;
|
|
1627
|
+
let updates = 0;
|
|
1628
|
+
buffer.appendBuffer(bytes.subarray(0, middle));
|
|
1629
|
+
buffer.addEventListener("updateend", () => {
|
|
1630
|
+
updates += 1;
|
|
1631
|
+
window.mse.updates = updates;
|
|
1632
|
+
window.mse.ready = document.getElementById("v").readyState;
|
|
1633
|
+
if (updates === 1) {
|
|
1634
|
+
document.getElementById("v").play();
|
|
1635
|
+
buffer.appendBuffer(bytes.subarray(middle));
|
|
1636
|
+
} else {
|
|
1637
|
+
window.mse.buffered = buffer.buffered.length ? buffer.buffered.end(0) : 0;
|
|
1638
|
+
window.ms.endOfStream();
|
|
1639
|
+
window.mse.ended = window.ms.readyState;
|
|
1640
|
+
}
|
|
1641
|
+
});
|
|
1642
|
+
})()`);
|
|
1643
|
+
await waitFor("window.mse.updates === 2 && window.mse.ended === 'ended'");
|
|
1644
|
+
expect(await waitFor("document.getElementById('v').readyState >= 1")).toBe(true);
|
|
1645
|
+
expect(await evaluate("window.mse.buffered > 0")).toBe(true);
|
|
1646
|
+
expect(await waitFor("(() => { const v = document.getElementById('v'); return !v.paused && v.currentTime > 0; })()")).toBe(true);
|
|
1647
|
+
expect(await evaluate("Math.round(document.getElementById('v').duration)")).toBe(1);
|
|
1648
|
+
});
|
|
1649
|
+
|
|
1650
|
+
test("MediaSource consumes an append that arrived before the decoder stalled", async () => {
|
|
1651
|
+
await load(`<video id="v" width="160" height="120"></video>`);
|
|
1652
|
+
await evaluate(`(() => {
|
|
1653
|
+
const video = document.getElementById("v");
|
|
1654
|
+
const source = new MediaSource();
|
|
1655
|
+
window.ms = source;
|
|
1656
|
+
video.src = URL.createObjectURL(source);
|
|
1657
|
+
source.addEventListener("sourceopen", async () => {
|
|
1658
|
+
const bytes = new Uint8Array(await (await fetch("/video.mp4")).arrayBuffer());
|
|
1659
|
+
const buffer = source.addSourceBuffer('video/mp4; codecs="avc1.4d401e, mp4a.40.2"');
|
|
1660
|
+
const middle = bytes.length >> 1;
|
|
1661
|
+
let append = 0;
|
|
1662
|
+
buffer.addEventListener("updateend", () => {
|
|
1663
|
+
append += 1;
|
|
1664
|
+
if (append === 1) {
|
|
1665
|
+
video.play();
|
|
1666
|
+
buffer.appendBuffer(bytes.subarray(middle));
|
|
1667
|
+
}
|
|
1668
|
+
});
|
|
1669
|
+
buffer.appendBuffer(bytes.subarray(0, middle));
|
|
1670
|
+
});
|
|
1671
|
+
})()`);
|
|
1672
|
+
// Deliberately leave the MediaSource open. Playback must reopen from the
|
|
1673
|
+
// already-appended second generation rather than wait for a third append.
|
|
1674
|
+
expect(await waitFor("document.getElementById('v').currentTime > 0.75", 4000)).toBe(true);
|
|
1675
|
+
});
|
|
1676
|
+
|
|
1677
|
+
test("a video waits for its missing audio SourceBuffer instead of outrunning sound", async () => {
|
|
1678
|
+
await load(`<video id="v" width="160" height="120"></video>`);
|
|
1679
|
+
await evaluate(`(() => {
|
|
1680
|
+
const video = document.getElementById("v");
|
|
1681
|
+
const source = new MediaSource();
|
|
1682
|
+
window.ms = source;
|
|
1683
|
+
window.mse = {};
|
|
1684
|
+
video.src = URL.createObjectURL(source);
|
|
1685
|
+
source.addEventListener("sourceopen", () => { window.mse.open = source.readyState; });
|
|
1686
|
+
})()`);
|
|
1687
|
+
await waitFor("window.mse.open === 'open'");
|
|
1688
|
+
await evaluate(`(async () => {
|
|
1689
|
+
const bytes = new Uint8Array(await (await fetch("/video.mp4")).arrayBuffer());
|
|
1690
|
+
const picture = window.ms.addSourceBuffer('video/mp4; codecs="avc1.4d401e"');
|
|
1691
|
+
const sound = window.ms.addSourceBuffer('audio/mp4; codecs="mp4a.40.2"');
|
|
1692
|
+
picture.addEventListener("updateend", () => {
|
|
1693
|
+
window.mse.videoUpdated = true;
|
|
1694
|
+
document.getElementById("v").play();
|
|
1695
|
+
});
|
|
1696
|
+
picture.appendBuffer(bytes);
|
|
1697
|
+
sound.appendBuffer(bytes.subarray(0, 32));
|
|
1698
|
+
})()`);
|
|
1699
|
+
await waitFor("window.mse.videoUpdated === true");
|
|
1700
|
+
expect(await waitFor(`(() => { const v = document.getElementById('v');
|
|
1701
|
+
return !v.paused && v.error === null && v.videoWidth > 0; })()`)).toBe(true);
|
|
1702
|
+
await Bun.sleep(1000);
|
|
1703
|
+
expect(await evaluate("document.getElementById('v').currentTime")).toBe(0);
|
|
1704
|
+
});
|
|
1705
|
+
|
|
1706
|
+
test("initial MSE playback waits briefly for a separate audio segment", async () => {
|
|
1707
|
+
await load(`<video id="v" width="160" height="120"></video>`);
|
|
1708
|
+
await evaluate(`(() => {
|
|
1709
|
+
const video = document.getElementById("v");
|
|
1710
|
+
video.addEventListener("play", () => { window.startedAt = performance.now(); });
|
|
1711
|
+
const source = new MediaSource();
|
|
1712
|
+
video.src = URL.createObjectURL(source);
|
|
1713
|
+
source.addEventListener("sourceopen", async () => {
|
|
1714
|
+
const bytes = new Uint8Array(await (await fetch("/video.mp4")).arrayBuffer());
|
|
1715
|
+
const audioBytes = new Uint8Array(await (await fetch("/tone.mp3")).arrayBuffer());
|
|
1716
|
+
const picture = source.addSourceBuffer('video/mp4; codecs="avc1.4d401e"');
|
|
1717
|
+
const sound = source.addSourceBuffer('audio/mpeg');
|
|
1718
|
+
picture.addEventListener("updateend", () => video.play(), { once: true });
|
|
1719
|
+
picture.appendBuffer(bytes);
|
|
1720
|
+
sound.appendBuffer(audioBytes.subarray(0, 16));
|
|
1721
|
+
setTimeout(() => {
|
|
1722
|
+
window.audioAppendedAt = performance.now();
|
|
1723
|
+
sound.appendBuffer(audioBytes.subarray(16));
|
|
1724
|
+
}, 250);
|
|
1725
|
+
});
|
|
1726
|
+
})()`);
|
|
1727
|
+
expect(await waitFor("Boolean(window.startedAt && window.audioAppendedAt)", 3000)).toBe(true);
|
|
1728
|
+
expect(await evaluate("window.startedAt >= window.audioAppendedAt")).toBe(true);
|
|
1729
|
+
expect(await waitFor("document.getElementById('v').currentTime > 0.1", 3000)).toBe(true);
|
|
1730
|
+
});
|
|
1731
|
+
|
|
1732
|
+
const waitFor = async (expression, timeout = 5000) => {
|
|
1733
|
+
const deadline = Date.now() + timeout;
|
|
1734
|
+
while (Date.now() < deadline) {
|
|
1735
|
+
const value = await evaluate(expression);
|
|
1736
|
+
if (value) return value;
|
|
1737
|
+
await Bun.sleep(50);
|
|
1738
|
+
}
|
|
1739
|
+
throw new Error(`timed out waiting for ${expression}`);
|
|
1740
|
+
};
|
|
1741
|
+
|
|
1742
|
+
test("same-origin iframes: documents, contentWindow, names, postMessage with source and origin", async () => {
|
|
1743
|
+
pages.set("https://page.test/child.html", `<!doctype html><body><p id="inner">child</p><script>
|
|
1744
|
+
window.addEventListener("message", (event) => {
|
|
1745
|
+
event.source.postMessage({ echo: event.data, fromParent: event.source === parent, origin: event.origin }, "*");
|
|
1746
|
+
});
|
|
1747
|
+
</script></body>`);
|
|
1748
|
+
await load(`<iframe id="frame" name="kid" src="child.html" style="width:200px;height:100px"></iframe><script>
|
|
1749
|
+
window.replies = [];
|
|
1750
|
+
window.addEventListener("message", (event) => replies.push({ data: event.data, fromChild: event.source === frames.kid,
|
|
1751
|
+
origin: event.origin }));
|
|
1752
|
+
document.getElementById("frame").addEventListener("load", () => {
|
|
1753
|
+
window.frameLoaded = true;
|
|
1754
|
+
frames.kid.postMessage("hello", "https://page.test");
|
|
1755
|
+
frames.kid.postMessage("never", "https://other.test");
|
|
1756
|
+
});
|
|
1757
|
+
</script>`);
|
|
1758
|
+
await waitFor("window.replies.length > 0");
|
|
1759
|
+
expect(await evaluate("frames.length")).toBe(1);
|
|
1760
|
+
expect(await evaluate(`document.getElementById("frame").contentDocument.getElementById("inner").textContent`)).toBe("child");
|
|
1761
|
+
expect(await evaluate(`document.getElementById("frame").contentWindow.parent === window`)).toBe(true);
|
|
1762
|
+
await Bun.sleep(100);
|
|
1763
|
+
// The message for another origin is not delivered.
|
|
1764
|
+
expect(await evaluate("replies")).toEqual([{
|
|
1765
|
+
data: { echo: "hello", fromParent: true, origin: "https://page.test" }, fromChild: true, origin: "https://page.test",
|
|
1766
|
+
}]);
|
|
1767
|
+
});
|
|
1768
|
+
|
|
1769
|
+
test("iframe messages retain their sender through promises and await", async () => {
|
|
1770
|
+
pages.set("https://page.test/async-child.html", `<!doctype html><script>
|
|
1771
|
+
addEventListener("message", async (event) => {
|
|
1772
|
+
window.fromParent = event.source === parent;
|
|
1773
|
+
await Promise.resolve();
|
|
1774
|
+
parent.postMessage("await reply", "*");
|
|
1775
|
+
Promise.resolve().then(() => parent.postMessage("then reply", "*"));
|
|
1776
|
+
});
|
|
1777
|
+
</script>`);
|
|
1778
|
+
await load(`<iframe id="frame" src="async-child.html"></iframe><script>
|
|
1779
|
+
window.asyncReplies = [];
|
|
1780
|
+
addEventListener("message", event => asyncReplies.push({
|
|
1781
|
+
data: event.data, fromChild: event.source === document.getElementById("frame").contentWindow,
|
|
1782
|
+
origin: event.origin
|
|
1783
|
+
}));
|
|
1784
|
+
document.getElementById("frame").addEventListener("load", async () => {
|
|
1785
|
+
await Promise.resolve();
|
|
1786
|
+
document.getElementById("frame").contentWindow.postMessage("start", "*");
|
|
1787
|
+
});
|
|
1788
|
+
</script>`);
|
|
1789
|
+
await waitFor("window.asyncReplies.length === 2");
|
|
1790
|
+
expect(await evaluate("asyncReplies")).toEqual([
|
|
1791
|
+
{ data: "await reply", fromChild: true, origin: "https://page.test" },
|
|
1792
|
+
{ data: "then reply", fromChild: true, origin: "https://page.test" },
|
|
1793
|
+
]);
|
|
1794
|
+
expect(await evaluate('document.getElementById("frame").contentWindow.fromParent')).toBe(true);
|
|
1795
|
+
});
|
|
1796
|
+
|
|
1797
|
+
test("an inserted iframe has its about:blank window at once and fires load once", async () => {
|
|
1798
|
+
await load(`<p>blank</p><script>
|
|
1799
|
+
const frame = document.createElement("iframe");
|
|
1800
|
+
document.body.appendChild(frame);
|
|
1801
|
+
const win = frame.contentWindow;
|
|
1802
|
+
window.result = [win.JSON.parse('{"a":1}').a, !!win.document.body, frame.contentDocument === win.document];
|
|
1803
|
+
window.loads = 0;
|
|
1804
|
+
frame.addEventListener("load", () => { loads++; window.sameWindow = frame.contentWindow === win; });
|
|
1805
|
+
</script>`);
|
|
1806
|
+
expect(await evaluate("result")).toEqual([1, true, true]);
|
|
1807
|
+
await waitFor("window.loads > 0");
|
|
1808
|
+
await Bun.sleep(100);
|
|
1809
|
+
expect(await evaluate("[loads, sameWindow]")).toEqual([1, true]);
|
|
1810
|
+
});
|
|
1811
|
+
|
|
1812
|
+
test("MessageChannel ports and dedicated workers with importScripts and transfers", async () => {
|
|
1813
|
+
scripts.set("https://page.test/helper.js", "self.double = (n) => n * 2;");
|
|
1814
|
+
scripts.set("https://page.test/worker.js", `importScripts("helper.js");
|
|
1815
|
+
onmessage = (event) => {
|
|
1816
|
+
const [port] = event.ports;
|
|
1817
|
+
port.postMessage({ doubled: double(event.data.n), bytes: event.data.buffer.byteLength });
|
|
1818
|
+
};`);
|
|
1819
|
+
await load(`<script>
|
|
1820
|
+
window.got = null;
|
|
1821
|
+
const channel = new MessageChannel();
|
|
1822
|
+
channel.port1.onmessage = (event) => { window.got = event.data; };
|
|
1823
|
+
const worker = new Worker("worker.js");
|
|
1824
|
+
const buffer = new ArrayBuffer(8);
|
|
1825
|
+
worker.postMessage({ n: 21, buffer }, [channel.port2, buffer]);
|
|
1826
|
+
window.detached = buffer.byteLength;
|
|
1827
|
+
</script>`);
|
|
1828
|
+
await waitFor("window.got");
|
|
1829
|
+
expect(await evaluate("[got, detached]")).toEqual([{ doubled: 42, bytes: 8 }, 0]);
|
|
1830
|
+
});
|
|
1831
|
+
|
|
1832
|
+
test("sloppy-mode this is the global object, also through the Function constructor", async () => {
|
|
1833
|
+
await load(`<script>
|
|
1834
|
+
window.viaFunction = Function("return this")() === window;
|
|
1835
|
+
window.viaCall = (function () { return this; })() === window;
|
|
1836
|
+
window.strict = (function () { "use strict"; return this; })() === undefined;
|
|
1837
|
+
</script>`);
|
|
1838
|
+
expect(await evaluate("[viaFunction, viaCall, strict]")).toEqual([true, true, true]);
|
|
1839
|
+
});
|
|
1840
|
+
|
|
1841
|
+
test("a click sends pointer and mouse events to the element under it, then focuses its focusable ancestor", async () => {
|
|
1842
|
+
await load(`<div id="tile" tabindex="0" style="width:100px;height:60px"><span id="label">tile</span></div><script>
|
|
1843
|
+
window.events = [];
|
|
1844
|
+
for (const type of ["pointerdown", "mousedown", "focus", "pointerup", "mouseup", "click"]) {
|
|
1845
|
+
document.getElementById("tile").addEventListener(type, (event) => events.push(type + ":" + event.target.id), true);
|
|
1846
|
+
}
|
|
1847
|
+
</script>`);
|
|
1848
|
+
await renderer.call("click", 80, 30);
|
|
1849
|
+
expect(await evaluate("events")).toEqual(["pointerdown:tile", "mousedown:tile", "focus:tile", "pointerup:tile", "mouseup:tile", "click:tile"]);
|
|
1850
|
+
expect(await evaluate("document.activeElement.id")).toBe("tile");
|
|
1851
|
+
});
|
|
1852
|
+
|
|
1853
|
+
test("a click inside an iframe goes to the element under it in the frame's document", async () => {
|
|
1854
|
+
pages.set("https://page.test/cells.html", `<!doctype html><body style="margin:0"><div id="cell" style="width:100px;height:100px"></div><script>
|
|
1855
|
+
window.clicked = [];
|
|
1856
|
+
document.getElementById("cell").addEventListener("click", (event) => clicked.push([event.clientX, event.clientY]));
|
|
1857
|
+
</script></body>`);
|
|
1858
|
+
await load(`<iframe id="frame" src="cells.html" style="position:absolute;left:50px;top:40px;width:200px;height:150px;border:0"></iframe>`);
|
|
1859
|
+
await waitFor(`document.getElementById("frame").contentWindow.clicked`);
|
|
1860
|
+
await renderer.call("click", 70, 60);
|
|
1861
|
+
expect(await evaluate(`document.getElementById("frame").contentWindow.clicked`)).toEqual([[20, 20]]);
|
|
1862
|
+
});
|
|
1863
|
+
|
|
1864
|
+
test("images a script inserts and measures at once still load", async () => {
|
|
1865
|
+
await load(`<div id="host"></div><script>
|
|
1866
|
+
const img = document.createElement("img");
|
|
1867
|
+
img.src = "/red-2x3.png";
|
|
1868
|
+
document.getElementById("host").appendChild(img);
|
|
1869
|
+
// Reading geometry lays the page out before any frame does.
|
|
1870
|
+
window.firstWidth = img.getBoundingClientRect().width;
|
|
1871
|
+
</script>`);
|
|
1872
|
+
await waitFor(`document.querySelector("img").getBoundingClientRect().width === 2`);
|
|
1873
|
+
expect(await evaluate(`[document.querySelector("img").getBoundingClientRect().height, firstWidth !== 2]`)).toEqual([3, true]);
|
|
1874
|
+
});
|
|
1875
|
+
|
|
1876
|
+
test("scroll containers scroll by wheel and script, move their content and hit-test where it shows", async () => {
|
|
1877
|
+
await load(`<div id="box" style="width:200px;height:100px;overflow-y:auto;border:2px solid black">${
|
|
1878
|
+
Array.from({ length: 10 }, (_, index) => `<p id="p${index}" style="margin:0;height:30px" onclick="window.hit = this.id">row ${index}</p>`).join("")
|
|
1879
|
+
}</div><div id="still" style="height:40px;overflow:hidden"><p style="margin:0;height:80px">hidden overflow</p></div><script>
|
|
1880
|
+
window.$ = (id) => document.getElementById(id);
|
|
1881
|
+
window.scrolls = 0;
|
|
1882
|
+
$("box").addEventListener("scroll", () => scrolls++);
|
|
1883
|
+
</script>`);
|
|
1884
|
+
expect(await evaluate(`[$("box").scrollTop, $("box").scrollHeight, $("box").clientHeight, $("p3").getBoundingClientRect().top]`))
|
|
1885
|
+
.toEqual([0, 300, 100, 92]);
|
|
1886
|
+
// The wheel over the container scrolls it, not the page: nothing is left over.
|
|
1887
|
+
expect(await renderer.call("wheel", 50, 50, 0, 75)).toEqual({ x: 0, y: 0 });
|
|
1888
|
+
await waitFor("window.scrolls > 0");
|
|
1889
|
+
expect(await evaluate(`[$("box").scrollTop, $("p3").getBoundingClientRect().top]`)).toEqual([75, 17]);
|
|
1890
|
+
await renderer.call("click", 50, 20);
|
|
1891
|
+
expect(await evaluate("window.hit")).toBe("p3");
|
|
1892
|
+
// Scripts scroll it too, clamped; overflow: hidden scrolls only by script.
|
|
1893
|
+
expect(await evaluate(`($("box").scrollTop = 1000, $("box").scrollTop)`)).toBe(200);
|
|
1894
|
+
expect(await evaluate(`$("p1").scrollIntoView(), [$("box").scrollTop, $("p1").getBoundingClientRect().top]`)).toEqual([30, 2]);
|
|
1895
|
+
const still = await evaluate(`$("still").getBoundingClientRect().toJSON()`);
|
|
1896
|
+
expect(await renderer.call("wheel", 50, still.y + 10, 0, 20)).toEqual({ x: 0, y: 20 });
|
|
1897
|
+
expect(await evaluate(`($("still").scrollTop = 25, $("still").scrollTop)`)).toBe(25);
|
|
1898
|
+
// What the container cannot take goes on to the page (scroll chaining).
|
|
1899
|
+
await evaluate(`$("box").scrollTop = 190`);
|
|
1900
|
+
expect(await renderer.call("wheel", 50, 50, 0, 30)).toEqual({ x: 0, y: 20 });
|
|
1901
|
+
expect(await evaluate(`$("box").scrollTop`)).toBe(200);
|
|
1902
|
+
// A wheel listener that cancels the event keeps it where it is.
|
|
1903
|
+
await evaluate(`$("box").scrollTop = 30; $("box").addEventListener("wheel", (event) => event.preventDefault())`);
|
|
1904
|
+
expect(await renderer.call("wheel", 50, 50, 0, 30)).toEqual({ x: 0, y: 0 });
|
|
1905
|
+
expect(await evaluate(`$("box").scrollTop`)).toBe(30);
|
|
1906
|
+
});
|
|
1907
|
+
|
|
1908
|
+
test("the wheel over an iframe scrolls its document first, unless scrolling=no, then what is around it", async () => {
|
|
1909
|
+
pages.set("https://page.test/tall.html", `<!doctype html><body style="margin:0"><div style="height:400px">tall</div></body>`);
|
|
1910
|
+
await load(`<div id="outer" style="width:220px;height:100px;overflow:auto">
|
|
1911
|
+
<iframe id="fixed" scrolling="no" src="tall.html" style="display:block;width:200px;height:150px;border:0"></iframe>
|
|
1912
|
+
<iframe id="free" src="tall.html" style="display:block;width:200px;height:150px;border:0"></iframe>
|
|
1913
|
+
</div><script>window.$ = (id) => document.getElementById(id);</script>`);
|
|
1914
|
+
await waitFor(`$("free").contentDocument?.body?.textContent === "tall" && $("fixed").contentDocument?.body?.textContent === "tall"`);
|
|
1915
|
+
// Over the scrolling=no frame: its document stays, the container scrolls.
|
|
1916
|
+
expect(await renderer.call("wheel", 50, 20, 0, 60)).toEqual({ x: 0, y: 0 });
|
|
1917
|
+
expect(await evaluate(`[$("outer").scrollTop, $("fixed").contentWindow.scrollY]`)).toEqual([60, 0]);
|
|
1918
|
+
// Over the other frame (now at 90..240): its document scrolls first.
|
|
1919
|
+
expect(await renderer.call("wheel", 50, 95, 0, 30)).toEqual({ x: 0, y: 0 });
|
|
1920
|
+
expect(await evaluate(`[$("outer").scrollTop, $("free").contentWindow.scrollY]`)).toEqual([60, 30]);
|
|
1921
|
+
});
|
|
1922
|
+
|
|
1923
|
+
test("compareDocumentPosition, document.styleSheets and user timing with PerformanceObserver", async () => {
|
|
1924
|
+
await load(`<style>p { color: red }</style><link rel="stylesheet" href="/missing.css" media="print">
|
|
1925
|
+
<div id="outer"><p id="first">a</p><p id="second">b</p></div><script>
|
|
1926
|
+
window.$ = (id) => document.getElementById(id);
|
|
1927
|
+
window.observed = [];
|
|
1928
|
+
new PerformanceObserver((list) => observed.push(...list.getEntries().map((entry) => entry.entryType + ":" + entry.name)))
|
|
1929
|
+
.observe({ entryTypes: ["mark", "measure"] });
|
|
1930
|
+
performance.mark("start");
|
|
1931
|
+
performance.mark("end");
|
|
1932
|
+
performance.measure("span", "start", "end");
|
|
1933
|
+
</script>`);
|
|
1934
|
+
expect(await evaluate(`[
|
|
1935
|
+
$("first").compareDocumentPosition($("second")), $("second").compareDocumentPosition($("first")),
|
|
1936
|
+
$("outer").compareDocumentPosition($("first")), $("first").compareDocumentPosition($("outer")),
|
|
1937
|
+
Node.DOCUMENT_POSITION_FOLLOWING, $("first").DOCUMENT_POSITION_CONTAINED_BY,
|
|
1938
|
+
]`)).toEqual([4, 2, 20, 10, 4, 16]);
|
|
1939
|
+
expect(await evaluate(`[document.styleSheets.length, document.styleSheets[0].href, document.styleSheets[1].href,
|
|
1940
|
+
document.styleSheets.item(1).media.mediaText, document.styleSheets[0].ownerNode.tagName]`))
|
|
1941
|
+
.toEqual([2, null, "https://page.test/missing.css", "print", "STYLE"]);
|
|
1942
|
+
await waitFor("window.observed.length === 3");
|
|
1943
|
+
expect(await evaluate(`[observed, performance.getEntriesByType("measure").length, PerformanceObserver.supportedEntryTypes]`))
|
|
1944
|
+
.toEqual([["mark:start", "mark:end", "measure:span"], 1, ["mark", "measure"]]);
|
|
1945
|
+
expect(await evaluate(`performance.clearMarks(), performance.clearMeasures("span"), performance.getEntries().length`)).toBe(0);
|
|
1946
|
+
});
|
|
1947
|
+
|
|
1948
|
+
test("selectors with CSS escapes parse, in type selectors and around combinators too", async () => {
|
|
1949
|
+
await load(`<div id="a:b" class="x,y 1x"><span class="p(q)" data-k='a"b'>1</span><p id="123">2</p></div><my-el>3</my-el>`);
|
|
1950
|
+
const selectors = [String.raw`style\=\"\"`, String.raw`#a\:b`, String.raw`.x\,y > span`, String.raw`.p\(q\)`,
|
|
1951
|
+
String.raw`my\-el`, String.raw`\64 iv`,
|
|
1952
|
+
// CSS.escape() output for names starting with a digit, and an escaped quote in a value.
|
|
1953
|
+
String.raw`#\31 23`, String.raw`.\31 x p`, String.raw`[data-k="a\"b"]`, String.raw`[d\61 ta-k]`];
|
|
1954
|
+
expect(await evaluate(`${JSON.stringify(selectors)}.map((selector) => document.querySelectorAll(selector).length)`))
|
|
1955
|
+
.toEqual([0, 1, 1, 1, 1, 1, 1, 1, 1, 1]);
|
|
1956
|
+
});
|
|
1957
|
+
|
|
1958
|
+
test("a canvas 2d context samples colours through CanvasKit", async () => {
|
|
1959
|
+
await load("");
|
|
1960
|
+
expect(await evaluate(`(() => {
|
|
1961
|
+
const canvas = document.createElementNS("http://www.w3.org/1999/xhtml", "canvas");
|
|
1962
|
+
canvas.width = canvas.height = 1;
|
|
1963
|
+
const context = canvas.getContext("2d");
|
|
1964
|
+
const sample = (color) => {
|
|
1965
|
+
context.fillStyle = "#000";
|
|
1966
|
+
context.fillStyle = color;
|
|
1967
|
+
const first = context.fillStyle;
|
|
1968
|
+
context.fillStyle = "#fff";
|
|
1969
|
+
context.fillStyle = color;
|
|
1970
|
+
if (first !== context.fillStyle) return null;
|
|
1971
|
+
context.fillRect(0, 0, 1, 1);
|
|
1972
|
+
const data = [...context.getImageData(0, 0, 1, 1).data];
|
|
1973
|
+
context.clearRect(0, 0, 1, 1);
|
|
1974
|
+
return data;
|
|
1975
|
+
};
|
|
1976
|
+
return {
|
|
1977
|
+
onDiv: "getContext" in document.createElement("div"),
|
|
1978
|
+
same: canvas.getContext("2d") === context,
|
|
1979
|
+
webgl: canvas.getContext("webgl"),
|
|
1980
|
+
size: [document.createElement("canvas").width, document.createElement("canvas").height],
|
|
1981
|
+
red: sample("red"),
|
|
1982
|
+
bad: sample("not-a-color"),
|
|
1983
|
+
};
|
|
1984
|
+
})()`)).toEqual({
|
|
1985
|
+
onDiv: false, same: true, webgl: null, size: [300, 150], red: [255, 0, 0, 255], bad: [0, 0, 0, 255],
|
|
1986
|
+
});
|
|
1987
|
+
});
|
|
1988
|
+
|
|
1989
|
+
test("the page scrolls over content that overflows a 100vh layout, not over clipped or fixed content", async () => {
|
|
1990
|
+
await load(`<div style="height:100vh;display:flex;flex-direction:column">
|
|
1991
|
+
<header style="height:50px">top</header><main style="height:2000px;flex-shrink:0">tall</main>
|
|
1992
|
+
<footer id="end" style="height:40px;flex-shrink:0">end</footer></div>
|
|
1993
|
+
<div style="height:20px;overflow:hidden"><div style="height:9000px">clipped</div></div>
|
|
1994
|
+
<div style="position:fixed;top:0;height:20000px;width:10px"></div>`);
|
|
1995
|
+
// 50 + 2000 + 40 of overflowing content: the footer can be scrolled to; clipped and fixed boxes add nothing.
|
|
1996
|
+
expect(await evaluate(`(scrollTo(0, 99999), [scrollY, Math.round(document.getElementById("end").getBoundingClientRect().bottom)])`))
|
|
1997
|
+
.toEqual([2090 - 300, 300]);
|
|
1998
|
+
});
|