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
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { expect, test } from "bun:test";
|
|
2
|
+
import { createBrowser } from "../lib/headless-shell.js";
|
|
2
3
|
|
|
3
4
|
async function readDevToolsUrl(stream, timeout = 15_000) {
|
|
4
5
|
const reader = stream.getReader();
|
|
@@ -63,6 +64,11 @@ test("top-level shell exposes the parsed document through Bun.WebView", async ()
|
|
|
63
64
|
headers: { "Content-Type": "text/html; charset=utf-8" },
|
|
64
65
|
});
|
|
65
66
|
}
|
|
67
|
+
if (pathname === "/piano-input") {
|
|
68
|
+
return new Response('<!doctype html><input id="buninu-piano-input" type="text">', {
|
|
69
|
+
headers: { "Content-Type": "text/html; charset=utf-8" },
|
|
70
|
+
});
|
|
71
|
+
}
|
|
66
72
|
if (["/image", "/broken-image", "/data-image", "/dynamic-data-image"].includes(pathname)) {
|
|
67
73
|
const dataUrl = `data:image/png;base64,${pixelPng.toString("base64")}`;
|
|
68
74
|
const src = pathname === "/image" ? "/pixel.png" : pathname === "/data-image" ? dataUrl : "/missing.png";
|
|
@@ -107,6 +113,20 @@ test("top-level shell exposes the parsed document through Bun.WebView", async ()
|
|
|
107
113
|
height: 240,
|
|
108
114
|
});
|
|
109
115
|
await view.resize(480, 240);
|
|
116
|
+
await view.resize(480, 500);
|
|
117
|
+
await view.navigate("about:audio");
|
|
118
|
+
expect(view.url).toBe("about:audio");
|
|
119
|
+
expect(await view.evaluate("document.getElementById('buninu-piano-input').placeholder"))
|
|
120
|
+
.toBe("Play Piano · 彈鋼琴 · ピアノを弾く");
|
|
121
|
+
const blackWidths = await view.evaluate("[...document.querySelectorAll('.black-key')].map(key => key.getBoundingClientRect().width)");
|
|
122
|
+
expect(new Set(blackWidths).size).toBe(1);
|
|
123
|
+
const demoKey = await view.evaluate("document.querySelector('[data-piano-note=\"69\"]').getBoundingClientRect().toJSON()");
|
|
124
|
+
await view.cdp("Input.dispatchMouseEvent", { type: "mousePressed", x: demoKey.x + demoKey.width / 2,
|
|
125
|
+
y: demoKey.y + demoKey.height - 15, button: "left", clickCount: 1 });
|
|
126
|
+
await view.cdp("Input.dispatchMouseEvent", { type: "mouseReleased", x: demoKey.x + demoKey.width / 2,
|
|
127
|
+
y: demoKey.y + demoKey.height - 15, button: "left", clickCount: 1 });
|
|
128
|
+
expect(await view.evaluate("document.activeElement.id")).toBe("buninu-piano-input");
|
|
129
|
+
await view.resize(480, 240);
|
|
110
130
|
await view.navigate(`http://127.0.0.1:${fixture.port}/`);
|
|
111
131
|
|
|
112
132
|
// The inline script runs (in the page sandbox) and renames the document.
|
|
@@ -173,6 +193,14 @@ test("top-level shell exposes the parsed document through Bun.WebView", async ()
|
|
|
173
193
|
await view.cdp("Input.insertText", { text: "q" });
|
|
174
194
|
expect(await view.evaluate("document.getElementById('result').textContent")).toBe("q");
|
|
175
195
|
expect(await view.evaluate("document.getElementById('editor').value")).toBe("original");
|
|
196
|
+
await view.navigate(`http://127.0.0.1:${fixture.port}/piano-input`);
|
|
197
|
+
const pianoRect = await view.evaluate("document.getElementById('buninu-piano-input').getBoundingClientRect().toJSON()");
|
|
198
|
+
await view.cdp("Input.dispatchMouseEvent", { type: "mousePressed", x: pianoRect.x + 10, y: pianoRect.y + 10, button: "left", clickCount: 1 });
|
|
199
|
+
await view.cdp("Input.dispatchMouseEvent", { type: "mouseReleased", x: pianoRect.x + 10, y: pianoRect.y + 10, button: "left", clickCount: 1 });
|
|
200
|
+
await view.cdp("Input.insertText", { text: "j" });
|
|
201
|
+
await view.cdp("Input.dispatchKeyEvent", { type: "rawKeyDown", key: "k", code: "KeyK" });
|
|
202
|
+
await view.cdp("Input.dispatchKeyEvent", { type: "keyUp", key: "k", code: "KeyK" });
|
|
203
|
+
expect(await view.evaluate("document.getElementById('buninu-piano-input').value")).toBe("");
|
|
176
204
|
await view.navigate(`http://127.0.0.1:${fixture.port}/`);
|
|
177
205
|
|
|
178
206
|
expect(await view.evaluate("scrollY")).toBe(0);
|
|
@@ -316,3 +344,31 @@ test("top-level shell exposes the parsed document through Bun.WebView", async ()
|
|
|
316
344
|
await browser.exited;
|
|
317
345
|
}
|
|
318
346
|
}, 30_000);
|
|
347
|
+
|
|
348
|
+
test("a client may set up and navigate before the first renderer is up", async () => {
|
|
349
|
+
let fetched = 0;
|
|
350
|
+
const site = Bun.serve({ port: 0, fetch() {
|
|
351
|
+
fetched++;
|
|
352
|
+
return new Response("<!doctype html><title>early</title><p>x</p>", { headers: { "content-type": "text/html" } });
|
|
353
|
+
} });
|
|
354
|
+
const browser = await createBrowser({ spareRenderer: false, waitForRenderer: false });
|
|
355
|
+
let ready = false;
|
|
356
|
+
browser.ready.then(() => { ready = true; });
|
|
357
|
+
try {
|
|
358
|
+
// Viewport and user agent are recorded at once, not after the renderer starts.
|
|
359
|
+
await browser.context.resize(321, 234, 1);
|
|
360
|
+
await browser.context.setUserAgent("EarlyAgent/1");
|
|
361
|
+
expect(ready).toBe(false);
|
|
362
|
+
// The document is fetched meanwhile; it commits once the renderer is up.
|
|
363
|
+
const navigation = browser.context.navigate(`http://127.0.0.1:${site.port}/`);
|
|
364
|
+
while (!fetched) await Bun.sleep(5);
|
|
365
|
+
expect(ready).toBe(false);
|
|
366
|
+
await navigation;
|
|
367
|
+
expect(fetched).toBe(1);
|
|
368
|
+
expect(await browser.context.title()).toBe("early");
|
|
369
|
+
expect(await browser.context.evaluate("innerWidth + 'x' + innerHeight + ' ' + navigator.userAgent")).toBe("321x234 EarlyAgent/1");
|
|
370
|
+
} finally {
|
|
371
|
+
browser.close();
|
|
372
|
+
site.stop(true);
|
|
373
|
+
}
|
|
374
|
+
}, 30_000);
|
package/test/hit-test.test.js
CHANGED
|
@@ -3,6 +3,7 @@ import { parseHTMLDocument } from "../lib/happy-dom/parser.js";
|
|
|
3
3
|
import { elementBounds, hitTest, interactiveRegions } from "../lib/input/hit-test.js";
|
|
4
4
|
import { layoutText } from "../lib/layout/text-layout.js";
|
|
5
5
|
import { RenderTreeBuilder } from "../lib/render-tree/index.js";
|
|
6
|
+
import { StyleEngine } from "../lib/style/computed-style.js";
|
|
6
7
|
|
|
7
8
|
test("creates separate clickable regions for links sharing a line", () => {
|
|
8
9
|
const { document, window } = parseHTMLDocument(
|
|
@@ -47,3 +48,39 @@ test("hit testing accounts for viewport scrolling", () => {
|
|
|
47
48
|
expect(hitTest(tree, layout, rect.left + 1, rect.top + 1, scroll, { width: 800, height: 100 })?.element.getAttribute("href")).toBe("target");
|
|
48
49
|
window.happyDOM.abort();
|
|
49
50
|
});
|
|
51
|
+
|
|
52
|
+
test("an overlapping broad text region does not steal a form control's click", () => {
|
|
53
|
+
const { document, window } = parseHTMLDocument(`<button id="button">+</button><textarea id="field"></textarea>`);
|
|
54
|
+
const button = document.getElementById("button");
|
|
55
|
+
const field = document.getElementById("field");
|
|
56
|
+
const tree = { nodesById: new Map([
|
|
57
|
+
[1, { domNode: button, style: {} }],
|
|
58
|
+
[2, { domNode: field, style: {} }],
|
|
59
|
+
]) };
|
|
60
|
+
const layout = {
|
|
61
|
+
fragments: [{ y: 0, height: 40, runs: [{ nodeId: 1, x: 0, width: 500 }] }],
|
|
62
|
+
boxes: [
|
|
63
|
+
{ nodeId: 1, x: 0, y: 0, width: 40, height: 40 },
|
|
64
|
+
{ nodeId: 2, x: 50, y: 0, width: 400, height: 40 },
|
|
65
|
+
],
|
|
66
|
+
};
|
|
67
|
+
expect(hitTest(tree, layout, 100, 20, { x: 0, y: 0 }, { width: 500, height: 100 })?.element).toBe(field);
|
|
68
|
+
window.happyDOM.abort();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("hidden and pointer-events: none boxes let clicks through, but their opted-in descendants take them", () => {
|
|
72
|
+
const { document, window } = parseHTMLDocument(
|
|
73
|
+
`<body>
|
|
74
|
+
<div class="menu"><a href="through">through</a> <a class="open" href="open">open</a></div>
|
|
75
|
+
<div style="visibility: hidden"><a href="hidden">hidden</a> <button>ghost</button>
|
|
76
|
+
<a style="visibility: visible" href="shown">shown</a></div>
|
|
77
|
+
<a href="plain">plain</a>
|
|
78
|
+
</body>`,
|
|
79
|
+
);
|
|
80
|
+
const tree = new RenderTreeBuilder().build(document, new StyleEngine().compute(document, [`.menu { pointer-events: none } .menu .open { pointer-events: auto }`]));
|
|
81
|
+
const layout = layoutText(tree);
|
|
82
|
+
const regions = interactiveRegions(tree, layout, { x: 0, y: 0 }, { width: 800, height: 600 });
|
|
83
|
+
|
|
84
|
+
expect(regions.map((region) => region.element.getAttribute("href") ?? region.element.tagName)).toEqual(["open", "shown", "plain"]);
|
|
85
|
+
window.happyDOM.abort();
|
|
86
|
+
});
|
package/test/log.test.js
CHANGED
|
@@ -22,13 +22,16 @@ test("the CLI writes controller and renderer output to the log file", async () =
|
|
|
22
22
|
});
|
|
23
23
|
const reader = child.stderr.getReader();
|
|
24
24
|
let seen = "";
|
|
25
|
-
|
|
25
|
+
// The endpoint opens first; the sandbox line follows once the first renderer is up.
|
|
26
|
+
while (!seen.includes("renderer sandbox:")) {
|
|
26
27
|
const { value, done } = await reader.read();
|
|
27
28
|
if (done) break;
|
|
28
29
|
seen += new TextDecoder().decode(value);
|
|
29
30
|
}
|
|
30
31
|
child.kill();
|
|
31
32
|
await child.exited;
|
|
33
|
+
expect(seen.indexOf("DevTools listening")).toBeGreaterThan(-1);
|
|
34
|
+
expect(seen.indexOf("DevTools listening")).toBeLessThan(seen.indexOf("renderer sandbox:"));
|
|
32
35
|
const log = await Bun.file(`${dir}/${DEFAULT_LOG_FILE}`).text();
|
|
33
36
|
expect(log).toContain("[log] started pid");
|
|
34
37
|
expect(log).toContain("renderer sandbox: seccomp");
|
|
@@ -149,6 +149,73 @@ test("preflights non-simple requests and strips forbidden headers", async () =>
|
|
|
149
149
|
await expect(network.pageFetch({ url: "file:///etc/passwd" }, document)).rejects.toThrow("cannot load file:");
|
|
150
150
|
});
|
|
151
151
|
|
|
152
|
+
test("coalesces identical subresources, caches fresh bodies, and revalidates ETags", async () => {
|
|
153
|
+
let freshRequests = 0;
|
|
154
|
+
let validatedRequests = 0;
|
|
155
|
+
const network = new NetworkService({ fetch: async (input, init = {}) => {
|
|
156
|
+
const url = new URL(typeof input === "string" ? input : input.url);
|
|
157
|
+
const headers = new Headers(init.headers ?? input.headers);
|
|
158
|
+
if (url.pathname === "/fresh") {
|
|
159
|
+
freshRequests++;
|
|
160
|
+
await Bun.sleep(10);
|
|
161
|
+
return new Response("fresh", { headers: { "cache-control": "public, max-age=60" } });
|
|
162
|
+
}
|
|
163
|
+
validatedRequests++;
|
|
164
|
+
if (headers.get("if-none-match") === '"v1"') return new Response(null, { status: 304 });
|
|
165
|
+
return new Response("validated", { headers: { "cache-control": "no-cache", etag: '"v1"' } });
|
|
166
|
+
} });
|
|
167
|
+
const document = "https://page.test/";
|
|
168
|
+
const [left, right] = await Promise.all([
|
|
169
|
+
network.subresource("https://asset.test/fresh", document),
|
|
170
|
+
network.subresource("https://asset.test/fresh", document),
|
|
171
|
+
]);
|
|
172
|
+
expect([new TextDecoder().decode(left.body), new TextDecoder().decode(right.body), freshRequests])
|
|
173
|
+
.toEqual(["fresh", "fresh", 1]);
|
|
174
|
+
await network.subresource("https://asset.test/fresh", document);
|
|
175
|
+
expect(freshRequests).toBe(1);
|
|
176
|
+
|
|
177
|
+
const first = await network.subresource("https://asset.test/revalidate", document);
|
|
178
|
+
const second = await network.subresource("https://asset.test/revalidate", document);
|
|
179
|
+
expect([new TextDecoder().decode(first.body), new TextDecoder().decode(second.body), validatedRequests])
|
|
180
|
+
.toEqual(["validated", "validated", 2]);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("at most six requests per host are in flight; waiting stylesheets go before scripts", async () => {
|
|
184
|
+
let active = 0;
|
|
185
|
+
let peak = 0;
|
|
186
|
+
const started = [];
|
|
187
|
+
const gates = [];
|
|
188
|
+
const network = new NetworkService({ fetch: async (input) => {
|
|
189
|
+
const url = new URL(typeof input === "string" ? input : input.url);
|
|
190
|
+
started.push(url.host + url.pathname);
|
|
191
|
+
if (url.host === "slow.test") {
|
|
192
|
+
active++;
|
|
193
|
+
peak = Math.max(peak, active);
|
|
194
|
+
await new Promise((resolve) => gates.push(resolve));
|
|
195
|
+
active--;
|
|
196
|
+
}
|
|
197
|
+
return new Response("x", { headers: { "cache-control": "no-store" } });
|
|
198
|
+
} });
|
|
199
|
+
const document = "https://page.test/";
|
|
200
|
+
const scripts = Array.from({ length: 8 }, (_, index) => network.subresource(`https://slow.test/s${index}.js`, document, "script"));
|
|
201
|
+
await Bun.sleep(5);
|
|
202
|
+
const stylesheet = network.subresource("https://slow.test/late.css", document, "stylesheet");
|
|
203
|
+
// Another host is not held up by this one's queue.
|
|
204
|
+
await network.subresource("https://other.test/free.js", document, "script");
|
|
205
|
+
expect(started.filter((entry) => entry.startsWith("slow.test"))).toHaveLength(6);
|
|
206
|
+
gates.shift()();
|
|
207
|
+
await Bun.sleep(5);
|
|
208
|
+
// The freed slot went to the stylesheet that came after the waiting scripts.
|
|
209
|
+
expect(started.at(-1)).toBe("slow.test/late.css");
|
|
210
|
+
while (active || gates.length) {
|
|
211
|
+
gates.shift()?.();
|
|
212
|
+
await Bun.sleep(1);
|
|
213
|
+
}
|
|
214
|
+
await Promise.all([...scripts, stylesheet]);
|
|
215
|
+
expect(peak).toBe(6);
|
|
216
|
+
expect(started.filter((entry) => entry.startsWith("slow.test"))).toHaveLength(9);
|
|
217
|
+
});
|
|
218
|
+
|
|
152
219
|
/** Opens a page WebSocket and resolves with how it ended. */
|
|
153
220
|
function socketOutcome(socket) {
|
|
154
221
|
return new Promise((resolve) => {
|