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.
- package/AGENTS.md +17 -0
- package/LICENSES/brotli.txt +19 -0
- package/README.md +41 -7
- package/THIRD_PARTY_NOTICES.md +26 -4
- 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 +28 -7
- package/lib/document-types.js +308 -0
- package/lib/download-directory.js +28 -0
- package/lib/download.js +63 -0
- package/lib/happy-dom/selector-matcher.js +83 -0
- package/lib/headless-shell.js +165 -26
- package/lib/input/hit-test.js +57 -8
- package/lib/layout/text-layout.js +290 -41
- package/lib/network/network-service.js +177 -40
- package/lib/network/resolver.js +16 -0
- package/lib/paint/display-list.js +25 -10
- package/lib/render-tree/index.js +58 -0
- package/lib/renderer/bindings.js +543 -55
- package/lib/renderer/flags.js +8 -5
- package/lib/renderer/host.js +103 -7
- package/lib/renderer/main.js +35 -3
- package/lib/renderer/module-transform.js +30 -23
- package/lib/renderer/page-realm.js +228 -24
- package/lib/renderer/page-renderer.js +1264 -221
- package/lib/screenshot-cli.js +61 -0
- package/lib/style/computed-style.js +558 -229
- package/lib/style/selector-keys.js +302 -0
- package/lib/style/selector-traits.js +240 -0
- 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/test/computed-style.test.js +113 -1
- 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 +56 -0
- package/test/hit-test.test.js +37 -0
- package/test/log.test.js +4 -1
- package/test/network-service.test.js +67 -0
- package/test/page-scripts.test.js +437 -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 +36 -1
- package/test/resolver.test.js +12 -1
- package/test/screenshot-cli.test.js +19 -0
- package/test/selector-keys.test.js +39 -0
- package/test/selector-traits.test.js +19 -0
- package/test/text-layout.test.js +91 -1
- 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
|
@@ -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("
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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: "
|
|
499
|
+
patchPrototype: "patched",
|
|
252
500
|
patchIntrinsic: "blocked",
|
|
253
501
|
patchUrl: "blocked",
|
|
254
|
-
dynamicImport: "
|
|
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/#
|
|
444
|
-
expect(await evaluate("changes.join(',')")).toBe("#plain:plain,#custom:custom
|
|
445
|
-
expect(await evaluate("location.href")).toBe("https://page.test/#
|
|
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
|
+
});
|