buninu-browser 0.1.0 → 0.2.1

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 (64) hide show
  1. package/AGENTS.md +17 -0
  2. package/LICENSES/brotli.txt +19 -0
  3. package/README.md +41 -7
  4. package/THIRD_PARTY_NOTICES.md +26 -4
  5. package/buninu-browser.js +10 -2
  6. package/lib/audio/pcm.js +16 -0
  7. package/lib/audio/piano.js +109 -0
  8. package/lib/audio/pulse-client.js +413 -0
  9. package/lib/cdp-server.js +28 -7
  10. package/lib/document-types.js +308 -0
  11. package/lib/download-directory.js +28 -0
  12. package/lib/download.js +63 -0
  13. package/lib/happy-dom/selector-matcher.js +83 -0
  14. package/lib/headless-shell.js +165 -26
  15. package/lib/input/hit-test.js +57 -8
  16. package/lib/layout/text-layout.js +290 -41
  17. package/lib/network/network-service.js +177 -40
  18. package/lib/network/resolver.js +16 -0
  19. package/lib/paint/display-list.js +25 -10
  20. package/lib/render-tree/index.js +58 -0
  21. package/lib/renderer/bindings.js +543 -55
  22. package/lib/renderer/flags.js +8 -5
  23. package/lib/renderer/host.js +103 -7
  24. package/lib/renderer/main.js +35 -3
  25. package/lib/renderer/module-transform.js +30 -23
  26. package/lib/renderer/page-realm.js +228 -24
  27. package/lib/renderer/page-renderer.js +1264 -221
  28. package/lib/screenshot-cli.js +61 -0
  29. package/lib/style/computed-style.js +558 -229
  30. package/lib/style/selector-keys.js +302 -0
  31. package/lib/style/selector-traits.js +240 -0
  32. package/lib/user-agent.js +12 -0
  33. package/package.json +1 -1
  34. package/patches/happy-dom/0003-defer-script-events-to-renderer.patch +40 -0
  35. package/patches/happy-dom/0004-insert-adjacent-html-in-order.patch +19 -0
  36. package/patches/happy-dom/0005-invalidate-matches-on-structure-changes.patch +43 -0
  37. package/patches/happy-dom/README.md +12 -0
  38. package/patches/ses/0001-allow-regexp-own-tostring.patch +13 -0
  39. package/patches/ses/README.md +16 -0
  40. package/test/computed-style.test.js +113 -1
  41. package/test/document-types.test.js +111 -0
  42. package/test/download.test.js +192 -0
  43. package/test/fixtures/tone.mp3 +0 -0
  44. package/test/flex.test.js +11 -0
  45. package/test/happy-dom-matches.test.js +25 -0
  46. package/test/headless-shell.test.js +56 -0
  47. package/test/hit-test.test.js +37 -0
  48. package/test/log.test.js +4 -1
  49. package/test/network-service.test.js +67 -0
  50. package/test/page-scripts.test.js +437 -29
  51. package/test/piano.test.js +65 -0
  52. package/test/positioning.test.js +11 -0
  53. package/test/pulse-client.test.js +130 -0
  54. package/test/render-tree.test.js +36 -1
  55. package/test/resolver.test.js +12 -1
  56. package/test/screenshot-cli.test.js +19 -0
  57. package/test/selector-keys.test.js +39 -0
  58. package/test/selector-traits.test.js +19 -0
  59. package/test/text-layout.test.js +91 -1
  60. package/test/user-agent.test.js +42 -0
  61. package/vendor/happy-dom/packages/happy-dom/src/nodes/element/Element.ts +3 -6
  62. package/vendor/happy-dom/packages/happy-dom/src/nodes/html-script-element/HTMLScriptElement.ts +2 -20
  63. package/vendor/happy-dom/packages/happy-dom/src/query-selector/QuerySelector.ts +15 -3
  64. package/vendor/ses/dist/ses.mjs +2 -0
@@ -7,6 +7,8 @@ import { RendererHost } from "../lib/renderer/host.js";
7
7
  */
8
8
  const scripts = new Map();
9
9
  const navigations = [];
10
+ // Every resource request with its time, for the preload test.
11
+ const requests = [];
10
12
  let renderer;
11
13
 
