buninu-browser 0.2.1 → 0.2.3
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/ai-debug.log +5537 -0
- 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/playback-buffer.js +27 -0
- package/lib/audio/pulse-client.js +137 -19
- package/lib/cdp-server.js +22 -11
- package/lib/happy-dom/parser.js +29 -4
- package/lib/happy-dom/selector-pseudo-adapter.js +14 -0
- package/lib/headless-shell.js +69 -19
- package/lib/input/hit-test.js +25 -7
- package/lib/layout/text-layout.js +268 -85
- package/lib/log.js +5 -2
- package/lib/media/byte-pipe.js +82 -0
- package/lib/media/position-audio.js +42 -0
- package/lib/media/video-keyframe.js +29 -0
- package/lib/network/network-service.js +161 -14
- package/lib/paint/display-list.js +316 -15
- package/lib/paint/gradient.js +11 -5
- package/lib/render-tree/index.js +19 -2
- package/lib/render-tree/svg.js +2 -1
- package/lib/renderer/bindings.js +995 -69
- package/lib/renderer/canvas-2d.js +92 -0
- package/lib/renderer/compositor.js +16 -1
- package/lib/renderer/custom-element-reactions.js +18 -0
- package/lib/renderer/history.js +42 -0
- package/lib/renderer/host.js +166 -9
- package/lib/renderer/image-loading.js +27 -0
- package/lib/renderer/interfaces.js +215 -0
- package/lib/renderer/keyboard-input.js +12 -0
- package/lib/renderer/main.js +162 -12
- package/lib/renderer/media-playback-events.js +13 -0
- 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 +1408 -242
- package/lib/renderer/page-renderer.js +1893 -223
- package/lib/renderer/piano-input.js +22 -0
- package/lib/renderer/pointer-move.js +29 -0
- 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/video-presentation.js +126 -0
- package/lib/renderer/workers.js +327 -0
- package/lib/screenshot-cli.js +2 -0
- package/lib/style/computed-style.js +186 -29
- 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 +41 -1
- package/test/computed-style.test.js +59 -1
- package/test/display-list.test.js +88 -1
- package/test/download.test.js +43 -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 +23 -1
- package/test/hit-test.test.js +25 -1
- package/test/media-playback-events.test.js +22 -0
- 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 +1203 -7
- package/test/piano-input.test.js +44 -0
- package/test/playback-buffer.test.js +31 -0
- package/test/position-audio.test.js +67 -0
- package/test/positioning.test.js +95 -1
- package/test/pulse-client.test.js +84 -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/text-layout.test.js +62 -0
- package/test/user-agent.test.js +60 -4
- package/test/video-presentation.test.js +67 -0
- 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,224 @@ 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("pointer movement delivers trusted mouse events to page controls", async () => {
|
|
230
|
+
await load(`<div id="control" style="width:100px;height:100px;background:red"></div><script>
|
|
231
|
+
window.moves = [];
|
|
232
|
+
for (const type of ['mouseenter','mousemove','mouseleave']) {
|
|
233
|
+
document.querySelector('#control').addEventListener(type, e => moves.push([e.type,e.isTrusted]));
|
|
234
|
+
}
|
|
235
|
+
</script>`);
|
|
236
|
+
await renderer.call('move', 30, 30);
|
|
237
|
+
await renderer.call('move', 150, 150);
|
|
238
|
+
expect(await evaluate('moves')).toEqual([['mouseenter',true],['mousemove',true],['mouseleave',true]]);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test("assigning element.style forwards to cssText without replacing its identity", async () => {
|
|
242
|
+
await load(`<div id="target"></div><script>
|
|
243
|
+
const target = document.querySelector('#target'), original = target.style;
|
|
244
|
+
target.style = 'width: 73px; color: red';
|
|
245
|
+
window.styleResult = [target.style === original, target.style.width, target.style.color];
|
|
246
|
+
target.style = '';
|
|
247
|
+
styleResult.push(target.style.cssText);
|
|
248
|
+
</script>`);
|
|
249
|
+
expect(JSON.parse(await evaluate("JSON.stringify(styleResult)"))).toEqual([true, '73px', 'red', '']);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
test("History methods borrowed from an iframe operate on the receiving history", async () => {
|
|
253
|
+
await load(`<iframe></iframe><script>
|
|
254
|
+
const frame = document.querySelector('iframe').contentWindow;
|
|
255
|
+
frame.History.prototype.replaceState.call(history, { step: 1 }, '', '/watch?v=example');
|
|
256
|
+
frame.History.prototype.pushState.call(history, { step: 2 }, '', '#comments');
|
|
257
|
+
window.historyResult = [location.href, history.state.step, history.length, frame.location.href];
|
|
258
|
+
try { frame.History.prototype.replaceState.call(history, {}, '', 'https://other.test/'); }
|
|
259
|
+
catch (e) { window.historyOriginError = e.name; }
|
|
260
|
+
</script>`);
|
|
261
|
+
expect(JSON.parse(await evaluate("JSON.stringify(historyResult)"))).toEqual([
|
|
262
|
+
'https://page.test/watch?v=example#comments', 2, 2, 'about:blank',
|
|
263
|
+
]);
|
|
264
|
+
expect(await evaluate("historyOriginError")).toBe('SecurityError');
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
test("custom elements receive observed attribute reactions and can enable deferred initialization", async () => {
|
|
268
|
+
await load(`<script>
|
|
269
|
+
window.reactions = [];
|
|
270
|
+
window.observedReads = 0;
|
|
271
|
+
class DeferredPanel extends HTMLElement {
|
|
272
|
+
static get observedAttributes() { observedReads++; return ['disable-upgrade']; }
|
|
273
|
+
attributeChangedCallback(name, before, after) {
|
|
274
|
+
reactions.push([name, before, after, this === document.querySelector('deferred-panel')]);
|
|
275
|
+
if (after === null) this.textContent = 'ready';
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
customElements.define('deferred-panel', DeferredPanel);
|
|
279
|
+
const panel = document.createElement('deferred-panel');
|
|
280
|
+
document.body.appendChild(panel);
|
|
281
|
+
panel.setAttribute('disable-upgrade', '');
|
|
282
|
+
panel.setAttribute('unobserved', 'value');
|
|
283
|
+
panel.removeAttribute('disable-upgrade');
|
|
284
|
+
</script>`);
|
|
285
|
+
expect(JSON.parse(await evaluate("JSON.stringify(reactions)"))).toEqual([
|
|
286
|
+
['disable-upgrade', null, '', true], ['disable-upgrade', '', null, true],
|
|
287
|
+
]);
|
|
288
|
+
expect(await evaluate("observedReads")).toBe(1);
|
|
289
|
+
expect(await evaluate("document.querySelector('deferred-panel').textContent")).toBe('ready');
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
test("documents and shadow roots have adoptedStyleSheets, which page patches of Element.prototype may extend", async () => {
|
|
293
|
+
await load(`<div id="host"></div><script>
|
|
294
|
+
// As a popover polyfill does: every new shadow root adopts its sheet.
|
|
295
|
+
const sheet = new CSSStyleSheet();
|
|
296
|
+
sheet.replaceSync(":host { color: red }");
|
|
297
|
+
const attach = Element.prototype.attachShadow;
|
|
298
|
+
Element.prototype.attachShadow = function (init) {
|
|
299
|
+
const root = attach.call(this, init);
|
|
300
|
+
root.adoptedStyleSheets = [...root.adoptedStyleSheets, sheet];
|
|
301
|
+
return root;
|
|
302
|
+
};
|
|
303
|
+
const root = document.getElementById("host").attachShadow({ mode: "open" });
|
|
304
|
+
document.adoptedStyleSheets.push(sheet);
|
|
305
|
+
window.adopted = [
|
|
306
|
+
Array.isArray(document.adoptedStyleSheets), document.adoptedStyleSheets.length, root.adoptedStyleSheets[0] === sheet,
|
|
307
|
+
(() => { try { document.adoptedStyleSheets = [{}]; return "accepted"; } catch (error) { return error.name; } })(),
|
|
308
|
+
(() => { try { root.adoptedStyleSheets = null; return "accepted"; } catch (error) { return error.name; } })(),
|
|
309
|
+
];
|
|
310
|
+
</script>`);
|
|
311
|
+
expect(JSON.parse(await evaluate("JSON.stringify(adopted)"))).toEqual([true, 1, true, "TypeError", "TypeError"]);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
test("Web APIs have their Web IDL property shapes", async () => {
|
|
315
|
+
await load(`<p>shape</p>`);
|
|
316
|
+
const shape = JSON.parse(await evaluate(`JSON.stringify((() => {
|
|
317
|
+
const d = (o, k) => Object.getOwnPropertyDescriptor(o, k);
|
|
318
|
+
const title = d(HTMLElement.prototype, "title");
|
|
319
|
+
const constant = d(Node, "ELEMENT_NODE");
|
|
320
|
+
const doc = d(window, "document");
|
|
321
|
+
const self = d(window, "self");
|
|
322
|
+
return [
|
|
323
|
+
title.get.name, title.set.name, d(HTMLElement.prototype, "onclick").get.name,
|
|
324
|
+
clearTimeout.name, clearInterval.name, cancelAnimationFrame.name, clearTimeout === clearInterval,
|
|
325
|
+
clearTimeout.length, cancelAnimationFrame.length, scrollTo.length, EventTarget.prototype.addEventListener.length,
|
|
326
|
+
Element.prototype.toggleAttribute.length, Promise.length, Function.length,
|
|
327
|
+
constant.writable, constant.configurable, d(EventTarget.prototype, "constructor").enumerable,
|
|
328
|
+
typeof doc.get, doc.configurable, doc.enumerable, typeof self.set, d(window, "console").enumerable,
|
|
329
|
+
(() => { window.innerWidth = 5; return window.innerWidth; })(),
|
|
330
|
+
];
|
|
331
|
+
})())`));
|
|
332
|
+
expect(shape).toEqual([
|
|
333
|
+
"get title", "set title", "get onclick",
|
|
334
|
+
"clearTimeout", "clearInterval", "cancelAnimationFrame", false,
|
|
335
|
+
0, 1, 0, 2,
|
|
336
|
+
1, 1, 1,
|
|
337
|
+
false, false, false,
|
|
338
|
+
"function", false, true, "function", false,
|
|
339
|
+
5,
|
|
340
|
+
]);
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
test("each HTML element has its interface, and unknown names are HTMLUnknownElement", async () => {
|
|
344
|
+
await load(`<p>tags</p>`);
|
|
345
|
+
const names = await evaluate(`["ins", "col", "caption", "data", "xmp", "article", "lh", "blink", "my-widget", "audio"]
|
|
346
|
+
.map((tag) => Object.prototype.toString.call(document.createElement(tag)).slice(8, -1)
|
|
347
|
+
+ (document.createElement(tag) instanceof HTMLUnknownElement ? "?" : "")).join()`);
|
|
348
|
+
expect(names).toBe("HTMLModElement,HTMLTableColElement,HTMLTableCaptionElement,HTMLDataElement,HTMLPreElement,"
|
|
349
|
+
+ "HTMLElement,HTMLUnknownElement?,HTMLUnknownElement?,HTMLElement,HTMLAudioElement");
|
|
350
|
+
expect(await evaluate("Object.hasOwn(HTMLMediaElement.prototype, 'currentTime') && !Object.hasOwn(HTMLAudioElement.prototype, 'currentTime')")).toBe(true);
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
test("unhandled rejections fire unhandledrejection, then rejectionhandled when caught later", async () => {
|
|
354
|
+
await load(`<script>
|
|
355
|
+
window.log = [];
|
|
356
|
+
addEventListener("unhandledrejection", (event) => {
|
|
357
|
+
log.push(event.type, event instanceof PromiseRejectionEvent, event.reason, event.promise === late, event.cancelable);
|
|
358
|
+
event.preventDefault();
|
|
359
|
+
});
|
|
360
|
+
onrejectionhandled = (event) => log.push(event.type, event.reason, event.promise === late);
|
|
361
|
+
var late = Promise.reject("why");
|
|
362
|
+
setTimeout(() => late.catch(() => {}), 50);
|
|
363
|
+
</script>`);
|
|
364
|
+
expect(await waitFor("log.length === 8", 3000)).toBe(true);
|
|
365
|
+
expect(await evaluate("JSON.stringify(log)")).toBe(JSON.stringify([
|
|
366
|
+
"unhandledrejection", true, "why", true, true, "rejectionhandled", "why", true,
|
|
367
|
+
]));
|
|
368
|
+
expect(await evaluate(`(() => { const p = Promise.resolve(); const e = new PromiseRejectionEvent("x", { promise: p, reason: 1 });
|
|
369
|
+
return [e.promise === p, e.reason, String(e), PromiseRejectionEvent.length,
|
|
370
|
+
(() => { try { new PromiseRejectionEvent("x", {}); return "ok"; } catch (error) { return error.name; } })()].join(); })()`))
|
|
371
|
+
.toBe("true,1,[object PromiseRejectionEvent],2,TypeError");
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
test("navigator reports cookies and User-Agent Client Hints consistently", async () => {
|
|
375
|
+
await load(`<script>
|
|
376
|
+
navigator.userAgentData.getHighEntropyValues(['uaFullVersion']).then(value => {
|
|
377
|
+
document.title = [navigator.cookieEnabled, navigator.userAgentData.platform,
|
|
378
|
+
navigator.userAgentData.brands[1].brand, value.uaFullVersion].join(',');
|
|
379
|
+
});
|
|
380
|
+
</script>`);
|
|
381
|
+
expect(await waitFor("document.title.startsWith('true,Linux,Chromium,')")).toBe(true);
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
test("events generated by browser input are trusted, script events are not", async () => {
|
|
385
|
+
await load(`<button style="width:100px;height:40px" onclick="window.trust = event.isTrusted">go</button>`);
|
|
386
|
+
expect(await evaluate("new Event('click').isTrusted")).toBe(false);
|
|
387
|
+
const rect = await evaluate(`(() => { const r = document.querySelector('button').getBoundingClientRect(); return { x:r.x, y:r.y, width:r.width, height:r.height } })()`);
|
|
388
|
+
await renderer.call("click", rect.x + rect.width / 2, rect.y + rect.height / 2);
|
|
389
|
+
expect(await evaluate("window.trust")).toBe(true);
|
|
390
|
+
});
|
|
391
|
+
|
|
89
392
|
test("parser-blocking head scripts run before document.body exists", async () => {
|
|
90
393
|
await load(`<head><script>window.bodyDuringHead = document.body</script></head><body><p>ready</p></body>`);
|
|
91
394
|
expect(await evaluate("bodyDuringHead === null")).toBe(true);
|
|
@@ -104,6 +407,46 @@ test("anchors expose parsed hyperlink URL components", async () => {
|
|
|
104
407
|
expect(await evaluate(`document.getElementById("link").href`)).toBe("https://other.test/path?q=one#part");
|
|
105
408
|
});
|
|
106
409
|
|
|
410
|
+
test("form.elements is a live named collection of form controls", async () => {
|
|
411
|
+
await load(`<form id="form"><input name="query"><button id="send">go</button></form>`);
|
|
412
|
+
expect(await evaluate(`(() => { const form = document.getElementById('form'); return [
|
|
413
|
+
form.elements.length, form.elements.namedItem('query') === form.elements[0],
|
|
414
|
+
form.elements.namedItem('send') === document.getElementById('send')
|
|
415
|
+
]; })()`)).toEqual([2, true, true]);
|
|
416
|
+
await evaluate(`document.getElementById('form').appendChild(document.createElement('select'))`);
|
|
417
|
+
expect(await evaluate("document.getElementById('form').elements.length")).toBe(3);
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
test("uncommitted links do not change the base used by subsequent fragment links", async () => {
|
|
421
|
+
for (const href of ["/archive.zip", "/LICENSE"]) {
|
|
422
|
+
for (const scripted of [false, true]) {
|
|
423
|
+
await load(`<a id="away" href="${href}">away</a><a id="toc" href="#contents">TOC</a>
|
|
424
|
+
<h2 id="contents">Contents</h2>`);
|
|
425
|
+
const before = await evaluate("[document.URL, document.baseURI, location.href, document.getElementById('toc').href]");
|
|
426
|
+
if (scripted) await evaluate("document.getElementById('away').click()");
|
|
427
|
+
else {
|
|
428
|
+
const point = await evaluate("(() => { const r=document.getElementById('away').getBoundingClientRect(); return [r.x+r.width/2,r.y+r.height/2]; })()");
|
|
429
|
+
expect(await renderer.call("click", ...point)).toBe(`https://page.test${href}`);
|
|
430
|
+
}
|
|
431
|
+
expect(await evaluate("[document.URL, document.baseURI, location.href, document.getElementById('toc').href]")).toEqual(before);
|
|
432
|
+
await evaluate("document.getElementById('toc').click()");
|
|
433
|
+
expect(await evaluate("[location.href, document.URL, document.baseURI]")).toEqual(Array(3).fill("https://page.test/#contents"));
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
test("constructable CSSStyleSheet supports feature checks and basic rule mutation", async () => {
|
|
439
|
+
await load(`<p>cssom</p>`);
|
|
440
|
+
expect(await evaluate(`(async () => {
|
|
441
|
+
const sheet = new CSSStyleSheet();
|
|
442
|
+
sheet.replaceSync('p { color: red }');
|
|
443
|
+
const inserted = sheet.insertRule('b { display: block }');
|
|
444
|
+
const same = await sheet.replace('i { color: blue }');
|
|
445
|
+
return [sheet instanceof CSSStyleSheet, sheet.cssRules[0] instanceof CSSStyleRule,
|
|
446
|
+
inserted, same === sheet, sheet.cssRules[0].cssText, typeof CSSGroupingRule];
|
|
447
|
+
})()`)).toEqual([true, true, 1, true, "i { color: blue }", "function"]);
|
|
448
|
+
});
|
|
449
|
+
|
|
107
450
|
test("getAttributeNode exposes a live Attr view", async () => {
|
|
108
451
|
await load(`<form id="search" onsubmit="return false"></form>`);
|
|
109
452
|
expect(await evaluate(`(() => { const form = document.getElementById("search"); const attr = form.getAttributeNode("onsubmit");
|
|
@@ -112,6 +455,40 @@ test("getAttributeNode exposes a live Attr view", async () => {
|
|
|
112
455
|
.toEqual(["onsubmit", "return false", 2, true, true, "return true", null]);
|
|
113
456
|
});
|
|
114
457
|
|
|
458
|
+
test("element.attributes is a live NamedNodeMap by index and by name", async () => {
|
|
459
|
+
await load(`<img id="pic" src="a.png" alt="x">`);
|
|
460
|
+
expect(await evaluate(`(() => { const img = document.getElementById("pic"); const map = img.attributes;
|
|
461
|
+
const before = [map.length, map[0].name, map.src.value, map.getNamedItem("alt").value, map.item(9), map.missing === undefined,
|
|
462
|
+
[...map].map((attribute) => attribute.name).join(), map === img.attributes, "src" in map];
|
|
463
|
+
img.setAttribute("title", "t"); map.removeNamedItem("alt");
|
|
464
|
+
return [...before, map.length, Array.from(map, (attribute) => attribute.name + "=" + attribute.value).join()]; })()`))
|
|
465
|
+
.toEqual([3, "id", "a.png", "x", null, true, "id,src,alt", true, true, 3, "id=pic,src=a.png,title=t"]);
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
test("document.elementFromPoint finds the topmost element painted there", async () => {
|
|
469
|
+
await load(`<div id="under" style="height:60px"><span id="text">text</span></div>
|
|
470
|
+
<div id="over" style="position:absolute;top:0;left:100px;width:50px;height:50px"></div>`);
|
|
471
|
+
expect(await evaluate(`[document.elementFromPoint(5, 5)?.id, document.elementFromPoint(110, 10)?.id,
|
|
472
|
+
document.elementFromPoint(300, 30)?.id, document.elementFromPoint(-1, 5)]`)).toEqual(["text", "over", "under", null]);
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
test("rounded overflow clips a child's pixels rather than only the parent's background", async () => {
|
|
476
|
+
await load('<div style="width:40px;height:40px;border-radius:20px;overflow:hidden"><div style="width:40px;height:40px;background:red"></div></div>');
|
|
477
|
+
const clipped = await renderer.call("screenshot");
|
|
478
|
+
const { default: init } = await import('../usr/lib/canvaskit/canvaskit.js');
|
|
479
|
+
const kit = await init({ locateFile: name => new URL(`../usr/lib/canvaskit/${name}`, import.meta.url).pathname });
|
|
480
|
+
const image = kit.MakeImageFromEncoded(Buffer.from(clipped, 'base64'));
|
|
481
|
+
try {
|
|
482
|
+
const pixel = (x, y) => Array.from(image.readPixels(x, y, {
|
|
483
|
+
width:1, height:1, colorType:kit.ColorType.RGBA_8888, alphaType:kit.AlphaType.Unpremul, colorSpace:kit.ColorSpace.SRGB,
|
|
484
|
+
})).slice(0, 3);
|
|
485
|
+
expect(pixel(0, 0)).toEqual([255, 255, 255]);
|
|
486
|
+
expect(pixel(20, 20)).toEqual([255, 0, 0]);
|
|
487
|
+
} finally { image.delete(); }
|
|
488
|
+
await load('<div style="width:40px;height:40px;background:red"></div>');
|
|
489
|
+
expect(await renderer.call("screenshot")).not.toBe(clipped);
|
|
490
|
+
});
|
|
491
|
+
|
|
115
492
|
test("scripts change the DOM and the next screenshot shows it", async () => {
|
|
116
493
|
await load(`<div id="box" style="height:20px"></div>
|
|
117
494
|
<script>document.getElementById('box').style.background = 'rgb(255, 0, 0)';
|
|
@@ -298,8 +675,42 @@ test("CSS.escape produces selectors for leading digits and punctuation", async (
|
|
|
298
675
|
expect(await evaluate("CSS.escape('1:a')")).toBe("\\31 \\:a");
|
|
299
676
|
});
|
|
300
677
|
|
|
678
|
+
test("legacy keyboard shortcuts cancel space scrolling on every press", async () => {
|
|
679
|
+
await load(`<div tabindex="0" id="player" style="height:100px"></div><div style="height:2000px"></div>
|
|
680
|
+
<script>var keys=[],player=document.getElementById('player');player.focus();player.addEventListener('keydown',e=>{
|
|
681
|
+
keys.push([e.key,e.code,e.keyCode,e.which,e.isTrusted]);if(e.keyCode===32)e.preventDefault();
|
|
682
|
+
});</script>`);
|
|
683
|
+
for (let i=0;i<6;i++) await renderer.call('press',' ',{code:'Space',windowsVirtualKeyCode:32});
|
|
684
|
+
expect(await evaluate('keys')).toEqual(Array.from({length:6},()=>[' ','Space',32,32,true]));
|
|
685
|
+
expect(await renderer.call('scrollOffset')).toEqual({x:0,y:0});
|
|
686
|
+
await evaluate('player.blur()');
|
|
687
|
+
await renderer.call('press',' ',{code:'Space'});
|
|
688
|
+
expect((await renderer.call('scrollOffset')).y).toBeGreaterThan(0);
|
|
689
|
+
});
|
|
690
|
+
|
|
691
|
+
test("site media controls can repeatedly toggle using playing/pause events and legacy space keys", async () => {
|
|
692
|
+
await load(`<div id="player" tabindex="0" style="height:100px"><video id="v" src="/long-video.mp4" style="width:100px;height:80px"></video></div><div style="height:2000px"></div>
|
|
693
|
+
<script>var state='paused',events=[],player=document.getElementById('player'),v=document.getElementById('v');
|
|
694
|
+
for(const type of ['play','playing','pause'])v.addEventListener(type,()=>{events.push(type);if(type!=='play')state=type;});
|
|
695
|
+
function toggle(){if(state==='playing')v.pause();else v.play();}
|
|
696
|
+
player.addEventListener('click',toggle);
|
|
697
|
+
player.addEventListener('keydown',e=>{if(e.keyCode===32){e.preventDefault();toggle();}});
|
|
698
|
+
player.focus();</script>`);
|
|
699
|
+
for (const input of ['click','press']) {
|
|
700
|
+
for(let i=0;i<6;i++) {
|
|
701
|
+
if(input==='click')await renderer.call('click',40,40);
|
|
702
|
+
else await renderer.call('press',' ',{code:'Space'});
|
|
703
|
+
const expected=i%2===0?'playing':'pause';
|
|
704
|
+
expect(await waitFor(`state===${JSON.stringify(expected)}`,5000)).toBe(true);
|
|
705
|
+
expect(await evaluate('v.paused')).toBe(i%2===1);
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
expect(await renderer.call('scrollOffset')).toEqual({x:0,y:0});
|
|
709
|
+
expect(await evaluate('events.slice(0,3)')).toEqual(['play','playing','pause']);
|
|
710
|
+
});
|
|
711
|
+
|
|
301
712
|
test("a video plays frames and pauses when clicked", async () => {
|
|
302
|
-
await load(`<video src="/video.mp4" controls width="160" height="120"></video>`);
|
|
713
|
+
await load(`<video src="/long-video.mp4" controls width="160" height="120"></video>`);
|
|
303
714
|
const bounds = await evaluate("document.querySelector('video').getBoundingClientRect().toJSON()");
|
|
304
715
|
const x = bounds.x + bounds.width / 2;
|
|
305
716
|
const y = bounds.y + bounds.height / 2;
|
|
@@ -308,8 +719,12 @@ test("a video plays frames and pauses when clicked", async () => {
|
|
|
308
719
|
await renderer.call("click", x, y);
|
|
309
720
|
expect(await evaluate("document.querySelector('video').paused")).toBeFalse();
|
|
310
721
|
const started = await renderer.call("screenshot");
|
|
311
|
-
|
|
312
|
-
|
|
722
|
+
// A later frame, however slowly frames come under load.
|
|
723
|
+
let during = started;
|
|
724
|
+
for (const deadline = Date.now() + 3000; during === started && Date.now() < deadline;) {
|
|
725
|
+
await Bun.sleep(100);
|
|
726
|
+
during = await renderer.call("screenshot");
|
|
727
|
+
}
|
|
313
728
|
expect(during).not.toBe(before);
|
|
314
729
|
expect(during).not.toBe(started);
|
|
315
730
|
expect(await evaluate("document.querySelector('video').currentTime > 0")).toBeTrue();
|
|
@@ -460,6 +875,17 @@ test("performance.timing follows the document lifecycle", async () => {
|
|
|
460
875
|
.toBe(JSON.stringify([[true, 0], true, true, 0, 21]));
|
|
461
876
|
});
|
|
462
877
|
|
|
878
|
+
test("each document starts a new performance timeline", async () => {
|
|
879
|
+
await load('<p>first</p>');
|
|
880
|
+
const previousOrigin = await evaluate('performance.timeOrigin');
|
|
881
|
+
await Bun.sleep(100);
|
|
882
|
+
await load('<script>var initialNow = performance.now(); var frameTime; requestAnimationFrame(t => frameTime = t);</script>');
|
|
883
|
+
expect(await evaluate('performance.timeOrigin')).toBeGreaterThan(previousOrigin);
|
|
884
|
+
expect(await evaluate('initialNow')).toBeLessThan(100);
|
|
885
|
+
expect(await waitFor('typeof frameTime === "number"')).toBe(true);
|
|
886
|
+
expect(await evaluate('frameTime >= initialNow && frameTime <= performance.now()')).toBe(true);
|
|
887
|
+
});
|
|
888
|
+
|
|
463
889
|
test("location assignment asks the controller to navigate", async () => {
|
|
464
890
|
navigations.length = 0;
|
|
465
891
|
await load(`<script>setTimeout(() => { location.href = '/elsewhere?x=1' }, 0)</script>`);
|
|
@@ -474,6 +900,124 @@ test("geometry APIs read the real layout", async () => {
|
|
|
474
900
|
expect(await evaluate("document.getElementById('box').offsetWidth + 'x' + innerWidth")).toBe("120x400");
|
|
475
901
|
});
|
|
476
902
|
|
|
903
|
+
test("DOM objects have their interfaces' shape: events, element.style, image attributes and Node methods", async () => {
|
|
904
|
+
await load(`<div id="host">a<b>c</b></div><script>
|
|
905
|
+
const event = document.createEvent("MouseEvents");
|
|
906
|
+
const enumerated = []; for (const key in event) enumerated.push(key);
|
|
907
|
+
const image = document.createElement("img");
|
|
908
|
+
const style = document.body.style;
|
|
909
|
+
style.setProperty("margin-top", "3px");
|
|
910
|
+
const frame = document.createElement("iframe");
|
|
911
|
+
document.body.appendChild(frame);
|
|
912
|
+
const host = document.getElementById("host");
|
|
913
|
+
host.appendChild(document.createTextNode("x"));
|
|
914
|
+
host.appendChild(document.createTextNode("y"));
|
|
915
|
+
host.normalize();
|
|
916
|
+
window.shapes = {
|
|
917
|
+
event: [Object.prototype.toString.call(event), event instanceof MouseEvent, event instanceof UIEvent,
|
|
918
|
+
Object.getOwnPropertyNames(event).join(), enumerated.includes("clientX"), enumerated.includes("preventDefault"),
|
|
919
|
+
typeof event.initMouseEvent, event.view, MouseEvent.prototype.hasOwnProperty("clientX"), Event.prototype.hasOwnProperty("clientX")],
|
|
920
|
+
unsupportedEvent: (() => { try { document.createEvent("NoSuchEvents"); return "created"; } catch (error) { return error.name; } })(),
|
|
921
|
+
style: [Object.prototype.toString.call(style), style instanceof CSSStyleDeclaration, style.setProperty.toString(),
|
|
922
|
+
style.getPropertyValue("margin-top"), style.marginTop],
|
|
923
|
+
image: [image.useMap, image.name, image.complete, image.decoding, image.crossOrigin, image.hspace],
|
|
924
|
+
frame: frame.contentWindow !== null,
|
|
925
|
+
node: [host.childNodes.length, host.isSameNode(host), host.isEqualNode(host.cloneNode(true)),
|
|
926
|
+
host.lookupNamespaceURI(null), host.lookupPrefix("http://www.w3.org/1999/xhtml"), host.getRootNode() === document, Node.NOTATION_NODE],
|
|
927
|
+
};
|
|
928
|
+
</script>`);
|
|
929
|
+
expect(JSON.parse(await evaluate("JSON.stringify(shapes)"))).toEqual({
|
|
930
|
+
event: ["[object MouseEvent]", true, true, "isTrusted", true, true, "function", null, true, false],
|
|
931
|
+
unsupportedEvent: "NotSupportedError",
|
|
932
|
+
style: ["[object CSSStyleDeclaration]", true, "function setProperty() { [native code] }", "3px", "3px"],
|
|
933
|
+
image: ["", "", true, "auto", null, 0],
|
|
934
|
+
frame: true,
|
|
935
|
+
node: [3, true, true, "http://www.w3.org/1999/xhtml", null, true, 12],
|
|
936
|
+
});
|
|
937
|
+
});
|
|
938
|
+
|
|
939
|
+
test("HTMLElement.prototype inherits Element's members, so patches to Element.prototype reach HTML elements", async () => {
|
|
940
|
+
await load(`<div id="host"></div><script>
|
|
941
|
+
const original = Element.prototype.setAttribute;
|
|
942
|
+
Element.prototype.setAttribute = function (name, value) { window.patched = name; return original.call(this, name, value); };
|
|
943
|
+
document.getElementById("host").setAttribute("data-x", "1");
|
|
944
|
+
window.shape = [Object.hasOwn(HTMLElement.prototype, "setAttribute"), Object.hasOwn(HTMLElement.prototype, "focus"),
|
|
945
|
+
Object.hasOwn(HTMLElement.prototype, "onclick"), Object.hasOwn(Element.prototype, "focus"), Object.hasOwn(Element.prototype, "id")];
|
|
946
|
+
</script>`);
|
|
947
|
+
expect(await evaluate("window.patched")).toBe("data-x");
|
|
948
|
+
expect(JSON.parse(await evaluate("JSON.stringify(shape)"))).toEqual([false, true, true, false, true]);
|
|
949
|
+
});
|
|
950
|
+
|
|
951
|
+
test("Trusted Types: policies make trusted values, and eval runs a TrustedScript", async () => {
|
|
952
|
+
await load(`<div id="host"></div><script>
|
|
953
|
+
const policy = trustedTypes.createPolicy("page", { createHTML: (s) => s + "!", createScript: (s) => s, createScriptURL: (s) => s });
|
|
954
|
+
const html = policy.createHTML("<b>x</b>");
|
|
955
|
+
document.getElementById("host").innerHTML = html;
|
|
956
|
+
const script = policy.createScript("1 + 2");
|
|
957
|
+
window.trusted = [
|
|
958
|
+
Object.prototype.toString.call(trustedTypes), html instanceof TrustedHTML, String(html), JSON.stringify(html),
|
|
959
|
+
trustedTypes.isHTML(html), trustedTypes.isHTML("x"), trustedTypes.isScript(script), policy.name,
|
|
960
|
+
eval(script), (0, eval)(script), eval(7), trustedTypes.emptyHTML.toString(),
|
|
961
|
+
trustedTypes.getPropertyType("div", "innerHTML"), trustedTypes.getAttributeType("script", "src"), trustedTypes.defaultPolicy,
|
|
962
|
+
(() => { try { trustedTypes.createPolicy("bare").createHTML("x"); return "created"; } catch (error) { return error.name; } })(),
|
|
963
|
+
(() => { try { new TrustedHTML(); return "constructed"; } catch (error) { return error.name; } })(),
|
|
964
|
+
document.getElementById("host").innerHTML,
|
|
965
|
+
String(trustedTypes.createPolicy),
|
|
966
|
+
];
|
|
967
|
+
</script>`);
|
|
968
|
+
expect(JSON.parse(await evaluate("JSON.stringify(trusted)"))).toEqual([
|
|
969
|
+
"[object TrustedTypePolicyFactory]", true, "<b>x</b>!", '"<b>x</b>!"',
|
|
970
|
+
true, false, true, "page",
|
|
971
|
+
3, 3, 7, "",
|
|
972
|
+
"TrustedHTML", "TrustedScriptURL", null,
|
|
973
|
+
"TypeError", "TypeError",
|
|
974
|
+
"<b>x</b>!",
|
|
975
|
+
"function createPolicy() { [native code] }",
|
|
976
|
+
]);
|
|
977
|
+
});
|
|
978
|
+
|
|
979
|
+
test("addEventListener reads capture, once, passive and signal, and an aborted signal removes the listener", async () => {
|
|
980
|
+
await load(`<div id="host"></div><script>
|
|
981
|
+
const read = [];
|
|
982
|
+
const options = {};
|
|
983
|
+
for (const name of ["passive", "signal", "once", "capture"]) {
|
|
984
|
+
Object.defineProperty(options, name, { get() { read.push(name); return undefined; } });
|
|
985
|
+
}
|
|
986
|
+
const host = document.getElementById("host");
|
|
987
|
+
host.addEventListener("ping", () => {}, options);
|
|
988
|
+
const controller = new AbortController();
|
|
989
|
+
window.calls = 0;
|
|
990
|
+
host.addEventListener("ping", () => window.calls++, { signal: controller.signal });
|
|
991
|
+
host.dispatchEvent(new Event("ping"));
|
|
992
|
+
controller.abort();
|
|
993
|
+
host.dispatchEvent(new Event("ping"));
|
|
994
|
+
host.addEventListener("ping", () => window.calls++, { signal: controller.signal });
|
|
995
|
+
host.dispatchEvent(new Event("ping"));
|
|
996
|
+
window.optionReads = read.join();
|
|
997
|
+
window.badSignal = (() => { try { host.addEventListener("ping", () => {}, { signal: {} }); return "added"; } catch (error) { return error.name; } })();
|
|
998
|
+
</script>`);
|
|
999
|
+
expect(await evaluate("optionReads")).toBe("capture,once,passive,signal");
|
|
1000
|
+
expect(await evaluate("calls")).toBe(1);
|
|
1001
|
+
expect(await evaluate("badSignal")).toBe("TypeError");
|
|
1002
|
+
});
|
|
1003
|
+
|
|
1004
|
+
test("offsetParent is the nearest positioned ancestor, and offsets are measured from its padding edge", async () => {
|
|
1005
|
+
await load(`<div id="outer" style="margin-left:20px;padding:5px">
|
|
1006
|
+
<div id="toggle" style="position:relative;border:3px solid;padding:8px;width:200px;overflow:hidden">
|
|
1007
|
+
<div id="tab" style="width:50px;height:10px"></div>
|
|
1008
|
+
<div id="selection" style="position:absolute;left:0;top:0;width:100%;height:4px"></div>
|
|
1009
|
+
</div></div>`);
|
|
1010
|
+
expect(JSON.parse(await evaluate(`JSON.stringify((() => {
|
|
1011
|
+
const tab = document.getElementById("tab"), toggle = document.getElementById("toggle");
|
|
1012
|
+
return [tab.offsetParent === toggle, tab.offsetLeft, tab.offsetTop, toggle.offsetParent === document.body,
|
|
1013
|
+
document.body.offsetParent, toggle.scrollWidth, toggle.clientWidth, document.body.offsetLeft, document.body.offsetTop];
|
|
1014
|
+
})())`))).toEqual([
|
|
1015
|
+
// An absolutely positioned child as wide as the padding box does not
|
|
1016
|
+
// add the end padding again: nothing overflows. The body's offsets are 0.
|
|
1017
|
+
true, 8, 8, true, null, 216, 216, 0, 0,
|
|
1018
|
+
]);
|
|
1019
|
+
});
|
|
1020
|
+
|
|
477
1021
|
test("page scripts cannot escape the sandbox or reach engine state", async () => {
|
|
478
1022
|
await load(`<p id="p">x</p><script>
|
|
479
1023
|
var probes = {};
|
|
@@ -484,8 +1028,10 @@ test("page scripts cannot escape the sandbox or reach engine state", async () =>
|
|
|
484
1028
|
attempt('privateState', () => Object.getOwnPropertySymbols(document).length + '/' + Object.getOwnPropertyNames(document).length);
|
|
485
1029
|
attempt('newBinding', () => new document.constructor());
|
|
486
1030
|
attempt('patchPrototype', () => { Object.getPrototypeOf(document.body).appendChild = () => 'pwned'; return 'patched'; });
|
|
487
|
-
|
|
488
|
-
|
|
1031
|
+
// Classic scripts are sloppy-mode code: assigning to a frozen property
|
|
1032
|
+
// fails silently, as in browsers. What matters is that nothing changed.
|
|
1033
|
+
attempt('patchIntrinsic', () => { Array.prototype.includes = () => true; return [].includes(1) ? 'patched' : 'blocked'; });
|
|
1034
|
+
attempt('patchUrl', () => { URL.prototype.toString = () => 'pwned'; return String(new URL('https://a.test/')) === 'pwned' ? 'patched' : 'blocked'; });
|
|
489
1035
|
attempt('dynamicImport', () => { const pending = eval('import("node:fs")'); pending.catch(() => {}); return pending instanceof Promise ? 'promise' : 'wrong'; });
|
|
490
1036
|
</script>`);
|
|
491
1037
|
const probes = await evaluate("JSON.stringify(probes)");
|
|
@@ -610,6 +1156,27 @@ test("styles inside noscript do not apply while page scripting is enabled", asyn
|
|
|
610
1156
|
expect(await evaluate(`getComputedStyle(document.getElementById("visible")).display`)).toBe("block");
|
|
611
1157
|
});
|
|
612
1158
|
|
|
1159
|
+
test("pages can wrap Promise methods without modifying another realm", async () => {
|
|
1160
|
+
await load(`<script>
|
|
1161
|
+
window.promiseCalls = 0;
|
|
1162
|
+
const original = Promise.prototype.then;
|
|
1163
|
+
Promise.prototype.then = function (...args) { promiseCalls++; return original.apply(this, args); };
|
|
1164
|
+
Promise.prototype.catch = function (reject) { return this.then(undefined, reject); };
|
|
1165
|
+
Promise.prototype.finally = function (done) { return this.then(value => { done(); return value; }); };
|
|
1166
|
+
window.promiseResult = null;
|
|
1167
|
+
Promise.resolve(42).finally(() => {}).then(value => { promiseResult = value; });
|
|
1168
|
+
</script>`);
|
|
1169
|
+
await waitFor("promiseResult === 42");
|
|
1170
|
+
expect(await evaluate("promiseCalls")).toBeGreaterThanOrEqual(2);
|
|
1171
|
+
expect(await evaluate("Promise.resolve(0) instanceof Promise")).toBe(true);
|
|
1172
|
+
expect(await evaluate("(async () => 1)() instanceof Promise")).toBe(true);
|
|
1173
|
+
expect(await evaluate("(() => { class Child extends Promise {} return [new Child(r => r()) instanceof Child, Promise.resolve() instanceof Child]; })()")).toEqual([true, false]);
|
|
1174
|
+
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]");
|
|
1175
|
+
await load(`<script>window.promiseResult = null; Promise.all([Promise.resolve(7)]).then(v => promiseResult = v[0]);</script>`);
|
|
1176
|
+
await waitFor("promiseResult === 7");
|
|
1177
|
+
expect(await evaluate("typeof promiseCalls")).toBe("undefined");
|
|
1178
|
+
});
|
|
1179
|
+
|
|
613
1180
|
test("page RegExp instances can override their own toString without changing the intrinsic", async () => {
|
|
614
1181
|
await load(`<script>
|
|
615
1182
|
const pattern = /demo/;
|
|
@@ -710,6 +1277,130 @@ test("fetch and XMLHttpRequest go through the controller with cookies and CORS",
|
|
|
710
1277
|
});
|
|
711
1278
|
});
|
|
712
1279
|
|
|
1280
|
+
test("fetch(request) uses the Request's internal state, not properties script defined on it", async () => {
|
|
1281
|
+
await load(`<script>
|
|
1282
|
+
window.network = {};
|
|
1283
|
+
(async () => {
|
|
1284
|
+
// Players check that fetch is not patched this way: the request still
|
|
1285
|
+
// fetches its own data: URL whatever its own properties now say.
|
|
1286
|
+
const request = new Request('data:application/json;base64,' + btoa(JSON.stringify({ ok: 42 })));
|
|
1287
|
+
Object.defineProperty(request, 'url', { get: () => 'https://page.test/api/echo' });
|
|
1288
|
+
Object.defineProperty(request, 'method', { get: () => 'POST' });
|
|
1289
|
+
Object.defineProperty(request, 'bodyUsed', { get: () => true });
|
|
1290
|
+
Object.defineProperty(request, 'mode', { get: () => 'same-origin' });
|
|
1291
|
+
network.shadowed = (await (await fetch(request)).json()).ok;
|
|
1292
|
+
const used = new Request('/api/echo', { method: 'POST', body: 'abc' });
|
|
1293
|
+
await used.text();
|
|
1294
|
+
network.used = await fetch(used).then(() => 'fetched', (error) => error.name);
|
|
1295
|
+
network.stringType = (await (await fetch(new Request('/api/echo', { method: 'POST', body: 'abc' }))).json()).contentType;
|
|
1296
|
+
network.done = true;
|
|
1297
|
+
})();
|
|
1298
|
+
</script>`);
|
|
1299
|
+
for (let attempt = 0; attempt < 50 && !(await evaluate("network.done === true")); attempt++) await Bun.sleep(20);
|
|
1300
|
+
expect(JSON.parse(await evaluate("JSON.stringify(network)"))).toEqual({
|
|
1301
|
+
shadowed: 42,
|
|
1302
|
+
used: "TypeError",
|
|
1303
|
+
stringType: "text/plain;charset=UTF-8",
|
|
1304
|
+
done: true,
|
|
1305
|
+
});
|
|
1306
|
+
});
|
|
1307
|
+
|
|
1308
|
+
test("classic scripts are sloppy-mode code whose missing receiver is the page global, never the engine's", async () => {
|
|
1309
|
+
await load(`<script>
|
|
1310
|
+
const page = (value) => value === window ? "page" : value && typeof value === "object" && ("Bun" in value || "process" in value) ? "ENGINE" : typeof value;
|
|
1311
|
+
window.sloppy = {
|
|
1312
|
+
call: page((function () { return this; })()),
|
|
1313
|
+
nullCall: page((function () { return this; }).call(null)),
|
|
1314
|
+
apply: page(Reflect.apply(function () { return this; }, undefined, [])),
|
|
1315
|
+
fn: page(Function("return this")()),
|
|
1316
|
+
evaluated: page(eval("(function () { return this; })")()),
|
|
1317
|
+
indirect: page((0, eval)("(function () { return this; })")()),
|
|
1318
|
+
topLevel: page(this),
|
|
1319
|
+
callee: (function () { return typeof arguments.callee; })(),
|
|
1320
|
+
withStatement: Function("with ({ a: 7 }) return a")(),
|
|
1321
|
+
octal: Function("return 010")(),
|
|
1322
|
+
implicitGlobal: (Function("implicitGlobal = 3")(), window.implicitGlobal),
|
|
1323
|
+
strictStaysStrict: (function () { "use strict"; return this; })(),
|
|
1324
|
+
engine: typeof Bun + " " + typeof process,
|
|
1325
|
+
};
|
|
1326
|
+
</script>`);
|
|
1327
|
+
expect(JSON.parse(await evaluate("JSON.stringify(sloppy)"))).toEqual({
|
|
1328
|
+
call: "page", nullCall: "page", apply: "page", fn: "page", evaluated: "page", indirect: "page", topLevel: "page",
|
|
1329
|
+
callee: "function", withStatement: 7, octal: 8, implicitGlobal: 3, strictStaysStrict: undefined,
|
|
1330
|
+
engine: "undefined undefined",
|
|
1331
|
+
});
|
|
1332
|
+
});
|
|
1333
|
+
|
|
1334
|
+
test("direct eval sees the caller's variables, and functions print the source the page wrote", async () => {
|
|
1335
|
+
await load(`<script>
|
|
1336
|
+
function counter(n) {
|
|
1337
|
+
const step = 2;
|
|
1338
|
+
eval("n += step");
|
|
1339
|
+
return [n, eval("typeof step"), eval("0,function () { return n * step; }")()];
|
|
1340
|
+
}
|
|
1341
|
+
function rewritten(n) { for (; n-- > 0;) eval("n"); return [this, (async () => {}).constructor, "<!-- -->"]; }
|
|
1342
|
+
window.evalResults = {
|
|
1343
|
+
counter: counter(1),
|
|
1344
|
+
declaresEval: eval('var eval = "shadowed"; eval'),
|
|
1345
|
+
source: rewritten.toString(),
|
|
1346
|
+
made: Function("a", "return this.a + a").toString(),
|
|
1347
|
+
};
|
|
1348
|
+
</script>`);
|
|
1349
|
+
expect(JSON.parse(await evaluate("JSON.stringify(evalResults)"))).toEqual({
|
|
1350
|
+
counter: [3, "number", 6],
|
|
1351
|
+
declaresEval: "shadowed",
|
|
1352
|
+
source: 'function rewritten(n) { for (; n-- > 0;) eval("n"); return [this, (async () => {}).constructor, "<!-- -->"]; }',
|
|
1353
|
+
made: "function anonymous(a\n) {\nreturn this.a + a\n}",
|
|
1354
|
+
});
|
|
1355
|
+
});
|
|
1356
|
+
|
|
1357
|
+
test("Web APIs look like a browser's built-ins: native functions, interface prototypes and tags", async () => {
|
|
1358
|
+
await load(`<script>
|
|
1359
|
+
const descriptor = Object.getOwnPropertyDescriptor(Navigator.prototype, "userAgent");
|
|
1360
|
+
window.shape = {
|
|
1361
|
+
fetch: fetch.toString(),
|
|
1362
|
+
fetchTag: Object.prototype.toString.call(fetch),
|
|
1363
|
+
getter: descriptor.get.toString(),
|
|
1364
|
+
illegalInvocation: (() => { try { descriptor.get.call({}); return "no error"; } catch (error) { return error.message; } })(),
|
|
1365
|
+
illegalConstructor: (() => { try { new Navigator(); return "no error"; } catch (error) { return error.message; } })(),
|
|
1366
|
+
navigatorOwn: Object.getOwnPropertyNames(navigator).length,
|
|
1367
|
+
tags: [window, navigator, screen, document.body, localStorage, performance].map((value) => Object.prototype.toString.call(value)),
|
|
1368
|
+
plugins: [navigator.plugins.length, navigator.mimeTypes.length, navigator.plugins[0].name, navigator.vendor],
|
|
1369
|
+
storage: (localStorage.theme = "dark", [localStorage.getItem("theme"), Object.keys(localStorage)]),
|
|
1370
|
+
enumerable: ["Array", "Navigator", "document", "setTimeout"].map((name) => Object.keys(window).includes(name)),
|
|
1371
|
+
sesGlobals: [typeof Compartment, typeof harden, typeof lockdown],
|
|
1372
|
+
};
|
|
1373
|
+
localStorage.clear();
|
|
1374
|
+
</script>`);
|
|
1375
|
+
expect(JSON.parse(await evaluate("JSON.stringify(shape)"))).toEqual({
|
|
1376
|
+
fetch: "function fetch() { [native code] }",
|
|
1377
|
+
fetchTag: "[object Function]",
|
|
1378
|
+
getter: "function get userAgent() { [native code] }",
|
|
1379
|
+
illegalInvocation: "Illegal invocation",
|
|
1380
|
+
illegalConstructor: "Illegal constructor",
|
|
1381
|
+
navigatorOwn: 0,
|
|
1382
|
+
tags: ["[object Window]", "[object Navigator]", "[object Screen]", "[object HTMLBodyElement]", "[object Storage]", "[object Performance]"],
|
|
1383
|
+
plugins: [5, 2, "PDF Viewer", "Google Inc."],
|
|
1384
|
+
storage: ["dark", ["theme"]],
|
|
1385
|
+
enumerable: [false, false, true, true],
|
|
1386
|
+
sesGlobals: ["undefined", "undefined", "undefined"],
|
|
1387
|
+
});
|
|
1388
|
+
});
|
|
1389
|
+
|
|
1390
|
+
test("aborting fetch cancels the controller's HTTP request", async () => {
|
|
1391
|
+
pageFetchAborted = false;
|
|
1392
|
+
await load(`<script>
|
|
1393
|
+
const controller = new AbortController();
|
|
1394
|
+
fetch('/slow-abort', { signal: controller.signal }).then(
|
|
1395
|
+
() => { window.abortResult = 'resolved'; },
|
|
1396
|
+
(error) => { window.abortResult = error.name; },
|
|
1397
|
+
);
|
|
1398
|
+
setTimeout(() => controller.abort(), 20);
|
|
1399
|
+
</script>`);
|
|
1400
|
+
expect(await waitFor("window.abortResult === 'AbortError'", 2000)).toBe(true);
|
|
1401
|
+
expect(pageFetchAborted).toBe(true);
|
|
1402
|
+
});
|
|
1403
|
+
|
|
713
1404
|
test("host mode: page globals that wrap host APIs do not call themselves, and rejections are not fatal", async () => {
|
|
714
1405
|
const host = new RendererHost({ timeout: 20_000, args: ["--dangerously-allow-host-js"] });
|
|
715
1406
|
try {
|
|
@@ -844,10 +1535,13 @@ test("Function constructors reached through prototypes work under SES", async ()
|
|
|
844
1535
|
var GeneratorFunction = (function* () {}).constructor;
|
|
845
1536
|
var plain = (function () {}).constructor;
|
|
846
1537
|
var classic = [[...new GeneratorFunction('yield 1; yield 2')()].join(), plain('return typeof window')(),
|
|
847
|
-
typeof Object.getPrototypeOf(async function* () {}).constructor('yield 1')
|
|
1538
|
+
typeof Object.getPrototypeOf(async function* () {}).constructor('yield 1'),
|
|
1539
|
+
({}).constructor === Object, typeof ({}).constructor.create];
|
|
848
1540
|
</script>`);
|
|
849
1541
|
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([
|
|
1542
|
+
expect(await evaluate("JSON.stringify([moduleResult, classic])")).toBe(JSON.stringify([
|
|
1543
|
+
[3, true, "AsyncFunction"], ["1,2", "object", "function", true, "function"],
|
|
1544
|
+
]));
|
|
851
1545
|
});
|
|
852
1546
|
|
|
853
1547
|
test("UI event constructors, table rows/cells and select options are available", async () => {
|
|
@@ -958,3 +1652,505 @@ test("scripts and module preloads are fetched while the stylesheets load, each o
|
|
|
958
1652
|
expect(times("/preload/skipped.js")).toEqual([]);
|
|
959
1653
|
});
|
|
960
1654
|
|
|
1655
|
+
|
|
1656
|
+
test("an error status shows the page the server sent, scripts included; an empty one gets a note", async () => {
|
|
1657
|
+
await renderer.call("loadDocument", {
|
|
1658
|
+
url: "https://page.test/limited",
|
|
1659
|
+
source: `<!doctype html><title>check</title><body><form id="f"><p>unusual traffic</p></form><script>document.title = "ran";</script></body>`,
|
|
1660
|
+
contentType: "text/html",
|
|
1661
|
+
status: 429,
|
|
1662
|
+
statusText: "Too Many Requests",
|
|
1663
|
+
});
|
|
1664
|
+
await renderer.call("whenLoaded");
|
|
1665
|
+
expect(await evaluate("[document.title, !!document.getElementById('f'), document.body.innerText.includes('HTTP 429')].join()"))
|
|
1666
|
+
.toBe("ran,true,false");
|
|
1667
|
+
await renderer.call("loadDocument", { url: "https://page.test/gone", source: "", contentType: "text/html", status: 404, statusText: "Not Found" });
|
|
1668
|
+
await renderer.call("whenLoaded");
|
|
1669
|
+
expect(await evaluate("document.body.textContent")).toBe("HTTP 404 Not Found");
|
|
1670
|
+
});
|
|
1671
|
+
|
|
1672
|
+
test("meta.content is the reflected attribute; template.content stays the fragment", async () => {
|
|
1673
|
+
await load(`<template id="t"><b>x</b></template><script>
|
|
1674
|
+
"use strict";
|
|
1675
|
+
const meta = document.createElement("meta");
|
|
1676
|
+
meta.httpEquiv = "origin-trial";
|
|
1677
|
+
meta.content = "token";
|
|
1678
|
+
const other = document.createElement("div");
|
|
1679
|
+
other.content = 5;
|
|
1680
|
+
window.metaResult = [meta.getAttribute("content"), meta.content, other.content,
|
|
1681
|
+
document.getElementById("t").content.firstChild.nodeName].join();
|
|
1682
|
+
</script>`);
|
|
1683
|
+
expect(await evaluate("metaResult")).toBe("token,token,5,B");
|
|
1684
|
+
});
|
|
1685
|
+
|
|
1686
|
+
test("a video starts from a prefix while the rest of the file is still downloading", async () => {
|
|
1687
|
+
dripFinished = false;
|
|
1688
|
+
await load(`<video id="v" src="/drip-video.mp4" width="160" height="120"></video>`);
|
|
1689
|
+
await evaluate("document.getElementById('v').play()");
|
|
1690
|
+
expect(dripFinished).toBe(false);
|
|
1691
|
+
expect(await evaluate(`(() => { const v = document.getElementById('v');
|
|
1692
|
+
return !v.paused && v.readyState >= 1 && v.currentTime > 0 && v.networkState === 2; })()`)).toBe(true);
|
|
1693
|
+
const deadline = Date.now() + 4000;
|
|
1694
|
+
while (!dripFinished && Date.now() < deadline) await Bun.sleep(50);
|
|
1695
|
+
expect(dripFinished).toBe(true);
|
|
1696
|
+
});
|
|
1697
|
+
|
|
1698
|
+
test("a video probe is not limited by the number of network chunks", async () => {
|
|
1699
|
+
await load(`<video id="v" src="/many-chunk-video.mp4" width="160" height="120"></video>`);
|
|
1700
|
+
await evaluate("document.getElementById('v').play()");
|
|
1701
|
+
expect(await waitFor(`(() => { const v = document.getElementById('v');
|
|
1702
|
+
return !v.paused && v.error === null && v.readyState >= 1 && v.currentTime > 0; })()`)).toBe(true);
|
|
1703
|
+
});
|
|
1704
|
+
|
|
1705
|
+
test("a progressive MP3 updates its duration when the rest of the body arrives", async () => {
|
|
1706
|
+
dripAudioFinished = false;
|
|
1707
|
+
await load(`<audio id="a" src="/drip-tone.mp3"></audio>`);
|
|
1708
|
+
await evaluate("document.getElementById('a').play()");
|
|
1709
|
+
const initial = await evaluate("document.getElementById('a').duration");
|
|
1710
|
+
expect(dripAudioFinished).toBe(false);
|
|
1711
|
+
expect(await waitFor("document.getElementById('a').duration > " + initial, 3000)).toBe(true);
|
|
1712
|
+
expect(dripAudioFinished).toBe(true);
|
|
1713
|
+
});
|
|
1714
|
+
|
|
1715
|
+
test("an unsupported video exposes an error instead of silently stopping", async () => {
|
|
1716
|
+
await load(`<video id="v" src="/invalid-video.mp4"></video>`);
|
|
1717
|
+
await evaluate(`(() => { const v = document.getElementById("v");
|
|
1718
|
+
window.mediaFailed = false;
|
|
1719
|
+
v.addEventListener("error", () => { window.mediaFailed = true; });
|
|
1720
|
+
v.play();
|
|
1721
|
+
})()`);
|
|
1722
|
+
expect(await waitFor("window.mediaFailed === true")).toBe(true);
|
|
1723
|
+
expect(await evaluate(`(() => { const v = document.getElementById("v");
|
|
1724
|
+
return v.paused && v.error?.code === 4 && v.networkState === 3; })()`)).toBe(true);
|
|
1725
|
+
});
|
|
1726
|
+
|
|
1727
|
+
test("MediaSource appends a file in parts and the element can play it", async () => {
|
|
1728
|
+
await load(`<video id="v" width="160" height="120"></video>`);
|
|
1729
|
+
expect(await evaluate(`(() => {
|
|
1730
|
+
try {
|
|
1731
|
+
new MediaSource().addSourceBuffer('video/mp4; codecs="avc1.4d401e"');
|
|
1732
|
+
return "no-throw";
|
|
1733
|
+
} catch (error) {
|
|
1734
|
+
return error.name;
|
|
1735
|
+
}
|
|
1736
|
+
})()`)).toBe("InvalidStateError");
|
|
1737
|
+
expect(await evaluate(`[
|
|
1738
|
+
MediaSource.isTypeSupported('video/mp4; codecs="avc1.4d401e"'),
|
|
1739
|
+
MediaSource.isTypeSupported('audio/mp4; codecs="mp4a.40.2"'),
|
|
1740
|
+
MediaSource.isTypeSupported('video/mp4; codecs="avc1.4d401e, mp4a.40.2"'),
|
|
1741
|
+
MediaSource.isTypeSupported("video/mp4"),
|
|
1742
|
+
MediaSource.isTypeSupported("video/x-unknown"),
|
|
1743
|
+
HTMLMediaElement.HAVE_METADATA,
|
|
1744
|
+
HTMLMediaElement.HAVE_NOTHING
|
|
1745
|
+
].join()`)).toBe("true,true,true,false,false,1,0");
|
|
1746
|
+
await evaluate(`(() => {
|
|
1747
|
+
const video = document.getElementById("v");
|
|
1748
|
+
const source = new MediaSource();
|
|
1749
|
+
window.ms = source;
|
|
1750
|
+
window.mse = {};
|
|
1751
|
+
video.src = URL.createObjectURL(source);
|
|
1752
|
+
source.addEventListener("sourceopen", () => { window.mse.open = source.readyState; });
|
|
1753
|
+
})()`);
|
|
1754
|
+
await waitFor("window.mse.open === 'open'");
|
|
1755
|
+
await evaluate(`(async () => {
|
|
1756
|
+
const bytes = new Uint8Array(await (await fetch("/video.mp4")).arrayBuffer());
|
|
1757
|
+
const buffer = window.ms.addSourceBuffer('video/mp4; codecs="avc1.4d401e, mp4a.40.2"');
|
|
1758
|
+
const middle = bytes.length >> 1;
|
|
1759
|
+
let updates = 0;
|
|
1760
|
+
buffer.appendBuffer(bytes.subarray(0, middle));
|
|
1761
|
+
buffer.addEventListener("updateend", () => {
|
|
1762
|
+
updates += 1;
|
|
1763
|
+
window.mse.updates = updates;
|
|
1764
|
+
window.mse.ready = document.getElementById("v").readyState;
|
|
1765
|
+
if (updates === 1) {
|
|
1766
|
+
document.getElementById("v").play();
|
|
1767
|
+
buffer.appendBuffer(bytes.subarray(middle));
|
|
1768
|
+
} else {
|
|
1769
|
+
window.mse.buffered = buffer.buffered.length ? buffer.buffered.end(0) : 0;
|
|
1770
|
+
window.ms.endOfStream();
|
|
1771
|
+
window.mse.ended = window.ms.readyState;
|
|
1772
|
+
}
|
|
1773
|
+
});
|
|
1774
|
+
})()`);
|
|
1775
|
+
await waitFor("window.mse.updates === 2 && window.mse.ended === 'ended'");
|
|
1776
|
+
expect(await waitFor("document.getElementById('v').readyState >= 1")).toBe(true);
|
|
1777
|
+
expect(await evaluate("window.mse.buffered > 0")).toBe(true);
|
|
1778
|
+
expect(await waitFor("(() => { const v = document.getElementById('v'); return !v.paused && v.currentTime > 0; })()")).toBe(true);
|
|
1779
|
+
expect(await evaluate("Math.round(document.getElementById('v').duration)")).toBe(1);
|
|
1780
|
+
});
|
|
1781
|
+
|
|
1782
|
+
test("MediaSource consumes an append that arrived before the decoder stalled", async () => {
|
|
1783
|
+
await load(`<video id="v" width="160" height="120"></video>`);
|
|
1784
|
+
await evaluate(`(() => {
|
|
1785
|
+
const video = document.getElementById("v");
|
|
1786
|
+
const source = new MediaSource();
|
|
1787
|
+
window.ms = source;
|
|
1788
|
+
video.src = URL.createObjectURL(source);
|
|
1789
|
+
source.addEventListener("sourceopen", async () => {
|
|
1790
|
+
const bytes = new Uint8Array(await (await fetch("/video.mp4")).arrayBuffer());
|
|
1791
|
+
const buffer = source.addSourceBuffer('video/mp4; codecs="avc1.4d401e, mp4a.40.2"');
|
|
1792
|
+
const middle = bytes.length >> 1;
|
|
1793
|
+
let append = 0;
|
|
1794
|
+
buffer.addEventListener("updateend", () => {
|
|
1795
|
+
append += 1;
|
|
1796
|
+
if (append === 1) {
|
|
1797
|
+
video.play();
|
|
1798
|
+
buffer.appendBuffer(bytes.subarray(middle));
|
|
1799
|
+
}
|
|
1800
|
+
});
|
|
1801
|
+
buffer.appendBuffer(bytes.subarray(0, middle));
|
|
1802
|
+
});
|
|
1803
|
+
})()`);
|
|
1804
|
+
// Deliberately leave the MediaSource open. Playback must reopen from the
|
|
1805
|
+
// already-appended second generation rather than wait for a third append.
|
|
1806
|
+
expect(await waitFor("document.getElementById('v').currentTime > 0.75", 4000)).toBe(true);
|
|
1807
|
+
});
|
|
1808
|
+
|
|
1809
|
+
test("a video waits for its missing audio SourceBuffer instead of outrunning sound", async () => {
|
|
1810
|
+
await load(`<video id="v" width="160" height="120"></video>`);
|
|
1811
|
+
await evaluate(`(() => {
|
|
1812
|
+
const video = document.getElementById("v");
|
|
1813
|
+
const source = new MediaSource();
|
|
1814
|
+
window.ms = source;
|
|
1815
|
+
window.mse = {};
|
|
1816
|
+
video.src = URL.createObjectURL(source);
|
|
1817
|
+
source.addEventListener("sourceopen", () => { window.mse.open = source.readyState; });
|
|
1818
|
+
})()`);
|
|
1819
|
+
await waitFor("window.mse.open === 'open'");
|
|
1820
|
+
await evaluate(`(async () => {
|
|
1821
|
+
const bytes = new Uint8Array(await (await fetch("/video.mp4")).arrayBuffer());
|
|
1822
|
+
const picture = window.ms.addSourceBuffer('video/mp4; codecs="avc1.4d401e"');
|
|
1823
|
+
const sound = window.ms.addSourceBuffer('audio/mp4; codecs="mp4a.40.2"');
|
|
1824
|
+
picture.addEventListener("updateend", () => {
|
|
1825
|
+
window.mse.videoUpdated = true;
|
|
1826
|
+
document.getElementById("v").play();
|
|
1827
|
+
});
|
|
1828
|
+
picture.appendBuffer(bytes);
|
|
1829
|
+
sound.appendBuffer(bytes.subarray(0, 32));
|
|
1830
|
+
})()`);
|
|
1831
|
+
await waitFor("window.mse.videoUpdated === true");
|
|
1832
|
+
expect(await waitFor(`(() => { const v = document.getElementById('v');
|
|
1833
|
+
return !v.paused && v.error === null && v.videoWidth > 0; })()`)).toBe(true);
|
|
1834
|
+
await Bun.sleep(1000);
|
|
1835
|
+
expect(await evaluate("document.getElementById('v').currentTime")).toBe(0);
|
|
1836
|
+
});
|
|
1837
|
+
|
|
1838
|
+
test("initial MSE playback waits briefly for a separate audio segment", async () => {
|
|
1839
|
+
await load(`<video id="v" width="160" height="120"></video>`);
|
|
1840
|
+
await evaluate(`(() => {
|
|
1841
|
+
const video = document.getElementById("v");
|
|
1842
|
+
// play is the unpause request; playing is when buffered media can advance.
|
|
1843
|
+
video.addEventListener("playing", () => { window.startedAt = performance.now(); }, { once:true });
|
|
1844
|
+
const source = new MediaSource();
|
|
1845
|
+
video.src = URL.createObjectURL(source);
|
|
1846
|
+
source.addEventListener("sourceopen", async () => {
|
|
1847
|
+
const bytes = new Uint8Array(await (await fetch("/video.mp4")).arrayBuffer());
|
|
1848
|
+
const audioBytes = new Uint8Array(await (await fetch("/tone.mp3")).arrayBuffer());
|
|
1849
|
+
const picture = source.addSourceBuffer('video/mp4; codecs="avc1.4d401e"');
|
|
1850
|
+
const sound = source.addSourceBuffer('audio/mpeg');
|
|
1851
|
+
picture.addEventListener("updateend", () => video.play(), { once: true });
|
|
1852
|
+
picture.appendBuffer(bytes);
|
|
1853
|
+
sound.appendBuffer(audioBytes.subarray(0, 16));
|
|
1854
|
+
setTimeout(() => {
|
|
1855
|
+
window.audioAppendedAt = performance.now();
|
|
1856
|
+
sound.appendBuffer(audioBytes.subarray(16));
|
|
1857
|
+
}, 250);
|
|
1858
|
+
});
|
|
1859
|
+
})()`);
|
|
1860
|
+
expect(await waitFor("Boolean(window.startedAt && window.audioAppendedAt)", 3000)).toBe(true);
|
|
1861
|
+
expect(await evaluate("window.startedAt >= window.audioAppendedAt")).toBe(true);
|
|
1862
|
+
expect(await waitFor("document.getElementById('v').currentTime > 0.1", 3000)).toBe(true);
|
|
1863
|
+
});
|
|
1864
|
+
|
|
1865
|
+
const waitFor = async (expression, timeout = 5000) => {
|
|
1866
|
+
const deadline = Date.now() + timeout;
|
|
1867
|
+
while (Date.now() < deadline) {
|
|
1868
|
+
const value = await evaluate(expression);
|
|
1869
|
+
if (value) return value;
|
|
1870
|
+
await Bun.sleep(50);
|
|
1871
|
+
}
|
|
1872
|
+
throw new Error(`timed out waiting for ${expression}`);
|
|
1873
|
+
};
|
|
1874
|
+
|
|
1875
|
+
test("same-origin iframes: documents, contentWindow, names, postMessage with source and origin", async () => {
|
|
1876
|
+
pages.set("https://page.test/child.html", `<!doctype html><body><p id="inner">child</p><script>
|
|
1877
|
+
window.addEventListener("message", (event) => {
|
|
1878
|
+
event.source.postMessage({ echo: event.data, fromParent: event.source === parent, origin: event.origin }, "*");
|
|
1879
|
+
});
|
|
1880
|
+
</script></body>`);
|
|
1881
|
+
await load(`<iframe id="frame" name="kid" src="child.html" style="width:200px;height:100px"></iframe><script>
|
|
1882
|
+
window.replies = [];
|
|
1883
|
+
window.addEventListener("message", (event) => replies.push({ data: event.data, fromChild: event.source === frames.kid,
|
|
1884
|
+
origin: event.origin }));
|
|
1885
|
+
document.getElementById("frame").addEventListener("load", () => {
|
|
1886
|
+
window.frameLoaded = true;
|
|
1887
|
+
frames.kid.postMessage("hello", "https://page.test");
|
|
1888
|
+
frames.kid.postMessage("never", "https://other.test");
|
|
1889
|
+
});
|
|
1890
|
+
</script>`);
|
|
1891
|
+
await waitFor("window.replies.length > 0");
|
|
1892
|
+
expect(await evaluate("frames.length")).toBe(1);
|
|
1893
|
+
expect(await evaluate(`document.getElementById("frame").contentDocument.getElementById("inner").textContent`)).toBe("child");
|
|
1894
|
+
expect(await evaluate(`document.getElementById("frame").contentWindow.parent === window`)).toBe(true);
|
|
1895
|
+
await Bun.sleep(100);
|
|
1896
|
+
// The message for another origin is not delivered.
|
|
1897
|
+
expect(await evaluate("replies")).toEqual([{
|
|
1898
|
+
data: { echo: "hello", fromParent: true, origin: "https://page.test" }, fromChild: true, origin: "https://page.test",
|
|
1899
|
+
}]);
|
|
1900
|
+
});
|
|
1901
|
+
|
|
1902
|
+
test("iframe messages retain their sender through promises and await", async () => {
|
|
1903
|
+
pages.set("https://page.test/async-child.html", `<!doctype html><script>
|
|
1904
|
+
addEventListener("message", async (event) => {
|
|
1905
|
+
window.fromParent = event.source === parent;
|
|
1906
|
+
await Promise.resolve();
|
|
1907
|
+
parent.postMessage("await reply", "*");
|
|
1908
|
+
Promise.resolve().then(() => parent.postMessage("then reply", "*"));
|
|
1909
|
+
});
|
|
1910
|
+
</script>`);
|
|
1911
|
+
await load(`<iframe id="frame" src="async-child.html"></iframe><script>
|
|
1912
|
+
window.asyncReplies = [];
|
|
1913
|
+
addEventListener("message", event => asyncReplies.push({
|
|
1914
|
+
data: event.data, fromChild: event.source === document.getElementById("frame").contentWindow,
|
|
1915
|
+
origin: event.origin
|
|
1916
|
+
}));
|
|
1917
|
+
document.getElementById("frame").addEventListener("load", async () => {
|
|
1918
|
+
await Promise.resolve();
|
|
1919
|
+
document.getElementById("frame").contentWindow.postMessage("start", "*");
|
|
1920
|
+
});
|
|
1921
|
+
</script>`);
|
|
1922
|
+
await waitFor("window.asyncReplies.length === 2");
|
|
1923
|
+
expect(await evaluate("asyncReplies")).toEqual([
|
|
1924
|
+
{ data: "await reply", fromChild: true, origin: "https://page.test" },
|
|
1925
|
+
{ data: "then reply", fromChild: true, origin: "https://page.test" },
|
|
1926
|
+
]);
|
|
1927
|
+
expect(await evaluate('document.getElementById("frame").contentWindow.fromParent')).toBe(true);
|
|
1928
|
+
});
|
|
1929
|
+
|
|
1930
|
+
test("an inserted iframe has its about:blank window at once and fires load once", async () => {
|
|
1931
|
+
await load(`<p>blank</p><script>
|
|
1932
|
+
const frame = document.createElement("iframe");
|
|
1933
|
+
document.body.appendChild(frame);
|
|
1934
|
+
const win = frame.contentWindow;
|
|
1935
|
+
window.result = [win.JSON.parse('{"a":1}').a, !!win.document.body, frame.contentDocument === win.document];
|
|
1936
|
+
window.loads = 0;
|
|
1937
|
+
frame.addEventListener("load", () => { loads++; window.sameWindow = frame.contentWindow === win; });
|
|
1938
|
+
</script>`);
|
|
1939
|
+
expect(await evaluate("result")).toEqual([1, true, true]);
|
|
1940
|
+
await waitFor("window.loads > 0");
|
|
1941
|
+
await Bun.sleep(100);
|
|
1942
|
+
expect(await evaluate("[loads, sameWindow]")).toEqual([1, true]);
|
|
1943
|
+
});
|
|
1944
|
+
|
|
1945
|
+
test("MessageChannel ports and dedicated workers with importScripts and transfers", async () => {
|
|
1946
|
+
scripts.set("https://page.test/helper.js", "self.double = (n) => n * 2;");
|
|
1947
|
+
scripts.set("https://page.test/worker.js", `importScripts("helper.js");
|
|
1948
|
+
onmessage = (event) => {
|
|
1949
|
+
const [port] = event.ports;
|
|
1950
|
+
port.postMessage({ doubled: double(event.data.n), bytes: event.data.buffer.byteLength });
|
|
1951
|
+
};`);
|
|
1952
|
+
await load(`<script>
|
|
1953
|
+
window.got = null;
|
|
1954
|
+
const channel = new MessageChannel();
|
|
1955
|
+
channel.port1.onmessage = (event) => { window.got = event.data; };
|
|
1956
|
+
const worker = new Worker("worker.js");
|
|
1957
|
+
const buffer = new ArrayBuffer(8);
|
|
1958
|
+
worker.postMessage({ n: 21, buffer }, [channel.port2, buffer]);
|
|
1959
|
+
window.detached = buffer.byteLength;
|
|
1960
|
+
</script>`);
|
|
1961
|
+
await waitFor("window.got");
|
|
1962
|
+
expect(await evaluate("[got, detached]")).toEqual([{ doubled: 42, bytes: 8 }, 0]);
|
|
1963
|
+
});
|
|
1964
|
+
|
|
1965
|
+
test("sloppy-mode this is the global object, also through the Function constructor", async () => {
|
|
1966
|
+
await load(`<script>
|
|
1967
|
+
window.viaFunction = Function("return this")() === window;
|
|
1968
|
+
window.viaCall = (function () { return this; })() === window;
|
|
1969
|
+
window.strict = (function () { "use strict"; return this; })() === undefined;
|
|
1970
|
+
</script>`);
|
|
1971
|
+
expect(await evaluate("[viaFunction, viaCall, strict]")).toEqual([true, true, true]);
|
|
1972
|
+
});
|
|
1973
|
+
|
|
1974
|
+
test("a click sends pointer and mouse events to the element under it, then focuses its focusable ancestor", async () => {
|
|
1975
|
+
await load(`<div id="tile" tabindex="0" style="width:100px;height:60px"><span id="label">tile</span></div><script>
|
|
1976
|
+
window.events = [];
|
|
1977
|
+
for (const type of ["pointerdown", "mousedown", "focus", "pointerup", "mouseup", "click"]) {
|
|
1978
|
+
document.getElementById("tile").addEventListener(type, (event) => events.push(type + ":" + event.target.id), true);
|
|
1979
|
+
}
|
|
1980
|
+
</script>`);
|
|
1981
|
+
await renderer.call("click", 80, 30);
|
|
1982
|
+
expect(await evaluate("events")).toEqual(["pointerdown:tile", "mousedown:tile", "focus:tile", "pointerup:tile", "mouseup:tile", "click:tile"]);
|
|
1983
|
+
expect(await evaluate("document.activeElement.id")).toBe("tile");
|
|
1984
|
+
});
|
|
1985
|
+
|
|
1986
|
+
test("a click inside an iframe goes to the element under it in the frame's document", async () => {
|
|
1987
|
+
pages.set("https://page.test/cells.html", `<!doctype html><body style="margin:0"><div id="cell" style="width:100px;height:100px"></div><script>
|
|
1988
|
+
window.clicked = [];
|
|
1989
|
+
document.getElementById("cell").addEventListener("click", (event) => clicked.push([event.clientX, event.clientY]));
|
|
1990
|
+
</script></body>`);
|
|
1991
|
+
await load(`<iframe id="frame" src="cells.html" style="position:absolute;left:50px;top:40px;width:200px;height:150px;border:0"></iframe>`);
|
|
1992
|
+
await waitFor(`document.getElementById("frame").contentWindow.clicked`);
|
|
1993
|
+
await renderer.call("click", 70, 60);
|
|
1994
|
+
expect(await evaluate(`document.getElementById("frame").contentWindow.clicked`)).toEqual([[20, 20]]);
|
|
1995
|
+
});
|
|
1996
|
+
|
|
1997
|
+
test("dynamic linked stylesheets fire load after their rules become available", async () => {
|
|
1998
|
+
await load(`<div id="target"></div><script>
|
|
1999
|
+
window.sheetLoaded = false;
|
|
2000
|
+
const link = document.createElement('link');
|
|
2001
|
+
link.rel = 'stylesheet';
|
|
2002
|
+
link.href = '/slow.css';
|
|
2003
|
+
link.onload = () => { window.sheetLoaded = getComputedStyle(document.body).color; };
|
|
2004
|
+
document.head.appendChild(link);
|
|
2005
|
+
</script>`);
|
|
2006
|
+
for (let i = 0; i < 60 && await evaluate('sheetLoaded') === false; i++) await Bun.sleep(25);
|
|
2007
|
+
expect(await evaluate('sheetLoaded')).toBe('rgba(255, 0, 0, 1)');
|
|
2008
|
+
});
|
|
2009
|
+
|
|
2010
|
+
test("decoded dynamic images report natural dimensions and fire load", async () => {
|
|
2011
|
+
await load(`<script>
|
|
2012
|
+
window.imageLoaded = false;
|
|
2013
|
+
const img = document.createElement('img');
|
|
2014
|
+
img.onload = () => { window.imageLoaded = [img.complete, img.naturalWidth, img.naturalHeight]; };
|
|
2015
|
+
img.src = '/red-2x3.png';
|
|
2016
|
+
document.body.appendChild(img);
|
|
2017
|
+
</script>`);
|
|
2018
|
+
for (let i = 0; i < 60 && await evaluate('imageLoaded') === false; i++) await Bun.sleep(25);
|
|
2019
|
+
expect(await evaluate('imageLoaded')).toEqual([true, 2, 3]);
|
|
2020
|
+
});
|
|
2021
|
+
|
|
2022
|
+
test("images a script inserts and measures at once still load", async () => {
|
|
2023
|
+
await load(`<div id="host"></div><script>
|
|
2024
|
+
const img = document.createElement("img");
|
|
2025
|
+
img.src = "/red-2x3.png";
|
|
2026
|
+
document.getElementById("host").appendChild(img);
|
|
2027
|
+
// Reading geometry lays the page out before any frame does.
|
|
2028
|
+
window.firstWidth = img.getBoundingClientRect().width;
|
|
2029
|
+
</script>`);
|
|
2030
|
+
await waitFor(`document.querySelector("img").getBoundingClientRect().width === 2`);
|
|
2031
|
+
expect(await evaluate(`[document.querySelector("img").getBoundingClientRect().height, firstWidth !== 2]`)).toEqual([3, true]);
|
|
2032
|
+
});
|
|
2033
|
+
|
|
2034
|
+
test("scroll containers scroll by wheel and script, move their content and hit-test where it shows", async () => {
|
|
2035
|
+
await load(`<div id="box" style="width:200px;height:100px;overflow-y:auto;border:2px solid black">${
|
|
2036
|
+
Array.from({ length: 10 }, (_, index) => `<p id="p${index}" style="margin:0;height:30px" onclick="window.hit = this.id">row ${index}</p>`).join("")
|
|
2037
|
+
}</div><div id="still" style="height:40px;overflow:hidden"><p style="margin:0;height:80px">hidden overflow</p></div><script>
|
|
2038
|
+
window.$ = (id) => document.getElementById(id);
|
|
2039
|
+
window.scrolls = 0;
|
|
2040
|
+
$("box").addEventListener("scroll", () => scrolls++);
|
|
2041
|
+
</script>`);
|
|
2042
|
+
expect(await evaluate(`[$("box").scrollTop, $("box").scrollHeight, $("box").clientHeight, $("p3").getBoundingClientRect().top]`))
|
|
2043
|
+
.toEqual([0, 300, 100, 92]);
|
|
2044
|
+
// The wheel over the container scrolls it, not the page: nothing is left over.
|
|
2045
|
+
expect(await renderer.call("wheel", 50, 50, 0, 75)).toEqual({ x: 0, y: 0 });
|
|
2046
|
+
await waitFor("window.scrolls > 0");
|
|
2047
|
+
expect(await evaluate(`[$("box").scrollTop, $("p3").getBoundingClientRect().top]`)).toEqual([75, 17]);
|
|
2048
|
+
await renderer.call("click", 50, 20);
|
|
2049
|
+
expect(await evaluate("window.hit")).toBe("p3");
|
|
2050
|
+
// Scripts scroll it too, clamped; overflow: hidden scrolls only by script.
|
|
2051
|
+
expect(await evaluate(`($("box").scrollTop = 1000, $("box").scrollTop)`)).toBe(200);
|
|
2052
|
+
expect(await evaluate(`$("p1").scrollIntoView(), [$("box").scrollTop, $("p1").getBoundingClientRect().top]`)).toEqual([30, 2]);
|
|
2053
|
+
const still = await evaluate(`$("still").getBoundingClientRect().toJSON()`);
|
|
2054
|
+
expect(await renderer.call("wheel", 50, still.y + 10, 0, 20)).toEqual({ x: 0, y: 20 });
|
|
2055
|
+
expect(await evaluate(`($("still").scrollTop = 25, $("still").scrollTop)`)).toBe(25);
|
|
2056
|
+
// What the container cannot take goes on to the page (scroll chaining).
|
|
2057
|
+
await evaluate(`$("box").scrollTop = 190`);
|
|
2058
|
+
expect(await renderer.call("wheel", 50, 50, 0, 30)).toEqual({ x: 0, y: 20 });
|
|
2059
|
+
expect(await evaluate(`$("box").scrollTop`)).toBe(200);
|
|
2060
|
+
// A wheel listener that cancels the event keeps it where it is.
|
|
2061
|
+
await evaluate(`$("box").scrollTop = 30; $("box").addEventListener("wheel", (event) => event.preventDefault())`);
|
|
2062
|
+
expect(await renderer.call("wheel", 50, 50, 0, 30)).toEqual({ x: 0, y: 0 });
|
|
2063
|
+
expect(await evaluate(`$("box").scrollTop`)).toBe(30);
|
|
2064
|
+
});
|
|
2065
|
+
|
|
2066
|
+
test("the wheel over an iframe scrolls its document first, unless scrolling=no, then what is around it", async () => {
|
|
2067
|
+
pages.set("https://page.test/tall.html", `<!doctype html><body style="margin:0"><div style="height:400px">tall</div></body>`);
|
|
2068
|
+
await load(`<div id="outer" style="width:220px;height:100px;overflow:auto">
|
|
2069
|
+
<iframe id="fixed" scrolling="no" src="tall.html" style="display:block;width:200px;height:150px;border:0"></iframe>
|
|
2070
|
+
<iframe id="free" src="tall.html" style="display:block;width:200px;height:150px;border:0"></iframe>
|
|
2071
|
+
</div><script>window.$ = (id) => document.getElementById(id);</script>`);
|
|
2072
|
+
await waitFor(`$("free").contentDocument?.body?.textContent === "tall" && $("fixed").contentDocument?.body?.textContent === "tall"`);
|
|
2073
|
+
// Over the scrolling=no frame: its document stays, the container scrolls.
|
|
2074
|
+
expect(await renderer.call("wheel", 50, 20, 0, 60)).toEqual({ x: 0, y: 0 });
|
|
2075
|
+
expect(await evaluate(`[$("outer").scrollTop, $("fixed").contentWindow.scrollY]`)).toEqual([60, 0]);
|
|
2076
|
+
// Over the other frame (now at 90..240): its document scrolls first.
|
|
2077
|
+
expect(await renderer.call("wheel", 50, 95, 0, 30)).toEqual({ x: 0, y: 0 });
|
|
2078
|
+
expect(await evaluate(`[$("outer").scrollTop, $("free").contentWindow.scrollY]`)).toEqual([60, 30]);
|
|
2079
|
+
});
|
|
2080
|
+
|
|
2081
|
+
test("compareDocumentPosition, document.styleSheets and user timing with PerformanceObserver", async () => {
|
|
2082
|
+
await load(`<style>p { color: red }</style><link rel="stylesheet" href="/missing.css" media="print">
|
|
2083
|
+
<div id="outer"><p id="first">a</p><p id="second">b</p></div><script>
|
|
2084
|
+
window.$ = (id) => document.getElementById(id);
|
|
2085
|
+
window.observed = [];
|
|
2086
|
+
new PerformanceObserver((list) => observed.push(...list.getEntries().map((entry) => entry.entryType + ":" + entry.name)))
|
|
2087
|
+
.observe({ entryTypes: ["mark", "measure"] });
|
|
2088
|
+
performance.mark("start");
|
|
2089
|
+
performance.mark("end");
|
|
2090
|
+
performance.measure("span", "start", "end");
|
|
2091
|
+
</script>`);
|
|
2092
|
+
expect(await evaluate(`[
|
|
2093
|
+
$("first").compareDocumentPosition($("second")), $("second").compareDocumentPosition($("first")),
|
|
2094
|
+
$("outer").compareDocumentPosition($("first")), $("first").compareDocumentPosition($("outer")),
|
|
2095
|
+
Node.DOCUMENT_POSITION_FOLLOWING, $("first").DOCUMENT_POSITION_CONTAINED_BY,
|
|
2096
|
+
]`)).toEqual([4, 2, 20, 10, 4, 16]);
|
|
2097
|
+
expect(await evaluate(`[document.styleSheets.length, document.styleSheets[0].href, document.styleSheets[1].href,
|
|
2098
|
+
document.styleSheets.item(1).media.mediaText, document.styleSheets[0].ownerNode.tagName]`))
|
|
2099
|
+
.toEqual([2, null, "https://page.test/missing.css", "print", "STYLE"]);
|
|
2100
|
+
await waitFor("window.observed.length === 3");
|
|
2101
|
+
expect(await evaluate(`[observed, performance.getEntriesByType("measure").length, PerformanceObserver.supportedEntryTypes]`))
|
|
2102
|
+
.toEqual([["mark:start", "mark:end", "measure:span"], 1, ["mark", "measure"]]);
|
|
2103
|
+
expect(await evaluate(`performance.clearMarks(), performance.clearMeasures("span"), performance.getEntries().length`)).toBe(0);
|
|
2104
|
+
});
|
|
2105
|
+
|
|
2106
|
+
test("selectors with CSS escapes parse, in type selectors and around combinators too", async () => {
|
|
2107
|
+
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>`);
|
|
2108
|
+
const selectors = [String.raw`style\=\"\"`, String.raw`#a\:b`, String.raw`.x\,y > span`, String.raw`.p\(q\)`,
|
|
2109
|
+
String.raw`my\-el`, String.raw`\64 iv`,
|
|
2110
|
+
// CSS.escape() output for names starting with a digit, and an escaped quote in a value.
|
|
2111
|
+
String.raw`#\31 23`, String.raw`.\31 x p`, String.raw`[data-k="a\"b"]`, String.raw`[d\61 ta-k]`];
|
|
2112
|
+
expect(await evaluate(`${JSON.stringify(selectors)}.map((selector) => document.querySelectorAll(selector).length)`))
|
|
2113
|
+
.toEqual([0, 1, 1, 1, 1, 1, 1, 1, 1, 1]);
|
|
2114
|
+
});
|
|
2115
|
+
|
|
2116
|
+
test("a canvas 2d context samples colours through CanvasKit", async () => {
|
|
2117
|
+
await load("");
|
|
2118
|
+
expect(await evaluate(`(() => {
|
|
2119
|
+
const canvas = document.createElementNS("http://www.w3.org/1999/xhtml", "canvas");
|
|
2120
|
+
canvas.width = canvas.height = 1;
|
|
2121
|
+
const context = canvas.getContext("2d");
|
|
2122
|
+
const sample = (color) => {
|
|
2123
|
+
context.fillStyle = "#000";
|
|
2124
|
+
context.fillStyle = color;
|
|
2125
|
+
const first = context.fillStyle;
|
|
2126
|
+
context.fillStyle = "#fff";
|
|
2127
|
+
context.fillStyle = color;
|
|
2128
|
+
if (first !== context.fillStyle) return null;
|
|
2129
|
+
context.fillRect(0, 0, 1, 1);
|
|
2130
|
+
const data = [...context.getImageData(0, 0, 1, 1).data];
|
|
2131
|
+
context.clearRect(0, 0, 1, 1);
|
|
2132
|
+
return data;
|
|
2133
|
+
};
|
|
2134
|
+
return {
|
|
2135
|
+
onDiv: "getContext" in document.createElement("div"),
|
|
2136
|
+
same: canvas.getContext("2d") === context,
|
|
2137
|
+
webgl: canvas.getContext("webgl"),
|
|
2138
|
+
size: [document.createElement("canvas").width, document.createElement("canvas").height],
|
|
2139
|
+
red: sample("red"),
|
|
2140
|
+
bad: sample("not-a-color"),
|
|
2141
|
+
};
|
|
2142
|
+
})()`)).toEqual({
|
|
2143
|
+
onDiv: false, same: true, webgl: null, size: [300, 150], red: [255, 0, 0, 255], bad: [0, 0, 0, 255],
|
|
2144
|
+
});
|
|
2145
|
+
});
|
|
2146
|
+
|
|
2147
|
+
test("the page scrolls over content that overflows a 100vh layout, not over clipped or fixed content", async () => {
|
|
2148
|
+
await load(`<div style="height:100vh;display:flex;flex-direction:column">
|
|
2149
|
+
<header style="height:50px">top</header><main style="height:2000px;flex-shrink:0">tall</main>
|
|
2150
|
+
<footer id="end" style="height:40px;flex-shrink:0">end</footer></div>
|
|
2151
|
+
<div style="height:20px;overflow:hidden"><div style="height:9000px">clipped</div></div>
|
|
2152
|
+
<div style="position:fixed;top:0;height:20000px;width:10px"></div>`);
|
|
2153
|
+
// 50 + 2000 + 40 of overflowing content: the footer can be scrolled to; clipped and fixed boxes add nothing.
|
|
2154
|
+
expect(await evaluate(`(scrollTo(0, 99999), [scrollY, Math.round(document.getElementById("end").getBoundingClientRect().bottom)])`))
|
|
2155
|
+
.toEqual([2090 - 300, 300]);
|
|
2156
|
+
});
|