buninu-browser 0.2.1 → 0.2.3

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.
Files changed (90) hide show
  1. package/AGENTS.md +4 -0
  2. package/README.md +74 -9
  3. package/ai-debug.log +5537 -0
  4. package/bunfig.toml +1 -0
  5. package/buninu-browser.js +4 -0
  6. package/index.js +5 -0
  7. package/lib/audio/piano.js +24 -1
  8. package/lib/audio/playback-buffer.js +27 -0
  9. package/lib/audio/pulse-client.js +137 -19
  10. package/lib/cdp-server.js +22 -11
  11. package/lib/happy-dom/parser.js +29 -4
  12. package/lib/happy-dom/selector-pseudo-adapter.js +14 -0
  13. package/lib/headless-shell.js +69 -19
  14. package/lib/input/hit-test.js +25 -7
  15. package/lib/layout/text-layout.js +268 -85
  16. package/lib/log.js +5 -2
  17. package/lib/media/byte-pipe.js +82 -0
  18. package/lib/media/position-audio.js +42 -0
  19. package/lib/media/video-keyframe.js +29 -0
  20. package/lib/network/network-service.js +161 -14
  21. package/lib/paint/display-list.js +316 -15
  22. package/lib/paint/gradient.js +11 -5
  23. package/lib/render-tree/index.js +19 -2
  24. package/lib/render-tree/svg.js +2 -1
  25. package/lib/renderer/bindings.js +995 -69
  26. package/lib/renderer/canvas-2d.js +92 -0
  27. package/lib/renderer/compositor.js +16 -1
  28. package/lib/renderer/custom-element-reactions.js +18 -0
  29. package/lib/renderer/history.js +42 -0
  30. package/lib/renderer/host.js +166 -9
  31. package/lib/renderer/image-loading.js +27 -0
  32. package/lib/renderer/interfaces.js +215 -0
  33. package/lib/renderer/keyboard-input.js +12 -0
  34. package/lib/renderer/main.js +162 -12
  35. package/lib/renderer/media-playback-events.js +13 -0
  36. package/lib/renderer/media-source.js +819 -0
  37. package/lib/renderer/messaging.js +411 -0
  38. package/lib/renderer/native-functions.js +113 -0
  39. package/lib/renderer/page-realm.js +1408 -242
  40. package/lib/renderer/page-renderer.js +1893 -223
  41. package/lib/renderer/piano-input.js +22 -0
  42. package/lib/renderer/pointer-move.js +29 -0
  43. package/lib/renderer/rejections.js +31 -0
  44. package/lib/renderer/rewrite-worker.js +22 -0
  45. package/lib/renderer/script-rewrite.js +805 -57
  46. package/lib/renderer/trusted-types.js +129 -0
  47. package/lib/renderer/video-presentation.js +126 -0
  48. package/lib/renderer/workers.js +327 -0
  49. package/lib/screenshot-cli.js +2 -0
  50. package/lib/style/computed-style.js +186 -29
  51. package/lib/user-agent.js +14 -2
  52. package/package.json +4 -1
  53. package/patches/happy-dom/0002-use-bun-platform-adapters.patch +139 -19
  54. package/patches/happy-dom/0006-parse-css-escapes-in-selectors.patch +115 -0
  55. package/patches/happy-dom/README.md +26 -0
  56. package/patches/ses/0002-embedder-native-functions-source-text-sloppy-code.patch +106 -0
  57. package/patches/ses/0003-cache-sloppy-evaluators.patch +47 -0
  58. package/patches/ses/README.md +4 -0
  59. package/test/canvas-2d.test.js +55 -0
  60. package/test/compositor.test.js +41 -1
  61. package/test/computed-style.test.js +59 -1
  62. package/test/display-list.test.js +88 -1
  63. package/test/download.test.js +43 -0
  64. package/test/fixtures/long-video.mp4 +0 -0
  65. package/test/fixtures/media-sync.js +57 -0
  66. package/test/happy-dom-platform-adapters.test.js +25 -0
  67. package/test/headless-shell.test.js +23 -1
  68. package/test/hit-test.test.js +25 -1
  69. package/test/media-playback-events.test.js +22 -0
  70. package/test/media-source.test.js +156 -0
  71. package/test/media-sync.test.js +12 -0
  72. package/test/mobile.test.js +2 -2
  73. package/test/navigation.test.js +61 -0
  74. package/test/network-service.test.js +14 -0
  75. package/test/page-scripts.test.js +1203 -7
  76. package/test/piano-input.test.js +44 -0
  77. package/test/playback-buffer.test.js +31 -0
  78. package/test/position-audio.test.js +67 -0
  79. package/test/positioning.test.js +95 -1
  80. package/test/pulse-client.test.js +84 -0
  81. package/test/renderer-host.test.js +5 -2
  82. package/test/screenshot-cli.test.js +25 -1
  83. package/test/script-rewrite.test.js +111 -6
  84. package/test/setup.js +5 -0
  85. package/test/text-layout.test.js +62 -0
  86. package/test/user-agent.test.js +60 -4
  87. package/test/video-presentation.test.js +67 -0
  88. package/vendor/happy-dom/packages/happy-dom/src/nodes/html-image-element/HTMLImageElement.ts +52 -22
  89. package/vendor/happy-dom/packages/happy-dom/src/query-selector/SelectorParser.ts +18 -17
  90. package/vendor/ses/dist/ses.mjs +52 -6