12
14
  beforeAll(async () => {
@@ -15,6 +17,11 @@ beforeAll(async () => {
15
17
  onNavigate: (url) => navigations.push(url),
16
18
  fetch: async (url, init = {}) => {
17
19
  const target = new URL(String(url));
20
+ requests.push({ url: target.href, at: performance.now() });
21
+ if (target.pathname === "/slow.css") {
22
+ await Bun.sleep(400);
23
+ return new Response("body { color: red }", { headers: { "content-type": "text/css" } });
24
+ }
18
25
  const headers = new Headers(init.headers);
19
26
  if (target.pathname === "/api/echo") {
20
27
  return new Response(JSON.stringify({
@@ -30,13 +37,10 @@ beforeAll(async () => {
30
37
  headers: target.searchParams.has("allow") ? { "access-control-allow-origin": "*" } : {},
31
38
  });
32
39
  }
33
- if (target.hostname === "api.github.com") {
34
- return new Response(JSON.stringify(target.pathname.endsWith("/compare/HEAD...fork:main")
35
- ? { ahead_by: 37, behind_by: 0 }
36
- : { default_branch: "main" }), { headers: { "content-type": "application/json" } });
37
- }
38
40
  if (target.pathname === "/video.mp4") return new Response(Bun.file(new URL("./fixtures/video.mp4", import.meta.url)),
39
41
  { headers: { "content-type": "video/mp4" } });
42
+ if (target.pathname === "/tone.mp3") return new Response(Bun.file(new URL("./fixtures/tone.mp3", import.meta.url)),
43
+ { headers: { "content-type": "audio/mpeg" } });
40
44
  const body = scripts.get(target.href);
41
45
  return body === undefined
42
46
  ? new Response("missing", { status: 404 })
@@ -82,6 +86,32 @@ test("classic scripts share globals, load in order and fire DOMContentLoaded the
82
86
  expect(await evaluate("document.readyState")).toBe("complete");
83
87
  });
84
88
 
89
+ test("parser-blocking head scripts run before document.body exists", async () => {
90
+ await load(`<head><script>window.bodyDuringHead = document.body</script></head><body><p>ready</p></body>`);
91
+ expect(await evaluate("bodyDuringHead === null")).toBe(true);
92
+ expect(await evaluate("document.body.textContent")).toBe("ready");
93
+ });
94
+
95
+ test("anchors expose parsed hyperlink URL components", async () => {
96
+ await load(`<a id="link" href="/path?q=one#part"></a>`);
97
+ expect(await evaluate(`(() => { const a = document.getElementById("link"); return [
98
+ a.href, a.origin, a.protocol, a.host, a.hostname, a.port, a.pathname, a.search, a.hash
99
+ ] })()`)).toEqual([
100
+ "https://page.test/path?q=one#part", "https://page.test", "https:", "page.test", "page.test", "",
101
+ "/path", "?q=one", "#part",
102
+ ]);
103
+ await evaluate(`document.getElementById("link").hostname = "other.test"`);
104
+ expect(await evaluate(`document.getElementById("link").href`)).toBe("https://other.test/path?q=one#part");
105
+ });
106
+
107
+ test("getAttributeNode exposes a live Attr view", async () => {
108
+ await load(`<form id="search" onsubmit="return false"></form>`);
109
+ expect(await evaluate(`(() => { const form = document.getElementById("search"); const attr = form.getAttributeNode("onsubmit");
110
+ const before = [attr.name, attr.value, attr.nodeType, attr.ownerElement === form, attr.specified];
111
+ attr.value = "return true"; return [...before, form.getAttribute("onsubmit"), form.getAttributeNode("missing")]; })()`))
112
+ .toEqual(["onsubmit", "return false", 2, true, true, "return true", null]);
113
+ });
114
+
85
115
  test("scripts change the DOM and the next screenshot shows it", async () => {
86
116
  await load(`<div id="box" style="height:20px"></div>
87
117
  <script>document.getElementById('box').style.background = 'rgb(255, 0, 0)';
@@ -136,20 +166,136 @@ test("template content can be cloned and typed arrays are available", async () =
136
166
  expect(await evaluate("document.getElementById('mount').dataset.number")).toBe("1.5");
137
167
  });
138
168
 
139
- test("a GitHub fork branch placeholder shows the live comparison", async () => {
140
- const embedded = JSON.stringify({ payload: {
141
- codeViewLayoutRoute: { repo: { isFork: true, ownerLogin: "fork" } },
142
- sidebarAbout: { repo: { parentRepo: { ownerLogin: "upstream", name: "repo" } } },
143
- codeViewRepoRoute: { refInfo: { refType: "branch", name: "main" } },
144
- } });
145
- await load(`<div data-testid="branch-info-bar"><div class="Skeleton">&nbsp;</div></div>
146
- <script type="application/json" data-target="react-app.embeddedData">${embedded}</script>`, "https://github.com/fork/repo");
147
- for (let attempt = 0; attempt < 20; attempt++) {
148
- if ((await evaluate("document.querySelector('[data-testid=branch-info-bar]').textContent")).includes("37 commits")) break;
149
- await Bun.sleep(10);
150
- }
151
- expect(await evaluate("document.querySelector('[data-testid=branch-info-bar]').textContent"))
152
- .toBe("This branch is 37 commits ahead of upstream/repo:main.");
169
+ test("DOM collection interfaces expose iterable array-compatible prototypes", async () => {
170
+ await load(`<p>one</p><p>two</p>`);
171
+ expect(await evaluate("typeof NodeList.prototype.forEach")).toBe("function");
172
+ expect(await evaluate("typeof HTMLCollection.prototype.item")).toBe("undefined");
173
+ expect(await evaluate("[...document.querySelectorAll('p')].map(p => p.textContent).join(',')")).toBe("one,two");
174
+ });
175
+
176
+ test("an unfocused page exposes an empty Selection", async () => {
177
+ await load(`<p>text</p>`);
178
+ expect(await evaluate("[getSelection().type, getSelection().rangeCount, getSelection().toString()].join(',')"))
179
+ .toBe("None,0,");
180
+ });
181
+
182
+ test("page-local DOM prototypes remain writable after binding hardening", async () => {
183
+ await load(`<button id="toggle">Toggle</button><p id="result"></p>
184
+ <script>
185
+ Document.prototype.customQuery = function (selector) { return this.querySelector(selector); };
186
+ Element.prototype.customMarker = "page-local";
187
+ document.querySelector("#toggle").addEventListener("click", () => {
188
+ document.customQuery("#result").textContent = "clicked";
189
+ });
190
+ window.ready = document.querySelector("#toggle").customMarker === "page-local";
191
+ </script>`);
192
+ expect(await evaluate("ready")).toBe(true);
193
+ const rect = await evaluate("document.getElementById('toggle').getBoundingClientRect().toJSON()");
194
+ await renderer.call("click", rect.x + 1, rect.y + 1);
195
+ expect(await evaluate("document.getElementById('result').textContent")).toBe("clicked");
196
+ await load("<p>next document</p>");
197
+ expect(await evaluate("Element.prototype.customMarker")).toBeUndefined();
198
+ });
199
+
200
+ test("comments expose their data like text nodes", async () => {
201
+ // React finds its hydration boundaries by reading comment data (<!--&-->).
202
+ await load(`<div id="host"><!--&--><!--/&--></div>`);
203
+ expect(await evaluate("[...document.getElementById('host').childNodes].map((node) => node.data).join(' ')")).toBe("& /&");
204
+ expect(await evaluate(`(() => {
205
+ const comment = document.createComment("a");
206
+ comment.data = "bc";
207
+ return [comment.data, comment.length, comment instanceof CharacterData, comment instanceof Text, comment instanceof Comment].join(",");
208
+ })()`)).toBe("bc,2,true,false,true");
209
+ expect(await evaluate("document.createTextNode('xy') instanceof CharacterData")).toBe(true);
210
+ });
211
+
212
+ test("EventTarget can be constructed and subclassed", async () => {
213
+ expect(await evaluate(`(() => {
214
+ class Store extends EventTarget {
215
+ constructor() { super(); this.value = 1; }
216
+ bump() { this.value++; this.dispatchEvent(new CustomEvent("change", { detail: this.value })); }
217
+ }
218
+ const store = new Store();
219
+ const seen = [];
220
+ const listener = function (event) { seen.push([event.detail, this === store, event.target === store, event.currentTarget === store]); };
221
+ store.addEventListener("change", listener);
222
+ store.bump();
223
+ store.removeEventListener("change", listener);
224
+ store.bump();
225
+ const plain = new EventTarget();
226
+ let fired = 0;
227
+ plain.addEventListener("ping", () => fired++, { once: true });
228
+ plain.dispatchEvent(new Event("ping"));
229
+ plain.dispatchEvent(new Event("ping"));
230
+ return JSON.stringify({ seen, fired, value: store.value,
231
+ checks: [store instanceof Store, store instanceof EventTarget, document.body instanceof EventTarget, window instanceof EventTarget, {} instanceof EventTarget] });
232
+ })()`)).toBe(JSON.stringify({ seen: [[2, true, true, true]], fired: 1, value: 3, checks: [true, true, true, true, false] }));
233
+ });
234
+
235
+ test("elements attach shadow roots, and prototype patches can wrap attachShadow", async () => {
236
+ await load(`<div id="host">light</div>`);
237
+ expect(await evaluate(`(() => {
238
+ // A polyfill pattern: keep the original and wrap it.
239
+ const original = HTMLElement.prototype.attachShadow;
240
+ let wrapped = 0;
241
+ HTMLElement.prototype.attachShadow = function (init) { wrapped++; return original.call(this, init); };
242
+ const host = document.getElementById("host");
243
+ const root = host.attachShadow({ mode: "open" });
244
+ root.innerHTML = "<span>shadow</span>";
245
+ const closed = document.createElement("div").attachShadow({ mode: "closed" });
246
+ return JSON.stringify({ wrapped, same: host.shadowRoot === root, host: root.host === host, mode: root.mode,
247
+ html: root.innerHTML, shadow: root instanceof ShadowRoot, fragment: root instanceof DocumentFragment,
248
+ closedHidden: closed.host.shadowRoot === null, light: host.textContent });
249
+ })()`)).toBe(JSON.stringify({ wrapped: 2, same: true, host: true, mode: "open", html: "<span>shadow</span>",
250
+ shadow: true, fragment: true, closedHidden: true, light: "light" }));
251
+ });
252
+
253
+ test("TreeWalker follows the DOM traversal algorithms with page filters", async () => {
254
+ await load(`<div id="root"><p id="a"><b id="a1"></b>text</p><!--c--><p id="b" data-skip><i id="b1"></i></p><p id="c" data-reject><i id="c1"></i></p><p id="d"></p></div>`);
255
+ expect(await evaluate(`(() => {
256
+ const root = document.getElementById("root");
257
+ const walk = (walker, step) => { const seen = []; for (let node = walker[step](); node; node = walker[step]()) seen.push(node.id || node.nodeName); return seen.join(","); };
258
+ const elements = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT, {
259
+ acceptNode: (node) => node.hasAttribute("data-reject") ? NodeFilter.FILTER_REJECT
260
+ : node.hasAttribute("data-skip") ? NodeFilter.FILTER_SKIP : NodeFilter.FILTER_ACCEPT,
261
+ });
262
+ const forward = walk(elements, "nextNode");
263
+ const backward = walk(elements, "previousNode");
264
+ const children = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT, (node) => node.hasAttribute("data-skip") ? 3 : 1);
265
+ const first = children.firstChild().id, last = children.lastChild(), top = children.currentNode.id;
266
+ children.currentNode = document.getElementById("a");
267
+ const siblings = walk(children, "nextSibling");
268
+ const everything = document.createTreeWalker(root);
269
+ const all = walk(everything, "nextNode");
270
+ const comments = walk(document.createTreeWalker(root, NodeFilter.SHOW_COMMENT), "nextNode");
271
+ return [forward, backward, first, last && last.id, top, siblings, all, comments, everything.parentNode() && everything.currentNode.id].join(" | ");
272
+ })()`)).toBe("a,a1,b1,d | b1,a1,a,root | a | a1 | a1 | b1,c,d | a,a1,#text,#comment,b,b1,c,c1,d | #comment | root");
273
+ });
274
+
275
+ test("style feature detection only finds properties the renderer implements", async () => {
276
+ await load("<p>x</p>");
277
+ expect(await evaluate(`["color", "backgroundColor", "setProperty", "cssText", "anchorName", "positionTryFallbacks", "notAProperty"]
278
+ .map((name) => name in document.body.style).join(",")`)).toBe("true,true,true,true,false,false,false");
279
+ expect(await evaluate(`[
280
+ CSS.supports("color", "red"),
281
+ CSS.supports("animation-timeline", "scroll()"),
282
+ CSS.supports("(display: grid)")
283
+ ].join(",")`)).toBe("true,false,false");
284
+ });
285
+
286
+ test("the root element's client size is the viewport, and computed style reports painting properties", async () => {
287
+ await load(`<div id="box" style="visibility: hidden; opacity: 0.5; overflow: hidden; pointer-events: none">x</div>
288
+ <div style="height: 5000px; width: 3000px"></div>`);
289
+ expect(await evaluate(`[document.documentElement.clientWidth === innerWidth, document.documentElement.clientHeight === innerHeight,
290
+ document.getElementById("box").clientWidth < 3000].join(",")`)).toBe("true,true,true");
291
+ expect(await evaluate(`(() => { const style = getComputedStyle(document.getElementById("box"));
292
+ return [style.visibility, style.opacity, style.overflow, style.overflowY, style.pointerEvents,
293
+ getComputedStyle(document.body).overflow].join(","); })()`)).toBe("hidden,0.5,hidden,hidden,none,visible");
294
+ });
295
+
296
+ test("CSS.escape produces selectors for leading digits and punctuation", async () => {
297
+ await load(`<div id="1:a"></div>`);
298
+ expect(await evaluate("CSS.escape('1:a')")).toBe("\\31 \\:a");
153
299
  });
154
300
 
155
301
  test("a video plays frames and pauses when clicked", async () => {
@@ -212,6 +358,108 @@ test("clicks reach page listeners; preventDefault cancels link navigation", asyn
212
358
  expect(await evaluate("clicks.join(',')")).toBe("click:kept:true,inline");
213
359
  });
214
360
 
361
+ test("fragment links navigate at once: later handlers see the new location, popstate fires, history grows", async () => {
362
+ await load(`<a href="#b" style="display:block;height:40px">b</a><div style="height:900px"></div>
363
+ <h2 id="b">b</h2><div style="height:900px"></div>
364
+ <script>var seen = [];
365
+ document.addEventListener('click', () => setTimeout(() => seen.push('timer ' + location.hash), 0));
366
+ addEventListener('popstate', (event) => seen.push('popstate ' + location.hash + ' ' + event.state));
367
+ addEventListener('hashchange', (event) => seen.push('hashchange ' + event.newURL.split('#')[1]));</script>`);
368
+ const clicked = await renderer.call("click", 3, 10);
369
+ expect(clicked).toEqual({ url: "https://page.test/#b", fragmentFrom: { x: 0, y: 0 } });
370
+ await Bun.sleep(50);
371
+ expect(await evaluate("seen.join(',')")).toBe("popstate #b null,timer #b,hashchange b");
372
+ expect(await evaluate(`[location.hash, history.length, history.state,
373
+ Math.round(scrollY) === Math.round(document.getElementById('b').getBoundingClientRect().top + scrollY)].join('|')`)).toBe("#b|2||true");
374
+ // Setting location.hash is a fragment navigation too: it reads back at once.
375
+ expect(await evaluate("(() => { location.hash = 'c'; return location.hash + ' ' + history.length })()")).toBe("#c 3");
376
+ });
377
+
378
+ test("insertAdjacentHTML keeps the order of the inserted nodes at every position", async () => {
379
+ await load(`<div id="host"><b id="ref">ref</b></div>`);
380
+ expect(await evaluate(`(() => {
381
+ const ref = document.getElementById("ref");
382
+ ref.insertAdjacentHTML("afterend", "<i>1</i><i>2</i>");
383
+ ref.insertAdjacentHTML("beforebegin", "<u>1</u><u>2</u>");
384
+ ref.insertAdjacentHTML("afterbegin", "<s>1</s><s>2</s>");
385
+ ref.insertAdjacentHTML("beforeend", "<em>1</em><em>2</em>");
386
+ return document.getElementById("host").innerHTML;
387
+ })()`)).toBe("<u>1</u><u>2</u><b id=\"ref\"><s>1</s><s>2</s>ref<em>1</em><em>2</em></b><i>1</i><i>2</i>");
388
+ });
389
+
390
+ test("style sheets that scripts add, change or remove apply to the page", async () => {
391
+ await load(`<p id="p">text</p><script>
392
+ document.body.insertAdjacentHTML("afterbegin", "<style id=s>#p { display: none }</style>");
393
+ </script>`);
394
+ const height = () => evaluate("document.getElementById('p').getBoundingClientRect().height");
395
+ expect(await height()).toBe(0);
396
+ await evaluate("document.getElementById('s').textContent = '#p { padding: 10px }'");
397
+ expect(await height()).toBeGreaterThan(20);
398
+ await evaluate("document.getElementById('s').remove()");
399
+ expect(await height()).toBeLessThan(25);
400
+ });
401
+
402
+ test("DOM records and live form state take their distinct style invalidation paths", async () => {
403
+ await load(`<style>
404
+ .on .child { color: red }
405
+ input:checked + span { display: none }
406
+ </style>
407
+ <div id="parent"><span class="child">child</span></div>
408
+ <input id="box" type="checkbox"><span id="after">after</span>`);
409
+ expect(await evaluate(`(() => {
410
+ const parent = document.getElementById("parent");
411
+ const child = parent.querySelector(".child");
412
+ parent.className = "on";
413
+ void child.offsetWidth;
414
+ const color = getComputedStyle(child).color;
415
+ const box = document.getElementById("box");
416
+ box.checked = true;
417
+ void document.getElementById("after").offsetWidth;
418
+ return color + "/" + getComputedStyle(document.getElementById("after")).display;
419
+ })()`)).toBe("rgba(255, 0, 0, 1)/none");
420
+ });
421
+
422
+ test("childNodes, children and getElementsBy* are live collections", async () => {
423
+ await load(`<div id="host"></div>`);
424
+ expect(await evaluate(`(() => {
425
+ const host = document.getElementById("host");
426
+ const source = document.createElement("div");
427
+ source.innerHTML = "<b>1</b>text<i class=x>2</i><u class=x>3</u>";
428
+ const nodes = source.childNodes, tags = source.getElementsByTagName("*"), named = source.getElementsByClassName("x");
429
+ const before = [nodes.length, source.children.length, tags.length, named.length, nodes === source.childNodes];
430
+ // Moving nodes out empties the live list, so this loop ends.
431
+ const fragment = document.createDocumentFragment();
432
+ let moves = 0;
433
+ while (nodes.length && moves < 10) { fragment.appendChild(nodes[0]); moves++; }
434
+ host.appendChild(fragment);
435
+ return JSON.stringify({ before, moves, after: [nodes.length, tags.length, named.length],
436
+ hostTags: Array.prototype.map.call(host.children, (element) => element.tagName).join(),
437
+ spread: [...host.childNodes].length, item: host.childNodes.item(1).nodeType, missing: host.childNodes.item(9),
438
+ named: host.children.namedItem("nope") });
439
+ })()`)).toBe(JSON.stringify({ before: [4, 3, 3, 2, true], moves: 4, after: [0, 0, 0],
440
+ hostTags: "B,I,U", spread: 4, item: 3, missing: null, named: null }));
441
+ });
442
+
443
+ test("the shown document is visible", async () => {
444
+ await load("<p>x</p>");
445
+ expect(await evaluate("[document.visibilityState, document.hidden].join()")).toBe("visible,false");
446
+ });
447
+
448
+ test("performance.timing follows the document lifecycle", async () => {
449
+ await load(`<script>var atParse = JSON.stringify([performance.timing.navigationStart > 0,
450
+ performance.timing.domInteractive, performance.timing.loadEventEnd]);
451
+ var atLoad; addEventListener("load", () => { atLoad = [performance.timing.loadEventStart > 0, performance.timing.loadEventEnd]; });</script>`);
452
+ await Bun.sleep(50);
453
+ expect(await evaluate("atParse")).toBe("[true,0,0]");
454
+ expect(await evaluate(`(() => { const t = performance.timing;
455
+ return JSON.stringify([atLoad, t.navigationStart === Math.round(performance.timeOrigin),
456
+ t.fetchStart <= t.responseEnd && t.responseEnd <= t.domLoading && t.domLoading <= t.domInteractive
457
+ && t.domInteractive <= t.domContentLoadedEventStart && t.domContentLoadedEventEnd <= t.domComplete
458
+ && t.domComplete <= t.loadEventStart && t.loadEventStart <= t.loadEventEnd,
459
+ performance.navigation.type, Object.keys(t.toJSON()).length]); })()`))
460
+ .toBe(JSON.stringify([[true, 0], true, true, 0, 21]));
461
+ });
462
+
215
463
  test("location assignment asks the controller to navigate", async () => {
216
464
  navigations.length = 0;
217
465
  await load(`<script>setTimeout(() => { location.href = '/elsewhere?x=1' }, 0)</script>`);
@@ -238,7 +486,7 @@ test("page scripts cannot escape the sandbox or reach engine state", async () =>
238
486
  attempt('patchPrototype', () => { Object.getPrototypeOf(document.body).appendChild = () => 'pwned'; return 'patched'; });
239
487
  attempt('patchIntrinsic', () => { Array.prototype.includes = () => true; return 'patched'; });
240
488
  attempt('patchUrl', () => { URL.prototype.toString = () => 'pwned'; return 'patched'; });
241
- attempt('dynamicImport', () => eval('import("node:fs")'));
489
+ attempt('dynamicImport', () => { const pending = eval('import("node:fs")'); pending.catch(() => {}); return pending instanceof Promise ? 'promise' : 'wrong'; });
242
490
  </script>`);
243
491
  const probes = await evaluate("JSON.stringify(probes)");
244
492
  expect(JSON.parse(probes)).toEqual({
@@ -248,11 +496,13 @@ test("page scripts cannot escape the sandbox or reach engine state", async () =>
248
496
  hostGlobals: "undefined/undefined/undefined/function",
249
497
  privateState: "0/0",
250
498
  newBinding: "blocked",
251
- patchPrototype: "blocked",
499
+ patchPrototype: "patched",
252
500
  patchIntrinsic: "blocked",
253
501
  patchUrl: "blocked",
254
- dynamicImport: "blocked",
502
+ dynamicImport: "promise",
255
503
  });
504
+ await load("<p>another page</p>");
505
+ expect(await evaluate("document.body.appendChild.toString().includes('pwned')")).toBe(false);
256
506
  // Page fetch cannot read local files.
257
507
  expect(await evaluate("fetch('file:///etc/passwd').then(() => 'read', (error) => 'blocked')")).toBe("blocked");
258
508
  // The engine itself is unaffected: the page still renders and URLs still work.
@@ -273,6 +523,7 @@ test("page-inserted scripts run; innerHTML scripts do not; on* handlers fire", a
273
523
  const external = document.createElement('script');
274
524
  external.src = '/dynamic.js';
275
525
  external.onload = () => log.push('external onload');
526
+ external.onerror = () => log.push('external onerror');
276
527
  document.head.appendChild(external);
277
528
  const inline = document.createElement('script');
278
529
  inline.textContent = "log.push('inline ran during insertion')";
@@ -284,6 +535,9 @@ test("page-inserted scripts run; innerHTML scripts do not; on* handlers fire", a
284
535
  inFragment.textContent = "log.push('fragment script ran')";
285
536
  fragment.appendChild(inFragment);
286
537
  document.body.appendChild(fragment);
538
+ const blobScript = document.createElement('script');
539
+ blobScript.src = URL.createObjectURL(new Blob(["log.push('blob script ran')"], { type: 'text/javascript' }));
540
+ document.head.appendChild(blobScript);
287
541
  </script>`);
288
542
  await Bun.sleep(100);
289
543
  expect(await evaluate("log.join(' | ')")).toBe([
@@ -291,12 +545,80 @@ test("page-inserted scripts run; innerHTML scripts do not; on* handlers fire", a
291
545
  "after inline insertion",
292
546
  "fragment script ran",
293
547
  // A script inserted during loading delays the window load event.
548
+ "blob script ran",
294
549
  "external ran:true",
295
550
  "external onload",
296
551
  "window.onload",
297
552
  ].join(" | "));
298
553
  });
299
554
 
555
+ test("a GET form submitted by page script navigates with its field values", async () => {
556
+ navigations.length = 0;
557
+ await load(`<form action="/search?old=1"><textarea name="q"></textarea><input type="hidden" name="source" value="home"></form>
558
+ <script>document.querySelector('textarea').value = 'two words'; document.forms[0].submit()</script>`);
559
+ expect(navigations).toEqual(["https://page.test/search?q=two+words&source=home"]);
560
+ });
561
+
562
+ test("a search textarea can be focused across its form and submitted with Enter", async () => {
563
+ navigations.length = 0;
564
+ await load(`<form role="search" action="/search" style="width:300px;height:50px">
565
+ <textarea name="q" inputmode="search" style="width:100px;height:25px"></textarea>
566
+ <input type="hidden" name="source" value="home"></form>`);
567
+ const rect = await evaluate(`document.querySelector('form').getBoundingClientRect().toJSON()`);
568
+ await renderer.call("click", rect.x + 200, rect.y + 15);
569
+ expect(await evaluate("document.activeElement.tagName")).toBe("TEXTAREA");
570
+ await renderer.call("type", "two words");
571
+ await renderer.call("press", "Enter", { code: "Enter" });
572
+ expect(navigations).toEqual(["https://page.test/search?q=two+words&source=home"]);
573
+
574
+ navigations.length = 0;
575
+ await load(`<form action="/note"><textarea name="body"></textarea></form>`);
576
+ const plain = await evaluate(`document.querySelector('textarea').getBoundingClientRect().toJSON()`);
577
+ await renderer.call("click", plain.x + 5, plain.y + 5);
578
+ await renderer.call("press", "Enter", { code: "Enter" });
579
+ expect(await evaluate("document.querySelector('textarea').value")).toBe("\n");
580
+ expect(navigations).toEqual([]);
581
+ });
582
+
583
+ test("restyles follow :has() and structural matches of inserted subtrees after later changes", async () => {
584
+ await load(`<style>.box:has(img) { display: none } li:first-child + li span { display: none }</style>
585
+ <div id="host"></div><ul id="list"><li>a</li></ul>`);
586
+ // Inserted, styled, then changed inside: the inserted elements' matches
587
+ // are what the next change is compared with.
588
+ expect(await evaluate(`(() => {
589
+ const host = document.getElementById("host");
590
+ const template = document.createElement("template");
591
+ template.innerHTML = '<div class="box" id="box"><p><img></p></div>';
592
+ host.append(template.content); // built detached, inserted whole
593
+ const before = getComputedStyle(document.getElementById("box")).display;
594
+ document.querySelector("#box img").remove();
595
+ return before + "," + getComputedStyle(document.getElementById("box")).display;
596
+ })()`)).toBe("none,block");
597
+ expect(await evaluate(`(() => {
598
+ const list = document.getElementById("list");
599
+ list.insertAdjacentHTML("beforeend", '<li><span id="s">b</span></li>');
600
+ const before = getComputedStyle(document.getElementById("s")).display;
601
+ list.prepend(document.createElement("li"));
602
+ list.firstElementChild.remove();
603
+ list.firstElementChild.remove();
604
+ return before + "," + getComputedStyle(document.getElementById("s")).display;
605
+ })()`)).toBe("none,inline");
606
+ });
607
+
608
+ test("styles inside noscript do not apply while page scripting is enabled", async () => {
609
+ await load(`<noscript><style>p { display: none }</style></noscript><p id="visible">visible</p>`);
610
+ expect(await evaluate(`getComputedStyle(document.getElementById("visible")).display`)).toBe("block");
611
+ });
612
+
613
+ test("page RegExp instances can override their own toString without changing the intrinsic", async () => {
614
+ await load(`<script>
615
+ const pattern = /demo/;
616
+ pattern.toString = () => "custom";
617
+ window.regexpOverride = [pattern.toString(), (/other/).toString()];
618
+ </script>`);
619
+ expect(await evaluate("regexpOverride")).toEqual(["custom", "/other/"]);
620
+ });
621
+
300
622
  test("ES modules: graphs, live exports, JSON, import.meta, dynamic import, TLA, cycles, import maps", async () => {
301
623
  const base = "https://page.test/modules/";
302
624
  scripts.set(`${base}math.js`, `
@@ -421,7 +743,6 @@ test("fragment navigation updates location, fires hashchange, and lets the page
421
743
  await load(`
422
744
  <div style="height:1000px"></div><h2 id="plain">plain</h2>
423
745
  <div style="height:1000px"></div><h2 id="custom-target">custom</h2>
424
- <div style="height:1000px"></div><h2><a id="user-content-readme">readme</a></h2>
425
746
  <div style="height:2000px"></div>
426
747
  <script>var changes = []; window.addEventListener('hashchange', () => {
427
748
  changes.push(location.hash + ':' + document.URL.split('#')[1]);
@@ -436,13 +757,10 @@ test("fragment navigation updates location, fires hashchange, and lets the page
436
757
  // A listener that maps the fragment scrolls itself.
437
758
  await renderer.call("showFragment", "https://page.test/#custom");
438
759
  expect(await scrollY()).toBe(await top("custom-target"));
439
- // No target and no script: rendered Markdown's "user-content-" id.
440
- await renderer.call("showFragment", "https://page.test/#readme");
441
- expect(await scrollY()).toBe(await top("user-content-readme"));
442
760
  // The same fragment again does not fire hashchange.
443
- await renderer.call("showFragment", "https://page.test/#readme");
444
- expect(await evaluate("changes.join(',')")).toBe("#plain:plain,#custom:custom,#readme:readme");
445
- expect(await evaluate("location.href")).toBe("https://page.test/#readme");
761
+ await renderer.call("showFragment", "https://page.test/#custom");
762
+ expect(await evaluate("changes.join(',')")).toBe("#plain:plain,#custom:custom");
763
+ expect(await evaluate("location.href")).toBe("https://page.test/#custom");
446
764
  });
447
765
 
448
766
  test("WebSocket connects through the controller: text, binary, subprotocol, Origin and close", async () => {
@@ -550,3 +868,93 @@ test("UI event constructors, table rows/cells and select options are available",
550
868
  out.push(key.key + ':' + key.code + ':' + key.repeat);</script>`);
551
869
  expect(await evaluate("JSON.stringify(out)")).toBe(JSON.stringify([2, 1, 2, 1, 1, "THEAD", 2, 1, "y", "x", true, "click:5:true:false", true, "Enter:Enter:true"]));
552
870
  });
871
+
872
+ test("labels and unchecked checkboxes are clickable; each click toggles once, as in Chromium", async () => {
873
+ await load(`<label id="l" style="display:block"><input type="checkbox" id="c"> Label text</label>
874
+ <script>var changes = 0; document.getElementById('c').addEventListener('change', () => changes++);</script>`);
875
+ const rect = async (id) => JSON.parse(await evaluate(`JSON.stringify(document.getElementById('${id}').getBoundingClientRect())`));
876
+ const box = await rect("c");
877
+ // The unchecked box has no text; it is hit over its whole box.
878
+ await renderer.call("click", box.left + box.width / 2, box.top + box.height / 2);
879
+ expect(await evaluate("[document.getElementById('c').checked, changes].join()")).toBe("true,1");
880
+ const label = await rect("l");
881
+ await renderer.call("click", label.left + label.width - 5, label.top + label.height / 2);
882
+ expect(await evaluate("[document.getElementById('c').checked, changes].join()")).toBe("false,2");
883
+ });
884
+
885
+ test("media elements share the HTMLMediaElement API: seek, play, pause, end, replay, events", async () => {
886
+ await load(`<video id="v" src="/video.mp4" width="160" height="120"></video><audio id="a" controls src="/tone.mp3"></audio>
887
+ <script>var events = [], v = document.getElementById("v"), a = document.getElementById("a");
888
+ for (const id of ["v", "a"]) for (const type of ["loadedmetadata", "seeked", "play", "pause", "ended"]) {
889
+ document.getElementById(id).addEventListener(type, () => events.push(id + ":" + type));
890
+ }</script>`);
891
+ const until = async (expression, ms = 3000) => {
892
+ for (const end = Date.now() + ms; Date.now() < end; await Bun.sleep(25)) if (await evaluate(expression)) return true;
893
+ return false;
894
+ };
895
+ expect(await evaluate("[v.paused, v.ended, v.currentTime, Number.isNaN(v.duration), a.paused]")).toEqual([true, false, 0, true, true]);
896
+
897
+ await evaluate("v.currentTime = 0.5; a.currentTime = 0.4");
898
+ expect(await until("events.includes('v:seeked') && events.includes('a:seeked')")).toBeTrue();
899
+ expect(await evaluate("[v.duration, Math.round(v.currentTime * 10), a.duration, Math.round(a.currentTime * 10), v.paused, a.paused]"))
900
+ .toEqual([1, 5, 1, 4, true, true]);
901
+
902
+ await evaluate("v.play()");
903
+ expect(await until("!v.paused && v.currentTime > 0.55")).toBeTrue();
904
+ await evaluate("v.pause()");
905
+ expect(await evaluate("v.paused")).toBeTrue();
906
+
907
+ // Playing to the end, then play starts over.
908
+ await evaluate("v.currentTime = 0.9; v.play()");
909
+ expect(await until("v.ended && v.paused")).toBeTrue();
910
+ await evaluate("v.play()");
911
+ expect(await until("!v.paused && !v.ended && v.currentTime < 0.5")).toBeTrue();
912
+ await evaluate("v.pause()");
913
+ const seen = await evaluate("events.join()");
914
+ for (const event of ["v:loadedmetadata", "a:loadedmetadata", "v:play", "v:pause", "v:ended"]) expect(seen).toContain(event);
915
+
916
+ // Audio plays through a PulseAudio server on 127.0.0.1:4713 when one runs (this makes a short tone).
917
+ if (await pulseServerRunning()) {
918
+ await evaluate("a.currentTime = 0; a.play()");
919
+ expect(await until("!a.paused && a.currentTime > 0.2")).toBeTrue();
920
+ await evaluate("a.pause()");
921
+ expect(await evaluate("a.paused")).toBeTrue();
922
+ await evaluate("a.currentTime = 0.8; a.play()");
923
+ expect(await until("a.ended && a.paused")).toBeTrue();
924
+ }
925
+ });
926
+
927
+ function pulseServerRunning() {
928
+ return new Promise((resolve) => {
929
+ const socket = require("node:net").connect({ host: "127.0.0.1", port: 4713 });
930
+ const done = (running) => {
931
+ socket.destroy();
932
+ resolve(running);
933
+ };
934
+ socket.once("connect", () => done(true));
935
+ socket.once("error", () => done(false));
936
+ setTimeout(() => done(false), 1000);
937
+ });
938
+ }
939
+
940
+ test("scripts and module preloads are fetched while the stylesheets load, each once", async () => {
941
+ const base = "https://page.test/preload/";
942
+ scripts.set(`${base}main.js`, "import { value } from './dep.js'; window.preloaded = value;");
943
+ scripts.set(`${base}dep.js`, "export const value = 'dep';");
944
+ scripts.set(`${base}classic.js`, "window.classic = 'ran';");
945
+ scripts.set(`${base}skipped.js`, "window.skipped = 'ran';");
946
+ requests.length = 0;
947
+ const started = performance.now();
948
+ await load(`<link rel="stylesheet" href="/slow.css"><link rel="modulepreload" href="/preload/dep.js">
949
+ <script type="module" src="/preload/main.js"></script><script src="/preload/classic.js"></script>
950
+ <script nomodule src="/preload/skipped.js"></script>`);
951
+ expect(await evaluate("[window.preloaded, window.classic, window.skipped].join()")).toBe("dep,ran,");
952
+ const times = (path) => requests.filter((request) => request.url === `https://page.test${path}`).map((request) => request.at - started);
953
+ // Requested before the 400 ms stylesheet arrived, not after it.
954
+ for (const path of ["/preload/main.js", "/preload/dep.js", "/preload/classic.js"]) {
955
+ expect(times(path).length).toBe(1);
956
+ expect(times(path)[0]).toBeLessThan(times("/slow.css")[0] + 300);
957
+ }
958
+ expect(times("/preload/skipped.js")).toEqual([]);
959
+ });
960
+
@@ -0,0 +1,65 @@
1
+ import { test, expect } from "bun:test";
2
+ import { PIANO_KEYS, PIANO_DO, pianoNote, pianoTone, PianoMixer } from "../lib/audio/piano.js";
3
+
4
+ test("piano keyboard mapping is editable in one table", () => {
5
+ expect(Object.keys(PIANO_KEYS)).toHaveLength(61);
6
+ expect(PIANO_DO).toBe(69);
7
+ expect(PIANO_KEYS[" "]).toBe(60);
8
+ expect(pianoNote(" ")).toBe(69);
9
+ expect(pianoNote("j")).toBe(71);
10
+ expect(pianoNote("K")).toBe(73);
11
+ expect(pianoNote("l")).toBe(74);
12
+ expect(pianoNote("F1")).toBe(38);
13
+ expect(pianoNote("F12")).toBe(98);
14
+ expect(pianoNote("z")).toBe(37);
15
+ expect(pianoNote("q")).toBe(40);
16
+ expect(pianoNote("/", "Slash")).toBe(71);
17
+ expect(pianoNote("Unidentified", "KeyJ")).toBe(71);
18
+ expect(pianoNote("!", "Digit1")).toBe(45);
19
+ expect(pianoNote("Escape")).toBeNull();
20
+ });
21
+
22
+ test("piano tone is 48 kHz mono PCM with a nonzero, decaying sound", () => {
23
+ const tone = pianoTone(60);
24
+ expect(tone.byteLength).toBe(Math.round(48000 * 0.7) * 2);
25
+ const view = new DataView(tone.buffer);
26
+ const peak = (start, end) => {
27
+ let value = 0;
28
+ for (let i = start; i < end; i++) value = Math.max(value, Math.abs(view.getInt16(i * 2, true)));
29
+ return value;
30
+ };
31
+ expect(peak(1000, 2000)).toBeGreaterThan(1000);
32
+ expect(peak(20000, 21000)).toBeLessThan(peak(1000, 2000));
33
+ expect(peak(0, tone.byteLength / 2)).toBeLessThan(32767);
34
+ const low = pianoTone(37);
35
+ const lowView = new DataView(low.buffer);
36
+ let lowPeak = 0;
37
+ for (let i = 1000; i < 2000; i++) lowPeak = Math.max(lowPeak, Math.abs(lowView.getInt16(i * 2, true)));
38
+ expect(lowPeak).toBeGreaterThan(peak(1000, 2000));
39
+ });
40
+
41
+ test("piano mixer keeps notes continuous across requests and limits chords", () => {
42
+ const one = new PianoMixer();
43
+ one.trigger(69);
44
+ const first = one.render(4096);
45
+ const second = one.render(4096);
46
+ const reference = pianoTone(69);
47
+ expect(first.byteLength).toBe(4096);
48
+ expect(second.byteLength).toBe(4096);
49
+ const combined = new Uint8Array(8192);
50
+ combined.set(first);
51
+ combined.set(second, 4096);
52
+ expect(combined.some((value) => value !== 0)).toBe(true);
53
+ const uninterrupted = new PianoMixer();
54
+ uninterrupted.trigger(69);
55
+ expect(combined).toEqual(uninterrupted.render(8192));
56
+ expect(reference.byteLength).toBeGreaterThan(combined.byteLength);
57
+ const chord = new PianoMixer();
58
+ for (const note of [69, 73, 76, 81]) chord.trigger(note);
59
+ const pcm = chord.render(8192);
60
+ const view = new DataView(pcm.buffer);
61
+ let peak = 0;
62
+ for (let i = 0; i < pcm.byteLength; i += 2) peak = Math.max(peak, Math.abs(view.getInt16(i, true)));
63
+ expect(peak).toBeGreaterThan(1000);
64
+ expect(peak).toBeLessThan(32767);
65
+ });