buninu-browser 0.2.3 → 0.2.4
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/lib/cdp-server.js +47 -14
- package/lib/happy-dom/document-base.js +33 -0
- package/lib/happy-dom/parser.js +15 -6
- package/lib/happy-dom/selector-matcher.js +2 -2
- package/lib/happy-dom/selector-pseudo-adapter.js +19 -0
- package/lib/happy-dom/svg-attribute-adapter.js +50 -0
- package/lib/headless-shell.js +9 -6
- package/lib/input/hit-test.js +67 -19
- package/lib/layout/text-layout.js +213 -91
- package/lib/media/byte-pipe.js +2 -2
- package/lib/media/position-audio.js +10 -1
- package/lib/media/source-snapshots.js +126 -0
- package/lib/media/video-keyframe.js +17 -4
- package/lib/media/webm-stream.js +106 -0
- package/lib/network/network-service.js +268 -120
- package/lib/paint/display-list.js +8 -28
- package/lib/paint/frame-cache.js +25 -0
- package/lib/paint/frame.js +42 -13
- package/lib/paint/paragraph-text.js +24 -0
- package/lib/paint/raster-readback.js +41 -0
- package/lib/paint/rounded-rect.js +82 -0
- package/lib/paint/shared-frame-pool.js +105 -0
- package/lib/render-tree/index.js +38 -6
- package/lib/render-tree/svg.js +15 -7
- package/lib/renderer/bindings.js +233 -80
- package/lib/renderer/broadcast-channel.js +76 -0
- package/lib/renderer/canvas-2d.js +257 -14
- package/lib/renderer/canvas-text.js +120 -0
- package/lib/renderer/compositor.js +13 -11
- package/lib/renderer/custom-element-reactions.js +25 -1
- package/lib/renderer/font-loading.js +227 -0
- package/lib/renderer/host.js +28 -4
- package/lib/renderer/image-resources.js +65 -0
- package/lib/renderer/interfaces.js +28 -0
- package/lib/renderer/layout-observers.js +236 -0
- package/lib/renderer/main.js +30 -4
- package/lib/renderer/media-source.js +126 -55
- package/lib/renderer/page-realm.js +270 -98
- package/lib/renderer/page-renderer.js +647 -235
- package/lib/renderer/prepared-script-cache.js +50 -0
- package/lib/renderer/rendered-text.js +108 -0
- package/lib/renderer/rewrite-worker.js +4 -3
- package/lib/renderer/script-rewrite.js +13 -2
- package/lib/renderer/scroll-into-view.js +28 -0
- package/lib/renderer/slot-assignment.js +60 -0
- package/lib/renderer/video-presentation.js +23 -4
- package/lib/renderer/workers.js +2 -2
- package/lib/style/computed-style.js +611 -104
- package/lib/style/selector-traits.js +49 -5
- package/package.json +1 -1
- package/test/broadcast-channel.test.js +26 -0
- package/test/canvas-2d.test.js +83 -2
- package/test/canvas-paint.test.js +9 -0
- package/test/canvas-text.test.js +41 -0
- package/test/cdp-stream.test.js +56 -0
- package/test/computed-style-binding.test.js +151 -0
- package/test/computed-style.test.js +258 -1
- package/test/display-list.test.js +12 -0
- package/test/document-base.test.js +87 -0
- package/test/dom-platform.test.js +461 -0
- package/test/fetch-lifecycle.test.js +161 -0
- package/test/fixtures/canvas-paint.js +117 -0
- package/test/fixtures/canvas-text.js +42 -0
- package/test/fixtures/image-lifetime.js +90 -0
- package/test/fixtures/media-seek-snapshot.js +77 -0
- package/test/fixtures/mse-audio-chirp.webm +0 -0
- package/test/fixtures/mse-switch-large.mp4 +0 -0
- package/test/fixtures/mse-switch-small.mp4 +0 -0
- package/test/fixtures/renderer-memory.js +110 -0
- package/test/fixtures/summary-style.js +28 -0
- package/test/font-loading.test.js +97 -0
- package/test/grid.test.js +58 -0
- package/test/hit-test.test.js +21 -1
- package/test/image-lifetime.test.js +12 -0
- package/test/image-resources.test.js +94 -0
- package/test/local-media.test.js +84 -0
- package/test/media-seek-snapshot.test.js +11 -0
- package/test/media-source-config.test.js +98 -0
- package/test/media-source-webm-accounting.test.js +98 -0
- package/test/media-source-webm.test.js +108 -0
- package/test/navigation.test.js +1 -0
- package/test/network-service.test.js +388 -0
- package/test/page-scripts.test.js +1 -1
- package/test/paint-frame.test.js +103 -0
- package/test/paint-paragraph-cache.test.js +87 -0
- package/test/position-audio.test.js +24 -0
- package/test/positioning.test.js +22 -0
- package/test/prepared-script-cache.test.js +45 -0
- package/test/pulse-client.test.js +10 -3
- package/test/raster-readback.test.js +40 -0
- package/test/render-tree.test.js +28 -0
- package/test/renderer-memory.test.js +11 -0
- package/test/selector-traits.test.js +24 -0
- package/test/shared-frame-pool.test.js +63 -0
- package/test/style-sheets.test.js +51 -0
- package/test/svg.test.js +29 -0
- package/test/text-layout.test.js +106 -0
- package/test/video-keyframe.test.js +61 -0
- package/test/video-presentation.test.js +69 -0
- package/tools/browser-lab.js +78 -0
- package/tools/browser-soak.js +75 -0
- package/tools/network-reference.js +49 -0
- package/tools/process-memory.js +38 -0
- package/tools/script-benchmark.js +25 -0
- package/ai-debug.log +0 -5537
|
@@ -2,9 +2,49 @@ import { expect, test } from "bun:test";
|
|
|
2
2
|
import { parseHTMLDocument } from "../lib/happy-dom/parser.js";
|
|
3
3
|
import { RenderTreeBuilder, renderTreeText } from "../lib/render-tree/index.js";
|
|
4
4
|
import {
|
|
5
|
-
absolutizeStyleSheetUrls, computeElementStyle, matchSelectors, parseDeclarations, StyleEngine,
|
|
5
|
+
absolutizeStyleSheetUrls, computeElementStyle, extractFontFaces, matchSelectors, parseDeclarations, StyleEngine,
|
|
6
|
+
parseStyleSheet,
|
|
6
7
|
} from "../lib/style/computed-style.js";
|
|
7
8
|
|
|
9
|
+
test("escaped selector punctuation does not consume following stylesheet rules", () => {
|
|
10
|
+
const { document, window } = parseHTMLDocument(`<div id="quoted" class="[feature:'alt']"></div><div id="punctuation" class="braces{}paren(,)"></div><div id="after" class="after"></div>`);
|
|
11
|
+
const css = String.raw`@layer utilities {
|
|
12
|
+
.\[feature\:\'alt\'\] { width: 20px }
|
|
13
|
+
.braces\{\}paren\(\,\) { height: 30px }
|
|
14
|
+
.after { --size: 40px; width: var(--size); height: 50px }
|
|
15
|
+
}`;
|
|
16
|
+
expect(parseStyleSheet(css)).toHaveLength(3);
|
|
17
|
+
const styles = new StyleEngine().compute(document, [css]);
|
|
18
|
+
expect(styles.get(document.getElementById('quoted')).width).toBe(20);
|
|
19
|
+
expect(styles.get(document.getElementById('punctuation')).height).toBe(30);
|
|
20
|
+
expect(styles.get(document.getElementById('after')).width).toBe(40);
|
|
21
|
+
expect(styles.get(document.getElementById('after')).height).toBe(50);
|
|
22
|
+
window.happyDOM.abort();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("border corner longhands, elliptical shorthand and logical corners follow the cascade", () => {
|
|
26
|
+
const { document, window } = parseHTMLDocument(`<div id="parent" style="border-radius:10px 20px 30px / 40px 50%;border-top-left-radius:3px 4px">
|
|
27
|
+
<div id="child" style="border-radius:inherit; border-bottom-left-radius:initial"></div></div>
|
|
28
|
+
<div id="logical" style="border-start-start-radius:9px;border-end-end-radius:11px;direction:rtl"></div>
|
|
29
|
+
<div id="invalid" style="border-radius:7px; border-top-left-radius:-2px; border-top-right-radius:1px 2px 3px"></div>`);
|
|
30
|
+
const styles = new StyleEngine().compute(document);
|
|
31
|
+
const parent = styles.get(document.querySelector('#parent'));
|
|
32
|
+
expect(parent.borderTopLeftRadius).toEqual([3, 4]);
|
|
33
|
+
expect(parent.borderTopRightRadius).toEqual([20, { unit: '%', value: 50 }]);
|
|
34
|
+
expect(parent.borderBottomRightRadius).toEqual([30, 40]);
|
|
35
|
+
expect(parent.borderBottomLeftRadius).toEqual([20, { unit: '%', value: 50 }]);
|
|
36
|
+
const child = styles.get(document.querySelector('#child'));
|
|
37
|
+
expect(child.borderTopLeftRadius).toEqual([3, 4]);
|
|
38
|
+
expect(child.borderBottomLeftRadius).toEqual([0, 0]);
|
|
39
|
+
const logical = styles.get(document.querySelector('#logical'));
|
|
40
|
+
expect(logical.borderTopRightRadius).toEqual([9, 9]);
|
|
41
|
+
expect(logical.borderBottomLeftRadius).toEqual([11, 11]);
|
|
42
|
+
const invalid = styles.get(document.querySelector('#invalid'));
|
|
43
|
+
expect(invalid.borderTopLeftRadius).toEqual([7, 7]);
|
|
44
|
+
expect(invalid.borderTopRightRadius).toEqual([7, 7]);
|
|
45
|
+
window.happyDOM.close();
|
|
46
|
+
});
|
|
47
|
+
|
|
8
48
|
test("computes the initial UA and inherited style subset", () => {
|
|
9
49
|
const { document, window } = parseHTMLDocument(`<body>
|
|
10
50
|
<div id="parent" style="color: red; font-size: 30px">
|
|
@@ -48,6 +88,223 @@ test("percentage root fonts set the basis for rem and descendant percentage font
|
|
|
48
88
|
window.happyDOM.abort();
|
|
49
89
|
});
|
|
50
90
|
|
|
91
|
+
test("ch lengths use the final font's zero advance independent of declaration order", () => {
|
|
92
|
+
const { document, window } = parseHTMLDocument(`<body style="font-size:20px; font-family:parent">
|
|
93
|
+
<div id="a" style="max-width:64ch; padding:1ch; font:30px child; font-weight:700"></div>
|
|
94
|
+
<div id="b" style="font:30px child; font-weight:700; max-width:64ch; padding:1ch"></div>
|
|
95
|
+
<div id="c" style="width:calc(2ch + 10px); font-size:2ch"></div>
|
|
96
|
+
</body>`);
|
|
97
|
+
const measureText = (text, style) => {
|
|
98
|
+
expect(text).toBe("0");
|
|
99
|
+
return style.fontSize * (style.fontFamily[0] === "child" && style.fontWeight === 700 ? 0.6 : 0.5);
|
|
100
|
+
};
|
|
101
|
+
const styles = new StyleEngine({ measureText }).compute(document);
|
|
102
|
+
for (const id of ["a", "b"]) {
|
|
103
|
+
const style = styles.get(document.getElementById(id));
|
|
104
|
+
expect(style.maxWidth).toBe(1152);
|
|
105
|
+
expect(style.padding).toEqual([18, 18, 18, 18]);
|
|
106
|
+
}
|
|
107
|
+
const child = styles.get(document.getElementById("c"));
|
|
108
|
+
expect(child.fontSize).toBe(20);
|
|
109
|
+
expect(child.width).toBe(30);
|
|
110
|
+
window.happyDOM.abort();
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("ch falls back to half an em without font measurements and preserves font cascade", () => {
|
|
114
|
+
const { document, window } = parseHTMLDocument(`<div style="width:10ch; font-family:custom; font:20px monospace; line-height:2"></div>`);
|
|
115
|
+
const style = computeElementStyle(document.querySelector("div"));
|
|
116
|
+
expect(style.width).toBe(100);
|
|
117
|
+
expect(style.fontFamily).toEqual(["monospace"]);
|
|
118
|
+
expect(style.lineHeight).toBe(40);
|
|
119
|
+
window.happyDOM.abort();
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("logical spacing, sizes and insets resolve in physical cascade order", () => {
|
|
123
|
+
const { document, window } = parseHTMLDocument(`<div dir="rtl" style="
|
|
124
|
+
padding:1px; padding-inline:2px 3px; padding-inline-start:4px; padding-left:5px;
|
|
125
|
+
margin-block:6px 7px; margin-inline:auto; inline-size:100px; min-block-size:20px;
|
|
126
|
+
inset-block:8px 9px; inset-inline-start:10px;
|
|
127
|
+
"></div>`);
|
|
128
|
+
const style = computeElementStyle(document.querySelector("div"));
|
|
129
|
+
expect(style.padding).toEqual([1, 4, 1, 5]);
|
|
130
|
+
expect(style.margin).toEqual([6, "auto", 7, "auto"]);
|
|
131
|
+
expect(style.inset).toEqual([8, 10, 9, "auto"]);
|
|
132
|
+
expect(style.width).toBe(100);
|
|
133
|
+
expect(style.minHeight).toBe(20);
|
|
134
|
+
window.happyDOM.abort();
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("logical declarations use final direction and reject invalid paired values together", () => {
|
|
138
|
+
const { document, window } = parseHTMLDocument(`<div style="
|
|
139
|
+
padding-inline-start:10px; direction:rtl; padding-block:1px -2px;
|
|
140
|
+
margin-inline:2px invalid; inline-size:90px; width:100px;
|
|
141
|
+
"><i style="padding-inline-start:inherit"></i></div>`);
|
|
142
|
+
const engine = new StyleEngine().compute(document);
|
|
143
|
+
const style = engine.get(document.querySelector("div"));
|
|
144
|
+
expect(style.padding).toEqual([0, 10, 0, 0]);
|
|
145
|
+
expect(style.margin).toEqual([0, 0, 0, 0]);
|
|
146
|
+
expect(style.width).toBe(100);
|
|
147
|
+
expect(engine.get(document.querySelector("i")).padding).toEqual([0, 10, 0, 0]);
|
|
148
|
+
window.happyDOM.abort();
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("flex-flow sets both longhands and resets omitted components", () => {
|
|
152
|
+
const { document, window } = parseHTMLDocument(`<div style="flex-direction:column;flex-flow:wrap"></div>
|
|
153
|
+
<i style="flex-flow:wrap column;flex-wrap:nowrap"></i>`);
|
|
154
|
+
expect(computeElementStyle(document.querySelector("div"))).toMatchObject({ flexDirection: "row", flexWrap: "wrap" });
|
|
155
|
+
expect(computeElementStyle(document.querySelector("i"))).toMatchObject({ flexDirection: "column", flexWrap: "nowrap" });
|
|
156
|
+
window.happyDOM.abort();
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("font styles inherit and the shorthand resets or inherits every supported component", () => {
|
|
160
|
+
const { document, window } = parseHTMLDocument(`<main style="font:italic bold condensed 20px/2 monospace">
|
|
161
|
+
<i id="reset" style="font:12px serif"></i><span id="inherited" style="font:inherit"></span>
|
|
162
|
+
<b id="override" style="font:oblique 16px serif;font-style:normal"></b><em></em>
|
|
163
|
+
</main>`);
|
|
164
|
+
const engine = new StyleEngine().compute(document);
|
|
165
|
+
expect(engine.get(document.querySelector("main"))).toMatchObject({ fontStyle: "italic", fontSize: 20, fontWeight: 700, fontStretch: 75 });
|
|
166
|
+
expect(engine.get(document.querySelector("#reset"))).toMatchObject({ fontStyle: "normal", fontSize: 12, fontWeight: 400, fontStretch: 100 });
|
|
167
|
+
expect(engine.get(document.querySelector("#inherited"))).toMatchObject({ fontStyle: "italic", fontSize: 20, fontWeight: 700, fontStretch: 75, lineHeight: 40 });
|
|
168
|
+
expect(engine.get(document.querySelector("#override")).fontStyle).toBe("normal");
|
|
169
|
+
expect(engine.get(document.querySelector("em")).fontStyle).toBe("italic");
|
|
170
|
+
window.happyDOM.abort();
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test("font-face extraction keeps resource candidates, weight ranges and unicode subsets", () => {
|
|
174
|
+
const faces = extractFontFaces(`@font-face {
|
|
175
|
+
font-family: 'Example Sans'; font-weight: 100 900; font-style: italic; font-stretch:62% 125%; font-display:swap;
|
|
176
|
+
src: local('Example Sans Regular'), url('../fonts/a.woff2') format('woff2'), url(b.ttf) format(truetype);
|
|
177
|
+
unicode-range: U+0-FF, U+4??, U+1F600;
|
|
178
|
+
}`, "https://example.test/css/main.css");
|
|
179
|
+
expect(faces).toEqual([{ family: "Example Sans", weight: [100, 900], style: "italic", stretch: [62, 125], display: "swap",
|
|
180
|
+
sources: [{ local: "Example Sans Regular" }, { url: "https://example.test/fonts/a.woff2", format: "woff2" },
|
|
181
|
+
{ url: "https://example.test/css/b.ttf", format: "truetype" }],
|
|
182
|
+
unicodeRanges: [[0, 255], [1024, 1279], [128512, 128512]] }]);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("font stretch percentages, width alias and ch measurements share the final font", () => {
|
|
186
|
+
const { document, window } = parseHTMLDocument(`<main style="font-stretch:87.5%"><div style="width:10ch;font-width:75%"></div>
|
|
187
|
+
<i style="font-width:unset"></i><b style="font-stretch:expanded;font-width:-2%"></b></main>`);
|
|
188
|
+
const engine = new StyleEngine({ measureText: (_text, style) => style.fontStretch / 10 }).compute(document);
|
|
189
|
+
expect(engine.get(document.querySelector("div"))).toMatchObject({ fontStretch: 75, width: 75 });
|
|
190
|
+
expect(engine.get(document.querySelector("i")).fontStretch).toBe(87.5);
|
|
191
|
+
expect(engine.get(document.querySelector("b")).fontStretch).toBe(125);
|
|
192
|
+
window.happyDOM.abort();
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test("small, large and dynamic viewport units track the headless viewport on resize", () => {
|
|
196
|
+
const {document,window} = parseHTMLDocument('<main style="min-height:100dvh;width:50svi;padding:2vmin 3lvmax"><i style="height:calc(100svh - 20px);width:20dvb"></i></main>');
|
|
197
|
+
const engine = new StyleEngine();
|
|
198
|
+
for (const [width,height] of [[1000,600],[400,800]]) {
|
|
199
|
+
engine.compute(document, [], {width,height});
|
|
200
|
+
expect(engine.get(document.querySelector('main'))).toMatchObject({minHeight:height,width:width/2,
|
|
201
|
+
padding:[Math.min(width,height)*.02,Math.max(width,height)*.03,Math.min(width,height)*.02,Math.max(width,height)*.03]});
|
|
202
|
+
expect(engine.get(document.querySelector('i'))).toMatchObject({height:height-20,width:height*.2});
|
|
203
|
+
}
|
|
204
|
+
window.happyDOM.abort();
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test("container query units use the small viewport when no query container exists", () => {
|
|
208
|
+
const {document,window} = parseHTMLDocument(`<main style="width:25cqw;height:25cqh;padding:2cqmin 3cqmax">
|
|
209
|
+
<i style="width:10cqi;height:10cqb"></i><b style="--size:clamp(48px,25cqw,136px);width:var(--size);height:calc(var(--size) / 2)"></b></main>`);
|
|
210
|
+
const engine = new StyleEngine();
|
|
211
|
+
for (const [width,height] of [[1000,600],[400,800]]) {
|
|
212
|
+
engine.compute(document, [], {width,height});
|
|
213
|
+
expect(engine.get(document.querySelector('main'))).toMatchObject({width:width/4,height:height/4,
|
|
214
|
+
padding:[Math.min(width,height)*.02,Math.max(width,height)*.03,Math.min(width,height)*.02,Math.max(width,height)*.03]});
|
|
215
|
+
expect(engine.get(document.querySelector('i'))).toMatchObject({width:width*.1,height:height*.1});
|
|
216
|
+
const size=Math.max(48,Math.min(width*.25,136));
|
|
217
|
+
expect(engine.get(document.querySelector('b'))).toMatchObject({width:size,height:size/2});
|
|
218
|
+
}
|
|
219
|
+
window.happyDOM.abort();
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test("author all resets override HTML and SVG presentation hints even inside cascade layers", () => {
|
|
223
|
+
const {document,window}=parseHTMLDocument('<img width="80" height="40"><svg><rect fill="red" stroke="blue"></rect></svg>');
|
|
224
|
+
const engine=new StyleEngine().compute(document,['@layer reset{img,rect{all:unset}}']);
|
|
225
|
+
expect(engine.get(document.querySelector('img'))).toMatchObject({width:'auto',height:'auto'});
|
|
226
|
+
expect(engine.get(document.querySelector('rect'))).toMatchObject({fill:'rgba(0, 0, 0, 1)',stroke:'none'});
|
|
227
|
+
window.happyDOM.abort();
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test("font-face extraction follows matching group rules and ignores invalid faces", () => {
|
|
231
|
+
const face = (family) => `@font-face { font-family:${family}; src:url(${family}.ttf) }`;
|
|
232
|
+
const faces = extractFontFaces(`@media (min-width:900px) { ${face("Wide")} }
|
|
233
|
+
@media (max-width:899px) { @layer text { ${face("Narrow")} } }
|
|
234
|
+
@supports (display:grid) { ${face("Supported")} }
|
|
235
|
+
@font-face { font-family: serif; src:url(bad.ttf) }
|
|
236
|
+
@font-face { font-family: MissingSource }
|
|
237
|
+
div { content:'@font-face { font-family:Fake; src:url(fake.ttf) }' }`,
|
|
238
|
+
"https://example.test/main.css", { width: 800 });
|
|
239
|
+
expect(faces.map((face) => face.family)).toEqual(["Narrow", "Supported"]);
|
|
240
|
+
expect(faces[0]).toMatchObject({ weight: [400, 400], style: "normal", unicodeRanges: [[0, 0x10ffff]] });
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
test("font-face strings allow escaped names and commas inside URLs", () => {
|
|
244
|
+
const [face] = extractFontFaces(String.raw`@font-face { font-family:'A\20 B';
|
|
245
|
+
src:url('data:font/ttf;base64,AA==') format('truetype'), url('name(1).woff2') format('woff2') tech(variations);
|
|
246
|
+
unicode-range:U+110000; font-weight:1500;
|
|
247
|
+
}`, "https://example.test/main.css");
|
|
248
|
+
expect(face.family).toBe("A B");
|
|
249
|
+
expect(face.sources).toEqual([{ url: "data:font/ttf;base64,AA==", format: "truetype" },
|
|
250
|
+
{ url: "https://example.test/name(1).woff2", format: "woff2", tech: ["variations"] }]);
|
|
251
|
+
expect(face.weight).toEqual([400, 400]);
|
|
252
|
+
expect(face.unicodeRanges).toEqual([[0, 0x10ffff]]);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
test("all unset removes UA heading styles while inheriting fonts and retaining custom properties", () => {
|
|
256
|
+
const { document, window } = parseHTMLDocument(`<main style="font:36px/50px monospace;color:red;direction:rtl">
|
|
257
|
+
<h1 style="all:unset;--size:24px;padding-inline-start:var(--size)">Title</h1>
|
|
258
|
+
</main>`);
|
|
259
|
+
const engine = new StyleEngine().compute(document);
|
|
260
|
+
const style = engine.get(document.querySelector("h1"));
|
|
261
|
+
expect(style).toMatchObject({ display: "inline", fontSize: 36, fontWeight: 400, lineHeight: 50,
|
|
262
|
+
color: "rgba(255, 0, 0, 1)", direction: "rtl" });
|
|
263
|
+
expect(style.fontFamily).toEqual(["monospace"]);
|
|
264
|
+
expect(style.margin).toEqual([0, 0, 0, 0]);
|
|
265
|
+
expect(style.padding).toEqual([0, 24, 0, 0]);
|
|
266
|
+
window.happyDOM.abort();
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
test("all reset participates in importance, source order and font-relative sizing", () => {
|
|
270
|
+
const { document, window } = parseHTMLDocument(`<div class="x" style="all:initial; font-size:20px; width:2em; padding-left:3px"></div>
|
|
271
|
+
<p class="x" style="font-size:20px;all:unset!important;width:10px"></p>`);
|
|
272
|
+
const engine = new StyleEngine().compute(document, [".x { margin:10px; color:red!important; font:30px serif }"]);
|
|
273
|
+
const first = engine.get(document.querySelector("div"));
|
|
274
|
+
expect(first).toMatchObject({ display: "inline", fontSize: 20, width: 40, color: "rgba(255, 0, 0, 1)" });
|
|
275
|
+
expect(first.fontFamily).toEqual(["sans-serif"]);
|
|
276
|
+
expect(first.padding).toEqual([0, 0, 0, 3]);
|
|
277
|
+
expect(first.margin).toEqual([0, 0, 0, 0]);
|
|
278
|
+
expect(engine.get(document.querySelector("p"))).toMatchObject({ width: "auto", fontSize: 16, color: "rgba(0, 0, 0, 1)" });
|
|
279
|
+
window.happyDOM.abort();
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
test("CSS-wide side resets preserve the other border and inset sides", () => {
|
|
283
|
+
const { document, window } = parseHTMLDocument(`<div style="border:3px solid red;border-left-width:initial;border-bottom-color:initial;inset:5px;top:initial"></div>`);
|
|
284
|
+
const style = computeElementStyle(document.querySelector("div"));
|
|
285
|
+
expect(style.borderWidths).toEqual([3, 3, 3, 3]);
|
|
286
|
+
expect(style.borderColors).toEqual(["rgba(255, 0, 0, 1)", "rgba(255, 0, 0, 1)", "rgba(0, 0, 0, 1)", "rgba(255, 0, 0, 1)"]);
|
|
287
|
+
expect(style.inset).toEqual(["auto", 5, 5, 5]);
|
|
288
|
+
window.happyDOM.abort();
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
test("complex functional selectors style the subject, not the first compound", () => {
|
|
292
|
+
const { document, window } = parseHTMLDocument(`<main class="prose"><h2 id="heading">Title <code id="code">code</code></h2>
|
|
293
|
+
<section class="not-prose"><h2 id="excluded">Excluded</h2></section></main>`);
|
|
294
|
+
const engine = new StyleEngine().compute(document, [`
|
|
295
|
+
h2 { font-size:inherit }
|
|
296
|
+
.prose :where(h2):not(:where(.not-prose, .not-prose *)) {font-size:24px}
|
|
297
|
+
.prose :where(h2 code):not(:where(.not-prose, .not-prose *)) {font-size:.875em}
|
|
298
|
+
`]);
|
|
299
|
+
expect(engine.get(document.getElementById("heading")).fontSize).toBe(24);
|
|
300
|
+
expect(engine.get(document.getElementById("code")).fontSize).toBe(21);
|
|
301
|
+
expect(engine.get(document.getElementById("excluded")).fontSize).toBe(16);
|
|
302
|
+
expect([...document.querySelectorAll(":is(.prose h2 > code)")].map(e => e.id)).toEqual(["code"]);
|
|
303
|
+
expect(document.getElementById("heading").matches(":is(*)")).toBe(true);
|
|
304
|
+
expect(document.getElementById("heading").matches(":where(h2 code)")).toBe(false);
|
|
305
|
+
window.happyDOM.abort();
|
|
306
|
+
});
|
|
307
|
+
|
|
51
308
|
test("inline declarations validate values and preserve important priority", () => {
|
|
52
309
|
const { document, window } = parseHTMLDocument(`<div id="box" style="
|
|
53
310
|
display: block; display: none !important; display: inline;
|
|
@@ -113,6 +113,18 @@ test("resolves percentage radii per axis and scales overlapping radii down", ()
|
|
|
113
113
|
window.happyDOM.abort();
|
|
114
114
|
});
|
|
115
115
|
|
|
116
|
+
test("independent elliptical corners share the CSS overlap factor and inset clipping per side", () => {
|
|
117
|
+
const { window, displayList } = displayListFor(
|
|
118
|
+
'<div style="width:100px;height:80px;border:10px solid black;border-left-width:20px;overflow:hidden;background:red;border-radius:80px 40px 20px 0 / 20px 40px 60px 0"><span>clipped</span></div>',
|
|
119
|
+
'body {margin:0}');
|
|
120
|
+
const fill = displayList.items.find(item => item.op === 'fillRect');
|
|
121
|
+
// Border box130x100: no radii overlap. Each clip subtracts its adjacent borders.
|
|
122
|
+
expect(fill.radii.corners).toEqual([[80,20],[40,40],[20,60],[0,0]]);
|
|
123
|
+
const text = displayList.items.find(item => item.op === 'drawText');
|
|
124
|
+
expect(text.roundedClips[0].radii.corners).toEqual([[60,10],[30,30],[10,50],[0,0]]);
|
|
125
|
+
window.happyDOM.close();
|
|
126
|
+
});
|
|
127
|
+
|
|
116
128
|
test("visibility, opacity and overflow/clip clipping apply to descendants", () => {
|
|
117
129
|
const { window, displayList } = displayListFor(`
|
|
118
130
|
<div id="hidden" style="visibility:hidden"><span>gone</span><b style="visibility:visible">shown</b></div>
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { afterAll, beforeAll, expect, test } from "bun:test";
|
|
2
|
+
import { RendererHost } from "../lib/renderer/host.js";
|
|
3
|
+
|
|
4
|
+
const requests = [], navigations = [];
|
|
5
|
+
let renderer;
|
|
6
|
+
beforeAll(async () => {
|
|
7
|
+
renderer = new RendererHost({ timeout: 20_000, onNavigate: url => navigations.push(url), fetch: async url => {
|
|
8
|
+
requests.push(String(url));
|
|
9
|
+
const path = new URL(url).pathname;
|
|
10
|
+
if (path === "/assets/sheet.css") return new Response("#target { color:blue }", {headers:{"content-type":"text/css"}});
|
|
11
|
+
if (path === "/assets/classic.js") return new Response("window.classicURL=document.currentScript.src;window.classicRuns=(window.classicRuns||0)+1", {headers:{"content-type":"text/javascript"}});
|
|
12
|
+
if (path === "/assets/module.js") return new Response("export const answer=42", {headers:{"content-type":"text/javascript"}});
|
|
13
|
+
if (path === "/assets/worker.js") return new Response("onmessage=()=>postMessage(location.href)", {headers:{"content-type":"text/javascript"}});
|
|
14
|
+
if (path === "/assets/pixel.png") return new Response(Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAIAAAADCAIAAAA2iEnWAAAAEElEQVR4nGP4z8AARAwoFABE0AX7pM/egAAAAABJRU5ErkJggg==","base64"),
|
|
15
|
+
{headers:{"content-type":"image/png","cache-control":"max-age=60"}});
|
|
16
|
+
return new Response("missing", {status:404});
|
|
17
|
+
}});
|
|
18
|
+
await renderer.start();
|
|
19
|
+
});
|
|
20
|
+
afterAll(() => renderer?.close());
|
|
21
|
+
const evaluate = source => renderer.call("evaluate", source);
|
|
22
|
+
async function load(source) {
|
|
23
|
+
await renderer.call("loadDocument",{url:"https://base.test/page/index.html",contentType:"text/html",status:200,source});
|
|
24
|
+
await renderer.call("whenLoaded");
|
|
25
|
+
}
|
|
26
|
+
async function until(predicate) {
|
|
27
|
+
const deadline=Date.now()+3000;
|
|
28
|
+
while (!await predicate() && Date.now()<deadline) await Bun.sleep(10);
|
|
29
|
+
expect(await predicate()).toBeTrue();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
test("document base URL consistently resolves scripts, styles, images, backgrounds and workers", async () => {
|
|
33
|
+
requests.length=0;
|
|
34
|
+
await load(`<!doctype html><base target="_self"><base href="/assets/">
|
|
35
|
+
<link rel="stylesheet" href="sheet.css"><style>#background{width:10px;height:10px;background-image:url(pixel.png)}</style>
|
|
36
|
+
<div id="target">Target</div><img id="image" src="pixel.png"><div id="background"></div>
|
|
37
|
+
<script src="classic.js"></script><script type="module">import {answer} from './module.js';window.moduleAnswer=answer;window.moduleURL=import.meta.url</script>`);
|
|
38
|
+
expect(await evaluate("[classicRuns,classicURL,moduleAnswer,moduleURL,document.baseURI,document.URL]")).toEqual([
|
|
39
|
+
1,"https://base.test/assets/classic.js",42,"https://base.test/assets/","https://base.test/assets/","https://base.test/page/index.html",
|
|
40
|
+
]);
|
|
41
|
+
expect(await evaluate("[getComputedStyle(document.getElementById('target')).color,document.getElementById('image').naturalWidth,document.styleSheets[0].href]"))
|
|
42
|
+
.toEqual(["rgba(0, 0, 255, 1)",2,"https://base.test/assets/sheet.css"]);
|
|
43
|
+
expect(requests.every(url=>url.startsWith("https://base.test/assets/"))).toBeTrue();
|
|
44
|
+
expect(requests.filter(url=>url.endsWith('/classic.js'))).toHaveLength(1);
|
|
45
|
+
expect(await evaluate("new Promise((resolve,reject)=>{const worker=new Worker('worker.js');worker.onmessage=e=>{resolve(e.data);worker.terminate()};worker.onerror=reject;worker.postMessage('go')})"))
|
|
46
|
+
.toBe("https://base.test/assets/worker.js");
|
|
47
|
+
expect(await evaluate("(()=>{document.querySelector('base[href]').href='https://foreign.test/';try{new Worker('worker.js');return 'allowed'}catch(error){return error.name}})()"))
|
|
48
|
+
.toBe("SecurityError");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("link activation and form actions use the base while an empty form action uses the document URL", async () => {
|
|
52
|
+
navigations.length=0;
|
|
53
|
+
await load(`<!doctype html><base href="/assets/"><a id="link" href="next">Next</a>
|
|
54
|
+
<form id="relative" action="submit"><input name="q" value="one"></form>
|
|
55
|
+
<form id="empty" action=""><input name="q" value="two"></form>`);
|
|
56
|
+
await evaluate("document.getElementById('link').click()");
|
|
57
|
+
await until(()=>navigations.length===1);
|
|
58
|
+
expect(navigations[0]).toBe("https://base.test/assets/next");
|
|
59
|
+
await evaluate("document.querySelector('base').href='/changed/';document.getElementById('link').click()");
|
|
60
|
+
await until(()=>navigations.length===2);
|
|
61
|
+
expect(navigations[1]).toBe("https://base.test/changed/next");
|
|
62
|
+
await evaluate("document.getElementById('relative').requestSubmit()");
|
|
63
|
+
await until(()=>navigations.length===3);
|
|
64
|
+
expect(navigations[2]).toBe("https://base.test/changed/submit?q=one");
|
|
65
|
+
await evaluate("document.getElementById('empty').requestSubmit()");
|
|
66
|
+
await until(()=>navigations.length===4);
|
|
67
|
+
expect(navigations[3]).toBe("https://base.test/page/index.html?q=two");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("base selection ignores target-only entries and unsupported schemes; hyperlink components share its URL", async () => {
|
|
71
|
+
await load(`<!doctype html><base target="_self"><base id="base" href="/assets/"><a id="link" href="next?q=1#part">Next</a>`);
|
|
72
|
+
expect(await evaluate("[document.baseURI,document.getElementById('link').pathname,document.getElementById('link').search,document.getElementById('link').hash]"))
|
|
73
|
+
.toEqual(["https://base.test/assets/","/assets/next","?q=1","#part"]);
|
|
74
|
+
expect(await evaluate("(()=>{const a=document.getElementById('link');a.hash='other';return a.href})()"))
|
|
75
|
+
.toBe("https://base.test/assets/next?q=1#other");
|
|
76
|
+
for (const href of ["javascript:void(0)","data:text/plain,base","http://["]) {
|
|
77
|
+
expect(await evaluate(`document.getElementById('base').setAttribute('href',${JSON.stringify(href)});document.baseURI`))
|
|
78
|
+
.toBe("https://base.test/page/index.html");
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("inline modules with a common base execute independently and expose the actual base URL", async () => {
|
|
83
|
+
await load(`<!doctype html><base href="/assets/">
|
|
84
|
+
<script type="module">const local=1;window.modules=[local,import.meta.url,import.meta.resolve('./module.js')]</script>
|
|
85
|
+
<script type="module">const local=2;window.modules.push(local,import.meta.url)</script>`);
|
|
86
|
+
expect(await evaluate("modules")).toEqual([1,"https://base.test/assets/","https://base.test/assets/module.js",2,"https://base.test/assets/"]);
|
|
87
|
+
});
|