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
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>`,
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { connect } from "node:net";
|
|
3
|
+
import { floatToS16, PulseClient } from "../lib/audio/pulse-client.js";
|
|
4
|
+
import { splitSamples } from "../lib/audio/pcm.js";
|
|
5
|
+
|
|
6
|
+
// The playback test needs a PulseAudio native-protocol server (jspulse, or a
|
|
7
|
+
// daemon with module-native-protocol-tcp) on 127.0.0.1:4713; without one it is skipped.
|
|
8
|
+
const serverRunning = await new Promise((resolve) => {
|
|
9
|
+
const socket = connect({ host: "127.0.0.1", port: 4713 });
|
|
10
|
+
const done = (running) => {
|
|
11
|
+
socket.destroy();
|
|
12
|
+
resolve(running);
|
|
13
|
+
};
|
|
14
|
+
socket.once("connect", () => done(true));
|
|
15
|
+
socket.once("error", () => done(false));
|
|
16
|
+
setTimeout(() => done(false), 1000);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("floatToS16 clamps and scales interleaved samples to s16le", () => {
|
|
20
|
+
const bytes = floatToS16(new Float32Array([0, 1, -1, 2, 0.5]));
|
|
21
|
+
expect([...new Int16Array(bytes.buffer)]).toEqual([0, 32767, -32768, 32767, 16384]);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("small audio requests consume only whole frames and preserve the remainder", () => {
|
|
25
|
+
const original = Float32Array.from({ length: 2048 }, (_, index) => index / 2048);
|
|
26
|
+
let pending = original;
|
|
27
|
+
const sent = [];
|
|
28
|
+
for (const bytes of [4, 80, 3840, 172]) {
|
|
29
|
+
const [part, rest] = splitSamples(pending, 2, bytes);
|
|
30
|
+
expect(part.length * 2).toBeLessThanOrEqual(bytes);
|
|
31
|
+
expect(part.length % 2).toBe(0);
|
|
32
|
+
sent.push(part);
|
|
33
|
+
pending = rest;
|
|
34
|
+
}
|
|
35
|
+
expect([...sent.flatMap((part) => [...part]), ...pending]).toEqual([...original]);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test.skipIf(!serverRunning)("plays a stream through the PulseAudio server on 127.0.0.1:4713", async () => {
|
|
39
|
+
const client = await PulseClient.connect();
|
|
40
|
+
const requests = [];
|
|
41
|
+
const stream = await client.openPlayback({ rate: 44100, channels: 2, onRequest: (bytes) => requests.push(bytes) });
|
|
42
|
+
expect(requests[0]).toBeGreaterThan(0);
|
|
43
|
+
stream.write(floatToS16(new Float32Array(44100 * 2 / 10))); // 0.1 s of stereo silence
|
|
44
|
+
await stream.finish();
|
|
45
|
+
expect(stream.killed).toBe(true);
|
|
46
|
+
client.close();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test.skipIf(!serverRunning)("opens independent playback streams at different sample rates", async () => {
|
|
50
|
+
const client = await PulseClient.connect();
|
|
51
|
+
const first = await client.openPlayback({ rate: 48000, channels: 1, name: "first" });
|
|
52
|
+
const second = await client.openPlayback({ rate: 44100, channels: 2, name: "second" });
|
|
53
|
+
expect(first.index).not.toBe(second.index);
|
|
54
|
+
await second.close();
|
|
55
|
+
await first.close();
|
|
56
|
+
client.close();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("connecting without a server fails with an error, not a hang", async () => {
|
|
60
|
+
await expect(PulseClient.connect({ port: 1 })).rejects.toThrow();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("audio is sent in whole frames, so stereo channels never swap", async () => {
|
|
64
|
+
// A minimal server: replies to every command, grants odd-sized requests, records audio frame sizes.
|
|
65
|
+
const sizes = [];
|
|
66
|
+
const offsets = [];
|
|
67
|
+
const flags = [];
|
|
68
|
+
const bufferAttributes = [];
|
|
69
|
+
let nextStreamIndex = 1;
|
|
70
|
+
const { createServer } = await import("node:net");
|
|
71
|
+
const server = createServer((socket) => {
|
|
72
|
+
let buffer = Buffer.alloc(0);
|
|
73
|
+
const reply = (tag, extra = []) => {
|
|
74
|
+
const body = Buffer.from([0x4c, 0, 0, 0, 2, 0x4c, ...u32(tag), ...extra]);
|
|
75
|
+
socket.write(Buffer.concat([header(body.length, 0xffffffff), body]));
|
|
76
|
+
};
|
|
77
|
+
socket.on("data", (chunk) => {
|
|
78
|
+
buffer = Buffer.concat([buffer, chunk]);
|
|
79
|
+
while (buffer.length >= 20 && buffer.length >= 20 + buffer.readUInt32BE(0)) {
|
|
80
|
+
const length = buffer.readUInt32BE(0);
|
|
81
|
+
const channel = buffer.readUInt32BE(4);
|
|
82
|
+
const offset = buffer.readBigInt64BE(8);
|
|
83
|
+
const flag = buffer.readUInt32BE(16);
|
|
84
|
+
const body = buffer.subarray(20, 20 + length);
|
|
85
|
+
buffer = buffer.subarray(20 + length);
|
|
86
|
+
if (channel !== 0xffffffff) {
|
|
87
|
+
sizes.push(length);
|
|
88
|
+
offsets.push(offset);
|
|
89
|
+
flags.push(flag);
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
const command = body.readUInt32BE(1);
|
|
93
|
+
const tag = body.readUInt32BE(6);
|
|
94
|
+
if (command === 3) {
|
|
95
|
+
const nameEnd = body.indexOf(0, 11);
|
|
96
|
+
const channelCount = body[nameEnd + 3];
|
|
97
|
+
const attrs = nameEnd + 1 + 7 + 2 + channelCount + 5 + 1 + 5 + 1;
|
|
98
|
+
bufferAttributes.push([0, 1, 2].map((index) => body.readUInt32BE(attrs + index * 5 + 1)));
|
|
99
|
+
reply(tag, [0x4c, ...u32(nextStreamIndex++), 0x4c, ...u32(1), 0x4c, ...u32(96003)]); // odd request, enough for several packets
|
|
100
|
+
}
|
|
101
|
+
else reply(tag);
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
106
|
+
const client = await PulseClient.connect({ port: server.address().port });
|
|
107
|
+
const stream = await client.openPlayback({ rate: 48000, channels: 2 });
|
|
108
|
+
stream.write(new Uint8Array(4 * 24000));
|
|
109
|
+
await Bun.sleep(100);
|
|
110
|
+
expect(sizes.length).toBeGreaterThan(1);
|
|
111
|
+
expect(sizes.every((size) => size % 4 === 0)).toBeTrue();
|
|
112
|
+
expect(offsets.every((offset) => offset === 0n)).toBeTrue();
|
|
113
|
+
expect(flags.every((flag) => flag === 0)).toBeTrue();
|
|
114
|
+
expect(bufferAttributes[0]).toEqual([76800, 15360, 7680]); // 400/80/40 ms at 48 kHz stereo
|
|
115
|
+
await client.openPlayback({ rate: 48000, channels: 1, leadSeconds: 0.12 });
|
|
116
|
+
expect(bufferAttributes[1]).toEqual([15360, 3840, 3840]); // 160/40/40 ms at 48 kHz mono
|
|
117
|
+
client.close();
|
|
118
|
+
server.close();
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
function u32(value) {
|
|
122
|
+
return [(value >>> 24) & 255, (value >>> 16) & 255, (value >>> 8) & 255, value & 255];
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function header(length, channel) {
|
|
126
|
+
const out = Buffer.alloc(20);
|
|
127
|
+
out.writeUInt32BE(length, 0);
|
|
128
|
+
out.writeUInt32BE(channel, 4);
|
|
129
|
+
return out;
|
|
130
|
+
}
|
package/test/render-tree.test.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { expect, test } from "bun:test";
|
|
2
2
|
import { parseHTMLDocument } from "../lib/happy-dom/parser.js";
|
|
3
|
-
import { RenderTreeBuilder, renderTreeText } from "../lib/render-tree/index.js";
|
|
3
|
+
import { RenderTreeBuilder, renderTreeText, sameLayoutInput } from "../lib/render-tree/index.js";
|
|
4
4
|
|
|
5
5
|
test("builds an engine-owned tree and excludes non-rendered DOM nodes", () => {
|
|
6
6
|
const { document, window } = parseHTMLDocument(`<body>
|
|
@@ -101,3 +101,38 @@ test("form controls render their live value, placeholder, selection and checked
|
|
|
101
101
|
expect(renderTreeText(tree)).toContain("bold label");
|
|
102
102
|
window.happyDOM.abort();
|
|
103
103
|
});
|
|
104
|
+
|
|
105
|
+
test("audio with controls is a 300x32 player painted from its state; without controls it is not rendered", async () => {
|
|
106
|
+
const { StyleEngine } = await import("../lib/style/computed-style.js");
|
|
107
|
+
const { document, window } = parseHTMLDocument(`<body><audio id="a" controls src="t.mp3">fallback</audio><audio id="b" src="t.mp3"></audio></body>`);
|
|
108
|
+
const engine = new StyleEngine().compute(document);
|
|
109
|
+
const state = { audio: true, playing: false };
|
|
110
|
+
const tree = new RenderTreeBuilder().build(document, engine, new Map([[document.getElementById("a"), state]]));
|
|
111
|
+
const node = (id) => [...tree.nodesById.values()].find((candidate) => candidate.domNode.id === id && !candidate.pseudo);
|
|
112
|
+
expect(node("a")).toMatchObject({ type: "image", tagName: "AUDIO", widthAttribute: 300, heightAttribute: 32 });
|
|
113
|
+
expect(node("a").resource).toBe(state);
|
|
114
|
+
expect(node("b")).toBeUndefined();
|
|
115
|
+
expect(renderTreeText(tree)).not.toContain("fallback");
|
|
116
|
+
window.happyDOM.abort();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("detects whether a rebuilt tree changes layout input", async () => {
|
|
120
|
+
const { StyleEngine } = await import("../lib/style/computed-style.js");
|
|
121
|
+
const { document, window } = parseHTMLDocument(`<body><p>visible</p><script>old</script></body>`);
|
|
122
|
+
const builder = new RenderTreeBuilder();
|
|
123
|
+
const styles = new StyleEngine().compute(document);
|
|
124
|
+
const first = builder.build(document, styles);
|
|
125
|
+
const firstParagraph = first.root.children[0];
|
|
126
|
+
|
|
127
|
+
document.querySelector("script").textContent = "new metadata";
|
|
128
|
+
const metadataOnly = builder.build(document, styles);
|
|
129
|
+
expect(sameLayoutInput(first.root, metadataOnly.root)).toBe(true);
|
|
130
|
+
expect(metadataOnly.root).toBe(first.root);
|
|
131
|
+
expect(metadataOnly.root.children[0]).toBe(firstParagraph);
|
|
132
|
+
|
|
133
|
+
document.querySelector("p").textContent = "different visible text";
|
|
134
|
+
const visibleChange = builder.build(document, styles);
|
|
135
|
+
expect(sameLayoutInput(metadataOnly.root, visibleChange.root)).toBe(false);
|
|
136
|
+
expect(visibleChange.root.children[0]).not.toBe(firstParagraph);
|
|
137
|
+
window.happyDOM.abort();
|
|
138
|
+
});
|
package/test/resolver.test.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { expect, test } from "bun:test";
|
|
2
|
-
import { fetchResolved, HostResolver } from "../lib/network/resolver.js";
|
|
2
|
+
import { fetchResolved, HostResolver, isLocalhostName } from "../lib/network/resolver.js";
|
|
3
3
|
|
|
4
4
|
const lookupFrom = (table, calls = []) => async (name, { family }) => {
|
|
5
5
|
calls.push(`${name}/${family}`);
|
|
@@ -49,3 +49,14 @@ test("connects to the address with the host name in Host and TLS, falling back o
|
|
|
49
49
|
const failing = async () => { throw Object.assign(new Error("bad cert"), { code: "ERR_TLS_CERT_ALTNAME_INVALID" }); };
|
|
50
50
|
await expect(fetchResolved(failing, resolver, "https://a.test/", {})).rejects.toThrow("bad cert");
|
|
51
51
|
});
|
|
52
|
+
|
|
53
|
+
test("localhost names are loopback without a lookup (no /etc/hosts needed)", async () => {
|
|
54
|
+
const resolver = new HostResolver({ lookup: async () => { throw Object.assign(new Error("no hosts"), { code: "ENOTFOUND" }); } });
|
|
55
|
+
const loopback = [{ address: "127.0.0.1", family: 4 }, { address: "::1", family: 6 }];
|
|
56
|
+
for (const name of ["localhost", "LocalHost", "localhost.", "app.localhost", "a.b.localhost."]) {
|
|
57
|
+
expect(await resolver.addresses(name)).toEqual(loopback);
|
|
58
|
+
}
|
|
59
|
+
for (const name of ["localhost.com", "notlocalhost", "localhost..", "my-localhost"]) {
|
|
60
|
+
expect(isLocalhostName(name)).toBe(false);
|
|
61
|
+
}
|
|
62
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { screenshotTargetUrl } from "../lib/screenshot-cli.js";
|
|
3
|
+
|
|
4
|
+
test("screenshot target treats Windows drive and UNC paths as files", () => {
|
|
5
|
+
const options = {
|
|
6
|
+
platform: "win32",
|
|
7
|
+
resolvePath: (path) => path,
|
|
8
|
+
fileURL: (path) => ({ href: `file:${path}` }),
|
|
9
|
+
};
|
|
10
|
+
for (const path of [String.raw`C:\Users\Ada\page.html`, "C:/Users/Ada/page.html", String.raw`\\server\share\page.html`, String.raw`pages\page.html`]) {
|
|
11
|
+
expect(screenshotTargetUrl(path, options)).toBe(`file:${path}`);
|
|
12
|
+
}
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("screenshot target preserves URLs and bare hostnames", () => {
|
|
16
|
+
expect(screenshotTargetUrl("file:///C:/Users/Ada/page.html", { platform: "win32" })).toBe("file:///C:/Users/Ada/page.html");
|
|
17
|
+
expect(screenshotTargetUrl("https://example.com", { platform: "win32" })).toBe("https://example.com");
|
|
18
|
+
expect(screenshotTargetUrl("example.com", { platform: "win32" })).toBe("https://example.com");
|
|
19
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { selectorKeys } from "../lib/style/selector-keys.js";
|
|
3
|
+
|
|
4
|
+
test("indexes a selector by its subject's first key and its ancestors' keys", () => {
|
|
5
|
+
expect(selectorKeys("DIV.Foo > SPAN#Bar.baz[Data-X]")).toEqual({
|
|
6
|
+
subject: [{ kind: "id", name: "Bar" }],
|
|
7
|
+
ancestors: ["div", ".Foo"],
|
|
8
|
+
});
|
|
9
|
+
// Sibling compounds are not ancestors; attribute and type names are lowercased.
|
|
10
|
+
expect(selectorKeys("ul [Role] + LI:hover")).toEqual({ subject: [{ kind: "tag", name: "li" }], ancestors: ["ul"] });
|
|
11
|
+
// :root is the "html" bucket as a subject, but not an ancestor filter key.
|
|
12
|
+
expect(selectorKeys(":root").subject).toEqual([{ kind: "tag", name: "html" }]);
|
|
13
|
+
expect(selectorKeys(":ROOT .x")).toEqual({ subject: [{ kind: "class", name: "x" }], ancestors: [] });
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test("escapes, quotes and digits in names", () => {
|
|
17
|
+
expect(selectorKeys(".a\\:hover .b\\.c").ancestors).toEqual([".a:hover"]);
|
|
18
|
+
expect(selectorKeys(".a\\:hover .b\\.c").subject).toEqual([{ kind: "class", name: "b.c" }]);
|
|
19
|
+
// A hex escape cannot be resolved here: the name is left out.
|
|
20
|
+
expect(selectorKeys(".\\31 0").subject).toBeNull();
|
|
21
|
+
// Class names cannot start with a digit; ids can.
|
|
22
|
+
expect(selectorKeys(".1a").subject).toBeNull();
|
|
23
|
+
expect(selectorKeys("#1a").subject).toEqual([{ kind: "id", name: "1a" }]);
|
|
24
|
+
// Brackets and "." inside a quoted attribute value are not structure.
|
|
25
|
+
expect(selectorKeys('[data-x="a] .b"] .c')).toEqual({ subject: [{ kind: "class", name: "c" }], ancestors: ["[data-x"] });
|
|
26
|
+
expect(selectorKeys(".日本 .語")).toEqual({ subject: [{ kind: "class", name: "語" }], ancestors: [".日本"] });
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test(":is() and :where() alternatives each need a key; anything not understood is unindexed", () => {
|
|
30
|
+
expect(selectorKeys(":is(.a, #b, p)").subject).toEqual([
|
|
31
|
+
{ kind: "class", name: "a" }, { kind: "id", name: "b" }, { kind: "tag", name: "p" },
|
|
32
|
+
]);
|
|
33
|
+
expect(selectorKeys(":where(.a .b, .c)").subject).toBeNull();
|
|
34
|
+
expect(selectorKeys(":is(.a, :not(.b))").subject).toBeNull();
|
|
35
|
+
expect(selectorKeys("ns|a b").ancestors).toEqual([]);
|
|
36
|
+
for (const selector of ["", "> a", "a >", "a 'unterminated", ":is(.a"]) {
|
|
37
|
+
expect(selectorKeys(selector)).toEqual({ subject: null, ancestors: [] });
|
|
38
|
+
}
|
|
39
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { sheetTraits } from "../lib/style/selector-traits.js";
|
|
3
|
+
|
|
4
|
+
test("finds :has() compounds and sibling-target compounds in a sheet", () => {
|
|
5
|
+
const traits = sheetTraits(`
|
|
6
|
+
/* .ignored:has(a) {} */
|
|
7
|
+
.a:has(> img), .b .c:has(+ .d)::before { color: red }
|
|
8
|
+
@media (min-width: 1px) { .e { color: blue } }
|
|
9
|
+
.f:has(.g) .h, .i { color: green }
|
|
10
|
+
li:first-child + li .x, ul > li:last-child ~ li { color: black }
|
|
11
|
+
.esc\\:a .y:not([data-z="a b"]) { color: white }
|
|
12
|
+
`);
|
|
13
|
+
expect([...traits.hasCompounds]).toEqual([".a:has(> img)", ".c:has(+ .d)", ".f:has(.g)"]);
|
|
14
|
+
expect(traits.hasSiblings).toBe(true);
|
|
15
|
+
expect([...traits.siblingTargetCompounds]).toEqual(["li:first-child + li"]);
|
|
16
|
+
expect([...traits.structuralCompounds]).toEqual(["li:first-child", "li:last-child"]);
|
|
17
|
+
expect(traits.descendantFeatures.has(".esc:a")).toBe(true);
|
|
18
|
+
expect(traits.sibling && traits.firstChild && traits.lastChild).toBe(true);
|
|
19
|
+
});
|
package/test/text-layout.test.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { expect, test } from "bun:test";
|
|
2
2
|
import { parseHTMLDocument } from "../lib/happy-dom/parser.js";
|
|
3
|
-
import { layoutText } from "../lib/layout/text-layout.js";
|
|
3
|
+
import { LayoutEngine, layoutText } from "../lib/layout/text-layout.js";
|
|
4
4
|
import { RenderTreeBuilder } from "../lib/render-tree/index.js";
|
|
5
5
|
import { StyleEngine } from "../lib/style/computed-style.js";
|
|
6
6
|
|
|
@@ -417,3 +417,93 @@ test("an auto grid track is limited by a min-width: 0 item instead of its conten
|
|
|
417
417
|
expect(layout.boxes.map((box) => box.width)).toContain(100);
|
|
418
418
|
expect(layout.boxes.map((box) => box.width)).not.toContain(160);
|
|
419
419
|
});
|
|
420
|
+
|
|
421
|
+
test("a grid body stacks items sharing an area and aligns its content with place-*", () => {
|
|
422
|
+
const { document, window } = parseHTMLDocument(`<body><i id="icon" style="display:block;width:40px;height:40px"></i>
|
|
423
|
+
<p id="a">one</p><p id="b">two two</p><p id="c">three</p></body>`);
|
|
424
|
+
const styles = new StyleEngine().compute(document, [`
|
|
425
|
+
body { margin:0; display:grid; min-height:300px; place-content:center; place-items:center }
|
|
426
|
+
p { margin:0; grid-area:2/1 }
|
|
427
|
+
`]);
|
|
428
|
+
const tree = new RenderTreeBuilder().build(document, styles);
|
|
429
|
+
const layout = layoutText(tree, {
|
|
430
|
+
x: 0, y: 0, width: 400, measureText: (text) => text.length * 10, fontMetrics: () => ({ ascent: 16, height: 20 }),
|
|
431
|
+
});
|
|
432
|
+
const boxOf = (id) => layout.boxes.find((box) => tree.nodesById.get(box.nodeId)?.domNode?.id === id);
|
|
433
|
+
const [icon, a, b, c] = ["icon", "a", "b", "c"].map(boxOf);
|
|
434
|
+
// One shared row for the paragraphs, centered in 300px under the 40px icon row.
|
|
435
|
+
expect([a.y, b.y, c.y]).toEqual([a.y, a.y, a.y]);
|
|
436
|
+
expect(icon.y).toBe((300 - 40 - 20) / 2);
|
|
437
|
+
expect(a.y).toBe(icon.y + 40);
|
|
438
|
+
// place-items: center centers each item in the 70px-wide column ("two two").
|
|
439
|
+
expect([icon.x, a.x, b.x]).toEqual([180, 185, 165]);
|
|
440
|
+
window.happyDOM.abort();
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
test("retained block results match a clean layout after a sibling changes", () => {
|
|
444
|
+
const { document, window } = parseHTMLDocument(`<body><section><p>unchanged subtree</p></section><section id="edit">short</section></body>`);
|
|
445
|
+
const styles = new StyleEngine().compute(document);
|
|
446
|
+
const builder = new RenderTreeBuilder();
|
|
447
|
+
const engine = new LayoutEngine({
|
|
448
|
+
measureText: (text) => text.length * 10,
|
|
449
|
+
fontMetrics: () => ({ ascent: 16, height: 20 }),
|
|
450
|
+
});
|
|
451
|
+
const options = { x: 0, y: 0, width: 240, measureText: (text) => text.length * 10, fontMetrics: () => ({ ascent: 16, height: 20 }) };
|
|
452
|
+
engine.layout(builder.build(document, styles), options);
|
|
453
|
+
|
|
454
|
+
document.getElementById("edit").textContent = "a changed sibling which wraps onto another line";
|
|
455
|
+
const tree = builder.build(document, styles);
|
|
456
|
+
const retained = engine.layout(tree, options);
|
|
457
|
+
const clean = layoutText(tree, options);
|
|
458
|
+
expect(retained).toEqual(clean);
|
|
459
|
+
window.happyDOM.abort();
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
/** A layout with paint layers as ranks: replays renumber layers, keeping their order. */
|
|
463
|
+
function rankedLayout(layout) {
|
|
464
|
+
const orders = new Set([0]);
|
|
465
|
+
for (const entry of [...layout.boxes, ...layout.fragments]) {
|
|
466
|
+
for (let layer = entry.layer; layer; layer = layer.parent) orders.add(layer.order);
|
|
467
|
+
}
|
|
468
|
+
const rank = new Map([...orders].sort((a, b) => a - b).map((order, index) => [order, index]));
|
|
469
|
+
const layer = (value) => value && { rank: rank.get(value.order), z: value.z, context: value.context, parent: value.parent ? rank.get(value.parent.order) : null };
|
|
470
|
+
return {
|
|
471
|
+
height: layout.height,
|
|
472
|
+
boxes: layout.boxes.map((box) => ({ ...box, layer: layer(box.layer) })),
|
|
473
|
+
fragments: layout.fragments.map(({ id, ...fragment }) => ({ ...fragment, layer: layer(fragment.layer) })),
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function retainedAndClean(html, change) {
|
|
478
|
+
const { document, window } = parseHTMLDocument(html);
|
|
479
|
+
const styles = new StyleEngine().compute(document);
|
|
480
|
+
const builder = new RenderTreeBuilder();
|
|
481
|
+
const measure = { measureText: (text) => text.length * 10, fontMetrics: () => ({ ascent: 16, height: 20 }) };
|
|
482
|
+
const engine = new LayoutEngine(measure);
|
|
483
|
+
const options = { x: 0, y: 0, width: 240, viewportHeight: 300, ...measure };
|
|
484
|
+
engine.layout(builder.build(document, styles), options);
|
|
485
|
+
change(document);
|
|
486
|
+
const tree = builder.build(document, styles);
|
|
487
|
+
const result = { retained: engine.layout(tree, options), clean: layoutText(tree, options), document };
|
|
488
|
+
window.happyDOM.abort();
|
|
489
|
+
return result;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
test("positioned boxes inside a retained block are replayed in their paint layers", () => {
|
|
493
|
+
const { retained, clean } = retainedAndClean(`<body><p id="edit">short</p>
|
|
494
|
+
<section><div style="position:relative;z-index:2">raised <span style="position:relative">inner</span></div>
|
|
495
|
+
<div style="position:relative">auto</div><p style="position:absolute;top:5px;z-index:-1">behind</p></section></body>`,
|
|
496
|
+
(document) => { document.getElementById("edit").textContent = "a longer paragraph that wraps onto two lines"; });
|
|
497
|
+
expect(rankedLayout(retained)).toEqual(rankedLayout(clean));
|
|
498
|
+
// The section moved down with the wrapped paragraph, and its layers came along.
|
|
499
|
+
expect(new Set(retained.boxes.map((box) => box.layer.order)).size).toBe(new Set(clean.boxes.map((box) => box.layer.order)).size);
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
test("an out-of-flow box keeps its static position when the block around its placeholder is replayed", () => {
|
|
503
|
+
// The absolute box's containing block is the positioned div; its placeholder is in the section,
|
|
504
|
+
// which is replayed (moved down) after the paragraph above it grows.
|
|
505
|
+
const { retained, clean } = retainedAndClean(`<body><div style="position:relative"><p id="edit">short</p>
|
|
506
|
+
<section><p>above</p><div style="position:absolute">static</div><p>below</p></section></div></body>`,
|
|
507
|
+
(document) => { document.getElementById("edit").textContent = "a longer paragraph that wraps onto two lines"; });
|
|
508
|
+
expect(rankedLayout(retained)).toEqual(rankedLayout(clean));
|
|
509
|
+
});
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { CdpServer } from "../lib/cdp-server.js";
|
|
3
|
+
import { createBrowser } from "../lib/headless-shell.js";
|
|
4
|
+
import { MOBILE_USER_AGENT } from "../lib/renderer/flags.js";
|
|
5
|
+
import { CHROMIUM_VERSION, DESKTOP_USER_AGENT, desktopUserAgent } from "../lib/user-agent.js";
|
|
6
|
+
|
|
7
|
+
test("desktop user agent uses the same version on each platform", () => {
|
|
8
|
+
for (const [platform, arch, system] of [
|
|
9
|
+
["linux", "arm64", "X11; Linux aarch64"],
|
|
10
|
+
["linux", "x64", "X11; Linux x86_64"],
|
|
11
|
+
["win32", "x64", "Windows NT 10.0; Win64; x64"],
|
|
12
|
+
["darwin", "arm64", "Macintosh; Intel Mac OS X 10_15_7"],
|
|
13
|
+
]) {
|
|
14
|
+
expect(desktopUserAgent(platform, arch)).toBe(`Mozilla/5.0 (${system}) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${CHROMIUM_VERSION} Safari/537.36`);
|
|
15
|
+
}
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("mobile uses current Chrome for Android rather than WebView", () => {
|
|
19
|
+
expect(MOBILE_USER_AGENT).toBe(`Mozilla/5.0 (Linux; Android 16; ASUSAI2501C Build/BQ2A.250525.001) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${CHROMIUM_VERSION} Mobile Safari/537.36`);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("CDP discovery, HTTP requests, and navigator report the same desktop user agent", async () => {
|
|
23
|
+
let requestUserAgent;
|
|
24
|
+
const site = Bun.serve({ port: 0, fetch(request) {
|
|
25
|
+
requestUserAgent = request.headers.get("user-agent");
|
|
26
|
+
return new Response("<!doctype html><title>UA test</title>", { headers: { "content-type": "text/html" } });
|
|
27
|
+
} });
|
|
28
|
+
const browser = await createBrowser({ spareRenderer: false });
|
|
29
|
+
const cdp = CdpServer.create(browser.context).listen(0, "127.0.0.1");
|
|
30
|
+
try {
|
|
31
|
+
const version = await fetch(`http://127.0.0.1:${cdp.port}/json/version`).then((response) => response.json());
|
|
32
|
+
expect(version.Browser).toBe(`Chrome/${CHROMIUM_VERSION}`);
|
|
33
|
+
expect(version["User-Agent"]).toBe(DESKTOP_USER_AGENT);
|
|
34
|
+
await browser.context.navigate(`http://127.0.0.1:${site.port}/`);
|
|
35
|
+
expect(requestUserAgent).toBe(DESKTOP_USER_AGENT);
|
|
36
|
+
expect(await browser.context.evaluate("navigator.userAgent")).toBe(DESKTOP_USER_AGENT);
|
|
37
|
+
} finally {
|
|
38
|
+
cdp.stop(true);
|
|
39
|
+
browser.close();
|
|
40
|
+
site.stop(true);
|
|
41
|
+
}
|
|
42
|
+
});
|
|
@@ -706,12 +706,9 @@ export default class Element
|
|
|
706
706
|
* @param text HTML string to insert.
|
|
707
707
|
*/
|
|
708
708
|
public insertAdjacentHTML(position: InsertAdjacentPosition, text: string): void {
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
];
|
|
712
|
-
while (childNodes.length) {
|
|
713
|
-
this.insertAdjacentElement(position, childNodes[0]);
|
|
714
|
-
}
|
|
709
|
+
// Insert the parsed fragment as a whole: inserting its nodes one at a
|
|
710
|
+
// time at "afterbegin" or "afterend" reversed their order.
|
|
711
|
+
this.insertAdjacentElement(position, new HTMLParser(this[PropertySymbol.window]).parse(text));
|
|
715
712
|
}
|
|
716
713
|
|
|
717
714
|
/**
|
package/vendor/happy-dom/packages/happy-dom/src/nodes/html-script-element/HTMLScriptElement.ts
CHANGED
|
@@ -566,16 +566,7 @@ export default class HTMLScriptElement extends HTMLElement {
|
|
|
566
566
|
browserSettings &&
|
|
567
567
|
(browserSettings.disableJavaScriptFileLoading || !browserSettings.enableJavaScriptEvaluation)
|
|
568
568
|
) {
|
|
569
|
-
|
|
570
|
-
this.dispatchEvent(new Event('load'));
|
|
571
|
-
} else {
|
|
572
|
-
const error = new window.DOMException(
|
|
573
|
-
`Failed to load module "${url}". JavaScript file loading is disabled.`,
|
|
574
|
-
DOMExceptionNameEnum.notSupportedError
|
|
575
|
-
);
|
|
576
|
-
browserFrame.page.console.error(error);
|
|
577
|
-
this.dispatchEvent(new Event('error'));
|
|
578
|
-
}
|
|
569
|
+
// The renderer owns script fetching and dispatches load/error after execution.
|
|
579
570
|
return;
|
|
580
571
|
}
|
|
581
572
|
|
|
@@ -647,16 +638,7 @@ export default class HTMLScriptElement extends HTMLElement {
|
|
|
647
638
|
browserSettings &&
|
|
648
639
|
(browserSettings.disableJavaScriptFileLoading || !browserSettings.enableJavaScriptEvaluation)
|
|
649
640
|
) {
|
|
650
|
-
|
|
651
|
-
this.dispatchEvent(new Event('load'));
|
|
652
|
-
} else {
|
|
653
|
-
const error = new window.DOMException(
|
|
654
|
-
`Failed to load script "${absoluteURL}". JavaScript file loading is disabled.`,
|
|
655
|
-
DOMExceptionNameEnum.notSupportedError
|
|
656
|
-
);
|
|
657
|
-
browserFrame.page.console.error(error);
|
|
658
|
-
this.dispatchEvent(new Event('error'));
|
|
659
|
-
}
|
|
641
|
+
// The renderer owns script fetching and dispatches load/error after execution.
|
|
660
642
|
return;
|
|
661
643
|
}
|
|
662
644
|
|
|
@@ -373,7 +373,10 @@ export default class QuerySelector {
|
|
|
373
373
|
);
|
|
374
374
|
}
|
|
375
375
|
|
|
376
|
-
|
|
376
|
+
// A :has() match depends on the whole subtree, which no affected-cache
|
|
377
|
+
// registration covers, so it is not cached.
|
|
378
|
+
const cacheable = !selector.includes(':has(');
|
|
379
|
+
const cachedResult = cacheable ? element[PropertySymbol.cache].matches.get(selector) : null;
|
|
377
380
|
|
|
378
381
|
if (cachedResult) {
|
|
379
382
|
if (cachedResult.result !== null) {
|
|
@@ -386,9 +389,11 @@ export default class QuerySelector {
|
|
|
386
389
|
result: { match: null }
|
|
387
390
|
};
|
|
388
391
|
|
|
389
|
-
|
|
392
|
+
if (cacheable) {
|
|
393
|
+
element[PropertySymbol.cache].matches.set(selector, cachedItem);
|
|
394
|
+
}
|
|
390
395
|
|
|
391
|
-
if (element[PropertySymbol.isConnected]) {
|
|
396
|
+
if (cacheable && element[PropertySymbol.isConnected]) {
|
|
392
397
|
// Document is affected for the ":target" selector
|
|
393
398
|
(element[PropertySymbol.ownerDocument] || element)[PropertySymbol.affectsCache].push(
|
|
394
399
|
cachedItem
|
|
@@ -462,6 +467,13 @@ export default class QuerySelector {
|
|
|
462
467
|
return null;
|
|
463
468
|
}
|
|
464
469
|
|
|
470
|
+
// Structural pseudo-classes and sibling combinators read the parent's
|
|
471
|
+
// child list, whose changes are reported on the parent.
|
|
472
|
+
const parent = element.parentNode;
|
|
473
|
+
if (parent) {
|
|
474
|
+
this.affectsCache(<Element>parent, cachedItem);
|
|
475
|
+
}
|
|
476
|
+
|
|
465
477
|
const result = selectorItem.match(scope, element, ignoreErrors);
|
|
466
478
|
|
|
467
479
|
if (result) {
|
package/vendor/ses/dist/ses.mjs
CHANGED
|
@@ -8108,6 +8108,8 @@ const methods = {
|
|
|
8108
8108
|
* is accepted.
|
|
8109
8109
|
*/
|
|
8110
8110
|
'%SetPrototype%': '*',
|
|
8111
|
+
// Page scripts may assign an own toString method to a RegExp instance.
|
|
8112
|
+
'%RegExpPrototype%': { toString: true },
|
|
8111
8113
|
};$h͏_once.severeEnablements(severeEnablements);
|
|
8112
8114
|
})()
|
|
8113
8115
|
,
|