buninu-browser 0.0.2 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +17 -0
- package/LICENSES/brotli.txt +19 -0
- package/README.md +41 -7
- package/THIRD_PARTY_NOTICES.md +30 -6
- package/buninu-browser.js +10 -2
- package/lib/audio/pcm.js +16 -0
- package/lib/audio/piano.js +109 -0
- package/lib/audio/pulse-client.js +413 -0
- package/lib/cdp-server.js +25 -6
- package/lib/document-types.js +308 -0
- package/lib/download-directory.js +28 -0
- package/lib/download.js +63 -0
- package/lib/headless-shell.js +108 -14
- package/lib/input/hit-test.js +57 -8
- package/lib/layout/text-layout.js +112 -27
- package/lib/network/network-service.js +7 -3
- package/lib/network/resolver.js +16 -0
- package/lib/paint/display-list.js +2 -2
- package/lib/render-tree/index.js +13 -0
- package/lib/renderer/bindings.js +541 -53
- package/lib/renderer/flags.js +8 -5
- package/lib/renderer/host.js +110 -9
- package/lib/renderer/main.js +35 -3
- package/lib/renderer/module-transform.js +30 -23
- package/lib/renderer/page-realm.js +226 -22
- package/lib/renderer/page-renderer.js +1293 -212
- package/lib/screenshot-cli.js +61 -0
- package/lib/style/computed-style.js +268 -20
- package/lib/user-agent.js +12 -0
- package/package.json +1 -1
- package/patches/happy-dom/0003-defer-script-events-to-renderer.patch +40 -0
- package/patches/happy-dom/0004-insert-adjacent-html-in-order.patch +19 -0
- package/patches/happy-dom/0005-invalidate-matches-on-structure-changes.patch +43 -0
- package/patches/happy-dom/README.md +12 -0
- package/patches/ses/0001-allow-regexp-own-tostring.patch +13 -0
- package/patches/ses/README.md +16 -0
- package/patches/wasmpeg/0001-windows-wasm-path.patch +14 -0
- package/patches/wasmpeg/README.md +28 -0
- package/test/computed-style.test.js +52 -0
- package/test/document-types.test.js +111 -0
- package/test/download.test.js +192 -0
- package/test/fixtures/tone.mp3 +0 -0
- package/test/flex.test.js +11 -0
- package/test/happy-dom-matches.test.js +25 -0
- package/test/headless-shell.test.js +27 -0
- package/test/hit-test.test.js +37 -0
- package/test/page-scripts.test.js +388 -29
- package/test/piano.test.js +65 -0
- package/test/positioning.test.js +11 -0
- package/test/pulse-client.test.js +130 -0
- package/test/render-tree.test.js +14 -0
- package/test/resolver.test.js +12 -1
- package/test/screenshot-cli.test.js +19 -0
- package/test/text-layout.test.js +22 -0
- package/test/user-agent.test.js +42 -0
- package/vendor/happy-dom/packages/happy-dom/src/nodes/element/Element.ts +3 -6
- package/vendor/happy-dom/packages/happy-dom/src/nodes/html-script-element/HTMLScriptElement.ts +2 -20
- package/vendor/happy-dom/packages/happy-dom/src/query-selector/QuerySelector.ts +15 -3
- package/vendor/ses/dist/ses.mjs +2 -0
- package/vendor/wasmpeg/VENDORING.md +7 -3
- package/vendor/wasmpeg/src/js/gpu.js +2 -1
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
import { pathToFileURL } from "node:url";
|
|
3
|
+
import { createBrowser } from "./headless-shell.js";
|
|
4
|
+
|
|
5
|
+
export function screenshotTargetUrl(input, { platform = process.platform, resolvePath = resolve, fileURL = pathToFileURL } = {}) {
|
|
6
|
+
const windowsPath = platform === "win32" && (/^[a-z]:/i.test(input) || input.startsWith("\\\\") || input.startsWith("//"));
|
|
7
|
+
if (windowsPath) return fileURL(resolvePath(input)).href;
|
|
8
|
+
if (/^[a-z][a-z\d+.-]*:/i.test(input)) return input;
|
|
9
|
+
if (input.includes("/") || input.startsWith(".") || (platform === "win32" && input.includes("\\"))) {
|
|
10
|
+
return fileURL(resolvePath(input)).href;
|
|
11
|
+
}
|
|
12
|
+
return `https://${input}`;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Chromium-style one-shot PNG capture, using the same renderer as CDP. */
|
|
16
|
+
export async function runScreenshot(args) {
|
|
17
|
+
const screenshot = args.find((arg) => arg === "--screenshot" || arg.startsWith("--screenshot="));
|
|
18
|
+
const filename = screenshot === "--screenshot" ? "screenshot.png" : screenshot.slice("--screenshot=".length);
|
|
19
|
+
const extension = filename.match(/\.(png|jpe?g|webp)$/i)?.[1]?.toLowerCase();
|
|
20
|
+
if (!extension) throw new Error("--screenshot filename must end in .png, .jpg, .jpeg, or .webp");
|
|
21
|
+
const format = extension === "jpg" ? "jpeg" : extension;
|
|
22
|
+
|
|
23
|
+
const size = args.find((arg) => arg.startsWith("--window-size="))?.slice("--window-size=".length);
|
|
24
|
+
const waitArg = args.find((arg) => arg.startsWith("--screenshot-wait="))?.slice("--screenshot-wait=".length);
|
|
25
|
+
const waitMs = waitArg === undefined ? 0 : Number(waitArg);
|
|
26
|
+
if (waitArg !== undefined && (!/^\d+$/.test(waitArg) || !Number.isSafeInteger(waitMs))) {
|
|
27
|
+
throw new Error("--screenshot-wait must be a nonnegative number of milliseconds");
|
|
28
|
+
}
|
|
29
|
+
const match = size?.match(/^(\d+)[,x](\d+)$/i);
|
|
30
|
+
if (size && (!match || !Number.isSafeInteger(Number(match[1])) || !Number.isSafeInteger(Number(match[2]))
|
|
31
|
+
|| Number(match[1]) < 1 || Number(match[2]) < 1)) {
|
|
32
|
+
throw new Error("--window-size must be WIDTH,HEIGHT with positive integers");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const urlArg = args.find((arg) => !arg.startsWith("-"));
|
|
36
|
+
if (!urlArg) throw new Error("--screenshot requires a URL");
|
|
37
|
+
const url = screenshotTargetUrl(urlArg);
|
|
38
|
+
|
|
39
|
+
const browser = await createBrowser({
|
|
40
|
+
allowHostJs: args.includes("--dangerously-allow-host-js"),
|
|
41
|
+
mobile: args.includes("--mobile"),
|
|
42
|
+
spareRenderer: false,
|
|
43
|
+
});
|
|
44
|
+
try {
|
|
45
|
+
if (match) await browser.context.resize(Number(match[1]), Number(match[2]));
|
|
46
|
+
await browser.context.navigate(url);
|
|
47
|
+
if (!browser.context.lifecycleState().includes("load")) {
|
|
48
|
+
await new Promise((done) => {
|
|
49
|
+
const unsubscribe = browser.context.onLifecycle((name) => {
|
|
50
|
+
if (name === "load") { unsubscribe(); done(); }
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
if (waitMs) await Bun.sleep(waitMs);
|
|
55
|
+
const data = await browser.context.screenshot({ format });
|
|
56
|
+
await Bun.write(filename, Buffer.from(data, "base64"));
|
|
57
|
+
console.error(`Screenshot saved to ${resolve(filename)}`);
|
|
58
|
+
} finally {
|
|
59
|
+
browser.close();
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -12,8 +12,17 @@ const BLOCK_TAGS = new Set([
|
|
|
12
12
|
]);
|
|
13
13
|
const NONE_TAGS = new Set(["HEAD", "LINK", "META", "NOFRAMES", "SCRIPT", "STYLE", "TEMPLATE", "TITLE"]);
|
|
14
14
|
const INHERITED = new Set([
|
|
15
|
-
"color", "font-family", "font-size", "font-weight", "line-height", "list-style-type", "text-align", "white-space",
|
|
16
|
-
"visibility", "overflow-wrap", "word-break", "border-spacing", "border-collapse",
|
|
15
|
+
"color", "color-scheme", "font-family", "font-size", "font-weight", "line-height", "list-style-type", "text-align", "white-space",
|
|
16
|
+
"visibility", "overflow-wrap", "word-break", "border-spacing", "border-collapse", "pointer-events",
|
|
17
|
+
]);
|
|
18
|
+
// Inherited properties whose computed value is copied from the parent as is.
|
|
19
|
+
// line-height is not: a unitless factor resolves against each element's own
|
|
20
|
+
// font size, so it goes through its specified form (copyProperty).
|
|
21
|
+
const INHERITED_FIELDS = new Map([
|
|
22
|
+
["color", "color"], ["font-family", "fontFamily"], ["font-size", "fontSize"], ["font-weight", "fontWeight"],
|
|
23
|
+
["list-style-type", "listStyleType"], ["text-align", "textAlign"], ["white-space", "whiteSpace"],
|
|
24
|
+
["visibility", "visibility"], ["overflow-wrap", "overflowWrap"], ["word-break", "wordBreak"],
|
|
25
|
+
["border-spacing", "borderSpacing"], ["border-collapse", "borderCollapse"], ["pointer-events", "pointerEvents"],
|
|
17
26
|
]);
|
|
18
27
|
const LIST_STYLE_TYPES = new Set([
|
|
19
28
|
"none", "disc", "circle", "square", "decimal", "decimal-leading-zero",
|
|
@@ -53,6 +62,9 @@ const PROPERTY_FIELDS = Object.freeze({
|
|
|
53
62
|
"grid-row": ["gridRowStart", "gridRowEnd"],
|
|
54
63
|
"grid-column": ["gridColumnStart", "gridColumnEnd"],
|
|
55
64
|
"grid-template": ["gridTemplateColumns", "gridTemplateRows"],
|
|
65
|
+
"place-content": ["alignContent", "justifyContent"],
|
|
66
|
+
"place-items": ["alignItems", "justifyItems"],
|
|
67
|
+
"place-self": ["alignSelf", "justifySelf"],
|
|
56
68
|
"line-height": ["lineHeight", "lineHeightFactor"],
|
|
57
69
|
"list-style": ["listStyleType"],
|
|
58
70
|
"border-radius": ["borderRadius"],
|
|
@@ -71,6 +83,7 @@ const INITIAL = Object.freeze({
|
|
|
71
83
|
textDecorationColor: null,
|
|
72
84
|
textDecorations: null,
|
|
73
85
|
color: "rgba(0, 0, 0, 1)",
|
|
86
|
+
colorScheme: "light",
|
|
74
87
|
backgroundColor: "rgba(0, 0, 0, 0)",
|
|
75
88
|
backgroundImage: null,
|
|
76
89
|
backgroundClip: "border-box",
|
|
@@ -102,6 +115,7 @@ const INITIAL = Object.freeze({
|
|
|
102
115
|
flexDirection: "row",
|
|
103
116
|
flexWrap: "nowrap",
|
|
104
117
|
justifyContent: "normal",
|
|
118
|
+
alignContent: "normal",
|
|
105
119
|
alignItems: "normal",
|
|
106
120
|
alignSelf: "auto",
|
|
107
121
|
gap: 0,
|
|
@@ -136,6 +150,8 @@ const INITIAL = Object.freeze({
|
|
|
136
150
|
tableLayout: "auto",
|
|
137
151
|
verticalAlign: "baseline",
|
|
138
152
|
visibility: "visible",
|
|
153
|
+
// Hit testing only (inherited): "none" lets clicks pass through.
|
|
154
|
+
pointerEvents: "auto",
|
|
139
155
|
// Breaking inside words (CSS Text 3 §5): only with these set.
|
|
140
156
|
overflowWrap: "normal",
|
|
141
157
|
wordBreak: "normal",
|
|
@@ -229,14 +245,23 @@ export class StyleEngine {
|
|
|
229
245
|
#styles = new WeakMap();
|
|
230
246
|
#customProperties = new WeakMap();
|
|
231
247
|
#pseudoStyles = new WeakMap();
|
|
248
|
+
#document = null;
|
|
249
|
+
#sheetChanges = null;
|
|
250
|
+
/** Elements computed by the last compute(), and whether it was incremental. */
|
|
251
|
+
restyled = 0;
|
|
252
|
+
incremental = false;
|
|
232
253
|
// Parsed and indexed rules for the last style sheet list and viewport:
|
|
233
254
|
// restyles after script changes reuse them instead of re-parsing.
|
|
234
255
|
#ruleCache = { styleSheets: null, key: "", index: null };
|
|
235
256
|
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
257
|
+
/**
|
|
258
|
+
* Computes the document's styles. With `dirty` (elements whose subtree's
|
|
259
|
+
* styles may have changed), only those subtrees are restyled and every
|
|
260
|
+
* other element keeps the style it had: unchanged rules and viewport, the
|
|
261
|
+
* same document, and a styled root are required, else it restyles fully.
|
|
262
|
+
* `restyled` afterwards counts the elements this call computed.
|
|
263
|
+
*/
|
|
264
|
+
compute(document, styleSheets = [], viewport = {}, dirty = null, selfOnly = null) {
|
|
240
265
|
const previous = environment;
|
|
241
266
|
environment = {
|
|
242
267
|
viewportWidth: viewport.width ?? DEFAULT_ENVIRONMENT.viewportWidth,
|
|
@@ -244,9 +269,58 @@ export class StyleEngine {
|
|
|
244
269
|
rootFontSize: DEFAULT_ENVIRONMENT.rootFontSize,
|
|
245
270
|
mobile: viewport.mobile === true,
|
|
246
271
|
};
|
|
272
|
+
this.restyled = 0;
|
|
247
273
|
try {
|
|
274
|
+
const before = this.#ruleCache.index;
|
|
248
275
|
const rules = this.#rulesFor(styleSheets);
|
|
249
|
-
|
|
276
|
+
const root = document.documentElement;
|
|
277
|
+
if (dirty !== null && rules !== before) {
|
|
278
|
+
const affected = this.#elementsForSheetChanges(document);
|
|
279
|
+
dirty = affected ? new Set([...dirty, ...affected]) : null;
|
|
280
|
+
}
|
|
281
|
+
const incremental = dirty !== null && this.#document === document
|
|
282
|
+
&& Boolean(root) && this.#styles.has(root) && !dirty.has(root);
|
|
283
|
+
this.incremental = incremental;
|
|
284
|
+
if (!incremental) {
|
|
285
|
+
this.#styles = new WeakMap();
|
|
286
|
+
this.#customProperties = new WeakMap();
|
|
287
|
+
this.#pseudoStyles = new WeakMap();
|
|
288
|
+
this.#document = document;
|
|
289
|
+
if (root) this.#visit(root, null, null, rules);
|
|
290
|
+
return this;
|
|
291
|
+
}
|
|
292
|
+
// rem units resolve against the root's font size, which is unchanged.
|
|
293
|
+
environment = { ...environment, rootFontSize: this.#styles.get(root).fontSize };
|
|
294
|
+
// Restyle from the nearest ancestor that has a style (new subtrees have
|
|
295
|
+
// none); many roots inside one new subtree share that start.
|
|
296
|
+
const starts = new Set();
|
|
297
|
+
for (const element of topmostConnected(dirty, root)) {
|
|
298
|
+
let start = element;
|
|
299
|
+
while (start !== root && !this.#styles.has(start.parentElement)) start = start.parentElement;
|
|
300
|
+
starts.add(start);
|
|
301
|
+
}
|
|
302
|
+
if (starts.has(root) || selfOnly?.has(root)) {
|
|
303
|
+
this.#visit(root, null, null, rules);
|
|
304
|
+
return this;
|
|
305
|
+
}
|
|
306
|
+
const topmost = topmostConnected(starts, root);
|
|
307
|
+
for (const start of topmost) {
|
|
308
|
+
const parent = start.parentElement;
|
|
309
|
+
const ancestors = new Map();
|
|
310
|
+
for (let node = parent; node; node = node.parentElement) {
|
|
311
|
+
for (const key of ancestorFilterKeys(elementKeys(node))) ancestors.set(key, (ancestors.get(key) ?? 0) + 1);
|
|
312
|
+
}
|
|
313
|
+
this.#visit(start, this.#styles.get(parent), this.#customProperties.get(parent) ?? EMPTY_MAP, rules, ancestors);
|
|
314
|
+
}
|
|
315
|
+
// Elements whose own matches may have changed (:empty, :first-child,
|
|
316
|
+
// ...) and that no restyled subtree covered.
|
|
317
|
+
const covered = new Set(topmost);
|
|
318
|
+
for (const element of selfOnly ?? []) {
|
|
319
|
+
if (!element || element.nodeType !== 1 || !root.contains(element) || !this.#styles.has(element.parentElement)) continue;
|
|
320
|
+
let inside = false;
|
|
321
|
+
for (let node = element; node && !inside; node = node.parentElement) inside = covered.has(node);
|
|
322
|
+
if (!inside) this.#restyleSelf(element, rules);
|
|
323
|
+
}
|
|
250
324
|
} finally {
|
|
251
325
|
environment = previous;
|
|
252
326
|
}
|
|
@@ -256,15 +330,37 @@ export class StyleEngine {
|
|
|
256
330
|
#rulesFor(styleSheets) {
|
|
257
331
|
const key = `${environment.viewportWidth}x${environment.viewportHeight}${environment.mobile ? ":mobile" : ""}`;
|
|
258
332
|
const cache = this.#ruleCache;
|
|
333
|
+
this.#sheetChanges = null;
|
|
259
334
|
if (cache.styleSheets !== styleSheets || cache.key !== key || cache.length !== styleSheets.length) {
|
|
260
335
|
const counter = { order: 0 };
|
|
261
|
-
const
|
|
336
|
+
const perSheet = styleSheets.map((source) => parseStyleSheet(source, environment, counter));
|
|
337
|
+
const rules = perSheet.flat();
|
|
262
338
|
if (counter.layers) rankLayers(counter.layers, rules);
|
|
263
|
-
|
|
339
|
+
// Sheets added or removed since the last list (same viewport): only the
|
|
340
|
+
// elements their rules match can change style, every other element
|
|
341
|
+
// keeps the same rules in the same relative order.
|
|
342
|
+
if (cache.index && cache.key === key) this.#sheetChanges = diffSheets(cache, styleSheets, perSheet);
|
|
343
|
+
this.#ruleCache = { styleSheets, key, length: styleSheets.length, index: new RuleIndex(rules), perSheet };
|
|
264
344
|
}
|
|
265
345
|
return this.#ruleCache.index;
|
|
266
346
|
}
|
|
267
347
|
|
|
348
|
+
/** Elements matched by the rules of added or removed sheets, or null when that is unknown. */
|
|
349
|
+
#elementsForSheetChanges(document) {
|
|
350
|
+
const changes = this.#sheetChanges;
|
|
351
|
+
// Each selector is a document-wide search: past a few hundred, a full
|
|
352
|
+
// restyle is cheaper (a large stylesheet arriving late, for example).
|
|
353
|
+
if (!changes || changes.rules.length > MAX_SHEET_CHANGE_RULES) return null;
|
|
354
|
+
const elements = new Set();
|
|
355
|
+
const selectors = new Set(changes.rules.map((rule) => rule.selector));
|
|
356
|
+
for (const selector of selectors) {
|
|
357
|
+
let matched;
|
|
358
|
+
try { matched = document.querySelectorAll(selector); } catch { continue; } // invalid: matches nothing
|
|
359
|
+
for (const element of matched) elements.add(element);
|
|
360
|
+
}
|
|
361
|
+
return elements;
|
|
362
|
+
}
|
|
363
|
+
|
|
268
364
|
get(element) {
|
|
269
365
|
return this.#styles.get(element) ?? INITIAL;
|
|
270
366
|
}
|
|
@@ -274,8 +370,8 @@ export class StyleEngine {
|
|
|
274
370
|
return this.#pseudoStyles.get(element)?.[pseudo] ?? null;
|
|
275
371
|
}
|
|
276
372
|
|
|
277
|
-
|
|
278
|
-
|
|
373
|
+
/** Computes and stores one element's style and ::before/::after styles. */
|
|
374
|
+
#styleOne(element, parentStyle, parentCustomProperties, rules, keys, ancestors) {
|
|
279
375
|
// Inline style takes part in the cascade before custom properties are
|
|
280
376
|
// resolved: style="--x: ..." must win over stylesheet values of --x.
|
|
281
377
|
const declarations = withInlineStyle(element, cascadeDeclarations(element, rules, null, keys, ancestors));
|
|
@@ -295,6 +391,18 @@ export class StyleEngine {
|
|
|
295
391
|
if (pseudoStyle.content != null && pseudoStyle.display !== "none") pseudoStyles[pseudo] = pseudoStyle;
|
|
296
392
|
}
|
|
297
393
|
if (pseudoStyles.before || pseudoStyles.after) this.#pseudoStyles.set(element, pseudoStyles);
|
|
394
|
+
else this.#pseudoStyles.delete(element);
|
|
395
|
+
this.restyled++;
|
|
396
|
+
return { style, customProperties };
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
#visit(element, parentStyle, parentCustomProperties, rules, ancestors = new Map()) {
|
|
400
|
+
const keys = elementKeys(element);
|
|
401
|
+
const { style, customProperties } = this.#styleOne(element, parentStyle, parentCustomProperties, rules, keys, ancestors);
|
|
402
|
+
this.#visitChildren(element, keys, style, customProperties, rules, ancestors);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
#visitChildren(element, keys, style, customProperties, rules, ancestors) {
|
|
298
406
|
// The element is an ancestor of everything below it.
|
|
299
407
|
const own = ancestorFilterKeys(keys);
|
|
300
408
|
for (const key of own) ancestors.set(key, (ancestors.get(key) ?? 0) + 1);
|
|
@@ -305,9 +413,94 @@ export class StyleEngine {
|
|
|
305
413
|
else ancestors.delete(key);
|
|
306
414
|
}
|
|
307
415
|
}
|
|
416
|
+
|
|
417
|
+
/** Ancestor filter counts for the elements above `element`. */
|
|
418
|
+
#ancestorsOf(element) {
|
|
419
|
+
const ancestors = new Map();
|
|
420
|
+
for (let node = element.parentElement; node; node = node.parentElement) {
|
|
421
|
+
for (const key of ancestorFilterKeys(elementKeys(node))) ancestors.set(key, (ancestors.get(key) ?? 0) + 1);
|
|
422
|
+
}
|
|
423
|
+
return ancestors;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Restyles one element; only when its style (or ::before/::after, or the
|
|
428
|
+
* custom properties it passes down) changed does its subtree follow.
|
|
429
|
+
*/
|
|
430
|
+
#restyleSelf(element, rules) {
|
|
431
|
+
const parent = element.parentElement;
|
|
432
|
+
const before = { style: this.#styles.get(element), pseudo: this.#pseudoStyles.get(element), custom: this.#customProperties.get(element) };
|
|
433
|
+
const keys = elementKeys(element);
|
|
434
|
+
const ancestors = this.#ancestorsOf(element);
|
|
435
|
+
const { style, customProperties } = this.#styleOne(element, this.#styles.get(parent), this.#customProperties.get(parent) ?? EMPTY_MAP, rules, keys, ancestors);
|
|
436
|
+
const pseudo = this.#pseudoStyles.get(element);
|
|
437
|
+
if (sameValue(before.style, style) && sameValue(before.pseudo ?? null, pseudo ?? null) && sameCustom(before.custom, customProperties)) {
|
|
438
|
+
// Unchanged: keep the previous objects so later comparisons stay cheap.
|
|
439
|
+
this.#styles.set(element, before.style);
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
this.#visitChildren(element, keys, style, customProperties, rules, ancestors);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/** Structural equality of computed style values (frozen objects and arrays). */
|
|
447
|
+
function sameValue(left, right) {
|
|
448
|
+
if (left === right) return true;
|
|
449
|
+
if (!left || !right || typeof left !== "object" || typeof right !== "object") return false;
|
|
450
|
+
const keys = Object.keys(left);
|
|
451
|
+
if (keys.length !== Object.keys(right).length) return false;
|
|
452
|
+
return keys.every((key) => sameValue(left[key], right[key]));
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function sameCustom(left, right) {
|
|
456
|
+
if (left === right) return true;
|
|
457
|
+
if (!left || !right || left.size !== right.size) return false;
|
|
458
|
+
for (const [name, value] of left) if (right.get(name) !== value) return false;
|
|
459
|
+
return true;
|
|
308
460
|
}
|
|
309
461
|
|
|
310
462
|
const EMPTY_MAP = new Map();
|
|
463
|
+
const MAX_SHEET_CHANGE_RULES = 300;
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* Rules from sheets present in only one of two sheet lists (by text), or null
|
|
467
|
+
* when such a change can reorder other rules: @layer declarations rank
|
|
468
|
+
* layers by first appearance and @property changes inheritance.
|
|
469
|
+
*/
|
|
470
|
+
function diffSheets(previous, styleSheets, perSheet) {
|
|
471
|
+
const remaining = new Map();
|
|
472
|
+
previous.styleSheets.forEach((source, index) => {
|
|
473
|
+
const list = remaining.get(source);
|
|
474
|
+
if (list) list.push(previous.perSheet[index]); else remaining.set(source, [previous.perSheet[index]]);
|
|
475
|
+
});
|
|
476
|
+
const rules = [];
|
|
477
|
+
const risky = (source) => /@layer\b|@property\b/i.test(source);
|
|
478
|
+
for (let index = 0; index < styleSheets.length; index++) {
|
|
479
|
+
const source = styleSheets[index];
|
|
480
|
+
const list = remaining.get(source);
|
|
481
|
+
if (list?.length) { list.pop(); continue; }
|
|
482
|
+
if (risky(source)) return null;
|
|
483
|
+
rules.push(...perSheet[index]);
|
|
484
|
+
}
|
|
485
|
+
for (const [source, lists] of remaining) {
|
|
486
|
+
if (!lists.length) continue;
|
|
487
|
+
if (risky(source)) return null;
|
|
488
|
+
for (const list of lists) rules.push(...list);
|
|
489
|
+
}
|
|
490
|
+
return { rules };
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/** The connected elements of `elements` that are not inside another of them. */
|
|
494
|
+
export function topmostConnected(elements, root) {
|
|
495
|
+
const result = [];
|
|
496
|
+
for (const element of elements) {
|
|
497
|
+
if (!element || element.nodeType !== 1 || !root.contains(element)) continue;
|
|
498
|
+
let covered = false;
|
|
499
|
+
for (let node = element.parentElement; node && !covered; node = node.parentElement) covered = elements.has(node);
|
|
500
|
+
if (!covered) result.push(element);
|
|
501
|
+
}
|
|
502
|
+
return result;
|
|
503
|
+
}
|
|
311
504
|
|
|
312
505
|
/** The cascaded declarations with the element's style attribute applied on top. */
|
|
313
506
|
function withInlineStyle(element, declarations) {
|
|
@@ -342,8 +535,12 @@ function withCustomProperties(inherited, declarations) {
|
|
|
342
535
|
|
|
343
536
|
export function computeElementStyle(element, parentStyle = null, authorDeclarations = null, customProperties = new Map()) {
|
|
344
537
|
const values = { ...INITIAL };
|
|
345
|
-
|
|
346
|
-
|
|
538
|
+
if (parentStyle) {
|
|
539
|
+
for (const property of INHERITED) {
|
|
540
|
+
const field = INHERITED_FIELDS.get(property);
|
|
541
|
+
if (field) values[field] = parentStyle[field];
|
|
542
|
+
else copyProperty(values, property, parentStyle);
|
|
543
|
+
}
|
|
347
544
|
}
|
|
348
545
|
// vertical-align is not inherited, except through the table UA rules' "inherit".
|
|
349
546
|
values.verticalAlign = "baseline";
|
|
@@ -381,13 +578,16 @@ export function computeElementStyle(element, parentStyle = null, authorDeclarati
|
|
|
381
578
|
} else if (fontSize) {
|
|
382
579
|
applyDeclaration(values, fontSize, customProperties, parentStyle);
|
|
383
580
|
}
|
|
581
|
+
// light-dark() depends on the used scheme regardless of declaration order.
|
|
582
|
+
const colorScheme = declarations.get("color-scheme");
|
|
583
|
+
if (colorScheme) applyDeclaration(values, colorScheme, customProperties, parentStyle);
|
|
384
584
|
// Apply in cascade order so a later shorthand overrides earlier longhands and vice versa.
|
|
385
585
|
const ordered = [...declarations.values()].sort((left, right) => compareCascade(
|
|
386
586
|
{ important: false, specificity: 0, order: 0, ...left, important: Boolean(left.important) },
|
|
387
587
|
{ important: false, specificity: 0, order: 0, ...right, important: Boolean(right.important) },
|
|
388
588
|
));
|
|
389
589
|
for (const declaration of ordered) {
|
|
390
|
-
if (declaration.name.startsWith("--") || declaration.name === "font-size") continue;
|
|
590
|
+
if (declaration.name.startsWith("--") || declaration.name === "font-size" || declaration.name === "color-scheme") continue;
|
|
391
591
|
// The shorthand's size was settled above; its other parts apply in order.
|
|
392
592
|
applyDeclaration(values, declaration.name === "font" ? { ...declaration, name: "font-parts" } : declaration, customProperties, parentStyle);
|
|
393
593
|
}
|
|
@@ -607,9 +807,16 @@ function parseFontShorthand(value) {
|
|
|
607
807
|
weight = lower === "bolder" ? "700" : lower === "lighter" ? "300" : lower;
|
|
608
808
|
continue;
|
|
609
809
|
}
|
|
610
|
-
// The size, optionally with /line-height
|
|
611
|
-
|
|
612
|
-
|
|
810
|
+
// The size, optionally with /line-height (spaces around "/" allowed),
|
|
811
|
+
// then the family list.
|
|
812
|
+
let [size, lineHeight] = token.split("/");
|
|
813
|
+
let rest = index + 1;
|
|
814
|
+
if (lineHeight === "") lineHeight = tokens[rest++]; // "14px/ 1.5"
|
|
815
|
+
else if (lineHeight === undefined && tokens[rest] === "/") { // "14px / 1.5"
|
|
816
|
+
lineHeight = tokens[rest + 1];
|
|
817
|
+
rest += 2;
|
|
818
|
+
} else if (lineHeight === undefined && tokens[rest]?.startsWith("/")) lineHeight = tokens[rest++].slice(1); // "14px /1.5"
|
|
819
|
+
const family = tokens.slice(rest).join(" ").trim();
|
|
613
820
|
if (!family || !(FONT_SIZE_KEYWORDS[size] || /^[\d.]/.test(size) || /^(?:calc|clamp|min|max)\(/.test(size))) return null;
|
|
614
821
|
return { size, lineHeight: lineHeight || null, weight, family };
|
|
615
822
|
}
|
|
@@ -751,6 +958,13 @@ function setProperty(style, name, value, parentStyle = null) {
|
|
|
751
958
|
}
|
|
752
959
|
if (typeof value !== "string") return;
|
|
753
960
|
switch (name) {
|
|
961
|
+
case "color-scheme": {
|
|
962
|
+
const schemes = value.toLowerCase().split(/\s+/).filter((part) => part !== "only");
|
|
963
|
+
if (schemes.every((part) => ["normal", "light", "dark"].includes(part))) {
|
|
964
|
+
style.colorScheme = schemes.includes("light") || !schemes.includes("dark") ? "light" : "dark";
|
|
965
|
+
}
|
|
966
|
+
break;
|
|
967
|
+
}
|
|
754
968
|
case "display": if (DISPLAYS.has(value)) style.display = value; break;
|
|
755
969
|
case "text-decoration-line": {
|
|
756
970
|
const lines = parseDecorationLines(value);
|
|
@@ -790,6 +1004,10 @@ function setProperty(style, name, value, parentStyle = null) {
|
|
|
790
1004
|
case "visibility":
|
|
791
1005
|
if (["visible", "hidden", "collapse"].includes(value)) style.visibility = value === "collapse" ? "hidden" : value;
|
|
792
1006
|
break;
|
|
1007
|
+
// SVG-only values (visiblePainted, fill, ...) hit like auto for HTML boxes.
|
|
1008
|
+
case "pointer-events":
|
|
1009
|
+
if (/^[a-z]+$/i.test(value)) style.pointerEvents = value === "none" ? "none" : "auto";
|
|
1010
|
+
break;
|
|
793
1011
|
case "opacity": {
|
|
794
1012
|
const number = /^([+-]?(?:\d+\.?\d*|\.\d+))(%?)$/.exec(value);
|
|
795
1013
|
if (number) style.opacity = Math.min(1, Math.max(0, Number(number[1]) / (number[2] ? 100 : 1)));
|
|
@@ -833,12 +1051,12 @@ function setProperty(style, name, value, parentStyle = null) {
|
|
|
833
1051
|
break;
|
|
834
1052
|
}
|
|
835
1053
|
case "color": {
|
|
836
|
-
const color =
|
|
1054
|
+
const color = resolvedColor(value, style.colorScheme);
|
|
837
1055
|
if (color) style.color = color;
|
|
838
1056
|
break;
|
|
839
1057
|
}
|
|
840
1058
|
case "background-color": {
|
|
841
|
-
const color =
|
|
1059
|
+
const color = resolvedColor(value, style.colorScheme);
|
|
842
1060
|
if (color) style.backgroundColor = color;
|
|
843
1061
|
break;
|
|
844
1062
|
}
|
|
@@ -847,7 +1065,7 @@ function setProperty(style, name, value, parentStyle = null) {
|
|
|
847
1065
|
const layers = splitTopLevel(value, ",");
|
|
848
1066
|
const images = layers.filter((layer) => GRADIENT.test(layer));
|
|
849
1067
|
const color = splitTopLevel(layers.at(-1) ?? "", " ")
|
|
850
|
-
.map((token) =>
|
|
1068
|
+
.map((token) => resolvedColor(token, style.colorScheme))
|
|
851
1069
|
.find(Boolean);
|
|
852
1070
|
style.backgroundColor = color ?? INITIAL.backgroundColor;
|
|
853
1071
|
style.backgroundImage = images.length ? images.join(", ") : null;
|
|
@@ -1097,6 +1315,24 @@ function setProperty(style, name, value, parentStyle = null) {
|
|
|
1097
1315
|
if (["normal", "start", "end", "center", "space-between", "space-around", "space-evenly"].includes(justify)) style.justifyContent = justify;
|
|
1098
1316
|
break;
|
|
1099
1317
|
}
|
|
1318
|
+
case "align-content": {
|
|
1319
|
+
const alignment = FLEX_ALIGNMENT[value] ?? value;
|
|
1320
|
+
if (["normal", "stretch", "start", "end", "center", "space-between", "space-around", "space-evenly"].includes(alignment)) {
|
|
1321
|
+
style.alignContent = alignment;
|
|
1322
|
+
}
|
|
1323
|
+
break;
|
|
1324
|
+
}
|
|
1325
|
+
// CSS Box Alignment 3 §5.1/§6.1/§6.2: <align> [<justify>]; one value sets both.
|
|
1326
|
+
case "place-content":
|
|
1327
|
+
case "place-items":
|
|
1328
|
+
case "place-self": {
|
|
1329
|
+
const [align, justify = align] = splitTopLevel(value, " ").filter(Boolean);
|
|
1330
|
+
if (!align) break;
|
|
1331
|
+
const part = name.slice("place-".length);
|
|
1332
|
+
setProperty(style, `align-${part}`, align, parentStyle);
|
|
1333
|
+
setProperty(style, `justify-${part}`, justify, parentStyle);
|
|
1334
|
+
break;
|
|
1335
|
+
}
|
|
1100
1336
|
case "align-items": {
|
|
1101
1337
|
const alignment = FLEX_ALIGNMENT[value] ?? value;
|
|
1102
1338
|
if (["normal", "stretch", "start", "end", "center", "baseline"].includes(alignment)) style.alignItems = alignment;
|
|
@@ -1678,6 +1914,7 @@ function cssValue(style, name) {
|
|
|
1678
1914
|
"line-height": style.lineHeightFactor != null ? String(style.lineHeightFactor)
|
|
1679
1915
|
: style.lineHeight == null ? "normal" : `${style.lineHeight}px`,
|
|
1680
1916
|
"list-style-type": style.listStyleType,
|
|
1917
|
+
"pointer-events": style.pointerEvents,
|
|
1681
1918
|
"text-align": style.textAlign,
|
|
1682
1919
|
"white-space": style.whiteSpace,
|
|
1683
1920
|
})[name] ?? style[name];
|
|
@@ -1988,6 +2225,17 @@ function flexShorthand(value, fontSize) {
|
|
|
1988
2225
|
};
|
|
1989
2226
|
}
|
|
1990
2227
|
|
|
2228
|
+
function resolvedColor(value, scheme) {
|
|
2229
|
+
const match = /^light-dark\((.*)\)$/is.exec(value.trim());
|
|
2230
|
+
if (match) {
|
|
2231
|
+
const choices = splitTopLevel(match[1], ",");
|
|
2232
|
+
if (choices.length !== 2) return null;
|
|
2233
|
+
return resolvedColor(choices[scheme === "dark" ? 1 : 0], scheme);
|
|
2234
|
+
}
|
|
2235
|
+
const color = Bun.color(value, "rgba");
|
|
2236
|
+
return /^rgba\(/.test(color ?? "") ? color : null;
|
|
2237
|
+
}
|
|
2238
|
+
|
|
1991
2239
|
function splitTopLevel(source, separator) {
|
|
1992
2240
|
const parts = [];
|
|
1993
2241
|
let depth = 0;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// Chrome 154 stable for desktop (2026-09-22). Keep CDP discovery, page JS, and
|
|
2
|
+
// HTTP requests on the same advertised version; casty reads /json/version.
|
|
3
|
+
export const CHROMIUM_VERSION = "154.0.8037.57";
|
|
4
|
+
|
|
5
|
+
export function desktopUserAgent(platform = process.platform, arch = process.arch) {
|
|
6
|
+
const system = platform === "darwin" ? "Macintosh; Intel Mac OS X 10_15_7"
|
|
7
|
+
: platform === "win32" ? "Windows NT 10.0; Win64; x64"
|
|
8
|
+
: `X11; Linux ${arch === "arm64" ? "aarch64" : "x86_64"}`;
|
|
9
|
+
return `Mozilla/5.0 (${system}) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${CHROMIUM_VERSION} Safari/537.36`;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const DESKTOP_USER_AGENT = desktopUserAgent();
|
package/package.json
CHANGED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
diff --git a/packages/happy-dom/src/nodes/html-script-element/HTMLScriptElement.ts b/packages/happy-dom/src/nodes/html-script-element/HTMLScriptElement.ts
|
|
2
|
+
index f439646..ce2dbe5 100644
|
|
3
|
+
--- a/packages/happy-dom/src/nodes/html-script-element/HTMLScriptElement.ts
|
|
4
|
+
+++ b/packages/happy-dom/src/nodes/html-script-element/HTMLScriptElement.ts
|
|
5
|
+
@@ -566,16 +566,7 @@ export default class HTMLScriptElement extends HTMLElement {
|
|
6
|
+
browserSettings &&
|
|
7
|
+
(browserSettings.disableJavaScriptFileLoading || !browserSettings.enableJavaScriptEvaluation)
|
|
8
|
+
) {
|
|
9
|
+
- if (browserSettings.handleDisabledFileLoadingAsSuccess) {
|
|
10
|
+
- this.dispatchEvent(new Event('load'));
|
|
11
|
+
- } else {
|
|
12
|
+
- const error = new window.DOMException(
|
|
13
|
+
- `Failed to load module "${url}". JavaScript file loading is disabled.`,
|
|
14
|
+
- DOMExceptionNameEnum.notSupportedError
|
|
15
|
+
- );
|
|
16
|
+
- browserFrame.page.console.error(error);
|
|
17
|
+
- this.dispatchEvent(new Event('error'));
|
|
18
|
+
- }
|
|
19
|
+
+ // The renderer owns script fetching and dispatches load/error after execution.
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
@@ -647,16 +638,7 @@ export default class HTMLScriptElement extends HTMLElement {
|
|
24
|
+
browserSettings &&
|
|
25
|
+
(browserSettings.disableJavaScriptFileLoading || !browserSettings.enableJavaScriptEvaluation)
|
|
26
|
+
) {
|
|
27
|
+
- if (browserSettings.handleDisabledFileLoadingAsSuccess) {
|
|
28
|
+
- this.dispatchEvent(new Event('load'));
|
|
29
|
+
- } else {
|
|
30
|
+
- const error = new window.DOMException(
|
|
31
|
+
- `Failed to load script "${absoluteURL}". JavaScript file loading is disabled.`,
|
|
32
|
+
- DOMExceptionNameEnum.notSupportedError
|
|
33
|
+
- );
|
|
34
|
+
- browserFrame.page.console.error(error);
|
|
35
|
+
- this.dispatchEvent(new Event('error'));
|
|
36
|
+
- }
|
|
37
|
+
+ // The renderer owns script fetching and dispatches load/error after execution.
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
diff --git a/packages/happy-dom/src/nodes/element/Element.ts b/packages/happy-dom/src/nodes/element/Element.ts
|
|
2
|
+
--- a/packages/happy-dom/src/nodes/element/Element.ts
|
|
3
|
+
+++ b/packages/happy-dom/src/nodes/element/Element.ts
|
|
4
|
+
@@ -706,12 +706,9 @@
|
|
5
|
+
* @param text HTML string to insert.
|
|
6
|
+
*/
|
|
7
|
+
public insertAdjacentHTML(position: InsertAdjacentPosition, text: string): void {
|
|
8
|
+
- const childNodes = new HTMLParser(this[PropertySymbol.window]).parse(text)[
|
|
9
|
+
- PropertySymbol.nodeArray
|
|
10
|
+
- ];
|
|
11
|
+
- while (childNodes.length) {
|
|
12
|
+
- this.insertAdjacentElement(position, childNodes[0]);
|
|
13
|
+
- }
|
|
14
|
+
+ // Insert the parsed fragment as a whole: inserting its nodes one at a
|
|
15
|
+
+ // time at "afterbegin" or "afterend" reversed their order.
|
|
16
|
+
+ this.insertAdjacentElement(position, new HTMLParser(this[PropertySymbol.window]).parse(text));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
diff --git a/packages/happy-dom/src/query-selector/QuerySelector.ts b/packages/happy-dom/src/query-selector/QuerySelector.ts
|
|
2
|
+
--- a/packages/happy-dom/src/query-selector/QuerySelector.ts
|
|
3
|
+
+++ b/packages/happy-dom/src/query-selector/QuerySelector.ts
|
|
4
|
+
@@ -373,7 +373,10 @@ export default class QuerySelector {
|
|
5
|
+
);
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
- const cachedResult = element[PropertySymbol.cache].matches.get(selector);
|
|
9
|
+
+ // A :has() match depends on the whole subtree, which no affected-cache
|
|
10
|
+
+ // registration covers, so it is not cached.
|
|
11
|
+
+ const cacheable = !selector.includes(':has(');
|
|
12
|
+
+ const cachedResult = cacheable ? element[PropertySymbol.cache].matches.get(selector) : null;
|
|
13
|
+
|
|
14
|
+
if (cachedResult) {
|
|
15
|
+
if (cachedResult.result !== null) {
|
|
16
|
+
@@ -386,9 +389,11 @@ export default class QuerySelector {
|
|
17
|
+
result: { match: null }
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
- element[PropertySymbol.cache].matches.set(selector, cachedItem);
|
|
21
|
+
+ if (cacheable) {
|
|
22
|
+
+ element[PropertySymbol.cache].matches.set(selector, cachedItem);
|
|
23
|
+
+ }
|
|
24
|
+
|
|
25
|
+
- if (element[PropertySymbol.isConnected]) {
|
|
26
|
+
+ if (cacheable && element[PropertySymbol.isConnected]) {
|
|
27
|
+
// Document is affected for the ":target" selector
|
|
28
|
+
(element[PropertySymbol.ownerDocument] || element)[PropertySymbol.affectsCache].push(
|
|
29
|
+
cachedItem
|
|
30
|
+
@@ -462,6 +467,13 @@ export default class QuerySelector {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
+ // Structural pseudo-classes and sibling combinators read the parent's
|
|
35
|
+
+ // child list, whose changes are reported on the parent.
|
|
36
|
+
+ const parent = element.parentNode;
|
|
37
|
+
+ if (parent) {
|
|
38
|
+
+ this.affectsCache(<Element>parent, cachedItem);
|
|
39
|
+
+ }
|
|
40
|
+
+
|
|
41
|
+
const result = selectorItem.match(scope, element, ignoreErrors);
|
|
42
|
+
|
|
43
|
+
if (result) {
|