buninu-browser 0.0.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) 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 +30 -6
  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 +25 -6
  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/headless-shell.js +108 -14
  14. package/lib/input/hit-test.js +57 -8
  15. package/lib/layout/text-layout.js +112 -27
  16. package/lib/network/network-service.js +7 -3
  17. package/lib/network/resolver.js +16 -0
  18. package/lib/paint/display-list.js +2 -2
  19. package/lib/render-tree/index.js +13 -0
  20. package/lib/renderer/bindings.js +541 -53
  21. package/lib/renderer/flags.js +8 -5
  22. package/lib/renderer/host.js +110 -9
  23. package/lib/renderer/main.js +35 -3
  24. package/lib/renderer/module-transform.js +30 -23
  25. package/lib/renderer/page-realm.js +226 -22
  26. package/lib/renderer/page-renderer.js +1293 -212
  27. package/lib/screenshot-cli.js +61 -0
  28. package/lib/style/computed-style.js +268 -20
  29. package/lib/user-agent.js +12 -0
  30. package/package.json +1 -1
  31. package/patches/happy-dom/0003-defer-script-events-to-renderer.patch +40 -0
  32. package/patches/happy-dom/0004-insert-adjacent-html-in-order.patch +19 -0
  33. package/patches/happy-dom/0005-invalidate-matches-on-structure-changes.patch +43 -0
  34. package/patches/happy-dom/README.md +12 -0
  35. package/patches/ses/0001-allow-regexp-own-tostring.patch +13 -0
  36. package/patches/ses/README.md +16 -0
  37. package/patches/wasmpeg/0001-windows-wasm-path.patch +14 -0
  38. package/patches/wasmpeg/README.md +28 -0
  39. package/test/computed-style.test.js +52 -0
  40. package/test/document-types.test.js +111 -0
  41. package/test/download.test.js +192 -0
  42. package/test/fixtures/tone.mp3 +0 -0
  43. package/test/flex.test.js +11 -0
  44. package/test/happy-dom-matches.test.js +25 -0
  45. package/test/headless-shell.test.js +27 -0
  46. package/test/hit-test.test.js +37 -0
  47. package/test/page-scripts.test.js +388 -29
  48. package/test/piano.test.js +65 -0
  49. package/test/positioning.test.js +11 -0
  50. package/test/pulse-client.test.js +130 -0
  51. package/test/render-tree.test.js +14 -0
  52. package/test/resolver.test.js +12 -1
  53. package/test/screenshot-cli.test.js +19 -0
  54. package/test/text-layout.test.js +22 -0
  55. package/test/user-agent.test.js +42 -0
  56. package/vendor/happy-dom/packages/happy-dom/src/nodes/element/Element.ts +3 -6
  57. package/vendor/happy-dom/packages/happy-dom/src/nodes/html-script-element/HTMLScriptElement.ts +2 -20
  58. package/vendor/happy-dom/packages/happy-dom/src/query-selector/QuerySelector.ts +15 -3
  59. package/vendor/ses/dist/ses.mjs +2 -0
  60. package/vendor/wasmpeg/VENDORING.md +7 -3
  61. package/vendor/wasmpeg/src/js/gpu.js +2 -1
@@ -30,13 +30,10 @@ beforeAll(async () => {
30
30
  headers: target.searchParams.has("allow") ? { "access-control-allow-origin": "*" } : {},
31
31
  });
32
32
  }
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
33
  if (target.pathname === "/video.mp4") return new Response(Bun.file(new URL("./fixtures/video.mp4", import.meta.url)),
39
34
  { headers: { "content-type": "video/mp4" } });
35
+ if (target.pathname === "/tone.mp3") return new Response(Bun.file(new URL("./fixtures/tone.mp3", import.meta.url)),
36
+ { headers: { "content-type": "audio/mpeg" } });
40
37
  const body = scripts.get(target.href);
41
38
  return body === undefined
42
39
  ? new Response("missing", { status: 404 })
@@ -82,6 +79,32 @@ test("classic scripts share globals, load in order and fire DOMContentLoaded the
82
79
  expect(await evaluate("document.readyState")).toBe("complete");
83
80
  });
84
81
 
