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.
Files changed (61) hide show
  1. package/AGENTS.md +17 -0
  2. package/LICENSES/brotli.txt +19 -0
  3. package/README.md +41 -7
  4. package/THIRD_PARTY_NOTICES.md +30 -6
  5. package/buninu-browser.js +10 -2
  6. package/lib/audio/pcm.js +16 -0
  7. package/lib/audio/piano.js +109 -0
  8. package/lib/audio/pulse-client.js +413 -0
  9. package/lib/cdp-server.js +25 -6
  10. package/lib/document-types.js +308 -0
  11. package/lib/download-directory.js +28 -0
  12. package/lib/download.js +63 -0
  13. package/lib/headless-shell.js +108 -14
  14. package/lib/input/hit-test.js +57 -8
  15. package/lib/layout/text-layout.js +112 -27
  16. package/lib/network/network-service.js +7 -3
  17. package/lib/network/resolver.js +16 -0
  18. package/lib/paint/display-list.js +2 -2
  19. package/lib/render-tree/index.js +13 -0
  20. package/lib/renderer/bindings.js +541 -53
  21. package/lib/renderer/flags.js +8 -5
  22. package/lib/renderer/host.js +110 -9
  23. package/lib/renderer/main.js +35 -3
  24. package/lib/renderer/module-transform.js +30 -23
  25. package/lib/renderer/page-realm.js +226 -22
  26. package/lib/renderer/page-renderer.js +1293 -212
  27. package/lib/screenshot-cli.js +61 -0
  28. package/lib/style/computed-style.js +268 -20
  29. package/lib/user-agent.js +12 -0
  30. package/package.json +1 -1
  31. package/patches/happy-dom/0003-defer-script-events-to-renderer.patch +40 -0
  32. package/patches/happy-dom/0004-insert-adjacent-html-in-order.patch +19 -0
  33. package/patches/happy-dom/0005-invalidate-matches-on-structure-changes.patch +43 -0
  34. package/patches/happy-dom/README.md +12 -0
  35. package/patches/ses/0001-allow-regexp-own-tostring.patch +13 -0
  36. package/patches/ses/README.md +16 -0
  37. package/patches/wasmpeg/0001-windows-wasm-path.patch +14 -0
  38. package/patches/wasmpeg/README.md +28 -0
  39. package/test/computed-style.test.js +52 -0
  40. package/test/document-types.test.js +111 -0
  41. package/test/download.test.js +192 -0
  42. package/test/fixtures/tone.mp3 +0 -0
  43. package/test/flex.test.js +11 -0
  44. package/test/happy-dom-matches.test.js +25 -0
  45. package/test/headless-shell.test.js +27 -0
  46. package/test/hit-test.test.js +37 -0
  47. package/test/page-scripts.test.js +388 -29
  48. package/test/piano.test.js +65 -0
  49. package/test/positioning.test.js +11 -0
  50. package/test/pulse-client.test.js +130 -0
  51. package/test/render-tree.test.js +14 -0
  52. package/test/resolver.test.js +12 -1
  53. package/test/screenshot-cli.test.js +19 -0
  54. package/test/text-layout.test.js +22 -0
  55. package/test/user-agent.test.js +42 -0
  56. package/vendor/happy-dom/packages/happy-dom/src/nodes/element/Element.ts +3 -6
  57. package/vendor/happy-dom/packages/happy-dom/src/nodes/html-script-element/HTMLScriptElement.ts +2 -20
  58. package/vendor/happy-dom/packages/happy-dom/src/query-selector/QuerySelector.ts +15 -3
  59. package/vendor/ses/dist/ses.mjs +2 -0
  60. package/vendor/wasmpeg/VENDORING.md +7 -3
  61. package/vendor/wasmpeg/src/js/gpu.js +2 -1
