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.
Files changed (72) hide show
  1. package/AGENTS.md +4 -0
  2. package/README.md +74 -9
  3. package/bunfig.toml +1 -0
  4. package/buninu-browser.js +4 -0
  5. package/index.js +5 -0
  6. package/lib/audio/piano.js +24 -1
  7. package/lib/audio/pulse-client.js +120 -12
  8. package/lib/cdp-server.js +16 -11
  9. package/lib/happy-dom/parser.js +24 -4
  10. package/lib/headless-shell.js +66 -19
  11. package/lib/input/hit-test.js +25 -7
  12. package/lib/layout/text-layout.js +196 -49
  13. package/lib/log.js +5 -2
  14. package/lib/media/byte-pipe.js +82 -0
  15. package/lib/media/position-audio.js +42 -0
  16. package/lib/network/network-service.js +161 -14
  17. package/lib/paint/display-list.js +300 -14
  18. package/lib/paint/gradient.js +11 -5
  19. package/lib/render-tree/index.js +17 -2
  20. package/lib/render-tree/svg.js +2 -1
  21. package/lib/renderer/bindings.js +985 -68
  22. package/lib/renderer/canvas-2d.js +92 -0
  23. package/lib/renderer/host.js +158 -8
  24. package/lib/renderer/interfaces.js +215 -0
  25. package/lib/renderer/main.js +160 -12
  26. package/lib/renderer/media-source.js +819 -0
  27. package/lib/renderer/messaging.js +411 -0
  28. package/lib/renderer/native-functions.js +113 -0
  29. package/lib/renderer/page-realm.js +1405 -198
  30. package/lib/renderer/page-renderer.js +1736 -193
  31. package/lib/renderer/rejections.js +31 -0
  32. package/lib/renderer/rewrite-worker.js +22 -0
  33. package/lib/renderer/script-rewrite.js +805 -57
  34. package/lib/renderer/trusted-types.js +129 -0
  35. package/lib/renderer/workers.js +327 -0
  36. package/lib/screenshot-cli.js +2 -0
  37. package/lib/style/computed-style.js +162 -22
  38. package/lib/user-agent.js +14 -2
  39. package/package.json +4 -1
  40. package/patches/happy-dom/0002-use-bun-platform-adapters.patch +139 -19
  41. package/patches/happy-dom/0006-parse-css-escapes-in-selectors.patch +115 -0
  42. package/patches/happy-dom/README.md +26 -0
  43. package/patches/ses/0002-embedder-native-functions-source-text-sloppy-code.patch +106 -0
  44. package/patches/ses/0003-cache-sloppy-evaluators.patch +47 -0
  45. package/patches/ses/README.md +4 -0
  46. package/test/canvas-2d.test.js +55 -0
  47. package/test/compositor.test.js +3 -1
  48. package/test/computed-style.test.js +38 -1
  49. package/test/display-list.test.js +74 -1
  50. package/test/download.test.js +35 -0
  51. package/test/fixtures/long-video.mp4 +0 -0
  52. package/test/fixtures/media-sync.js +57 -0
  53. package/test/happy-dom-platform-adapters.test.js +25 -0
  54. package/test/headless-shell.test.js +1 -1
  55. package/test/hit-test.test.js +25 -1
  56. package/test/media-source.test.js +156 -0
  57. package/test/media-sync.test.js +12 -0
  58. package/test/mobile.test.js +2 -2
  59. package/test/navigation.test.js +61 -0
  60. package/test/network-service.test.js +14 -0
  61. package/test/page-scripts.test.js +1045 -7
  62. package/test/position-audio.test.js +67 -0
  63. package/test/positioning.test.js +95 -1
  64. package/test/pulse-client.test.js +69 -0
  65. package/test/renderer-host.test.js +5 -2
  66. package/test/screenshot-cli.test.js +25 -1
  67. package/test/script-rewrite.test.js +111 -6
  68. package/test/setup.js +5 -0
  69. package/test/user-agent.test.js +60 -4
  70. package/vendor/happy-dom/packages/happy-dom/src/nodes/html-image-element/HTMLImageElement.ts +52 -22
  71. package/vendor/happy-dom/packages/happy-dom/src/query-selector/SelectorParser.ts +18 -17
  72. 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
- await Bun.sleep(300);
312
- const during = await renderer.call("screenshot");
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
- attempt('patchIntrinsic', () => { Array.prototype.includes = () => true; return 'patched'; });
488
- attempt('patchUrl', () => { URL.prototype.toString = () => 'pwned'; return 'patched'; });
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([[3, true, "AsyncFunction"], ["1,2", "object", "function"]]));
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
+ });