buninu-browser 0.2.1 → 0.2.2

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 (72) hide show
  1. package/AGENTS.md +4 -0
  2. package/README.md +74 -9
  3. package/bunfig.toml +1 -0
  4. package/buninu-browser.js +4 -0
  5. package/index.js +5 -0
  6. package/lib/audio/piano.js +24 -1
  7. package/lib/audio/pulse-client.js +120 -12
  8. package/lib/cdp-server.js +16 -11
  9. package/lib/happy-dom/parser.js +24 -4
  10. package/lib/headless-shell.js +66 -19
  11. package/lib/input/hit-test.js +25 -7
  12. package/lib/layout/text-layout.js +196 -49
  13. package/lib/log.js +5 -2
  14. package/lib/media/byte-pipe.js +82 -0
  15. package/lib/media/position-audio.js +42 -0
  16. package/lib/network/network-service.js +161 -14
  17. package/lib/paint/display-list.js +300 -14
  18. package/lib/paint/gradient.js +11 -5
  19. package/lib/render-tree/index.js +17 -2
  20. package/lib/render-tree/svg.js +2 -1
  21. package/lib/renderer/bindings.js +985 -68
  22. package/lib/renderer/canvas-2d.js +92 -0
  23. package/lib/renderer/host.js +158 -8
  24. package/lib/renderer/interfaces.js +215 -0
  25. package/lib/renderer/main.js +160 -12
  26. package/lib/renderer/media-source.js +819 -0
  27. package/lib/renderer/messaging.js +411 -0
  28. package/lib/renderer/native-functions.js +113 -0
  29. package/lib/renderer/page-realm.js +1405 -198
  30. package/lib/renderer/page-renderer.js +1736 -193
  31. package/lib/renderer/rejections.js +31 -0
  32. package/lib/renderer/rewrite-worker.js +22 -0
  33. package/lib/renderer/script-rewrite.js +805 -57
  34. package/lib/renderer/trusted-types.js +129 -0
  35. package/lib/renderer/workers.js +327 -0
  36. package/lib/screenshot-cli.js +2 -0
  37. package/lib/style/computed-style.js +162 -22
  38. package/lib/user-agent.js +14 -2
  39. package/package.json +4 -1
  40. package/patches/happy-dom/0002-use-bun-platform-adapters.patch +139 -19
  41. package/patches/happy-dom/0006-parse-css-escapes-in-selectors.patch +115 -0
  42. package/patches/happy-dom/README.md +26 -0
  43. package/patches/ses/0002-embedder-native-functions-source-text-sloppy-code.patch +106 -0
  44. package/patches/ses/0003-cache-sloppy-evaluators.patch +47 -0
  45. package/patches/ses/README.md +4 -0
  46. package/test/canvas-2d.test.js +55 -0
  47. package/test/compositor.test.js +3 -1
  48. package/test/computed-style.test.js +38 -1
  49. package/test/display-list.test.js +74 -1
  50. package/test/download.test.js +35 -0
  51. package/test/fixtures/long-video.mp4 +0 -0
  52. package/test/fixtures/media-sync.js +57 -0
  53. package/test/happy-dom-platform-adapters.test.js +25 -0
  54. package/test/headless-shell.test.js +1 -1
  55. package/test/hit-test.test.js +25 -1
  56. package/test/media-source.test.js +156 -0
  57. package/test/media-sync.test.js +12 -0
  58. package/test/mobile.test.js +2 -2
  59. package/test/navigation.test.js +61 -0
  60. package/test/network-service.test.js +14 -0
  61. package/test/page-scripts.test.js +1045 -7
  62. package/test/position-audio.test.js +67 -0
  63. package/test/positioning.test.js +95 -1
  64. package/test/pulse-client.test.js +69 -0
  65. package/test/renderer-host.test.js +5 -2
  66. package/test/screenshot-cli.test.js +25 -1
  67. package/test/script-rewrite.test.js +111 -6
  68. package/test/setup.js +5 -0
  69. package/test/user-agent.test.js +60 -4
  70. package/vendor/happy-dom/packages/happy-dom/src/nodes/html-image-element/HTMLImageElement.ts +52 -22
  71. package/vendor/happy-dom/packages/happy-dom/src/query-selector/SelectorParser.ts +18 -17
  72. package/vendor/ses/dist/ses.mjs +52 -6