@@ -22,8 +22,17 @@ const REFLECTED_PROPERTIES = [
22
22
  export class BindingRealm {
23
23
  #wrappers = new WeakMap();
24
24
  #listeners = new WeakMap();
25
+ #events = new WeakMap();
26
+ #liveViews = new WeakMap();
25
27
  #hooks;
26
28
  #window;
29
+ eventPrototype = null;
30
+ elementPrototype = null;
31
+ fragmentPrototype = null;
32
+ shadowRootPrototype = null;
33
+ textPrototype = null;
34
+ characterDataPrototype = null;
35
+ nodePrototype = null;
27
36
 
28
37
  /**
29
38
  * @param {object} window the Happy DOM window of the document
@@ -57,21 +66,86 @@ export class BindingRealm {
57
66
  if (!wrapper) {
58
67
  const Binding = node.nodeType === 1 ? ElementBinding
59
68
  : node.nodeType === 9 ? DocumentBinding
60
- : node.nodeType === 11 ? FragmentBinding
69
+ : node.nodeType === 11 ? (node instanceof this.#window.ShadowRoot ? ShadowRootBinding : FragmentBinding)
61
70
  : node.nodeType === 3 ? TextBinding
62
- : NodeBinding;
71
+ : node.nodeType === 8 ? CharacterDataBinding
72
+ : NodeBinding;
63
73
  wrapper = new Binding(CONSTRUCT, node, this);
74
+ if (Binding === ElementBinding && this.elementPrototype) Object.setPrototypeOf(wrapper, this.elementPrototype);
75
+ if (Binding === FragmentBinding && this.fragmentPrototype) Object.setPrototypeOf(wrapper, this.fragmentPrototype);
76
+ if (Binding === ShadowRootBinding && this.shadowRootPrototype) Object.setPrototypeOf(wrapper, this.shadowRootPrototype);
77
+ if (Binding === TextBinding && this.textPrototype) Object.setPrototypeOf(wrapper, this.textPrototype);
78
+ if (Binding === CharacterDataBinding && this.characterDataPrototype) Object.setPrototypeOf(wrapper, this.characterDataPrototype);
79
+ if (Binding === NodeBinding && this.nodePrototype) Object.setPrototypeOf(wrapper, this.nodePrototype);
64
80
  this.#wrappers.set(node, wrapper);
65
81
  }
66
82
  return wrapper;
67
83
  }
68
84
 
85
+ /**
86
+ * A live NodeList/HTMLCollection over a Happy DOM collection (DOM §4.2.10):
87
+ * length and indices read through on every access, so a loop that moves
88
+ * nodes out (`while (list.length) fragment.appendChild(list[0])`) ends as
89
+ * it does in browsers. It is an Array proxy so array-style use keeps
90
+ * working; one view per collection keeps `el.childNodes === el.childNodes`.
91
+ */
92
+ live(collection) {
93
+ if (!collection) return null;
94
+ let view = this.#liveViews.get(collection);
95
+ if (view) return view;
96
+ const realm = this;
97
+ const at = (index) => index < collection.length ? realm.wrap(collection[index] ?? collection.item?.(index)) : undefined;
98
+ const isIndex = (property) => typeof property === "string" && /^(?:0|[1-9]\d*)$/.test(property);
99
+ const methods = {
100
+ item: (index) => at(Number(index) >>> 0) ?? null,
101
+ namedItem: (name) => {
102
+ const key = String(name);
103
+ for (let index = 0; index < collection.length; index++) {
104
+ const node = collection[index];
105
+ if (node?.getAttribute?.("id") === key || node?.getAttribute?.("name") === key) return realm.wrap(node);
106
+ }
107
+ return null;
108
+ },
109
+ };
110
+ view = new Proxy([], {
111
+ get(target, property, receiver) {
112
+ if (property === "length") return collection.length;
113
+ if (isIndex(property)) return at(Number(property));
114
+ if (Object.hasOwn(methods, property)) return methods[property];
115
+ return Reflect.get(target, property, receiver);
116
+ },
117
+ has: (target, property) => property === "length" || Object.hasOwn(methods, property)
118
+ || (isIndex(property) ? Number(property) < collection.length : Reflect.has(target, property)),
119
+ getOwnPropertyDescriptor(target, property) {
120
+ if (property === "length") return { value: collection.length, writable: true, enumerable: false, configurable: false };
121
+ if (isIndex(property) && Number(property) < collection.length) {
122
+ return { value: at(Number(property)), writable: false, enumerable: true, configurable: true };
123
+ }
124
+ return undefined;
125
+ },
126
+ ownKeys: () => [...Array.from({ length: collection.length }, (_, index) => String(index)), "length"],
127
+ set: () => false,
128
+ defineProperty: () => false,
129
+ deleteProperty: () => false,
130
+ });
131
+ this.#liveViews.set(collection, view);
132
+ return view;
133
+ }
134
+
69
135
  wrapAll(nodes) {
70
136
  return Array.from(nodes ?? [], (node) => this.wrap(node));
71
137
  }
72
138
 
73
- wrapEvent(event) {
74
- return event ? new EventBinding(CONSTRUCT, event, this) : null;
139
+ /** One wrapper per event, so listeners see the dispatched object (and its detail). */
140
+ wrapEvent(event, detail = undefined) {
141
+ if (!event) return null;
142
+ let wrapper = this.#events.get(event);
143
+ if (!wrapper) {
144
+ wrapper = new EventBinding(CONSTRUCT, event, this, detail);
145
+ if (this.eventPrototype) Object.setPrototypeOf(wrapper, this.eventPrototype);
146
+ this.#events.set(event, wrapper);
147
+ }
148
+ return wrapper;
75
149
  }
76
150
 
77
151
  /** Host listener registered on the real target for a page listener. */
@@ -90,6 +164,19 @@ export function nodeOf(value) {
90
164
  return NodeBinding.unwrap(value);
91
165
  }
92
166
 
167
+ /** Writable page-local facade over a frozen engine binding prototype. */
168
+ export function mutableBindingPrototype(base, parent = base) {
169
+ const prototype = Object.create(parent);
170
+ for (const key of Reflect.ownKeys(base)) {
171
+ if (key === "constructor") continue;
172
+ const descriptor = Object.getOwnPropertyDescriptor(base, key);
173
+ descriptor.configurable = true;
174
+ if ("value" in descriptor) descriptor.writable = true;
175
+ Object.defineProperty(prototype, key, descriptor);
176
+ }
177
+ return prototype;
178
+ }
179
+
93
180
  function realmOf(wrapper) {
94
181
  return NodeBinding.realmOf(wrapper);
95
182
  }
@@ -101,8 +188,9 @@ function toNode(wrapper, value) {
101
188
  return realmOf(wrapper).window.document.createTextNode(String(value));
102
189
  }
103
190
 
191
+ /** The page changed `wrapper`'s node (state the style engine may read: attributes, checked, value). */
104
192
  function touch(wrapper) {
105
- realmOf(wrapper).hooks.touch();
193
+ realmOf(wrapper).hooks.touch(NodeBinding.unwrap(wrapper));
106
194
  }
107
195
 
108
196
  /**
@@ -111,7 +199,7 @@ function touch(wrapper) {
111
199
  */
112
200
  function inserted(wrapper, nodes) {
113
201
  const realm = realmOf(wrapper);
114
- realm.hooks.touch();
202
+ realm.hooks.touch(NodeBinding.unwrap(wrapper));
115
203
  realm.hooks.inserted?.(nodes);
116
204
  }
117
205
 
@@ -190,7 +278,7 @@ class NodeBinding {
190
278
  }
191
279
 
192
280
  get childNodes() {
193
- return this.#realm.wrapAll(this.#node.childNodes);
281
+ return this.#realm.live(this.#node.childNodes);
194
282
  }
195
283
 
196
284
  get firstChild() {
@@ -271,9 +359,7 @@ class NodeBinding {
271
359
  dispatchEvent(event) {
272
360
  const real = EventBinding.unwrap(event);
273
361
  if (!real) throw new TypeError("dispatchEvent requires an Event");
274
- const result = this.#node.dispatchEvent(real);
275
- touch(this);
276
- return result;
362
+ return this.#node.dispatchEvent(real);
277
363
  }
278
364
  }
279
365
 
@@ -283,7 +369,8 @@ function requireNode(value) {
283
369
  return node;
284
370
  }
285
371
 
286
- class TextBinding extends NodeBinding {
372
+ /** Text and comments; frameworks read comment data (React's hydration markers). */
373
+ class CharacterDataBinding extends NodeBinding {
287
374
  get data() {
288
375
  return nodeOf(this).data;
289
376
  }
@@ -298,10 +385,12 @@ class TextBinding extends NodeBinding {
298
385
  }
299
386
  }
300
387
 
388
+ class TextBinding extends CharacterDataBinding {}
389
+
301
390
  /** Methods shared by elements, documents and fragments (ParentNode). */
302
391
  class ParentBinding extends NodeBinding {
303
392
  get children() {
304
- return realmOf(this).wrapAll(nodeOf(this).children);
393
+ return realmOf(this).live(nodeOf(this).children);
305
394
  }
306
395
 
307
396
  get firstElementChild() {
@@ -325,11 +414,11 @@ class ParentBinding extends NodeBinding {
325
414
  }
326
415
 
327
416
  getElementsByTagName(name) {
328
- return realmOf(this).wrapAll(nodeOf(this).getElementsByTagName(String(name)));
417
+ return realmOf(this).live(nodeOf(this).getElementsByTagName(String(name)));
329
418
  }
330
419
 
331
420
  getElementsByClassName(names) {
332
- return realmOf(this).wrapAll(nodeOf(this).getElementsByClassName(String(names)));
421
+ return realmOf(this).live(nodeOf(this).getElementsByClassName(String(names)));
333
422
  }
334
423
 
335
424
  append(...values) {
@@ -360,6 +449,29 @@ class FragmentBinding extends ParentBinding {
360
449
  }
361
450
  }
362
451
 
452
+ /**
453
+ * Shadow roots work for scripts (custom elements build into them), but the
454
+ * renderer does not draw shadow trees yet: the host's light DOM is shown.
455
+ */
456
+ class ShadowRootBinding extends FragmentBinding {
457
+ get host() {
458
+ return realmOf(this).wrap(nodeOf(this).host);
459
+ }
460
+
461
+ get mode() {
462
+ return nodeOf(this).mode;
463
+ }
464
+
465
+ get innerHTML() {
466
+ return nodeOf(this).innerHTML;
467
+ }
468
+
469
+ set innerHTML(value) {
470
+ nodeOf(this).innerHTML = String(value);
471
+ touch(this);
472
+ }
473
+ }
474
+
363
475
  class ElementBinding extends ParentBinding {
364
476
  #style = null;
365
477
  #classList = null;
@@ -369,29 +481,51 @@ class ElementBinding extends ParentBinding {
369
481
  return nodeOf(this).tagName;
370
482
  }
371
483
 
484
+ attachShadow(init) {
485
+ const mode = init?.mode === "closed" ? "closed" : "open";
486
+ return realmOf(this).wrap(nodeOf(this).attachShadow({ mode }));
487
+ }
488
+
489
+ get shadowRoot() {
490
+ return realmOf(this).wrap(nodeOf(this).shadowRoot);
491
+ }
492
+
372
493
  get content() {
373
494
  const content = nodeOf(this).content;
374
495
  return content === undefined ? undefined : realmOf(this).wrap(content);
375
496
  }
376
497
 
498
+ // HTMLMediaElement (video and audio): the subset the renderer plays.
377
499
  get paused() {
378
- return this.tagName === "VIDEO" ? realmOf(this).hooks.videoState?.(nodeOf(this)).paused : undefined;
500
+ return isMedia(this) ? realmOf(this).hooks.mediaState?.(nodeOf(this)).paused ?? true : undefined;
501
+ }
502
+
503
+ get ended() {
504
+ return isMedia(this) ? realmOf(this).hooks.mediaState?.(nodeOf(this)).ended ?? false : undefined;
379
505
  }
380
506
 
381
507
  get currentTime() {
382
- return this.tagName === "VIDEO" ? realmOf(this).hooks.videoState?.(nodeOf(this)).currentTime : undefined;
508
+ return isMedia(this) ? realmOf(this).hooks.mediaState?.(nodeOf(this)).currentTime ?? 0 : undefined;
509
+ }
510
+
511
+ set currentTime(value) {
512
+ if (!isMedia(this)) return;
513
+ const seconds = Number(value);
514
+ if (!Number.isFinite(seconds)) throw new TypeError("The provided double value is non-finite.");
515
+ void realmOf(this).hooks.seekMedia?.(nodeOf(this), seconds);
383
516
  }
384
517
 
385
518
  get duration() {
386
- return this.tagName === "VIDEO" ? realmOf(this).hooks.videoState?.(nodeOf(this)).duration : undefined;
519
+ return isMedia(this) ? realmOf(this).hooks.mediaState?.(nodeOf(this)).duration ?? NaN : undefined;
387
520
  }
388
521
 
389
522
  play() {
390
- if (this.tagName === "VIDEO") return realmOf(this).hooks.playVideo?.(nodeOf(this));
523
+ if (!isMedia(this)) return undefined;
524
+ return Promise.resolve(realmOf(this).hooks.playMedia?.(nodeOf(this))).then(() => undefined);
391
525
  }
392
526
 
393
527
  pause() {
394
- if (this.tagName === "VIDEO") realmOf(this).hooks.pauseVideo?.(nodeOf(this));
528
+ if (isMedia(this)) realmOf(this).hooks.pauseMedia?.(nodeOf(this));
395
529
  }
396
530
 
397
531
  get localName() {
@@ -403,8 +537,12 @@ class ElementBinding extends ParentBinding {
403
537
  }
404
538
 
405
539
  set id(value) {
406
- nodeOf(this).id = String(value);
407
- touch(this);
540
+ const node = nodeOf(this);
541
+ const next = String(value);
542
+ if (node.id !== next) {
543
+ node.id = next;
544
+ touch(this);
545
+ }
408
546
  }
409
547
 
410
548
  get className() {
@@ -412,8 +550,12 @@ class ElementBinding extends ParentBinding {
412
550
  }
413
551
 
414
552
  set className(value) {
415
- nodeOf(this).className = String(value);
416
- touch(this);
553
+ const node = nodeOf(this);
554
+ const next = String(value);
555
+ if (node.className !== next) {
556
+ node.className = next;
557
+ touch(this);
558
+ }
417
559
  }
418
560
 
419
561
  get classList() {
@@ -435,14 +577,46 @@ class ElementBinding extends ParentBinding {
435
577
  return nodeOf(this).getAttribute(String(name));
436
578
  }
437
579
 
580
+ getAttributeNode(name) {
581
+ const element = nodeOf(this);
582
+ const attribute = element.getAttributeNode(String(name));
583
+ if (!attribute) return null;
584
+ const ownerElement = this;
585
+ return {
586
+ get name() { return attribute.name; },
587
+ get localName() { return attribute.localName; },
588
+ get namespaceURI() { return attribute.namespaceURI; },
589
+ get prefix() { return attribute.prefix; },
590
+ get value() { return attribute.value; },
591
+ set value(value) { attribute.value = String(value); touch(ownerElement); },
592
+ get nodeName() { return attribute.name; },
593
+ get nodeValue() { return attribute.value; },
594
+ set nodeValue(value) { attribute.value = String(value ?? ""); touch(ownerElement); },
595
+ get textContent() { return attribute.value; },
596
+ set textContent(value) { attribute.value = String(value ?? ""); touch(ownerElement); },
597
+ ownerElement,
598
+ specified: true,
599
+ nodeType: 2,
600
+ };
601
+ }
602
+
438
603
  setAttribute(name, value) {
439
- nodeOf(this).setAttribute(String(name), String(value));
440
- touch(this);
604
+ const node = nodeOf(this);
605
+ const attribute = String(name);
606
+ const next = String(value);
607
+ if (node.getAttribute(attribute) !== next) {
608
+ node.setAttribute(attribute, next);
609
+ touch(this);
610
+ }
441
611
  }
442
612
 
443
613
  removeAttribute(name) {
444
- nodeOf(this).removeAttribute(String(name));
445
- touch(this);
614
+ const node = nodeOf(this);
615
+ const attribute = String(name);
616
+ if (node.hasAttribute(attribute)) {
617
+ node.removeAttribute(attribute);
618
+ touch(this);
619
+ }
446
620
  }
447
621
 
448
622
  hasAttribute(name) {
@@ -543,8 +717,32 @@ class ElementBinding extends ParentBinding {
543
717
 
544
718
  click() {
545
719
  const realm = realmOf(this);
546
- nodeOf(this).dispatchEvent(new realm.window.MouseEvent("click", { bubbles: true, cancelable: true }));
547
- touch(this);
720
+ const element = nodeOf(this);
721
+ const event = new realm.window.MouseEvent("click", { bubbles: true, cancelable: true });
722
+ element.dispatchEvent(event);
723
+ if (!event.defaultPrevented) {
724
+ const anchor = element.closest?.("a[href]");
725
+ if (anchor) realm.hooks.activateLink?.(anchor);
726
+ }
727
+ }
728
+
729
+ submit() {
730
+ const form = nodeOf(this);
731
+ if (form.tagName === "FORM") realmOf(this).hooks.submitForm?.(form);
732
+ }
733
+
734
+ requestSubmit(submitter) {
735
+ const realm = realmOf(this);
736
+ const form = nodeOf(this);
737
+ if (form.tagName !== "FORM") return;
738
+ const realSubmitter = submitter == null ? null : nodeOf(submitter);
739
+ if (realSubmitter && realSubmitter.form !== form) throw new DOMException("Submitter is not owned by this form", "NotFoundError");
740
+ if (!form.checkValidity()) return;
741
+ const event = new realm.window.SubmitEvent("submit", {
742
+ bubbles: true, cancelable: true, submitter: realSubmitter ?? form,
743
+ });
744
+ form.dispatchEvent(event);
745
+ if (!event.defaultPrevented) realm.hooks.submitForm?.(form, realSubmitter);
548
746
  }
549
747
 
550
748
  focus() {
@@ -582,12 +780,15 @@ class ElementBinding extends ParentBinding {
582
780
  return Math.round(this.getBoundingClientRect().top + viewport().scrollY);
583
781
  }
584
782
 
783
+ // The root element's client size is the viewport (CSSOM View §6).
585
784
  get clientWidth() {
586
- return this.offsetWidth;
785
+ const root = nodeOf(this) === nodeOf(this).ownerDocument?.documentElement;
786
+ return root ? realmOf(this).hooks.viewport().width : this.offsetWidth;
587
787
  }
588
788
 
589
789
  get clientHeight() {
590
- return this.offsetHeight;
790
+ const root = nodeOf(this) === nodeOf(this).ownerDocument?.documentElement;
791
+ return root ? realmOf(this).hooks.viewport().height : this.offsetHeight;
591
792
  }
592
793
 
593
794
  // Tables and selects (undefined on other elements, as in browsers).
@@ -664,6 +865,10 @@ class ElementBinding extends ParentBinding {
664
865
  }
665
866
  }
666
867
 
868
+ function isMedia(wrapper) {
869
+ return wrapper.tagName === "VIDEO" || wrapper.tagName === "AUDIO";
870
+ }
871
+
667
872
  /** on* event handler properties (element.onclick = fn, script.onload = fn). */
668
873
  export const EVENT_HANDLER_NAMES = Object.freeze([
669
874
  "click", "dblclick", "mousedown", "mouseup", "mousemove", "mouseover", "mouseout", "mouseenter", "mouseleave",
@@ -726,6 +931,26 @@ for (const property of REFLECTED_PROPERTIES) {
726
931
  });
727
932
  }
728
933
 
934
+ // HTMLHyperlinkElementUtils: anchors and areas expose their parsed URL, not
935
+ // only the reflected href string. Happy DOM owns resolution against baseURI;
936
+ // bindings forward the primitive components without exposing the host node.
937
+ for (const property of ["origin", "protocol", "username", "password", "host", "hostname", "port", "pathname", "search", "hash"]) {
938
+ Object.defineProperty(ElementBinding.prototype, property, {
939
+ configurable: true,
940
+ enumerable: true,
941
+ get() {
942
+ const node = nodeOf(this);
943
+ return node.tagName === "A" || node.tagName === "AREA" ? String(node[property] ?? "") : undefined;
944
+ },
945
+ set(value) {
946
+ const node = nodeOf(this);
947
+ if (node.tagName !== "A" && node.tagName !== "AREA") return;
948
+ node[property] = String(value);
949
+ touch(this);
950
+ },
951
+ });
952
+ }
953
+
729
954
  class DocumentBinding extends ParentBinding {
730
955
  get documentElement() {
731
956
  return realmOf(this).wrap(nodeOf(this).documentElement);
@@ -736,7 +961,8 @@ class DocumentBinding extends ParentBinding {
736
961
  }
737
962
 
738
963
  get body() {
739
- return realmOf(this).wrap(nodeOf(this).body);
964
+ const realm = realmOf(this);
965
+ return realm.wrap(realm.hooks.body ? realm.hooks.body() : nodeOf(this).body);
740
966
  }
741
967
 
742
968
  get title() {
@@ -751,6 +977,15 @@ class DocumentBinding extends ParentBinding {
751
977
  return realmOf(this).hooks.readyState?.() ?? "complete";
752
978
  }
753
979
 
980
+ // Page Visibility: the renderer only runs the page its client is showing.
981
+ get visibilityState() {
982
+ return "visible";
983
+ }
984
+
985
+ get hidden() {
986
+ return false;
987
+ }
988
+
754
989
  get URL() {
755
990
  return nodeOf(this).URL;
756
991
  }
@@ -759,6 +994,14 @@ class DocumentBinding extends ParentBinding {
759
994
  return nodeOf(this).URL;
760
995
  }
761
996
 
997
+ get location() {
998
+ return realmOf(this).hooks.pageGlobal?.()?.location ?? null;
999
+ }
1000
+
1001
+ get domain() {
1002
+ return new URL(nodeOf(this).URL).hostname;
1003
+ }
1004
+
762
1005
  get characterSet() {
763
1006
  return "UTF-8";
764
1007
  }
@@ -832,6 +1075,10 @@ class DocumentBinding extends ParentBinding {
832
1075
  return realmOf(this).wrap(nodeOf(this).createComment(String(text)));
833
1076
  }
834
1077
 
1078
+ createTreeWalker(root, whatToShow = NodeFilter.SHOW_ALL, filter = null) {
1079
+ return new TreeWalkerBinding(CONSTRUCT, realmOf(this), requireNode(root), whatToShow, filter);
1080
+ }
1081
+
835
1082
  createDocumentFragment() {
836
1083
  return realmOf(this).wrap(nodeOf(this).createDocumentFragment());
837
1084
  }
@@ -909,6 +1156,22 @@ class EventBinding {
909
1156
  return this.#detail ?? null;
910
1157
  }
911
1158
 
1159
+ // PopStateEvent and HashChangeEvent fields; the engine only fires popstate
1160
+ // with a null state, so no engine object reaches the page.
1161
+ get state() {
1162
+ if (!("state" in this.#event)) return undefined;
1163
+ const state = this.#event.state;
1164
+ return state !== null && typeof state === "object" ? null : state ?? null;
1165
+ }
1166
+
1167
+ get oldURL() {
1168
+ return typeof this.#event.oldURL === "string" ? this.#event.oldURL : undefined;
1169
+ }
1170
+
1171
+ get newURL() {
1172
+ return typeof this.#event.newURL === "string" ? this.#event.newURL : undefined;
1173
+ }
1174
+
912
1175
  get key() {
913
1176
  return this.#event.key;
914
1177
  }
@@ -1002,6 +1265,159 @@ class EventBinding {
1002
1265
  }
1003
1266
  }
1004
1267
 
1268
+ export const NodeFilter = Object.freeze({
1269
+ FILTER_ACCEPT: 1, FILTER_REJECT: 2, FILTER_SKIP: 3,
1270
+ SHOW_ALL: 0xffffffff, SHOW_ELEMENT: 0x1, SHOW_ATTRIBUTE: 0x2, SHOW_TEXT: 0x4, SHOW_CDATA_SECTION: 0x8,
1271
+ SHOW_PROCESSING_INSTRUCTION: 0x40, SHOW_COMMENT: 0x80, SHOW_DOCUMENT: 0x100, SHOW_DOCUMENT_TYPE: 0x200,
1272
+ SHOW_DOCUMENT_FRAGMENT: 0x400,
1273
+ });
1274
+ const { FILTER_ACCEPT, FILTER_REJECT, FILTER_SKIP } = NodeFilter;
1275
+
1276
+ /**
1277
+ * DOM §6.2 TreeWalker over the real nodes. The page's filter (a function or
1278
+ * an object with acceptNode) sees node wrappers, and its exceptions reach the
1279
+ * caller as the spec says.
1280
+ */
1281
+ class TreeWalkerBinding {
1282
+ #realm;
1283
+ #root;
1284
+ #current;
1285
+ #whatToShow;
1286
+ #filter;
1287
+
1288
+ constructor(key, realm, root, whatToShow, filter) {
1289
+ if (key !== CONSTRUCT) throw new TypeError("Illegal constructor");
1290
+ this.#realm = realm;
1291
+ this.#root = root;
1292
+ this.#current = root;
1293
+ this.#whatToShow = Number(whatToShow) >>> 0;
1294
+ this.#filter = filter ?? null;
1295
+ }
1296
+
1297
+ get root() { return this.#realm.wrap(this.#root); }
1298
+ get whatToShow() { return this.#whatToShow; }
1299
+ get filter() { return this.#filter; }
1300
+ get currentNode() { return this.#realm.wrap(this.#current); }
1301
+ set currentNode(value) { this.#current = requireNode(value); }
1302
+
1303
+ #accept(node) {
1304
+ if (!(this.#whatToShow & (1 << (node.nodeType - 1)))) return FILTER_SKIP;
1305
+ const filter = this.#filter;
1306
+ if (filter === null) return FILTER_ACCEPT;
1307
+ const wrapper = this.#realm.wrap(node);
1308
+ const result = typeof filter === "function"
1309
+ ? Reflect.apply(filter, undefined, [wrapper])
1310
+ : Reflect.apply(filter.acceptNode, filter, [wrapper]);
1311
+ return Number(result);
1312
+ }
1313
+
1314
+ #move(node) {
1315
+ this.#current = node;
1316
+ return this.#realm.wrap(node);
1317
+ }
1318
+
1319
+ parentNode() {
1320
+ let node = this.#current;
1321
+ while (node && node !== this.#root) {
1322
+ node = node.parentNode;
1323
+ if (node && this.#accept(node) === FILTER_ACCEPT) return this.#move(node);
1324
+ }
1325
+ return null;
1326
+ }
1327
+
1328
+ firstChild() { return this.#children(true); }
1329
+ lastChild() { return this.#children(false); }
1330
+ nextSibling() { return this.#siblings(true); }
1331
+ previousSibling() { return this.#siblings(false); }
1332
+
1333
+ #children(first) {
1334
+ let node = first ? this.#current.firstChild : this.#current.lastChild;
1335
+ while (node) {
1336
+ const result = this.#accept(node);
1337
+ if (result === FILTER_ACCEPT) return this.#move(node);
1338
+ if (result === FILTER_SKIP) {
1339
+ const child = first ? node.firstChild : node.lastChild;
1340
+ if (child) {
1341
+ node = child;
1342
+ continue;
1343
+ }
1344
+ }
1345
+ while (node) {
1346
+ const sibling = first ? node.nextSibling : node.previousSibling;
1347
+ if (sibling) {
1348
+ node = sibling;
1349
+ break;
1350
+ }
1351
+ const parent = node.parentNode;
1352
+ if (!parent || parent === this.#root || parent === this.#current) return null;
1353
+ node = parent;
1354
+ }
1355
+ }
1356
+ return null;
1357
+ }
1358
+
1359
+ #siblings(next) {
1360
+ let node = this.#current;
1361
+ if (node === this.#root) return null;
1362
+ for (;;) {
1363
+ let sibling = next ? node.nextSibling : node.previousSibling;
1364
+ while (sibling) {
1365
+ node = sibling;
1366
+ const result = this.#accept(node);
1367
+ if (result === FILTER_ACCEPT) return this.#move(node);
1368
+ sibling = next ? node.firstChild : node.lastChild;
1369
+ if (result === FILTER_REJECT || !sibling) sibling = next ? node.nextSibling : node.previousSibling;
1370
+ }
1371
+ node = node.parentNode;
1372
+ if (!node || node === this.#root) return null;
1373
+ if (this.#accept(node) === FILTER_ACCEPT) return null;
1374
+ }
1375
+ }
1376
+
1377
+ previousNode() {
1378
+ let node = this.#current;
1379
+ while (node !== this.#root) {
1380
+ let sibling = node.previousSibling;
1381
+ while (sibling) {
1382
+ node = sibling;
1383
+ let result = this.#accept(node);
1384
+ while (result !== FILTER_REJECT && node.lastChild) {
1385
+ node = node.lastChild;
1386
+ result = this.#accept(node);
1387
+ }
1388
+ if (result === FILTER_ACCEPT) return this.#move(node);
1389
+ sibling = node.previousSibling;
1390
+ }
1391
+ if (node === this.#root || !node.parentNode) return null;
1392
+ node = node.parentNode;
1393
+ if (this.#accept(node) === FILTER_ACCEPT) return this.#move(node);
1394
+ }
1395
+ return null;
1396
+ }
1397
+
1398
+ nextNode() {
1399
+ let node = this.#current;
1400
+ let result = FILTER_ACCEPT;
1401
+ for (;;) {
1402
+ while (result !== FILTER_REJECT && node.firstChild) {
1403
+ node = node.firstChild;
1404
+ result = this.#accept(node);
1405
+ if (result === FILTER_ACCEPT) return this.#move(node);
1406
+ }
1407
+ let sibling = null;
1408
+ for (let temporary = node; temporary; temporary = temporary.parentNode) {
1409
+ if (temporary === this.#root) return null;
1410
+ sibling = temporary.nextSibling;
1411
+ if (sibling) break;
1412
+ }
1413
+ if (!sibling) return null;
1414
+ node = sibling;
1415
+ result = this.#accept(node);
1416
+ if (result === FILTER_ACCEPT) return this.#move(node);
1417
+ }
1418
+ }
1419
+ }
1420
+
1005
1421
  class ClassListBinding {
1006
1422
  #node;
1007
1423
  #realm;
@@ -1029,30 +1445,35 @@ class ClassListBinding {
1029
1445
  }
1030
1446
 
1031
1447
  add(...tokens) {
1448
+ const before = this.#node.className;
1032
1449
  this.#node.classList.add(...tokens.map(String));
1033
- this.#realm.hooks.touch();
1450
+ if (this.#node.className !== before) this.#realm.hooks.touch(this.#node);
1034
1451
  }
1035
1452
 
1036
1453
  remove(...tokens) {
1454
+ const before = this.#node.className;
1037
1455
  this.#node.classList.remove(...tokens.map(String));
1038
- this.#realm.hooks.touch();
1456
+ if (this.#node.className !== before) this.#realm.hooks.touch(this.#node);
1039
1457
  }
1040
1458
 
1041
1459
  toggle(token, force) {
1042
1460
  const result = this.#node.classList.toggle(String(token), force === undefined ? undefined : Boolean(force));
1043
- this.#realm.hooks.touch();
1461
+ this.#realm.hooks.touch(this.#node);
1044
1462
  return result;
1045
1463
  }
1046
1464
 
1047
1465
  replace(oldToken, newToken) {
1048
1466
  const result = this.#node.classList.replace(String(oldToken), String(newToken));
1049
- this.#realm.hooks.touch();
1467
+ this.#realm.hooks.touch(this.#node);
1050
1468
  return result;
1051
1469
  }
1052
1470
 
1053
1471
  set value(value) {
1054
- this.#node.className = String(value);
1055
- this.#realm.hooks.touch();
1472
+ const next = String(value);
1473
+ if (this.#node.className !== next) {
1474
+ this.#node.className = next;
1475
+ this.#realm.hooks.touch(this.#node);
1476
+ }
1056
1477
  }
1057
1478
 
1058
1479
  forEach(callback, thisArg) {
@@ -1081,6 +1502,20 @@ class ClassListBinding {
1081
1502
  }
1082
1503
  }
1083
1504
 
1505
+ /**
1506
+ * Properties Happy DOM stores but the renderer does not implement. `in` says
1507
+ * no for them, so feature detection (`"anchorName" in el.style`) takes the
1508
+ * page's fallback path instead of relying on layout that never happens.
1509
+ */
1510
+ const UNRENDERED_STYLE_PROPERTIES = new Set([
1511
+ // CSS Anchor Positioning
1512
+ "anchorName", "anchorScope", "positionAnchor", "positionArea", "insetArea", "positionTry",
1513
+ "positionTryFallbacks", "positionTryOrder", "positionVisibility",
1514
+ // Scroll-driven animations
1515
+ "animationTimeline", "scrollTimeline", "scrollTimelineAxis", "scrollTimelineName", "viewTimeline",
1516
+ "viewTimelineAxis", "viewTimelineInset", "viewTimelineName",
1517
+ ]);
1518
+
1084
1519
  /**
1085
1520
  * element.style: a proxy that reads and writes the real declaration block
1086
1521
  * with string values only. Mutations go to the style attribute, which the
@@ -1093,12 +1528,13 @@ function styleBinding(node, realm) {
1093
1528
  const methods = {
1094
1529
  getPropertyValue: (property) => style.getPropertyValue(String(property)),
1095
1530
  setProperty: (property, value, priority = "") => {
1531
+ const before = style.cssText;
1096
1532
  style.setProperty(String(property), value === null ? "" : String(value), String(priority));
1097
- realm.hooks.touch();
1533
+ if (style.cssText !== before) realm.hooks.touch(node);
1098
1534
  },
1099
1535
  removeProperty: (property) => {
1100
1536
  const previous = style.removeProperty(String(property));
1101
- realm.hooks.touch();
1537
+ realm.hooks.touch(node);
1102
1538
  return previous;
1103
1539
  },
1104
1540
  };
@@ -1112,12 +1548,18 @@ function styleBinding(node, realm) {
1112
1548
  },
1113
1549
  set(_target, property, value) {
1114
1550
  if (typeof property !== "string") return false;
1551
+ const before = style.cssText;
1115
1552
  if (property === "cssText") style.cssText = String(value);
1116
1553
  else style.setProperty(toKebab(property), value === null ? "" : String(value));
1117
- realm.hooks.touch();
1554
+ if (style.cssText !== before) realm.hooks.touch(node);
1118
1555
  return true;
1119
1556
  },
1120
- has: (_target, property) => typeof property === "string",
1557
+ has: (_target, property) => {
1558
+ if (typeof property !== "string") return false;
1559
+ const camel = property.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
1560
+ return Object.hasOwn(methods, property) || property === "cssText" || property === "length"
1561
+ || (property in style && !UNRENDERED_STYLE_PROPERTIES.has(camel));
1562
+ },
1121
1563
  defineProperty: () => false,
1122
1564
  deleteProperty: () => false,
1123
1565
  getPrototypeOf: () => null,
@@ -1136,14 +1578,14 @@ function datasetBinding(node, realm) {
1136
1578
  set(_target, property, value) {
1137
1579
  if (typeof property !== "string") return false;
1138
1580
  node.setAttribute(attribute(property), String(value));
1139
- realm.hooks.touch();
1581
+ realm.hooks.touch(node);
1140
1582
  return true;
1141
1583
  },
1142
1584
  has: (_target, property) => typeof property === "string" && node.hasAttribute(attribute(property)),
1143
1585
  deleteProperty(_target, property) {
1144
1586
  if (typeof property !== "string") return false;
1145
1587
  node.removeAttribute(attribute(property));
1146
- realm.hooks.touch();
1588
+ realm.hooks.touch(node);
1147
1589
  return true;
1148
1590
  },
1149
1591
  ownKeys: () => node.getAttributeNames()
@@ -1203,6 +1645,49 @@ export function windowEventMethods(realm, pageGlobal) {
1203
1645
  };
1204
1646
  }
1205
1647
 
1648
+ /**
1649
+ * Page-constructible EventTarget (`new EventTarget()` and subclasses). Each
1650
+ * instance is backed by its own Happy DOM EventTarget; `targetOf` maps that
1651
+ * back to the page object for event.target and the listener's `this`.
1652
+ */
1653
+ export function eventTargetConstructor(realm, isTarget) {
1654
+ const backing = new WeakMap();
1655
+ const owners = new WeakMap();
1656
+ const realOf = (self) => {
1657
+ const real = backing.get(self);
1658
+ if (!real) throw new TypeError("Illegal invocation");
1659
+ return real;
1660
+ };
1661
+ function EventTarget() {
1662
+ if (!new.target) throw new TypeError("Constructor EventTarget requires 'new'");
1663
+ const self = Object.create(new.target.prototype);
1664
+ const real = new realm.window.EventTarget();
1665
+ backing.set(self, real);
1666
+ owners.set(real, self);
1667
+ return self;
1668
+ }
1669
+ EventTarget.prototype = {
1670
+ constructor: EventTarget,
1671
+ addEventListener(type, listener, options) {
1672
+ addListener(realm, realOf(this), type, listener, options);
1673
+ },
1674
+ removeEventListener(type, listener, options) {
1675
+ removeListener(realm, realOf(this), type, listener, options);
1676
+ },
1677
+ dispatchEvent(event) {
1678
+ const real = EventBinding.unwrap(event);
1679
+ if (!real) throw new TypeError("dispatchEvent requires an Event");
1680
+ return realOf(this).dispatchEvent(real);
1681
+ },
1682
+ };
1683
+ // Nodes and the window are event targets too, though not built from this constructor.
1684
+ Object.defineProperty(EventTarget, Symbol.hasInstance, {
1685
+ value: (value) => backing.has(value) || isTarget(value)
1686
+ || Function.prototype[Symbol.hasInstance].call(EventTarget, value),
1687
+ });
1688
+ return { EventTarget, targetOf: (real) => owners.get(real) ?? null };
1689
+ }
1690
+
1206
1691
  const UI_EVENT_TYPES = ["UIEvent", "MouseEvent", "PointerEvent", "WheelEvent", "KeyboardEvent", "FocusEvent", "InputEvent"];
1207
1692
  const UI_EVENT_FIELDS = [
1208
1693
  ...["detail", "clientX", "clientY", "screenX", "screenY", "button", "buttons", "deltaX", "deltaY", "deltaZ", "deltaMode",
@@ -1221,15 +1706,16 @@ export function eventConstructors(realm) {
1221
1706
  });
1222
1707
  function Event(type, options = undefined) {
1223
1708
  if (!new.target) throw new TypeError("Constructor Event requires 'new'");
1224
- return new EventBinding(CONSTRUCT, new window.Event(String(type), init(options)), realm);
1709
+ return realm.wrapEvent(new window.Event(String(type), init(options)));
1225
1710
  }
1226
1711
  function CustomEvent(type, options = undefined) {
1227
1712
  if (!new.target) throw new TypeError("Constructor CustomEvent requires 'new'");
1228
1713
  // detail stays in the binding: page values never enter Happy DOM.
1229
- return new EventBinding(CONSTRUCT, new window.CustomEvent(String(type), init(options)), realm, options?.detail);
1714
+ return realm.wrapEvent(new window.CustomEvent(String(type), init(options)), options?.detail);
1230
1715
  }
1231
- Event.prototype = EventBinding.prototype;
1232
- CustomEvent.prototype = EventBinding.prototype;
1716
+ realm.eventPrototype = mutableBindingPrototype(EventBinding.prototype);
1717
+ Event.prototype = realm.eventPrototype;
1718
+ CustomEvent.prototype = realm.eventPrototype;
1233
1719
  // UI event subclasses: their init dictionaries are copied as primitives only
1234
1720
  // (view and relatedTarget are dropped).
1235
1721
  const uiEvents = {};
@@ -1242,10 +1728,10 @@ export function eventConstructors(realm) {
1242
1728
  for (const [key, coerce] of UI_EVENT_FIELDS) {
1243
1729
  if (options?.[key] !== undefined) fields[key] = coerce(options[key]);
1244
1730
  }
1245
- return new EventBinding(CONSTRUCT, new RealEvent(String(type), fields), realm);
1731
+ return realm.wrapEvent(new RealEvent(String(type), fields));
1246
1732
  };
1247
1733
  Object.defineProperty(Constructor, "name", { value: name });
1248
- Constructor.prototype = EventBinding.prototype;
1734
+ Constructor.prototype = realm.eventPrototype;
1249
1735
  uiEvents[name] = Constructor;
1250
1736
  }
1251
1737
  return { Event, CustomEvent, ...uiEvents };
@@ -1271,8 +1757,8 @@ function cssEscape(value) {
1271
1757
  */
1272
1758
  export function hardenBindings() {
1273
1759
  if (typeof harden !== "function") return;
1274
- for (const Binding of [NodeBinding, TextBinding, ParentBinding, FragmentBinding, ElementBinding,
1275
- DocumentBinding, EventBinding, ClassListBinding]) {
1760
+ for (const Binding of [NodeBinding, CharacterDataBinding, TextBinding, ParentBinding, FragmentBinding, ShadowRootBinding, ElementBinding,
1761
+ DocumentBinding, EventBinding, ClassListBinding, TreeWalkerBinding]) {
1276
1762
  harden(Binding);
1277
1763
  }
1278
1764
  }
@@ -1280,12 +1766,14 @@ export function hardenBindings() {
1280
1766
  /** Interface objects for `instanceof` checks; all throw "Illegal constructor" when called. */
1281
1767
  export const INTERFACES = Object.freeze({
1282
1768
  Node: NodeBinding,
1283
- CharacterData: TextBinding,
1769
+ CharacterData: CharacterDataBinding,
1284
1770
  Text: TextBinding,
1285
1771
  Element: ElementBinding,
1286
1772
  HTMLElement: ElementBinding,
1287
1773
  Document: DocumentBinding,
1288
1774
  HTMLDocument: DocumentBinding,
1289
1775
  DocumentFragment: FragmentBinding,
1776
+ ShadowRoot: ShadowRootBinding,
1777
+ TreeWalker: TreeWalkerBinding,
1290
1778
  DOMTokenList: ClassListBinding,
1291
1779
  });