82
+ test("parser-blocking head scripts run before document.body exists", async () => {
83
+ await load(`<head><script>window.bodyDuringHead = document.body</script></head><body><p>ready</p></body>`);
84
+ expect(await evaluate("bodyDuringHead === null")).toBe(true);
85
+ expect(await evaluate("document.body.textContent")).toBe("ready");
86
+ });
87
+
88
+ test("anchors expose parsed hyperlink URL components", async () => {
89
+ await load(`<a id="link" href="/path?q=one#part"></a>`);
90
+ expect(await evaluate(`(() => { const a = document.getElementById("link"); return [
91
+ a.href, a.origin, a.protocol, a.host, a.hostname, a.port, a.pathname, a.search, a.hash
92
+ ] })()`)).toEqual([
93
+ "https://page.test/path?q=one#part", "https://page.test", "https:", "page.test", "page.test", "",
94
+ "/path", "?q=one", "#part",
95
+ ]);
96
+ await evaluate(`document.getElementById("link").hostname = "other.test"`);
97
+ expect(await evaluate(`document.getElementById("link").href`)).toBe("https://other.test/path?q=one#part");
98
+ });
99
+
100
+ test("getAttributeNode exposes a live Attr view", async () => {
101
+ await load(`<form id="search" onsubmit="return false"></form>`);
102
+ expect(await evaluate(`(() => { const form = document.getElementById("search"); const attr = form.getAttributeNode("onsubmit");
103
+ const before = [attr.name, attr.value, attr.nodeType, attr.ownerElement === form, attr.specified];
104
+ attr.value = "return true"; return [...before, form.getAttribute("onsubmit"), form.getAttributeNode("missing")]; })()`))
105
+ .toEqual(["onsubmit", "return false", 2, true, true, "return true", null]);
106
+ });
107
+
85
108
  test("scripts change the DOM and the next screenshot shows it", async () => {
86
109
  await load(`<div id="box" style="height:20px"></div>
87
110
  <script>document.getElementById('box').style.background = 'rgb(255, 0, 0)';
@@ -136,20 +159,136 @@ test("template content can be cloned and typed arrays are available", async () =
136
159
  expect(await evaluate("document.getElementById('mount').dataset.number")).toBe("1.5");
137
160
  });
138
161
 
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.");
162
+ test("DOM collection interfaces expose iterable array-compatible prototypes", async () => {
163
+ await load(`<p>one</p><p>two</p>`);
164
+ expect(await evaluate("typeof NodeList.prototype.forEach")).toBe("function");
165
+ expect(await evaluate("typeof HTMLCollection.prototype.item")).toBe("undefined");
166
+ expect(await evaluate("[...document.querySelectorAll('p')].map(p => p.textContent).join(',')")).toBe("one,two");
167
+ });
168
+
169
+ test("an unfocused page exposes an empty Selection", async () => {
170
+ await load(`<p>text</p>`);
171
+ expect(await evaluate("[getSelection().type, getSelection().rangeCount, getSelection().toString()].join(',')"))
172
+ .toBe("None,0,");
173
+ });
174
+
175
+ test("page-local DOM prototypes remain writable after binding hardening", async () => {
176
+ await load(`<button id="toggle">Toggle</button><p id="result"></p>
177
+ <script>
178
+ Document.prototype.customQuery = function (selector) { return this.querySelector(selector); };
179
+ Element.prototype.customMarker = "page-local";
180
+ document.querySelector("#toggle").addEventListener("click", () => {
181
+ document.customQuery("#result").textContent = "clicked";
182
+ });
183
+ window.ready = document.querySelector("#toggle").customMarker === "page-local";
184
+ </script>`);
185
+ expect(await evaluate("ready")).toBe(true);
186
+ const rect = await evaluate("document.getElementById('toggle').getBoundingClientRect().toJSON()");
187
+ await renderer.call("click", rect.x + 1, rect.y + 1);
188
+ expect(await evaluate("document.getElementById('result').textContent")).toBe("clicked");
189
+ await load("<p>next document</p>");
190
+ expect(await evaluate("Element.prototype.customMarker")).toBeUndefined();
191
+ });
192
+
193
+ test("comments expose their data like text nodes", async () => {
194
+ // React finds its hydration boundaries by reading comment data (<!--&-->).
195
+ await load(`<div id="host"><!--&--><!--/&--></div>`);
196
+ expect(await evaluate("[...document.getElementById('host').childNodes].map((node) => node.data).join(' ')")).toBe("& /&");
197
+ expect(await evaluate(`(() => {
198
+ const comment = document.createComment("a");
199
+ comment.data = "bc";
200
+ return [comment.data, comment.length, comment instanceof CharacterData, comment instanceof Text, comment instanceof Comment].join(",");
201
+ })()`)).toBe("bc,2,true,false,true");
202
+ expect(await evaluate("document.createTextNode('xy') instanceof CharacterData")).toBe(true);
203
+ });
204
+
205
+ test("EventTarget can be constructed and subclassed", async () => {
206
+ expect(await evaluate(`(() => {
207
+ class Store extends EventTarget {
208
+ constructor() { super(); this.value = 1; }
209
+ bump() { this.value++; this.dispatchEvent(new CustomEvent("change", { detail: this.value })); }
210
+ }
211
+ const store = new Store();
212
+ const seen = [];
213
+ const listener = function (event) { seen.push([event.detail, this === store, event.target === store, event.currentTarget === store]); };
214
+ store.addEventListener("change", listener);
215
+ store.bump();
216
+ store.removeEventListener("change", listener);
217
+ store.bump();
218
+ const plain = new EventTarget();
219
+ let fired = 0;
220
+ plain.addEventListener("ping", () => fired++, { once: true });
221
+ plain.dispatchEvent(new Event("ping"));
222
+ plain.dispatchEvent(new Event("ping"));
223
+ return JSON.stringify({ seen, fired, value: store.value,
224
+ checks: [store instanceof Store, store instanceof EventTarget, document.body instanceof EventTarget, window instanceof EventTarget, {} instanceof EventTarget] });
225
+ })()`)).toBe(JSON.stringify({ seen: [[2, true, true, true]], fired: 1, value: 3, checks: [true, true, true, true, false] }));
226
+ });
227
+
228
+ test("elements attach shadow roots, and prototype patches can wrap attachShadow", async () => {
229
+ await load(`<div id="host">light</div>`);
230
+ expect(await evaluate(`(() => {
231
+ // A polyfill pattern: keep the original and wrap it.
232
+ const original = HTMLElement.prototype.attachShadow;
233
+ let wrapped = 0;
234
+ HTMLElement.prototype.attachShadow = function (init) { wrapped++; return original.call(this, init); };
235
+ const host = document.getElementById("host");
236
+ const root = host.attachShadow({ mode: "open" });
237
+ root.innerHTML = "<span>shadow</span>";
238
+ const closed = document.createElement("div").attachShadow({ mode: "closed" });
239
+ return JSON.stringify({ wrapped, same: host.shadowRoot === root, host: root.host === host, mode: root.mode,
240
+ html: root.innerHTML, shadow: root instanceof ShadowRoot, fragment: root instanceof DocumentFragment,
241
+ closedHidden: closed.host.shadowRoot === null, light: host.textContent });
242
+ })()`)).toBe(JSON.stringify({ wrapped: 2, same: true, host: true, mode: "open", html: "<span>shadow</span>",
243
+ shadow: true, fragment: true, closedHidden: true, light: "light" }));
244
+ });
245
+
246
+ test("TreeWalker follows the DOM traversal algorithms with page filters", async () => {
247
+ 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>`);
248
+ expect(await evaluate(`(() => {
249
+ const root = document.getElementById("root");
250
+ const walk = (walker, step) => { const seen = []; for (let node = walker[step](); node; node = walker[step]()) seen.push(node.id || node.nodeName); return seen.join(","); };
251
+ const elements = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT, {
252
+ acceptNode: (node) => node.hasAttribute("data-reject") ? NodeFilter.FILTER_REJECT
253
+ : node.hasAttribute("data-skip") ? NodeFilter.FILTER_SKIP : NodeFilter.FILTER_ACCEPT,
254
+ });
255
+ const forward = walk(elements, "nextNode");
256
+ const backward = walk(elements, "previousNode");
257
+ const children = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT, (node) => node.hasAttribute("data-skip") ? 3 : 1);
258
+ const first = children.firstChild().id, last = children.lastChild(), top = children.currentNode.id;
259
+ children.currentNode = document.getElementById("a");
260
+ const siblings = walk(children, "nextSibling");
261
+ const everything = document.createTreeWalker(root);
262
+ const all = walk(everything, "nextNode");
263
+ const comments = walk(document.createTreeWalker(root, NodeFilter.SHOW_COMMENT), "nextNode");
264
+ return [forward, backward, first, last && last.id, top, siblings, all, comments, everything.parentNode() && everything.currentNode.id].join(" | ");
265
+ })()`)).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");
266
+ });
267
+
268
+ test("style feature detection only finds properties the renderer implements", async () => {
269
+ await load("<p>x</p>");
270
+ expect(await evaluate(`["color", "backgroundColor", "setProperty", "cssText", "anchorName", "positionTryFallbacks", "notAProperty"]
271
+ .map((name) => name in document.body.style).join(",")`)).toBe("true,true,true,true,false,false,false");
272
+ expect(await evaluate(`[
273
+ CSS.supports("color", "red"),
274
+ CSS.supports("animation-timeline", "scroll()"),
275
+ CSS.supports("(display: grid)")
276
+ ].join(",")`)).toBe("true,false,false");
277
+ });
278
+
279
+ test("the root element's client size is the viewport, and computed style reports painting properties", async () => {
280
+ await load(`<div id="box" style="visibility: hidden; opacity: 0.5; overflow: hidden; pointer-events: none">x</div>
281
+ <div style="height: 5000px; width: 3000px"></div>`);
282
+ expect(await evaluate(`[document.documentElement.clientWidth === innerWidth, document.documentElement.clientHeight === innerHeight,
283
+ document.getElementById("box").clientWidth < 3000].join(",")`)).toBe("true,true,true");
284
+ expect(await evaluate(`(() => { const style = getComputedStyle(document.getElementById("box"));
285
+ return [style.visibility, style.opacity, style.overflow, style.overflowY, style.pointerEvents,
286
+ getComputedStyle(document.body).overflow].join(","); })()`)).toBe("hidden,0.5,hidden,hidden,none,visible");
287
+ });
288
+
289
+ test("CSS.escape produces selectors for leading digits and punctuation", async () => {
290
+ await load(`<div id="1:a"></div>`);
291
+ expect(await evaluate("CSS.escape('1:a')")).toBe("\\31 \\:a");
153
292
  });