@@ -32,6 +32,7 @@ const LIST_STYLE_TYPES = new Set([
32
32
  "lower-alpha", "upper-alpha", "lower-latin", "upper-latin", "lower-roman", "upper-roman",
33
33
  ]);
34
34
  const DISPLAYS = new Set([
35
+ "-webkit-box", "-webkit-inline-box",
35
36
  "none", "block", "flex", "grid", "inline", "inline-block", "inline-flex", "inline-grid", "list-item",
36
37
  "table", "inline-table", "table-row-group", "table-header-group", "table-footer-group", "table-row",
37
38
  "table-cell", "table-caption", "table-column", "table-column-group",
@@ -50,13 +51,23 @@ const VERTICAL_ALIGNS = new Set(["baseline", "top", "middle", "bottom", "text-to
50
51
  const AUTO_TRACK = Object.freeze({ min: "auto", max: "auto" });
51
52
  const FLEX_ALIGNMENT = Object.freeze({ "flex-start": "start", "flex-end": "end", left: "start", right: "end" });
52
53
  const GRADIENT = /^\s*(?:repeating-)?(?:linear|radial)-gradient\(/i;
54
+ // A background layer's image: a URL or a gradient.
55
+ const BACKGROUND_IMAGE = /^(?:url\(|(?:repeating-)?(?:linear|radial)-gradient\()/i;
56
+ const BACKGROUND_REPEATS = new Set(["repeat", "repeat-x", "repeat-y", "no-repeat", "space", "round"]);
57
+ const BACKGROUND_BOXES = new Set(["border-box", "padding-box", "content-box"]);
58
+ const BACKGROUND_ATTACHMENTS = new Set(["scroll", "fixed", "local"]);
53
59
  const BOX_SIDES = Object.freeze(["top", "right", "bottom", "left"]);
54
60
  const WHITE_SPACES = new Set(["normal", "nowrap", "pre", "pre-wrap"]);
55
61
  // CSS Cascade 4 §7.3 CSS-wide keywords; revert is treated as unset (no user/UA origin split).
56
62
  const CSS_WIDE_KEYWORDS = new Set(["inherit", "initial", "unset", "revert", "revert-layer"]);
57
63
  // Computed-style fields a shorthand (or a property not named after its field) covers.
58
64
  const PROPERTY_FIELDS = Object.freeze({
59
- background: ["backgroundColor", "backgroundImage"],
65
+ "-webkit-line-clamp": ["webkitLineClamp"],
66
+ "-webkit-box-orient": ["webkitBoxOrient"],
67
+ overflow: ["overflowX", "overflowY", "overflowScrollX", "overflowScrollY"],
68
+ "overflow-x": ["overflowX", "overflowScrollX"],
69
+ "overflow-y": ["overflowY", "overflowScrollY"],
70
+ background: ["backgroundColor", "backgroundImage", "backgroundPosition", "backgroundSize", "backgroundRepeat"],
60
71
  flex: ["flexGrow", "flexShrink", "flexBasis"],
61
72
  "flex-flow": ["flexDirection", "flexWrap"],
62
73
  gap: ["rowGap", "columnGap", "gap"],
@@ -89,6 +100,10 @@ const INITIAL = Object.freeze({
89
100
  colorScheme: "light",
90
101
  backgroundColor: "rgba(0, 0, 0, 0)",
91
102
  backgroundImage: null,
103
+ // Comma-separated, one entry per background-image layer (repeated as needed); null is the initial value.
104
+ backgroundPosition: null,
105
+ backgroundSize: null,
106
+ backgroundRepeat: null,
92
107
  backgroundClip: "border-box",
93
108
  boxShadow: null,
94
109
  fontFamily: Object.freeze(["sans-serif"]),
@@ -99,6 +114,8 @@ const INITIAL = Object.freeze({
99
114
  lineHeightFactor: null,
100
115
  textAlign: "start",
101
116
  whiteSpace: "normal",
117
+ webkitLineClamp: null,
118
+ webkitBoxOrient: "horizontal",
102
119
  listStyleType: "disc",
103
120
  margin: Object.freeze([0, 0, 0, 0]),
104
121
  padding: Object.freeze([0, 0, 0, 0]),
@@ -133,6 +150,8 @@ const INITIAL = Object.freeze({
133
150
  inset: Object.freeze(["auto", "auto", "auto", "auto"]),
134
151
  zIndex: "auto",
135
152
  transform: null,
153
+ // Raw text (default "50% 50%"); paint resolves it against the border box.
154
+ transformOrigin: null,
136
155
  justifyItems: "normal",
137
156
  justifySelf: "auto",
138
157
  gridTemplateColumns: null,
@@ -161,6 +180,10 @@ const INITIAL = Object.freeze({
161
180
  opacity: 1,
162
181
  overflowX: "visible",
163
182
  overflowY: "visible",
183
+ // Scroll containers (CSS Overflow 3 §3): "auto" for auto/scroll/overlay,
184
+ // which the user scrolls; "hidden", which only scripts scroll; else null.
185
+ overflowScrollX: null,
186
+ overflowScrollY: null,
164
187
  textOverflow: "clip",
165
188
  clip: null,
166
189
  clipPath: null,
@@ -187,6 +210,7 @@ const UA_DECLARATIONS = new Map(Object.entries({
187
210
  FIGURE: "margin: 1em 40px",
188
211
  PRE: "margin: 1em 0; font-family: monospace",
189
212
  CODE: "font-family: monospace",
213
+ IFRAME: "border: 2px inset",
190
214
  KBD: "font-family: monospace",
191
215
  SAMP: "font-family: monospace",
192
216
  TT: "font-family: monospace",
@@ -237,6 +261,8 @@ export function controlKind(element) {
237
261
  }
238
262
 
239
263
  /** HTML §15.3.4: :link (visited links are not tracked). */
264
+ // <iframe frameborder="0"> (or "no"): no border in place of the default one.
265
+ const UA_FRAMELESS_DECLARATIONS = parseDeclarations("border: none");
240
266
  const UA_LINK_DECLARATIONS = parseDeclarations("color: #0000EE; text-decoration: underline; cursor: pointer");
241
267
  const DECORATION_LINES = new Set(["underline", "overline", "line-through"]);
242
268
  const DECORATION_STYLES = new Set(["solid", "double", "dotted", "dashed", "wavy"]);
@@ -422,6 +448,11 @@ export class StyleEngine {
422
448
  return this.#styles.get(element) ?? INITIAL;
423
449
  }
424
450
 
451
+ /** An element's custom properties (--*), name to value text, inherited ones included. */
452
+ customPropertiesOf(element) {
453
+ return this.#customProperties.get(element) ?? EMPTY_MAP;
454
+ }
455
+
425
456
  /** The computed style of an element's ::before/::after box, or null when it generates none. */
426
457
  getPseudo(element, pseudo) {
427
458
  return this.#pseudoStyles.get(element)?.[pseudo] ?? null;
@@ -618,7 +649,9 @@ export function computeElementStyle(element, parentStyle = null, authorDeclarati
618
649
  const isLink = (element.tagName === "A" || element.tagName === "AREA") && element.hasAttribute("href");
619
650
  const control = controlKind(element);
620
651
  if (control === "hidden") values.display = "none";
621
- for (const declaration of [...(UA_DECLARATIONS.get(element.tagName)?.values() ?? []),
652
+ const frameless = element.tagName === "IFRAME" && ["0", "no"].includes(element.getAttribute("frameborder")?.trim().toLowerCase());
653
+ const uaDeclarations = frameless ? UA_FRAMELESS_DECLARATIONS : UA_DECLARATIONS.get(element.tagName);
654
+ for (const declaration of [...(uaDeclarations?.values() ?? []),
622
655
  ...(isLink ? UA_LINK_DECLARATIONS.values() : []), ...(UA_CONTROL_DECLARATIONS[control]?.values() ?? [])]) {
623
656
  if (!declarations.has(declaration.name)) {
624
657
  declarations.set(declaration.name, { ...declaration, specificity: -1, order: -1, layer: -Infinity });
@@ -1062,6 +1095,13 @@ function setProperty(style, name, value, parentStyle = null) {
1062
1095
  break;
1063
1096
  }
1064
1097
  case "display": if (DISPLAYS.has(value)) style.display = value; break;
1098
+ case "-webkit-line-clamp":
1099
+ if (value === "none") style.webkitLineClamp = null;
1100
+ else if (/^[1-9]\d*$/.test(value)) style.webkitLineClamp = Number(value);
1101
+ break;
1102
+ case "-webkit-box-orient":
1103
+ if (["vertical", "horizontal"].includes(value)) style.webkitBoxOrient = value;
1104
+ break;
1065
1105
  case "text-decoration-line": {
1066
1106
  const lines = parseDecorationLines(value);
1067
1107
  if (lines) style.textDecorationLine = lines;
@@ -1112,13 +1152,22 @@ function setProperty(style, name, value, parentStyle = null) {
1112
1152
  case "overflow":
1113
1153
  case "overflow-x":
1114
1154
  case "overflow-y": {
1115
- // Scrolling boxes are not implemented: every non-visible value clips.
1155
+ // Every non-visible value clips; all but clip also make a scroll container.
1116
1156
  const parts = value.split(/\s+/);
1117
1157
  const clips = (part) => (["hidden", "clip", "auto", "scroll", "overlay"].includes(part) ? "clip" : part === "visible" ? "visible" : null);
1118
- const x = clips(parts[0]);
1119
- const y = clips(parts[1] ?? parts[0]);
1120
- if (name !== "overflow-y" && x) style.overflowX = x;
1121
- if (name !== "overflow-x" && (name === "overflow" ? y : x)) style.overflowY = name === "overflow" ? y : x;
1158
+ const scrolls = (part) => (["auto", "scroll", "overlay"].includes(part) ? "auto" : part === "hidden" ? "hidden" : null);
1159
+ const xPart = parts[0];
1160
+ const yPart = name === "overflow" ? parts[1] ?? parts[0] : parts[0];
1161
+ const x = clips(xPart);
1162
+ const y = clips(yPart);
1163
+ if (name !== "overflow-y" && x) {
1164
+ style.overflowX = x;
1165
+ style.overflowScrollX = scrolls(xPart);
1166
+ }
1167
+ if (name !== "overflow-x" && y) {
1168
+ style.overflowY = y;
1169
+ style.overflowScrollY = scrolls(yPart);
1170
+ }
1122
1171
  break;
1123
1172
  }
1124
1173
  case "text-overflow":
@@ -1157,24 +1206,37 @@ function setProperty(style, name, value, parentStyle = null) {
1157
1206
  break;
1158
1207
  }
1159
1208
  case "background": {
1160
- // The shorthand resets both longhands; only the final layer may carry a color.
1161
- const layers = splitTopLevel(value, ",");
1162
- const images = layers.filter((layer) => GRADIENT.test(layer));
1163
- const color = splitTopLevel(layers.at(-1) ?? "", " ")
1164
- .map((token) => resolvedColor(token, style.colorScheme))
1165
- .find(Boolean);
1166
- style.backgroundColor = color ?? INITIAL.backgroundColor;
1167
- style.backgroundImage = images.length ? images.join(", ") : null;
1209
+ // The shorthand resets the longhands; only the final layer may carry a color.
1210
+ const layers = splitTopLevel(value, ",").map(parseBackgroundLayer);
1211
+ if (layers.some((layer) => !layer)) break;
1212
+ style.backgroundColor = layers.at(-1).color
1213
+ ? resolvedColor(layers.at(-1).color, style.colorScheme) ?? INITIAL.backgroundColor
1214
+ : INITIAL.backgroundColor;
1215
+ const hasImage = layers.some((layer) => layer.image !== "none");
1216
+ style.backgroundImage = hasImage ? layers.map((layer) => layer.image).join(", ") : null;
1217
+ const list = (field, initial) => layers.some((layer) => layer[field] !== initial)
1218
+ ? layers.map((layer) => layer[field]).join(", ")
1219
+ : null;
1220
+ style.backgroundPosition = hasImage ? list("position", "0% 0%") : null;
1221
+ style.backgroundSize = hasImage ? list("size", "auto") : null;
1222
+ style.backgroundRepeat = hasImage ? list("repeat", "repeat") : null;
1168
1223
  break;
1169
1224
  }
1170
1225
  case "background-image": {
1171
1226
  if (value === "none") style.backgroundImage = null;
1172
1227
  else {
1173
- const images = splitTopLevel(value, ",").filter((layer) => GRADIENT.test(layer));
1174
- if (images.length) style.backgroundImage = images.join(", ");
1228
+ const images = splitTopLevel(value, ",").map((layer) => BACKGROUND_IMAGE.test(layer) || layer === "none" ? layer : null);
1229
+ if (images.every(Boolean) && images.some((layer) => layer !== "none")) style.backgroundImage = images.join(", ");
1175
1230
  }
1176
1231
  break;
1177
1232
  }
1233
+ case "background-position":
1234
+ case "background-size":
1235
+ case "background-repeat": {
1236
+ const field = { "background-position": "backgroundPosition", "background-size": "backgroundSize", "background-repeat": "backgroundRepeat" }[name];
1237
+ style[field] = value.toLowerCase().replace(/\s+/g, " ");
1238
+ break;
1239
+ }
1178
1240
  case "box-shadow": {
1179
1241
  const shadows = value === "none" ? null : parseBoxShadows(value, style.fontSize);
1180
1242
  if (value === "none" || shadows) style.boxShadow = shadows;
@@ -1186,8 +1248,11 @@ function setProperty(style, name, value, parentStyle = null) {
1186
1248
  break;
1187
1249
  case "font-size": {
1188
1250
  const parentSize = parentStyle?.fontSize ?? INITIAL.fontSize;
1251
+ const specified = computedLength(value, parentSize, true);
1252
+ const relativeSize = specified?.unit === "%" ? parentSize * specified.value / 100
1253
+ : specified?.unit === "math" && specified.kind === "calc" ? specified.px + parentSize * specified.percent / 100 : specified;
1189
1254
  const length = FONT_SIZE_KEYWORDS[value] ?? (value === "larger" ? parentSize * 1.2 : value === "smaller" ? parentSize / 1.2
1190
- : computedLength(value, parentSize));
1255
+ : relativeSize);
1191
1256
  if (length > 0) style.fontSize = length;
1192
1257
  break;
1193
1258
  }
@@ -1229,10 +1294,13 @@ function setProperty(style, name, value, parentStyle = null) {
1229
1294
  break;
1230
1295
  }
1231
1296
  case "transform": {
1232
- const transform = value === "none" ? null : parseTranslate(value, style.fontSize);
1297
+ const transform = value === "none" ? null : parseTransform(value, style.fontSize);
1233
1298
  if (transform !== undefined) style.transform = transform;
1234
1299
  break;
1235
1300
  }
1301
+ case "transform-origin":
1302
+ style.transformOrigin = value.toLowerCase().replace(/\s+/g, " ");
1303
+ break;
1236
1304
  case "content": {
1237
1305
  const content = parseContent(value);
1238
1306
  if (content !== undefined) style.content = content;
@@ -1296,12 +1364,15 @@ function setProperty(style, name, value, parentStyle = null) {
1296
1364
  if (value === "normal") {
1297
1365
  style.lineHeight = null;
1298
1366
  style.lineHeightFactor = null;
1299
- } else if (Number.isFinite(numeric) && numeric > 0) {
1367
+ } else if (value !== "" && Number.isFinite(numeric) && numeric >= 0) {
1300
1368
  style.lineHeight = numeric * style.fontSize;
1301
1369
  style.lineHeightFactor = numeric;
1302
1370
  } else {
1303
- const length = computedLength(value, style.fontSize);
1304
- if (length > 0) {
1371
+ // Non-negative, so 0 is valid (icon buttons set line-height: 0); a
1372
+ // percentage computes to a length against this element's font size.
1373
+ const percentage = /^(\d+\.?\d*|\.\d+)%$/.exec(value);
1374
+ const length = percentage ? Number(percentage[1]) / 100 * style.fontSize : computedLength(value, style.fontSize);
1375
+ if (length >= 0) {
1305
1376
  style.lineHeight = length;
1306
1377
  style.lineHeightFactor = null;
1307
1378
  }
@@ -1314,7 +1385,7 @@ function setProperty(style, name, value, parentStyle = null) {
1314
1385
  break;
1315
1386
  }
1316
1387
  case "padding": {
1317
- const box = boxLengths(value, false, style.fontSize, false);
1388
+ const box = boxLengths(value, false, style.fontSize, false, true);
1318
1389
  if (box) style.padding = box;
1319
1390
  break;
1320
1391
  }
@@ -1328,8 +1399,9 @@ function setProperty(style, name, value, parentStyle = null) {
1328
1399
  case "padding-left": {
1329
1400
  const [box, side] = name.split("-");
1330
1401
  const isMargin = box === "margin";
1331
- const length = isMargin && value === "auto" ? "auto" : computedLength(value, style.fontSize);
1332
- if (length === "auto" || (Number.isFinite(length) && (isMargin || length >= 0))) {
1402
+ const length = isMargin && value === "auto" ? "auto" : computedLength(value, style.fontSize, !isMargin);
1403
+ if (length === "auto" || (!isMargin && isLength(length) && typeof length === "object"
1404
+ && (!isPercentage(length) || length.value >= 0)) || (Number.isFinite(length) && (isMargin || length >= 0))) {
1333
1405
  style[box] = style[box].with(BOX_SIDES.indexOf(side), length);
1334
1406
  }
1335
1407
  break;
@@ -1878,6 +1950,32 @@ function pseudoElement(pseudo) {
1878
1950
  * percentages of the element's own border box). Other functions are ignored
1879
1951
  * until transforms are painted with matrices.
1880
1952
  */
1953
+ /**
1954
+ * The translation (layout moves the box) and scale (paint scales it about
1955
+ * transform-origin) of a transform list; other functions are ignored.
1956
+ */
1957
+ function parseTransform(value, fontSize) {
1958
+ const translation = parseTranslate(value, fontSize);
1959
+ if (translation === undefined) return undefined;
1960
+ let scaleX = 1;
1961
+ let scaleY = 1;
1962
+ let found = false;
1963
+ for (const match of value.matchAll(/\b(scale|scaleX|scaleY|scale3d)\(([^)]*)\)/gi)) {
1964
+ const args = splitTopLevel(match[2], ",").map((part) => part.endsWith("%") ? Number(part.slice(0, -1)) / 100 : Number(part));
1965
+ if (!args.length || args.some((part) => !Number.isFinite(part))) return undefined;
1966
+ const kind = match[1].toLowerCase();
1967
+ if (kind === "scalex") scaleX *= args[0];
1968
+ else if (kind === "scaley") scaleY *= args[0];
1969
+ else {
1970
+ scaleX *= args[0];
1971
+ scaleY *= args[1] ?? args[0];
1972
+ }
1973
+ found = true;
1974
+ }
1975
+ if (!found || (scaleX === 1 && scaleY === 1)) return translation;
1976
+ return Object.freeze({ x: translation?.x ?? 0, y: translation?.y ?? 0, scaleX, scaleY });
1977
+ }
1978
+
1881
1979
  function parseTranslate(value, fontSize) {
1882
1980
  let x = 0;
1883
1981
  let y = 0;
@@ -1916,7 +2014,7 @@ function parseContent(value) {
1916
2014
  * undefined property without a fallback leaves nothing (the declaration then
1917
2015
  * has no effect).
1918
2016
  */
1919
- function resolveVariables(value, customProperties, seen = new Set()) {
2017
+ export function resolveVariables(value, customProperties, seen = new Set()) {
1920
2018
  const text = String(value);
1921
2019
  // Most values use no custom properties; skip the parsing.
1922
2020
  if (!text.includes("var(")) return text.trim();
@@ -2317,6 +2415,64 @@ function resolvedColor(value, scheme) {
2317
2415
  return /^rgba\(/.test(color ?? "") ? color : null;
2318
2416
  }
2319
2417
 
2418
+ /**
2419
+ * One layer of the background shorthand, its parts as text: the image, the
2420
+ * position (before a slash) and size (after it), the repeat style and a color.
2421
+ * Null when a token fits none of them.
2422
+ */
2423
+ function parseBackgroundLayer(layer) {
2424
+ const tokens = [];
2425
+ let depth = 0;
2426
+ let token = "";
2427
+ for (const character of layer) {
2428
+ if (depth === 0 && (/\s/.test(character) || character === "/")) {
2429
+ if (token) tokens.push(token);
2430
+ if (character === "/") tokens.push("/");
2431
+ token = "";
2432
+ continue;
2433
+ }
2434
+ if (character === "(") depth++;
2435
+ else if (character === ")") depth--;
2436
+ token += character;
2437
+ }
2438
+ if (token) tokens.push(token);
2439
+ const result = { image: "none", position: "0% 0%", size: "auto", repeat: "repeat", color: null };
2440
+ const position = [];
2441
+ const size = [];
2442
+ const repeat = [];
2443
+ let afterSlash = false;
2444
+ for (const raw of tokens) {
2445
+ const lower = raw.toLowerCase();
2446
+ if (raw === "/") afterSlash = true;
2447
+ else if (BACKGROUND_IMAGE.test(raw)) result.image = raw;
2448
+ else if (lower === "none") result.image = "none";
2449
+ else if (BACKGROUND_REPEATS.has(lower)) repeat.push(lower);
2450
+ else if (BACKGROUND_BOXES.has(lower) || BACKGROUND_ATTACHMENTS.has(lower)) continue;
2451
+ else if (afterSlash && (lower === "cover" || lower === "contain" || lower === "auto" || /^-?[\d.]/.test(lower) || lower.startsWith("calc("))) size.push(lower);
2452
+ else if (["left", "right", "top", "bottom", "center"].includes(lower) || /^-?[\d.]/.test(lower) || lower.startsWith("calc(")) {
2453
+ afterSlash = false;
2454
+ position.push(lower);
2455
+ } else if (resolvedColor(raw, "light")) result.color = raw;
2456
+ else return null;
2457
+ }
2458
+ if (position.length) result.position = position.join(" ");
2459
+ if (size.length) result.size = size.join(" ");
2460
+ if (repeat.length) result.repeat = repeat.join(" ");
2461
+ return result;
2462
+ }
2463
+
2464
+ /** A style sheet with each relative url() made absolute against the sheet's own URL. */
2465
+ export function absolutizeStyleSheetUrls(text, base) {
2466
+ return text.replace(/url\(\s*(["']?)([^"')]*)\1\s*\)/gi, (whole, quote, url) => {
2467
+ if (!url || /^(?:[a-z][a-z\d+.-]*:|#)/i.test(url)) return whole;
2468
+ try {
2469
+ return `url(${quote}${new URL(url, base).href}${quote})`;
2470
+ } catch {
2471
+ return whole;
2472
+ }
2473
+ });
2474
+ }
2475
+
2320
2476
  function splitTopLevel(source, separator) {
2321
2477
  const parts = [];
2322
2478
  let depth = 0;
@@ -2325,7 +2481,7 @@ function splitTopLevel(source, separator) {
2325
2481
  const character = source[index];
2326
2482
  if (character === "(") depth++;
2327
2483
  else if (character === ")") depth--;
2328
- else if (character === separator && depth === 0) {
2484
+ else if ((character === separator || separator === " " && /\s/.test(character)) && depth === 0) {
2329
2485
  parts.push(source.slice(start, index).trim());
2330
2486
  start = index + 1;
2331
2487
  }
@@ -2335,10 +2491,11 @@ function splitTopLevel(source, separator) {
2335
2491
  }
2336
2492
 
2337
2493
  function boxLengths(value, allowNegative, emSize, allowAuto, allowPercentage = false) {
2338
- const parts = value.trim().split(/\s+/).map((part) =>
2494
+ const parts = splitTopLevel(value.trim(), " ").filter(Boolean).map((part) =>
2339
2495
  allowAuto && part === "auto" ? "auto" : computedLength(part, emSize, allowPercentage));
2340
2496
  if (parts.length < 1 || parts.length > 4 || parts.some((part) =>
2341
- part !== "auto" && !isPercentage(part) && (!Number.isFinite(part) || (!allowNegative && part < 0)))) return null;
2497
+ part !== "auto" && !(allowPercentage && isLength(part) && typeof part === "object"
2498
+ && (allowNegative || !isPercentage(part) || part.value >= 0)) && (!Number.isFinite(part) || (!allowNegative && part < 0)))) return null;
2342
2499
  const [top, right = top, bottom = top, left = right] = parts.length === 3
2343
2500
  ? [parts[0], parts[1], parts[2], parts[1]]
2344
2501
  : parts.length === 2
package/lib/user-agent.js CHANGED
@@ -1,5 +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.
1
+ import packageInfo from "../package.json" with { type: "json" };
2
+
3
+ export const BROWSER_PRODUCT = `Buninu/${packageInfo.version}`;
4
+ // Compatibility identities, not the version of this engine. Bun runs
5
+ // JavaScriptCore, so a WebKit profile can avoid a V8/JavaScriptCore mismatch.
6
+ // Keep the complete profile together: changing just the UA leaves conflicting
7
+ // vendor and User-Agent Client Hints exposed to scripts.
8
+ export const WEBKIT_USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.6 Safari/605.1.15";
9
+ // Chrome 154 compatibility profile (2026-09-22).
3
10
  export const CHROMIUM_VERSION = "154.0.8037.57";
4
11
 
5
12
  export function desktopUserAgent(platform = process.platform, arch = process.arch) {
@@ -10,3 +17,8 @@ export function desktopUserAgent(platform = process.platform, arch = process.arc
10
17
  }
11
18
 
12
19
  export const DESKTOP_USER_AGENT = desktopUserAgent();
20
+
21
+ /** Desktop compatibility flags are last-wins; mobile presentation takes priority. */
22
+ export function webkitFromArgs(args) {
23
+ return !args.includes("--mobile") && args.lastIndexOf("--webkit") >= args.lastIndexOf("--chromium");
24
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "buninu-browser",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "description": "Buninu Browser is a browser engine written in JavaScript and WebAssembly for Bun.",
5
5
  "keywords": [
6
6
  "buninu",
@@ -32,6 +32,9 @@
32
32
  "bugs": {
33
33
  "url": "https://github.com/jjtseng93/buninu-browser/issues"
34
34
  },
35
+ "scripts": {
36
+ "test": "bun test --parallel"
37
+ },
35
38
  "license": "MIT",
36
39
  "engines": {
37
40
  "bun": ">=1.4.2"
@@ -9,7 +9,7 @@ index 2de8270..0c5034b 100644
9
9
  import ProgressEvent from '../event/events/ProgressEvent.js';
10
10
  import DOMExceptionNameEnum from '../exception/DOMExceptionNameEnum.js';
11
11
  diff --git a/packages/happy-dom/src/nodes/html-image-element/HTMLImageElement.ts b/packages/happy-dom/src/nodes/html-image-element/HTMLImageElement.ts
12
- index f2c85d6..961edb1 100644
12
+ index f2c85d6..dcef8e0 100644
13
13
  --- a/packages/happy-dom/src/nodes/html-image-element/HTMLImageElement.ts
14
14
  +++ b/packages/happy-dom/src/nodes/html-image-element/HTMLImageElement.ts
15
15
  @@ -4,7 +4,7 @@ import type ICanvasShape from '../../canvas/ICanvasShape.js';
@@ -21,39 +21,159 @@ index f2c85d6..961edb1 100644
21
21
  import type Response from '../../fetch/Response.js';
22
22
  import type Attr from '../attr/Attr.js';
23
23
 
24
- @@ -424,15 +424,18 @@ export default class HTMLImageElement extends HTMLElement implements ICanvasShap
25
- const buffer = Buffer.from(dataUrlMatch[2], 'base64');
24
+ @@ -19,6 +19,8 @@ const DATA_URL_REGEX = /^\s*data:([^,]+)?,(.*)/;
25
+ export default class HTMLImageElement extends HTMLElement implements ICanvasShape {
26
+ public [PropertySymbol.tagName] = 'IMG';
27
+ public [PropertySymbol.complete] = true;
28
+ + // Bumped whenever src changes: an image decoded for an earlier src is dropped.
29
+ + #loadGeneration = 0;
30
+ public [PropertySymbol.naturalHeight] = 0;
31
+ public [PropertySymbol.naturalWidth] = 0;
32
+ public [PropertySymbol.loading] = 'auto';
33
+ @@ -357,6 +359,7 @@ export default class HTMLImageElement extends HTMLElement implements ICanvasShap
34
+ super[PropertySymbol.onSetAttribute](attribute, replacedAttribute);
35
+
36
+ if (attribute[PropertySymbol.name] === 'src') {
37
+ + const generation = ++this.#loadGeneration;
38
+ const url = this.#parseUrl(attribute[PropertySymbol.value]);
39
+
40
+ if (!url) {
41
+ @@ -367,13 +370,13 @@ export default class HTMLImageElement extends HTMLElement implements ICanvasShap
42
+ return;
43
+ }
44
+
45
+ - if (this.#loadDataUrl(url)) {
46
+ + if (this.#loadDataUrl(url, generation)) {
47
+ return;
48
+ }
49
+
50
+ const settings = new WindowBrowserContext(this[PropertySymbol.window]).getSettings();
51
+ if (settings?.enableImageFileLoading) {
52
+ - this.#loadUrlSource(this.src);
53
+ + this.#loadUrlSource(this.src, generation);
54
+ }
55
+ }
56
+ }
57
+ @@ -385,6 +388,7 @@ export default class HTMLImageElement extends HTMLElement implements ICanvasShap
58
+ super[PropertySymbol.onRemoveAttribute](removedAttribute);
59
+
60
+ if (removedAttribute[PropertySymbol.name] === 'src') {
61
+ + this.#loadGeneration++;
26
62
  this[PropertySymbol.complete] = true;
63
+ this[PropertySymbol.naturalHeight] = 0;
64
+ this[PropertySymbol.naturalWidth] = 0;
65
+ @@ -415,24 +419,37 @@ export default class HTMLImageElement extends HTMLElement implements ICanvasShap
66
+ * Loads the image from the given data URL.
67
+ *
68
+ * @param src Source.
69
+ + * @param generation The load this is (see #loadGeneration).
70
+ * @returns True if the image was loaded, false otherwise.
71
+ */
72
+ - #loadDataUrl(src: string): boolean {
73
+ + #loadDataUrl(src: string, generation: number): boolean {
74
+ const dataUrlMatch = src.match(DATA_URL_REGEX);
75
+
76
+ if (dataUrlMatch) {
77
+ const buffer = Buffer.from(dataUrlMatch[2], 'base64');
78
+ - this[PropertySymbol.complete] = true;
27
79
  this[PropertySymbol.buffer] = buffer;
28
80
  - try {
29
81
  - const dimensions = BufferImageSize(buffer);
30
- + this[PropertySymbol.complete] = false;
31
- + ImageSize(buffer).then((dimensions) => {
32
- this[PropertySymbol.naturalHeight] = dimensions.height;
33
- this[PropertySymbol.naturalWidth] = dimensions.width;
82
+ - this[PropertySymbol.naturalHeight] = dimensions.height;
83
+ - this[PropertySymbol.naturalWidth] = dimensions.width;
34
84
  - } catch (e) {
35
- + this[PropertySymbol.complete] = true;
36
- + this.dispatchEvent(new this[PropertySymbol.window].Event('load'));
37
- + }).catch(() => {
38
- this[PropertySymbol.naturalHeight] = 0;
39
- this[PropertySymbol.naturalWidth] = 0;
85
+ - this[PropertySymbol.naturalHeight] = 0;
86
+ - this[PropertySymbol.naturalWidth] = 0;
40
87
  - }
41
88
  - this.dispatchEvent(new this[PropertySymbol.window].Event('load'));
42
- + this[PropertySymbol.complete] = true;
43
- + this.dispatchEvent(new this[PropertySymbol.window].Event('error'));
44
- + });
89
+ + this[PropertySymbol.complete] = false;
90
+ + // The size is read asynchronously; a src set meanwhile wins.
91
+ + ImageSize(buffer).then(
92
+ + (dimensions) => {
93
+ + if (generation !== this.#loadGeneration) {
94
+ + return;
95
+ + }
96
+ + this[PropertySymbol.naturalHeight] = dimensions.height;
97
+ + this[PropertySymbol.naturalWidth] = dimensions.width;
98
+ + this[PropertySymbol.complete] = true;
99
+ + this.dispatchEvent(new this[PropertySymbol.window].Event('load'));
100
+ + },
101
+ + () => {
102
+ + if (generation !== this.#loadGeneration) {
103
+ + return;
104
+ + }
105
+ + this[PropertySymbol.naturalHeight] = 0;
106
+ + this[PropertySymbol.naturalWidth] = 0;
107
+ + this[PropertySymbol.complete] = true;
108
+ + this.dispatchEvent(new this[PropertySymbol.window].Event('error'));
109
+ + }
110
+ + );
45
111
  return true;
46
112
  }
47
113
  return false;
48
- @@ -484,7 +487,7 @@ export default class HTMLImageElement extends HTMLElement implements ICanvasShap
114
+ @@ -442,8 +459,9 @@ export default class HTMLImageElement extends HTMLElement implements ICanvasShap
115
+ * Loads the image from the given source.
116
+ *
117
+ * @param src Source.
118
+ + * @param generation The load this is (see #loadGeneration).
119
+ */
120
+ - async #loadUrlSource(src: string): Promise<void> {
121
+ + async #loadUrlSource(src: string, generation: number): Promise<void> {
122
+ this[PropertySymbol.complete] = false;
123
+ this[PropertySymbol.naturalHeight] = 0;
124
+ this[PropertySymbol.naturalWidth] = 0;
125
+ @@ -457,6 +475,10 @@ export default class HTMLImageElement extends HTMLElement implements ICanvasShap
126
+ error = <Error>e;
127
+ }
128
+
129
+ + if (generation !== this.#loadGeneration) {
130
+ + return;
131
+ + }
132
+ +
133
+ if (error || !response?.ok) {
134
+ this[PropertySymbol.complete] = true;
135
+ this.dispatchEvent(new this[PropertySymbol.window].Event('error'));
136
+ @@ -475,23 +497,34 @@ export default class HTMLImageElement extends HTMLElement implements ICanvasShap
137
+ try {
138
+ buffer = await response.buffer();
139
+ } catch (e) {
140
+ + if (generation !== this.#loadGeneration) {
141
+ + return;
142
+ + }
143
+ this[PropertySymbol.complete] = true;
144
+ this.dispatchEvent(new this[PropertySymbol.window].Event('error'));
145
+ return;
146
+ }
147
+
148
+ - this[PropertySymbol.complete] = true;
149
+ + if (generation !== this.#loadGeneration) {
150
+ + return;
151
+ + }
152
+ +
49
153
  this[PropertySymbol.buffer] = buffer;
50
154
 
155
+ + let dimensions = { width: 0, height: 0 };
51
156
  try {
52
157
  - const dimensions = BufferImageSize(buffer);
53
- + const dimensions = await ImageSize(buffer);
54
- this[PropertySymbol.naturalHeight] = dimensions.height;
55
- this[PropertySymbol.naturalWidth] = dimensions.width;
158
+ - this[PropertySymbol.naturalHeight] = dimensions.height;
159
+ - this[PropertySymbol.naturalWidth] = dimensions.width;
160
+ + dimensions = await ImageSize(buffer);
56
161
  } catch (e) {
162
+ - this[PropertySymbol.naturalHeight] = 0;
163
+ - this[PropertySymbol.naturalWidth] = 0;
164
+ + // Undecodable: no natural size.
165
+ }
166
+
167
+ + if (generation !== this.#loadGeneration) {
168
+ + return;
169
+ + }
170
+ +
171
+ + this[PropertySymbol.naturalHeight] = dimensions.height;
172
+ + this[PropertySymbol.naturalWidth] = dimensions.width;
173
+ + this[PropertySymbol.complete] = true;
174
+ this.dispatchEvent(new this[PropertySymbol.window].Event('load'));
175
+ }
176
+ }
57
177
  diff --git a/packages/happy-dom/src/web-socket/WebSocket.ts b/packages/happy-dom/src/web-socket/WebSocket.ts
58
178
  index 3075e3a..bea5f97 100644
59
179
  --- a/packages/happy-dom/src/web-socket/WebSocket.ts