@@ -50,13 +50,21 @@ const VERTICAL_ALIGNS = new Set(["baseline", "top", "middle", "bottom", "text-to
50
50
  const AUTO_TRACK = Object.freeze({ min: "auto", max: "auto" });
51
51
  const FLEX_ALIGNMENT = Object.freeze({ "flex-start": "start", "flex-end": "end", left: "start", right: "end" });
52
52
  const GRADIENT = /^\s*(?:repeating-)?(?:linear|radial)-gradient\(/i;
53
+ // A background layer's image: a URL or a gradient.
54
+ const BACKGROUND_IMAGE = /^(?:url\(|(?:repeating-)?(?:linear|radial)-gradient\()/i;
55
+ const BACKGROUND_REPEATS = new Set(["repeat", "repeat-x", "repeat-y", "no-repeat", "space", "round"]);
56
+ const BACKGROUND_BOXES = new Set(["border-box", "padding-box", "content-box"]);
57
+ const BACKGROUND_ATTACHMENTS = new Set(["scroll", "fixed", "local"]);
53
58
  const BOX_SIDES = Object.freeze(["top", "right", "bottom", "left"]);
54
59
  const WHITE_SPACES = new Set(["normal", "nowrap", "pre", "pre-wrap"]);
55
60
  // CSS Cascade 4 §7.3 CSS-wide keywords; revert is treated as unset (no user/UA origin split).
56
61
  const CSS_WIDE_KEYWORDS = new Set(["inherit", "initial", "unset", "revert", "revert-layer"]);
57
62
  // Computed-style fields a shorthand (or a property not named after its field) covers.
58
63
  const PROPERTY_FIELDS = Object.freeze({
59
- background: ["backgroundColor", "backgroundImage"],
64
+ overflow: ["overflowX", "overflowY", "overflowScrollX", "overflowScrollY"],
65
+ "overflow-x": ["overflowX", "overflowScrollX"],
66
+ "overflow-y": ["overflowY", "overflowScrollY"],
67
+ background: ["backgroundColor", "backgroundImage", "backgroundPosition", "backgroundSize", "backgroundRepeat"],
60
68
  flex: ["flexGrow", "flexShrink", "flexBasis"],
61
69
  "flex-flow": ["flexDirection", "flexWrap"],
62
70
  gap: ["rowGap", "columnGap", "gap"],
@@ -89,6 +97,10 @@ const INITIAL = Object.freeze({
89
97
  colorScheme: "light",
90
98
  backgroundColor: "rgba(0, 0, 0, 0)",
91
99
  backgroundImage: null,
100
+ // Comma-separated, one entry per background-image layer (repeated as needed); null is the initial value.
101
+ backgroundPosition: null,
102
+ backgroundSize: null,
103
+ backgroundRepeat: null,
92
104
  backgroundClip: "border-box",
93
105
  boxShadow: null,
94
106
  fontFamily: Object.freeze(["sans-serif"]),
@@ -133,6 +145,8 @@ const INITIAL = Object.freeze({
133
145
  inset: Object.freeze(["auto", "auto", "auto", "auto"]),
134
146
  zIndex: "auto",
135
147
  transform: null,
148
+ // Raw text (default "50% 50%"); paint resolves it against the border box.
149
+ transformOrigin: null,
136
150
  justifyItems: "normal",
137
151
  justifySelf: "auto",
138
152
  gridTemplateColumns: null,
@@ -161,6 +175,10 @@ const INITIAL = Object.freeze({
161
175
  opacity: 1,
162
176
  overflowX: "visible",
163
177
  overflowY: "visible",
178
+ // Scroll containers (CSS Overflow 3 §3): "auto" for auto/scroll/overlay,
179
+ // which the user scrolls; "hidden", which only scripts scroll; else null.
180
+ overflowScrollX: null,
181
+ overflowScrollY: null,
164
182
  textOverflow: "clip",
165
183
  clip: null,
166
184
  clipPath: null,
@@ -187,6 +205,7 @@ const UA_DECLARATIONS = new Map(Object.entries({
187
205
  FIGURE: "margin: 1em 40px",
188
206
  PRE: "margin: 1em 0; font-family: monospace",
189
207
  CODE: "font-family: monospace",
208
+ IFRAME: "border: 2px inset",
190
209
  KBD: "font-family: monospace",
191
210
  SAMP: "font-family: monospace",
192
211
  TT: "font-family: monospace",
@@ -237,6 +256,8 @@ export function controlKind(element) {
237
256
  }
238
257
 
239
258
  /** HTML §15.3.4: :link (visited links are not tracked). */
259
+ // <iframe frameborder="0"> (or "no"): no border in place of the default one.
260
+ const UA_FRAMELESS_DECLARATIONS = parseDeclarations("border: none");
240
261
  const UA_LINK_DECLARATIONS = parseDeclarations("color: #0000EE; text-decoration: underline; cursor: pointer");
241
262
  const DECORATION_LINES = new Set(["underline", "overline", "line-through"]);
242
263
  const DECORATION_STYLES = new Set(["solid", "double", "dotted", "dashed", "wavy"]);
@@ -422,6 +443,11 @@ export class StyleEngine {
422
443
  return this.#styles.get(element) ?? INITIAL;
423
444
  }
424
445
 
446
+ /** An element's custom properties (--*), name to value text, inherited ones included. */
447
+ customPropertiesOf(element) {
448
+ return this.#customProperties.get(element) ?? EMPTY_MAP;
449
+ }
450
+
425
451
  /** The computed style of an element's ::before/::after box, or null when it generates none. */
426
452
  getPseudo(element, pseudo) {
427
453
  return this.#pseudoStyles.get(element)?.[pseudo] ?? null;
@@ -618,7 +644,9 @@ export function computeElementStyle(element, parentStyle = null, authorDeclarati
618
644
  const isLink = (element.tagName === "A" || element.tagName === "AREA") && element.hasAttribute("href");
619
645
  const control = controlKind(element);
620
646
  if (control === "hidden") values.display = "none";
621
- for (const declaration of [...(UA_DECLARATIONS.get(element.tagName)?.values() ?? []),
647
+ const frameless = element.tagName === "IFRAME" && ["0", "no"].includes(element.getAttribute("frameborder")?.trim().toLowerCase());
648
+ const uaDeclarations = frameless ? UA_FRAMELESS_DECLARATIONS : UA_DECLARATIONS.get(element.tagName);
649
+ for (const declaration of [...(uaDeclarations?.values() ?? []),
622
650
  ...(isLink ? UA_LINK_DECLARATIONS.values() : []), ...(UA_CONTROL_DECLARATIONS[control]?.values() ?? [])]) {
623
651
  if (!declarations.has(declaration.name)) {
624
652
  declarations.set(declaration.name, { ...declaration, specificity: -1, order: -1, layer: -Infinity });
@@ -1112,13 +1140,22 @@ function setProperty(style, name, value, parentStyle = null) {
1112
1140
  case "overflow":
1113
1141
  case "overflow-x":
1114
1142
  case "overflow-y": {
1115
- // Scrolling boxes are not implemented: every non-visible value clips.
1143
+ // Every non-visible value clips; all but clip also make a scroll container.
1116
1144
  const parts = value.split(/\s+/);
1117
1145
  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;
1146
+ const scrolls = (part) => (["auto", "scroll", "overlay"].includes(part) ? "auto" : part === "hidden" ? "hidden" : null);
1147
+ const xPart = parts[0];
1148
+ const yPart = name === "overflow" ? parts[1] ?? parts[0] : parts[0];
1149
+ const x = clips(xPart);
1150
+ const y = clips(yPart);
1151
+ if (name !== "overflow-y" && x) {
1152
+ style.overflowX = x;
1153
+ style.overflowScrollX = scrolls(xPart);
1154
+ }
1155
+ if (name !== "overflow-x" && y) {
1156
+ style.overflowY = y;
1157
+ style.overflowScrollY = scrolls(yPart);
1158
+ }
1122
1159
  break;
1123
1160
  }
1124
1161
  case "text-overflow":
@@ -1157,24 +1194,37 @@ function setProperty(style, name, value, parentStyle = null) {
1157
1194
  break;
1158
1195
  }
1159
1196
  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;
1197
+ // The shorthand resets the longhands; only the final layer may carry a color.
1198
+ const layers = splitTopLevel(value, ",").map(parseBackgroundLayer);
1199
+ if (layers.some((layer) => !layer)) break;
1200
+ style.backgroundColor = layers.at(-1).color
1201
+ ? resolvedColor(layers.at(-1).color, style.colorScheme) ?? INITIAL.backgroundColor
1202
+ : INITIAL.backgroundColor;
1203
+ const hasImage = layers.some((layer) => layer.image !== "none");
1204
+ style.backgroundImage = hasImage ? layers.map((layer) => layer.image).join(", ") : null;
1205
+ const list = (field, initial) => layers.some((layer) => layer[field] !== initial)
1206
+ ? layers.map((layer) => layer[field]).join(", ")
1207
+ : null;
1208
+ style.backgroundPosition = hasImage ? list("position", "0% 0%") : null;
1209
+ style.backgroundSize = hasImage ? list("size", "auto") : null;
1210
+ style.backgroundRepeat = hasImage ? list("repeat", "repeat") : null;
1168
1211
  break;
1169
1212
  }
1170
1213
  case "background-image": {
1171
1214
  if (value === "none") style.backgroundImage = null;
1172
1215
  else {
1173
- const images = splitTopLevel(value, ",").filter((layer) => GRADIENT.test(layer));
1174
- if (images.length) style.backgroundImage = images.join(", ");
1216
+ const images = splitTopLevel(value, ",").map((layer) => BACKGROUND_IMAGE.test(layer) || layer === "none" ? layer : null);
1217
+ if (images.every(Boolean) && images.some((layer) => layer !== "none")) style.backgroundImage = images.join(", ");
1175
1218
  }
1176
1219
  break;
1177
1220
  }
1221
+ case "background-position":
1222
+ case "background-size":
1223
+ case "background-repeat": {
1224
+ const field = { "background-position": "backgroundPosition", "background-size": "backgroundSize", "background-repeat": "backgroundRepeat" }[name];
1225
+ style[field] = value.toLowerCase().replace(/\s+/g, " ");
1226
+ break;
1227
+ }
1178
1228
  case "box-shadow": {
1179
1229
  const shadows = value === "none" ? null : parseBoxShadows(value, style.fontSize);
1180
1230
  if (value === "none" || shadows) style.boxShadow = shadows;
@@ -1229,10 +1279,13 @@ function setProperty(style, name, value, parentStyle = null) {
1229
1279
  break;
1230
1280
  }
1231
1281
  case "transform": {
1232
- const transform = value === "none" ? null : parseTranslate(value, style.fontSize);
1282
+ const transform = value === "none" ? null : parseTransform(value, style.fontSize);
1233
1283
  if (transform !== undefined) style.transform = transform;
1234
1284
  break;
1235
1285
  }
1286
+ case "transform-origin":
1287
+ style.transformOrigin = value.toLowerCase().replace(/\s+/g, " ");
1288
+ break;
1236
1289
  case "content": {
1237
1290
  const content = parseContent(value);
1238
1291
  if (content !== undefined) style.content = content;
@@ -1296,12 +1349,15 @@ function setProperty(style, name, value, parentStyle = null) {
1296
1349
  if (value === "normal") {
1297
1350
  style.lineHeight = null;
1298
1351
  style.lineHeightFactor = null;
1299
- } else if (Number.isFinite(numeric) && numeric > 0) {
1352
+ } else if (value !== "" && Number.isFinite(numeric) && numeric >= 0) {
1300
1353
  style.lineHeight = numeric * style.fontSize;
1301
1354
  style.lineHeightFactor = numeric;
1302
1355
  } else {
1303
- const length = computedLength(value, style.fontSize);
1304
- if (length > 0) {
1356
+ // Non-negative, so 0 is valid (icon buttons set line-height: 0); a
1357
+ // percentage computes to a length against this element's font size.
1358
+ const percentage = /^(\d+\.?\d*|\.\d+)%$/.exec(value);
1359
+ const length = percentage ? Number(percentage[1]) / 100 * style.fontSize : computedLength(value, style.fontSize);
1360
+ if (length >= 0) {
1305
1361
  style.lineHeight = length;
1306
1362
  style.lineHeightFactor = null;
1307
1363
  }
@@ -1878,6 +1934,32 @@ function pseudoElement(pseudo) {
1878
1934
  * percentages of the element's own border box). Other functions are ignored
1879
1935
  * until transforms are painted with matrices.
1880
1936
  */
1937
+ /**
1938
+ * The translation (layout moves the box) and scale (paint scales it about
1939
+ * transform-origin) of a transform list; other functions are ignored.
1940
+ */
1941
+ function parseTransform(value, fontSize) {
1942
+ const translation = parseTranslate(value, fontSize);
1943
+ if (translation === undefined) return undefined;
1944
+ let scaleX = 1;
1945
+ let scaleY = 1;
1946
+ let found = false;
1947
+ for (const match of value.matchAll(/\b(scale|scaleX|scaleY|scale3d)\(([^)]*)\)/gi)) {
1948
+ const args = splitTopLevel(match[2], ",").map((part) => part.endsWith("%") ? Number(part.slice(0, -1)) / 100 : Number(part));
1949
+ if (!args.length || args.some((part) => !Number.isFinite(part))) return undefined;
1950
+ const kind = match[1].toLowerCase();
1951
+ if (kind === "scalex") scaleX *= args[0];
1952
+ else if (kind === "scaley") scaleY *= args[0];
1953
+ else {
1954
+ scaleX *= args[0];
1955
+ scaleY *= args[1] ?? args[0];
1956
+ }
1957
+ found = true;
1958
+ }
1959
+ if (!found || (scaleX === 1 && scaleY === 1)) return translation;
1960
+ return Object.freeze({ x: translation?.x ?? 0, y: translation?.y ?? 0, scaleX, scaleY });
1961
+ }
1962
+
1881
1963
  function parseTranslate(value, fontSize) {
1882
1964
  let x = 0;
1883
1965
  let y = 0;
@@ -1916,7 +1998,7 @@ function parseContent(value) {
1916
1998
  * undefined property without a fallback leaves nothing (the declaration then
1917
1999
  * has no effect).
1918
2000
  */
1919
- function resolveVariables(value, customProperties, seen = new Set()) {
2001
+ export function resolveVariables(value, customProperties, seen = new Set()) {
1920
2002
  const text = String(value);
1921
2003
  // Most values use no custom properties; skip the parsing.
1922
2004
  if (!text.includes("var(")) return text.trim();
@@ -2317,6 +2399,64 @@ function resolvedColor(value, scheme) {
2317
2399
  return /^rgba\(/.test(color ?? "") ? color : null;
2318
2400
  }
2319
2401
 
2402
+ /**
2403
+ * One layer of the background shorthand, its parts as text: the image, the
2404
+ * position (before a slash) and size (after it), the repeat style and a color.
2405
+ * Null when a token fits none of them.
2406
+ */
2407
+ function parseBackgroundLayer(layer) {
2408
+ const tokens = [];
2409
+ let depth = 0;
2410
+ let token = "";
2411
+ for (const character of layer) {
2412
+ if (depth === 0 && (/\s/.test(character) || character === "/")) {
2413
+ if (token) tokens.push(token);
2414
+ if (character === "/") tokens.push("/");
2415
+ token = "";
2416
+ continue;
2417
+ }
2418
+ if (character === "(") depth++;
2419
+ else if (character === ")") depth--;
2420
+ token += character;
2421
+ }
2422
+ if (token) tokens.push(token);
2423
+ const result = { image: "none", position: "0% 0%", size: "auto", repeat: "repeat", color: null };
2424
+ const position = [];
2425
+ const size = [];
2426
+ const repeat = [];
2427
+ let afterSlash = false;
2428
+ for (const raw of tokens) {
2429
+ const lower = raw.toLowerCase();
2430
+ if (raw === "/") afterSlash = true;
2431
+ else if (BACKGROUND_IMAGE.test(raw)) result.image = raw;
2432
+ else if (lower === "none") result.image = "none";
2433
+ else if (BACKGROUND_REPEATS.has(lower)) repeat.push(lower);
2434
+ else if (BACKGROUND_BOXES.has(lower) || BACKGROUND_ATTACHMENTS.has(lower)) continue;
2435
+ else if (afterSlash && (lower === "cover" || lower === "contain" || lower === "auto" || /^-?[\d.]/.test(lower) || lower.startsWith("calc("))) size.push(lower);
2436
+ else if (["left", "right", "top", "bottom", "center"].includes(lower) || /^-?[\d.]/.test(lower) || lower.startsWith("calc(")) {
2437
+ afterSlash = false;
2438
+ position.push(lower);
2439
+ } else if (resolvedColor(raw, "light")) result.color = raw;
2440
+ else return null;
2441
+ }
2442
+ if (position.length) result.position = position.join(" ");
2443
+ if (size.length) result.size = size.join(" ");
2444
+ if (repeat.length) result.repeat = repeat.join(" ");
2445
+ return result;
2446
+ }
2447
+
2448
+ /** A style sheet with each relative url() made absolute against the sheet's own URL. */
2449
+ export function absolutizeStyleSheetUrls(text, base) {
2450
+ return text.replace(/url\(\s*(["']?)([^"')]*)\1\s*\)/gi, (whole, quote, url) => {
2451
+ if (!url || /^(?:[a-z][a-z\d+.-]*:|#)/i.test(url)) return whole;
2452
+ try {
2453
+ return `url(${quote}${new URL(url, base).href}${quote})`;
2454
+ } catch {
2455
+ return whole;
2456
+ }
2457
+ });
2458
+ }
2459
+
2320
2460
  function splitTopLevel(source, separator) {
2321
2461
  const parts = [];
2322
2462
  let depth = 0;
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.2",
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
@@ -0,0 +1,115 @@
1
+ diff --git a/packages/happy-dom/src/query-selector/SelectorParser.ts b/packages/happy-dom/src/query-selector/SelectorParser.ts
2
+ index 60cc951..62cf4b9 100644
3
+ --- a/packages/happy-dom/src/query-selector/SelectorParser.ts
4
+ +++ b/packages/happy-dom/src/query-selector/SelectorParser.ts
5
+ @@ -12,10 +12,11 @@ import type { TGlobalMatchFunction } from './TGlobalMatchFunction.js';
6
+ /**
7
+ * Selector group RegExp.
8
+ *
9
+ - * Group 1: Combinator (" ", ",", "+", ">", "̣~")
10
+ - * Group 2: Parentheses or brackets.
11
+ + * Group 1: An escaped character (e.g. "\:", "\31 "), skipped.
12
+ + * Group 2: Combinator (" ", ",", "+", ">", "̣~")
13
+ + * Group 3: Parentheses, brackets or quotes.
14
+ */
15
+ -const SELECTOR_GROUP_REGEXP = /(\s*[\s,+>~]\s*)|([\[\]\(\)"'])/g;
16
+ +const SELECTOR_GROUP_REGEXP = /(\\[0-9a-fA-F]{1,6}\s?|\\[\s\S])|(\s*[\s,+>~]\s*)|([\[\]\(\)"'])/g;
17
+
18
+ /**
19
+ * Selector RegExp.
20
+ @@ -45,7 +46,7 @@ const SELECTOR_GROUP_REGEXP = /(\s*[\s,+>~]\s*)|([\[\]\(\)"'])/g;
21
+ * Group 23: Pseudo element (e.g. "::after", "::-webkit-inner-spin-button").
22
+ */
23
+ const SELECTOR_REGEXP =
24
+ - /(\*)|([a-zA-Z0-9\u00A0-\uFFFF-]+)|#(([a-zA-Z0-9\u00A0-\uFFFF_-]|\\.)+)|\.(([a-zA-Z0-9\u00A0-\uFFFF_-]|\\.)+)|\[(([a-zA-Z0-9-_]|\\.)+)\]|\[(([a-zA-Z0-9-_]|\\.)+)\s*([~|^$*]{0,1})\s*=\s*("([^"]*)"|'([^']*)')\s*(s|i){0,1}\]|\[(([a-zA-Z0-9-_]|\\.)+)\s*([~|^$*]{0,1})\s*=\s*(([a-zA-Z0-9\u00A0-\uFFFF_¤£-]|\\.)+)\]|:([a-zA-Z-]+)\s*\(.+\)|:([a-zA-Z-]+)|::([a-zA-Z-]+)/g;
25
+ + /(\*)|((?:[a-zA-Z0-9\u00A0-\uFFFF-]|\\[0-9a-fA-F]{1,6}\s?|\\.)+)|#(([a-zA-Z0-9\u00A0-\uFFFF_-]|\\[0-9a-fA-F]{1,6}\s?|\\.)+)|\.(([a-zA-Z0-9\u00A0-\uFFFF_-]|\\[0-9a-fA-F]{1,6}\s?|\\.)+)|\[(([a-zA-Z0-9-_]|\\[0-9a-fA-F]{1,6}\s?|\\.)+)\]|\[(([a-zA-Z0-9-_]|\\[0-9a-fA-F]{1,6}\s?|\\.)+)\s*([~|^$*]{0,1})\s*=\s*("((?:[^"\\]|\\[\s\S])*)"|'((?:[^'\\]|\\[\s\S])*)')\s*(s|i){0,1}\]|\[(([a-zA-Z0-9-_]|\\[0-9a-fA-F]{1,6}\s?|\\.)+)\s*([~|^$*]{0,1})\s*=\s*(([a-zA-Z0-9\u00A0-\uFFFF_¤£-]|\\[0-9a-fA-F]{1,6}\s?|\\.)+)\]|:([a-zA-Z-]+)\s*\(.+\)|:([a-zA-Z-]+)|::([a-zA-Z-]+)/g;
26
+
27
+ /**
28
+ * Selector pseudo RegExp.
29
+ @@ -55,11 +56,6 @@ const SELECTOR_REGEXP =
30
+ */
31
+ const SELECTOR_PSEUDO_REGEXP = /:([a-zA-Z-]+)|([()])/g;
32
+
33
+ -/**
34
+ - * Escaped Character RegExp.
35
+ - */
36
+ -const ESCAPED_CHARACTER_REGEXP = /\\/g;
37
+ -
38
+ /**
39
+ * CSS Escape RegExp.
40
+ *
41
+ @@ -187,6 +183,11 @@ export default class SelectorParser {
42
+
43
+ while ((match = regExp.exec(selector))) {
44
+ if (match[1]) {
45
+ + // An escaped character (CSS Syntax §4.3.7) is part of the selector it is in,
46
+ + // never a combinator, bracket or quote.
47
+ + continue;
48
+ + }
49
+ + if (match[2]) {
50
+ // Matches combinator (" ", ",", "+", ">", "̣~")
51
+ // We should ignore combinators that are inside parentheses, brackets or apostrophes
52
+
53
+ @@ -198,7 +199,7 @@ export default class SelectorParser {
54
+ ) {
55
+ const childSelector = selector.substring(lastIndex, match.index).trim();
56
+
57
+ - switch (match[1].trim()) {
58
+ + switch (match[2].trim()) {
59
+ case ',':
60
+ selectorItem = this.getSelectorGroupItem(childSelector, combinator);
61
+ if (!selectorItem) {
62
+ @@ -262,7 +263,7 @@ export default class SelectorParser {
63
+ } else {
64
+ // Matches parentheses or brackets.
65
+
66
+ - switch (match[2]) {
67
+ + switch (match[3]) {
68
+ case '(':
69
+ if (depth.singleApostrophe === 0 && depth.doubleApostrophe === 0) {
70
+ depth.parentheses++;
71
+ @@ -365,22 +366,22 @@ export default class SelectorParser {
72
+ } else if (match[2]) {
73
+ // Matches tag name, e.g. "div"
74
+
75
+ - selectorItem.tagName = match[2].toUpperCase();
76
+ + selectorItem.tagName = SelectorParser.cssUnescape(match[2]).toUpperCase();
77
+ } else if (match[3]) {
78
+ // Matches ID, e.g. "#id"
79
+
80
+ - selectorItem.id = match[3].replace(ESCAPED_CHARACTER_REGEXP, '');
81
+ + selectorItem.id = SelectorParser.cssUnescape(match[3]);
82
+ } else if (match[5]) {
83
+ // Matches class names, e.g. ".class1"
84
+
85
+ selectorItem.classNames = selectorItem.classNames || [];
86
+ - selectorItem.classNames.push(match[5].replace(ESCAPED_CHARACTER_REGEXP, ''));
87
+ + selectorItem.classNames.push(SelectorParser.cssUnescape(match[5]));
88
+ } else if (match[7]) {
89
+ // Matches attributes without value, e.g. [attr]
90
+
91
+ selectorItem.attributes = selectorItem.attributes || [];
92
+ selectorItem.attributes.push({
93
+ - name: match[7].replace(ESCAPED_CHARACTER_REGEXP, ''),
94
+ + name: SelectorParser.cssUnescape(match[7]),
95
+ operator: null,
96
+ value: null,
97
+ modifier: null,
98
+ @@ -393,7 +394,7 @@ export default class SelectorParser {
99
+ const unescapedValue = SelectorParser.cssUnescape(value);
100
+ selectorItem.attributes = selectorItem.attributes || [];
101
+ selectorItem.attributes.push({
102
+ - name: match[9].replace(ESCAPED_CHARACTER_REGEXP, ''),
103
+ + name: SelectorParser.cssUnescape(match[9]),
104
+ operator: match[11] || null,
105
+ value: unescapedValue,
106
+ modifier: <'s'>match[15] || null,
107
+ @@ -409,7 +410,7 @@ export default class SelectorParser {
108
+ const unescapedValue = SelectorParser.cssUnescape(match[19]);
109
+ selectorItem.attributes = selectorItem.attributes || [];
110
+ selectorItem.attributes.push({
111
+ - name: match[16].replace(ESCAPED_CHARACTER_REGEXP, ''),
112
+ + name: SelectorParser.cssUnescape(match[16]),
113
+ operator: match[18] || null,
114
+ value: unescapedValue,
115
+ modifier: null,