154
293
 
155
294
  test("a video plays frames and pauses when clicked", async () => {
@@ -212,6 +351,88 @@ test("clicks reach page listeners; preventDefault cancels link navigation", asyn
212
351
  expect(await evaluate("clicks.join(',')")).toBe("click:kept:true,inline");
213
352
  });
214
353
 
354
+ test("fragment links navigate at once: later handlers see the new location, popstate fires, history grows", async () => {
355
+ await load(`<a href="#b" style="display:block;height:40px">b</a><div style="height:900px"></div>
356
+ <h2 id="b">b</h2><div style="height:900px"></div>
357
+ <script>var seen = [];
358
+ document.addEventListener('click', () => setTimeout(() => seen.push('timer ' + location.hash), 0));
359
+ addEventListener('popstate', (event) => seen.push('popstate ' + location.hash + ' ' + event.state));
360
+ addEventListener('hashchange', (event) => seen.push('hashchange ' + event.newURL.split('#')[1]));</script>`);
361
+ const clicked = await renderer.call("click", 3, 10);
362
+ expect(clicked).toEqual({ url: "https://page.test/#b", fragmentFrom: { x: 0, y: 0 } });
363
+ await Bun.sleep(50);
364
+ expect(await evaluate("seen.join(',')")).toBe("popstate #b null,timer #b,hashchange b");
365
+ expect(await evaluate(`[location.hash, history.length, history.state,
366
+ Math.round(scrollY) === Math.round(document.getElementById('b').getBoundingClientRect().top + scrollY)].join('|')`)).toBe("#b|2||true");
367
+ // Setting location.hash is a fragment navigation too: it reads back at once.
368
+ expect(await evaluate("(() => { location.hash = 'c'; return location.hash + ' ' + history.length })()")).toBe("#c 3");
369
+ });
370
+
371
+ test("insertAdjacentHTML keeps the order of the inserted nodes at every position", async () => {
372
+ await load(`<div id="host"><b id="ref">ref</b></div>`);
373
+ expect(await evaluate(`(() => {
374
+ const ref = document.getElementById("ref");
375
+ ref.insertAdjacentHTML("afterend", "<i>1</i><i>2</i>");
376
+ ref.insertAdjacentHTML("beforebegin", "<u>1</u><u>2</u>");
377
+ ref.insertAdjacentHTML("afterbegin", "<s>1</s><s>2</s>");
378
+ ref.insertAdjacentHTML("beforeend", "<em>1</em><em>2</em>");
379
+ return document.getElementById("host").innerHTML;
380
+ })()`)).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>");
381
+ });
382
+
383
+ test("style sheets that scripts add, change or remove apply to the page", async () => {
384
+ await load(`<p id="p">text</p><script>
385
+ document.body.insertAdjacentHTML("afterbegin", "<style id=s>#p { display: none }</style>");
386
+ </script>`);
387
+ const height = () => evaluate("document.getElementById('p').getBoundingClientRect().height");
388
+ expect(await height()).toBe(0);
389
+ await evaluate("document.getElementById('s').textContent = '#p { padding: 10px }'");
390
+ expect(await height()).toBeGreaterThan(20);
391
+ await evaluate("document.getElementById('s').remove()");
392
+ expect(await height()).toBeLessThan(25);
393
+ });
394
+
395
+ test("childNodes, children and getElementsBy* are live collections", async () => {
396
+ await load(`<div id="host"></div>`);
397
+ expect(await evaluate(`(() => {
398
+ const host = document.getElementById("host");
399
+ const source = document.createElement("div");
400
+ source.innerHTML = "<b>1</b>text<i class=x>2</i><u class=x>3</u>";
401
+ const nodes = source.childNodes, tags = source.getElementsByTagName("*"), named = source.getElementsByClassName("x");
402
+ const before = [nodes.length, source.children.length, tags.length, named.length, nodes === source.childNodes];
403
+ // Moving nodes out empties the live list, so this loop ends.
404
+ const fragment = document.createDocumentFragment();
405
+ let moves = 0;
406
+ while (nodes.length && moves < 10) { fragment.appendChild(nodes[0]); moves++; }
407
+ host.appendChild(fragment);
408
+ return JSON.stringify({ before, moves, after: [nodes.length, tags.length, named.length],
409
+ hostTags: Array.prototype.map.call(host.children, (element) => element.tagName).join(),
410
+ spread: [...host.childNodes].length, item: host.childNodes.item(1).nodeType, missing: host.childNodes.item(9),
411
+ named: host.children.namedItem("nope") });
412
+ })()`)).toBe(JSON.stringify({ before: [4, 3, 3, 2, true], moves: 4, after: [0, 0, 0],
413
+ hostTags: "B,I,U", spread: 4, item: 3, missing: null, named: null }));
414
+ });
415
+
416
+ test("the shown document is visible", async () => {
417
+ await load("<p>x</p>");
418
+ expect(await evaluate("[document.visibilityState, document.hidden].join()")).toBe("visible,false");
419
+ });
420
+
421
+ test("performance.timing follows the document lifecycle", async () => {
422
+ await load(`<script>var atParse = JSON.stringify([performance.timing.navigationStart > 0,
423
+ performance.timing.domInteractive, performance.timing.loadEventEnd]);
424
+ var atLoad; addEventListener("load", () => { atLoad = [performance.timing.loadEventStart > 0, performance.timing.loadEventEnd]; });</script>`);
425
+ await Bun.sleep(50);
426
+ expect(await evaluate("atParse")).toBe("[true,0,0]");
427
+ expect(await evaluate(`(() => { const t = performance.timing;
428
+ return JSON.stringify([atLoad, t.navigationStart === Math.round(performance.timeOrigin),
429
+ t.fetchStart <= t.responseEnd && t.responseEnd <= t.domLoading && t.domLoading <= t.domInteractive
430
+ && t.domInteractive <= t.domContentLoadedEventStart && t.domContentLoadedEventEnd <= t.domComplete
431
+ && t.domComplete <= t.loadEventStart && t.loadEventStart <= t.loadEventEnd,
432
+ performance.navigation.type, Object.keys(t.toJSON()).length]); })()`))
433
+ .toBe(JSON.stringify([[true, 0], true, true, 0, 21]));
434
+ });
435
+
215
436
  test("location assignment asks the controller to navigate", async () => {
216
437
  navigations.length = 0;
217
438
  await load(`<script>setTimeout(() => { location.href = '/elsewhere?x=1' }, 0)</script>`);
@@ -238,7 +459,7 @@ test("page scripts cannot escape the sandbox or reach engine state", async () =>
238
459
  attempt('patchPrototype', () => { Object.getPrototypeOf(document.body).appendChild = () => 'pwned'; return 'patched'; });
239
460
  attempt('patchIntrinsic', () => { Array.prototype.includes = () => true; return 'patched'; });
240
461
  attempt('patchUrl', () => { URL.prototype.toString = () => 'pwned'; return 'patched'; });
241
- attempt('dynamicImport', () => eval('import("node:fs")'));
462
+ attempt('dynamicImport', () => { const pending = eval('import("node:fs")'); pending.catch(() => {}); return pending instanceof Promise ? 'promise' : 'wrong'; });
242
463
  </script>`);
243
464
  const probes = await evaluate("JSON.stringify(probes)");
244
465
  expect(JSON.parse(probes)).toEqual({
@@ -248,11 +469,13 @@ test("page scripts cannot escape the sandbox or reach engine state", async () =>
248
469
  hostGlobals: "undefined/undefined/undefined/function",
249
470
  privateState: "0/0",
250
471
  newBinding: "blocked",
251
- patchPrototype: "blocked",
472
+ patchPrototype: "patched",
252
473
  patchIntrinsic: "blocked",
253
474
  patchUrl: "blocked",
254
- dynamicImport: "blocked",
475
+ dynamicImport: "promise",
255
476
  });
477
+ await load("<p>another page</p>");
478
+ expect(await evaluate("document.body.appendChild.toString().includes('pwned')")).toBe(false);
256
479
  // Page fetch cannot read local files.
257
480
  expect(await evaluate("fetch('file:///etc/passwd').then(() => 'read', (error) => 'blocked')")).toBe("blocked");
258
481
  // The engine itself is unaffected: the page still renders and URLs still work.
@@ -273,6 +496,7 @@ test("page-inserted scripts run; innerHTML scripts do not; on* handlers fire", a
273
496
  const external = document.createElement('script');
274
497
  external.src = '/dynamic.js';
275
498
  external.onload = () => log.push('external onload');
499
+ external.onerror = () => log.push('external onerror');
276
500
  document.head.appendChild(external);
277
501
  const inline = document.createElement('script');
278
502
  inline.textContent = "log.push('inline ran during insertion')";
@@ -284,6 +508,9 @@ test("page-inserted scripts run; innerHTML scripts do not; on* handlers fire", a
284
508
  inFragment.textContent = "log.push('fragment script ran')";
285
509
  fragment.appendChild(inFragment);
286
510
  document.body.appendChild(fragment);
511
+ const blobScript = document.createElement('script');
512
+ blobScript.src = URL.createObjectURL(new Blob(["log.push('blob script ran')"], { type: 'text/javascript' }));
513
+ document.head.appendChild(blobScript);
287
514
  </script>`);
288
515
  await Bun.sleep(100);
289
516
  expect(await evaluate("log.join(' | ')")).toBe([
@@ -291,12 +518,80 @@ test("page-inserted scripts run; innerHTML scripts do not; on* handlers fire", a
291
518
  "after inline insertion",
292
519
  "fragment script ran",
293
520
  // A script inserted during loading delays the window load event.
521
+ "blob script ran",
294
522
  "external ran:true",
295
523
  "external onload",
296
524
  "window.onload",
297
525
  ].join(" | "));
298
526
  });
299
527
 
528
+ test("a GET form submitted by page script navigates with its field values", async () => {
529
+ navigations.length = 0;
530
+ await load(`<form action="/search?old=1"><textarea name="q"></textarea><input type="hidden" name="source" value="home"></form>
531
+ <script>document.querySelector('textarea').value = 'two words'; document.forms[0].submit()</script>`);
532
+ expect(navigations).toEqual(["https://page.test/search?q=two+words&source=home"]);
533
+ });
534
+
535
+ test("a search textarea can be focused across its form and submitted with Enter", async () => {
536
+ navigations.length = 0;
537
+ await load(`<form role="search" action="/search" style="width:300px;height:50px">
538
+ <textarea name="q" inputmode="search" style="width:100px;height:25px"></textarea>
539
+ <input type="hidden" name="source" value="home"></form>`);
540
+ const rect = await evaluate(`document.querySelector('form').getBoundingClientRect().toJSON()`);
541
+ await renderer.call("click", rect.x + 200, rect.y + 15);
542
+ expect(await evaluate("document.activeElement.tagName")).toBe("TEXTAREA");
543
+ await renderer.call("type", "two words");
544
+ await renderer.call("press", "Enter", { code: "Enter" });
545
+ expect(navigations).toEqual(["https://page.test/search?q=two+words&source=home"]);
546
+
547
+ navigations.length = 0;
548
+ await load(`<form action="/note"><textarea name="body"></textarea></form>`);
549
+ const plain = await evaluate(`document.querySelector('textarea').getBoundingClientRect().toJSON()`);
550
+ await renderer.call("click", plain.x + 5, plain.y + 5);
551
+ await renderer.call("press", "Enter", { code: "Enter" });
552
+ expect(await evaluate("document.querySelector('textarea').value")).toBe("\n");
553
+ expect(navigations).toEqual([]);
554
+ });
555
+
556
+ test("restyles follow :has() and structural matches of inserted subtrees after later changes", async () => {
557
+ await load(`<style>.box:has(img) { display: none } li:first-child + li span { display: none }</style>
558
+ <div id="host"></div><ul id="list"><li>a</li></ul>`);
559
+ // Inserted, styled, then changed inside: the inserted elements' matches
560
+ // are what the next change is compared with.
561
+ expect(await evaluate(`(() => {
562
+ const host = document.getElementById("host");
563
+ const template = document.createElement("template");
564
+ template.innerHTML = '<div class="box" id="box"><p><img></p></div>';
565
+ host.append(template.content); // built detached, inserted whole
566
+ const before = getComputedStyle(document.getElementById("box")).display;
567
+ document.querySelector("#box img").remove();
568
+ return before + "," + getComputedStyle(document.getElementById("box")).display;
569
+ })()`)).toBe("none,block");
570
+ expect(await evaluate(`(() => {
571
+ const list = document.getElementById("list");
572
+ list.insertAdjacentHTML("beforeend", '<li><span id="s">b</span></li>');
573
+ const before = getComputedStyle(document.getElementById("s")).display;
574
+ list.prepend(document.createElement("li"));
575
+ list.firstElementChild.remove();
576
+ list.firstElementChild.remove();
577
+ return before + "," + getComputedStyle(document.getElementById("s")).display;
578
+ })()`)).toBe("none,inline");
579
+ });
580
+
581
+ test("styles inside noscript do not apply while page scripting is enabled", async () => {
582
+ await load(`<noscript><style>p { display: none }</style></noscript><p id="visible">visible</p>`);
583
+ expect(await evaluate(`getComputedStyle(document.getElementById("visible")).display`)).toBe("block");
584
+ });
585
+
586
+ test("page RegExp instances can override their own toString without changing the intrinsic", async () => {
587
+ await load(`<script>
588
+ const pattern = /demo/;
589
+ pattern.toString = () => "custom";
590
+ window.regexpOverride = [pattern.toString(), (/other/).toString()];
591
+ </script>`);
592
+ expect(await evaluate("regexpOverride")).toEqual(["custom", "/other/"]);
593
+ });
594
+
300
595
  test("ES modules: graphs, live exports, JSON, import.meta, dynamic import, TLA, cycles, import maps", async () => {
301
596
  const base = "https://page.test/modules/";
302
597
  scripts.set(`${base}math.js`, `
@@ -421,7 +716,6 @@ test("fragment navigation updates location, fires hashchange, and lets the page
421
716
  await load(`
422
717
  <div style="height:1000px"></div><h2 id="plain">plain</h2>
423
718
  <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
719
  <div style="height:2000px"></div>
426
720
  <script>var changes = []; window.addEventListener('hashchange', () => {
427
721
  changes.push(location.hash + ':' + document.URL.split('#')[1]);
@@ -436,13 +730,10 @@ test("fragment navigation updates location, fires hashchange, and lets the page
436
730
  // A listener that maps the fragment scrolls itself.
437
731
  await renderer.call("showFragment", "https://page.test/#custom");
438
732
  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
733
  // 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");
734
+ await renderer.call("showFragment", "https://page.test/#custom");
735
+ expect(await evaluate("changes.join(',')")).toBe("#plain:plain,#custom:custom");
736
+ expect(await evaluate("location.href")).toBe("https://page.test/#custom");
446
737
  });
447
738
 
448
739
  test("WebSocket connects through the controller: text, binary, subprotocol, Origin and close", async () => {
@@ -550,3 +841,71 @@ test("UI event constructors, table rows/cells and select options are available",
550
841
  out.push(key.key + ':' + key.code + ':' + key.repeat);</script>`);
551
842
  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
843
  });
844
+
845
+ test("labels and unchecked checkboxes are clickable; each click toggles once, as in Chromium", async () => {
846
+ await load(`<label id="l" style="display:block"><input type="checkbox" id="c"> Label text</label>
847
+ <script>var changes = 0; document.getElementById('c').addEventListener('change', () => changes++);</script>`);
848
+ const rect = async (id) => JSON.parse(await evaluate(`JSON.stringify(document.getElementById('${id}').getBoundingClientRect())`));
849
+ const box = await rect("c");
850
+ // The unchecked box has no text; it is hit over its whole box.
851
+ await renderer.call("click", box.left + box.width / 2, box.top + box.height / 2);
852
+ expect(await evaluate("[document.getElementById('c').checked, changes].join()")).toBe("true,1");
853
+ const label = await rect("l");
854
+ await renderer.call("click", label.left + label.width - 5, label.top + label.height / 2);
855
+ expect(await evaluate("[document.getElementById('c').checked, changes].join()")).toBe("false,2");
856
+ });
857
+
858
+ test("media elements share the HTMLMediaElement API: seek, play, pause, end, replay, events", async () => {
859
+ await load(`<video id="v" src="/video.mp4" width="160" height="120"></video><audio id="a" controls src="/tone.mp3"></audio>
860
+ <script>var events = [], v = document.getElementById("v"), a = document.getElementById("a");
861
+ for (const id of ["v", "a"]) for (const type of ["loadedmetadata", "seeked", "play", "pause", "ended"]) {
862
+ document.getElementById(id).addEventListener(type, () => events.push(id + ":" + type));
863
+ }</script>`);
864
+ const until = async (expression, ms = 3000) => {
865
+ for (const end = Date.now() + ms; Date.now() < end; await Bun.sleep(25)) if (await evaluate(expression)) return true;
866
+ return false;
867
+ };
868
+ expect(await evaluate("[v.paused, v.ended, v.currentTime, Number.isNaN(v.duration), a.paused]")).toEqual([true, false, 0, true, true]);
869
+
870
+ await evaluate("v.currentTime = 0.5; a.currentTime = 0.4");
871
+ expect(await until("events.includes('v:seeked') && events.includes('a:seeked')")).toBeTrue();
872
+ expect(await evaluate("[v.duration, Math.round(v.currentTime * 10), a.duration, Math.round(a.currentTime * 10), v.paused, a.paused]"))
873
+ .toEqual([1, 5, 1, 4, true, true]);
874
+
875
+ await evaluate("v.play()");
876
+ expect(await until("!v.paused && v.currentTime > 0.55")).toBeTrue();
877
+ await evaluate("v.pause()");
878
+ expect(await evaluate("v.paused")).toBeTrue();
879
+
880
+ // Playing to the end, then play starts over.
881
+ await evaluate("v.currentTime = 0.9; v.play()");
882
+ expect(await until("v.ended && v.paused")).toBeTrue();
883
+ await evaluate("v.play()");
884
+ expect(await until("!v.paused && !v.ended && v.currentTime < 0.5")).toBeTrue();
885
+ await evaluate("v.pause()");
886
+ const seen = await evaluate("events.join()");
887
+ for (const event of ["v:loadedmetadata", "a:loadedmetadata", "v:play", "v:pause", "v:ended"]) expect(seen).toContain(event);
888
+
889
+ // Audio plays through a PulseAudio server on 127.0.0.1:4713 when one runs (this makes a short tone).
890
+ if (await pulseServerRunning()) {
891
+ await evaluate("a.currentTime = 0; a.play()");
892
+ expect(await until("!a.paused && a.currentTime > 0.2")).toBeTrue();
893
+ await evaluate("a.pause()");
894
+ expect(await evaluate("a.paused")).toBeTrue();
895
+ await evaluate("a.currentTime = 0.8; a.play()");
896
+ expect(await until("a.ended && a.paused")).toBeTrue();
897
+ }
898
+ });
899
+
900
+ function pulseServerRunning() {
901
+ return new Promise((resolve) => {
902
+ const socket = require("node:net").connect({ host: "127.0.0.1", port: 4713 });
903
+ const done = (running) => {
904
+ socket.destroy();
905
+ resolve(running);
906
+ };
907
+ socket.once("connect", () => done(true));
908
+ socket.once("error", () => done(false));
909
+ setTimeout(() => done(false), 1000);
910
+ });
911
+ }
@@ -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
+ });
@@ -54,6 +54,17 @@ test("left+right and top+bottom stretch an absolute box, percentages use the con
54
54
  expect(box(".half")).toMatchObject({ x: 50, y: 50 });
55
55
  });
56
56
 
57
+ test("absolute percentage width uses the full containing block after a left inset", () => {
58
+ const { box } = render(
59
+ `<div class="frame"><div class="first"></div><div class="second"></div></div>`,
60
+ `.frame { position:relative; width:200px; height:100px }
61
+ .first, .second { position:absolute; top:0; width:10%; height:20px }
62
+ .first { left:10% } .second { left:70% }`,
63
+ );
64
+ expect(box(".first")).toEqual({ x: 20, y: 0, width: 20, height: 20 });
65
+ expect(box(".second")).toEqual({ x: 140, y: 0, width: 20, height: 20 });
66
+ });
67
+
57
68
  test("relative offsets and translate move a box and its content without affecting siblings", () => {
58
69
  const { box, layout } = render(
59
70
  `<div class="moved">text</div><div class="next">after</div><div class="centered"></div>`,