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.
- package/AGENTS.md +17 -0
- package/LICENSES/brotli.txt +19 -0
- package/README.md +41 -7
- package/THIRD_PARTY_NOTICES.md +30 -6
- package/buninu-browser.js +10 -2
- package/lib/audio/pcm.js +16 -0
- package/lib/audio/piano.js +109 -0
- package/lib/audio/pulse-client.js +413 -0
- package/lib/cdp-server.js +25 -6
- package/lib/document-types.js +308 -0
- package/lib/download-directory.js +28 -0
- package/lib/download.js +63 -0
- package/lib/headless-shell.js +108 -14
- package/lib/input/hit-test.js +57 -8
- package/lib/layout/text-layout.js +112 -27
- package/lib/network/network-service.js +7 -3
- package/lib/network/resolver.js +16 -0
- package/lib/paint/display-list.js +2 -2
- package/lib/render-tree/index.js +13 -0
- package/lib/renderer/bindings.js +541 -53
- package/lib/renderer/flags.js +8 -5
- package/lib/renderer/host.js +110 -9
- package/lib/renderer/main.js +35 -3
- package/lib/renderer/module-transform.js +30 -23
- package/lib/renderer/page-realm.js +226 -22
- package/lib/renderer/page-renderer.js +1293 -212
- package/lib/screenshot-cli.js +61 -0
- package/lib/style/computed-style.js +268 -20
- package/lib/user-agent.js +12 -0
- package/package.json +1 -1
- package/patches/happy-dom/0003-defer-script-events-to-renderer.patch +40 -0
- package/patches/happy-dom/0004-insert-adjacent-html-in-order.patch +19 -0
- package/patches/happy-dom/0005-invalidate-matches-on-structure-changes.patch +43 -0
- package/patches/happy-dom/README.md +12 -0
- package/patches/ses/0001-allow-regexp-own-tostring.patch +13 -0
- package/patches/ses/README.md +16 -0
- package/patches/wasmpeg/0001-windows-wasm-path.patch +14 -0
- package/patches/wasmpeg/README.md +28 -0
- package/test/computed-style.test.js +52 -0
- package/test/document-types.test.js +111 -0
- package/test/download.test.js +192 -0
- package/test/fixtures/tone.mp3 +0 -0
- package/test/flex.test.js +11 -0
- package/test/happy-dom-matches.test.js +25 -0
- package/test/headless-shell.test.js +27 -0
- package/test/hit-test.test.js +37 -0
- package/test/page-scripts.test.js +388 -29
- package/test/piano.test.js +65 -0
- package/test/positioning.test.js +11 -0
- package/test/pulse-client.test.js +130 -0
- package/test/render-tree.test.js +14 -0
- package/test/resolver.test.js +12 -1
- package/test/screenshot-cli.test.js +19 -0
- package/test/text-layout.test.js +22 -0
- package/test/user-agent.test.js +42 -0
- package/vendor/happy-dom/packages/happy-dom/src/nodes/element/Element.ts +3 -6
- package/vendor/happy-dom/packages/happy-dom/src/nodes/html-script-element/HTMLScriptElement.ts +2 -20
- package/vendor/happy-dom/packages/happy-dom/src/query-selector/QuerySelector.ts +15 -3
- package/vendor/ses/dist/ses.mjs +2 -0
- package/vendor/wasmpeg/VENDORING.md +7 -3
- 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("
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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: "
|
|
472
|
+
patchPrototype: "patched",
|
|
252
473
|
patchIntrinsic: "blocked",
|
|
253
474
|
patchUrl: "blocked",
|
|
254
|
-
dynamicImport: "
|
|
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/#
|
|
444
|
-
expect(await evaluate("changes.join(',')")).toBe("#plain:plain,#custom:custom
|
|
445
|
-
expect(await evaluate("location.href")).toBe("https://page.test/#
|
|
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
|
+
});
|
package/test/positioning.test.js
CHANGED
|
@@ -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>`,
|