dommy 0.9.0 → 0.10.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 (48) hide show
  1. checksums.yaml +4 -4
  2. data/lib/dommy/attr.rb +46 -8
  3. data/lib/dommy/backend/makiri_adapter.rb +34 -0
  4. data/lib/dommy/backend.rb +29 -0
  5. data/lib/dommy/blob.rb +48 -6
  6. data/lib/dommy/browser.rb +239 -36
  7. data/lib/dommy/callable_invoker.rb +6 -2
  8. data/lib/dommy/document.rb +525 -57
  9. data/lib/dommy/element.rb +479 -239
  10. data/lib/dommy/event.rb +296 -15
  11. data/lib/dommy/fetch.rb +431 -25
  12. data/lib/dommy/history.rb +24 -3
  13. data/lib/dommy/html_collection.rb +145 -21
  14. data/lib/dommy/html_elements.rb +1675 -266
  15. data/lib/dommy/interaction/driver.rb +155 -14
  16. data/lib/dommy/interaction/event_synthesis.rb +115 -7
  17. data/lib/dommy/interaction/field_interactor.rb +60 -0
  18. data/lib/dommy/internal/child_node.rb +199 -0
  19. data/lib/dommy/internal/css/cascade.rb +44 -2
  20. data/lib/dommy/internal/global_functions.rb +23 -10
  21. data/lib/dommy/internal/mutation_coordinator.rb +27 -7
  22. data/lib/dommy/internal/node_wrapper_cache.rb +56 -14
  23. data/lib/dommy/internal/observer_manager.rb +6 -0
  24. data/lib/dommy/internal/observer_matcher.rb +6 -8
  25. data/lib/dommy/internal/parent_node.rb +37 -73
  26. data/lib/dommy/internal/selector_matcher.rb +89 -0
  27. data/lib/dommy/internal/selector_parser.rb +44 -7
  28. data/lib/dommy/js/custom_element_bridge.rb +13 -2
  29. data/lib/dommy/js/dom_interfaces.rb +19 -4
  30. data/lib/dommy/js/host_bridge.rb +21 -0
  31. data/lib/dommy/js/host_runtime.js +926 -64
  32. data/lib/dommy/js/script_boot.rb +80 -0
  33. data/lib/dommy/location.rb +76 -13
  34. data/lib/dommy/mutation_observer.rb +3 -4
  35. data/lib/dommy/navigation.rb +263 -0
  36. data/lib/dommy/node.rb +180 -27
  37. data/lib/dommy/range.rb +15 -3
  38. data/lib/dommy/scheduler.rb +16 -0
  39. data/lib/dommy/shadow_root.rb +33 -0
  40. data/lib/dommy/storage.rb +61 -10
  41. data/lib/dommy/tree_walker.rb +18 -36
  42. data/lib/dommy/url.rb +4 -2
  43. data/lib/dommy/version.rb +1 -1
  44. data/lib/dommy/web_socket.rb +45 -2
  45. data/lib/dommy/window.rb +126 -7
  46. data/lib/dommy/xml_http_request.rb +30 -4
  47. data/lib/dommy.rb +2 -0
  48. metadata +6 -10
@@ -14,20 +14,76 @@ module Dommy
14
14
  # DocumentType (`<!doctype html>`) — exposes name / publicId / systemId and
15
15
  # nodeType=10. HTML5 doctypes carry empty public/system IDs, but
16
16
  # `implementation.createDocumentType` can set them.
17
+ #
18
+ # Two modes:
19
+ # * node-backed — wraps the Makiri DocumentType node of a parsed document
20
+ # (`document.doctype`). Participates in the tree machinery
21
+ # (compareDocumentPosition / getRootNode / sibling links) like any other
22
+ # backend-backed node, via the shared Node mixin.
23
+ # * synthetic — a standalone doctype (`implementation.createDocumentType`)
24
+ # carrying just name/public/system id and an owner document. No backend node,
25
+ # so it stays tree-DISCONNECTED per its detached nature.
17
26
  class DocumentType
18
27
  include Node
19
28
 
20
- attr_reader :name
29
+ # Mixed into a node-backed doctype only, so a synthetic one does NOT respond
30
+ # to `__dommy_backend_node__` — leaving the Node mixin's guards (which key off
31
+ # `respond_to?(:__dommy_backend_node__)`) to treat it as disconnected.
32
+ module NodeBacked
33
+ def __dommy_backend_node__ = @__node__
34
+ end
35
+
36
+ # `owner_document:` links a synthetic doctype to its document so the ChildNode
37
+ # methods can act on the tree; a standalone one has none, so those methods are
38
+ # no-ops per spec. `backend_node:` + `document:` build the node-backed variant
39
+ # (a parsed-tree doctype or the createDocumentType factory node), which reads
40
+ # name/publicId/systemId straight off the node (the factory preserves case).
41
+ def initialize(name = "", public_id = "", system_id = "", owner_document: nil, backend_node: nil, document: nil)
42
+ @__node__ = backend_node
43
+ if backend_node
44
+ @document = document
45
+ @owner_document = document
46
+ extend(NodeBacked)
47
+ else
48
+ @name = name.to_s
49
+ @public_id = public_id.to_s
50
+ @system_id = system_id.to_s
51
+ @owner_document = owner_document
52
+ end
53
+ end
54
+
55
+ # The document this doctype currently belongs to (nil for a detached
56
+ # synthetic doctype). Lets a cross-document appendChild/insert detect that
57
+ # the node must be adopted (re-created) into the destination backend.
58
+ def document
59
+ @document || @owner_document
60
+ end
61
+
62
+ def name
63
+ @__node__ ? @__node__.name : @name
64
+ end
65
+
66
+ # Makiri reports nil for an absent public/system id; DOM exposes "".
67
+ def public_id
68
+ @__node__ ? @__node__.public_id.to_s : @public_id
69
+ end
70
+
71
+ def system_id
72
+ @__node__ ? @__node__.system_id.to_s : @system_id
73
+ end
74
+
75
+ def parent_node
76
+ # wrap_node maps the backend document node (the doctype's parent) to the
77
+ # Dommy Document.
78
+ @__node__ && @__node__.parent && @document.wrap_node(@__node__.parent)
79
+ end
80
+
81
+ def next_sibling
82
+ @__node__ && @__node__.next && @document.wrap_node(@__node__.next)
83
+ end
21
84
 
22
- # `owner_document:` links a live doctype (document.doctype) to its document so
23
- # the ChildNode methods (remove/before/after/replaceWith) act on the tree; a
24
- # standalone doctype (DOMImplementation.createDocumentType) has none, so those
25
- # methods are no-ops per spec.
26
- def initialize(name, public_id = "", system_id = "", owner_document: nil)
27
- @name = name.to_s
28
- @public_id = public_id.to_s
29
- @system_id = system_id.to_s
30
- @owner_document = owner_document
85
+ def previous_sibling
86
+ @__node__ && @__node__.previous && @document.wrap_node(@__node__.previous)
31
87
  end
32
88
 
33
89
  # ChildNode mixin — the doctype's parent is the document.
@@ -61,33 +117,70 @@ module Dommy
61
117
  def __js_get__(key)
62
118
  case key
63
119
  when "name"
64
- @name
120
+ name
65
121
  when "nodeName"
66
122
  # WHATWG: a DocumentType's nodeName is its name.
67
- @name
123
+ name
68
124
  when "nodeType"
69
125
  10
70
126
  when "publicId"
71
- @public_id
127
+ public_id
72
128
  when "systemId"
73
- @system_id
129
+ system_id
74
130
  when "ownerDocument"
75
131
  @owner_document
132
+ when "parentNode"
133
+ parent_node
134
+ when "parentElement"
135
+ nil
136
+ when "nextSibling"
137
+ next_sibling
138
+ when "previousSibling"
139
+ previous_sibling
140
+ when "childNodes"
141
+ NodeList.new
142
+ when "firstChild", "lastChild"
143
+ nil
76
144
  end
77
145
  end
78
146
 
79
147
  include EventTarget
80
148
 
81
149
  def __internal_event_parent__
82
- nil
150
+ parent_node
151
+ end
152
+
153
+ # Node.cloneNode on a doctype: a detached copy with the same name/publicId/
154
+ # systemId (a doctype is a leaf, so `deep` is irrelevant). Node-backed when a
155
+ # backend factory is available, else synthetic — either reports the same
156
+ # values and isEqualNode-matches the original.
157
+ def clone_node(_deep = false)
158
+ if @__node__ && @document
159
+ node = begin
160
+ Backend.create_document_type(name, public_id, system_id, @document.backend_doc)
161
+ rescue StandardError
162
+ nil
163
+ end
164
+ return DocumentType.new(backend_node: node, document: @document) if node
165
+ end
166
+ DocumentType.new(name, public_id, system_id, owner_document: @owner_document)
83
167
  end
84
168
 
85
169
  include Bridge::Methods
86
170
  js_methods %w[isEqualNode isSameNode getRootNode hasChildNodes normalize compareDocumentPosition contains
87
- appendChild insertBefore removeChild replaceChild before after replaceWith remove
171
+ cloneNode appendChild insertBefore removeChild replaceChild before after replaceWith remove
172
+ lookupNamespaceURI lookupPrefix isDefaultNamespace
88
173
  addEventListener removeEventListener dispatchEvent]
89
174
  def __js_call__(method, args)
90
175
  case method
176
+ when "lookupNamespaceURI"
177
+ lookup_namespace_uri(args[0])
178
+ when "lookupPrefix"
179
+ lookup_prefix(args[0])
180
+ when "isDefaultNamespace"
181
+ is_default_namespace(args[0])
182
+ when "cloneNode"
183
+ clone_node(args[0])
91
184
  when "hasChildNodes"
92
185
  false
93
186
  when "contains"
@@ -101,11 +194,13 @@ module Dommy
101
194
  get_root_node(args[0])
102
195
  when "compareDocumentPosition"
103
196
  compare_document_position(args[0])
104
- when "appendChild", "insertBefore"
197
+ when "appendChild", "insertBefore", "replaceChild"
198
+ # Pre-insert / replace step 1 rejects a non-parent context node with
199
+ # HierarchyRequestError before the reference-child check.
105
200
  raise Bridge::TypeError, "Argument is not a Node." unless args[0].is_a?(Dommy::Node)
106
201
 
107
202
  raise DOMException::HierarchyRequestError, "a DocumentType may not have children"
108
- when "removeChild", "replaceChild"
203
+ when "removeChild"
109
204
  raise Bridge::TypeError, "Argument is not a Node." unless args[0].is_a?(Dommy::Node)
110
205
 
111
206
  raise DOMException::NotFoundError, "the node to be removed is not a child of this node"
@@ -117,6 +212,7 @@ module Dommy
117
212
  replace_with(*args)
118
213
  when "remove"
119
214
  remove
215
+ Bridge::UNDEFINED # DocumentType (ChildNode)#remove is void -> JS undefined
120
216
  when "normalize"
121
217
  nil
122
218
  when "addEventListener"
@@ -135,11 +231,24 @@ module Dommy
135
231
  @document = document
136
232
  end
137
233
 
138
- # A created DocumentType's node document is the implementation's document.
139
- # (Qualified-name validation against the QName production is not enforced
140
- # a couple of invalid-name WPT cases stay as documented gaps.)
234
+ # A created DocumentType's node document is the implementation's document. When
235
+ # the backend ships a doctype factory (the HTML backend) and accepts the name,
236
+ # the result is a real, node-backed (but detached) DocumentType that can join
237
+ # the tree; otherwise it falls back to a synthetic one. (Qualified-name QName
238
+ # validation isn't enforced — createDocumentType is permissive, so the factory's
239
+ # stricter name check is bypassed via the synthetic fallback rather than
240
+ # raising; a couple of invalid-name WPT cases stay documented gaps.)
141
241
  def create_document_type(qualified_name, public_id, system_id)
142
- DocumentType.new(qualified_name, public_id, system_id, owner_document: @document)
242
+ qn = qualified_name.to_s
243
+ pub = public_id.to_s
244
+ sys = system_id.to_s
245
+ node =
246
+ begin
247
+ Backend.create_document_type(qn, pub, sys, @document.backend_doc)
248
+ rescue ArgumentError
249
+ nil
250
+ end
251
+ node ? DocumentType.new(backend_node: node, document: @document) : DocumentType.new(qn, pub, sys, owner_document: @document)
143
252
  end
144
253
 
145
254
  # `hasFeature()` is a no-op that always returns true (DOM Standard).
@@ -147,11 +256,10 @@ module Dommy
147
256
  true
148
257
  end
149
258
 
150
- # createDocument(namespace, qualifiedName, doctype?) — a fresh XML document,
151
- # with a document element (namespace, qualifiedName) when qualifiedName is
152
- # non-empty. (The doctype argument is accepted but not stored, as document
153
- # equality compares only structure that survives wrap_node.)
154
- def create_document(namespace, qualified_name, _doctype = nil)
259
+ # createDocument(namespace, qualifiedName, doctype?) — a fresh XML document
260
+ # with, in tree order, the doctype (when given) then a document element
261
+ # (namespace, qualifiedName) when qualifiedName is non-empty.
262
+ def create_document(namespace, qualified_name, doctype = nil)
155
263
  doc = Document.new(nil, backend_doc: Backend.empty_xml_document)
156
264
  # createDocument's content type is keyed off the namespace. None is
157
265
  # "text/html", so tagName keeps its case; xhtml+xml still routes
@@ -168,9 +276,37 @@ module Dommy
168
276
  el = doc.send(:create_element_ns, namespace, qualified_name)
169
277
  Backend.set_document_root(doc.backend_doc, el.__dommy_backend_node__)
170
278
  end
279
+ adopt_doctype_into(doc, doctype)
171
280
  doc
172
281
  end
173
282
 
283
+ private
284
+
285
+ # Place `doctype` (a DocumentType passed to createDocument) as `doc`'s first
286
+ # child. Makiri can't move a node between documents, so — like adoption — the
287
+ # doctype is re-created in `doc`'s backend from its name/publicId/systemId.
288
+ # No-op for nil/undefined, a non-DocumentType, a backend without an XML doctype
289
+ # factory, or a public/system id the backend rejects (createDocument itself
290
+ # doesn't validate those — only XML *serialization* would — so a rejection just
291
+ # leaves the doctype unplaced rather than throwing).
292
+ def adopt_doctype_into(doc, doctype)
293
+ return if doctype.nil? || doctype.equal?(Bridge::UNDEFINED)
294
+ return unless doctype.is_a?(DocumentType)
295
+
296
+ node =
297
+ begin
298
+ Backend.create_document_type(doctype.name, doctype.public_id, doctype.system_id, doc.backend_doc)
299
+ rescue StandardError
300
+ nil
301
+ end
302
+ return unless node
303
+
304
+ root = doc.backend_doc.root
305
+ root ? root.add_previous_sibling(node) : doc.backend_doc.add_child(node)
306
+ end
307
+
308
+ public
309
+
174
310
  # createHTMLDocument(title?) — a fresh HTML document (doctype + html > head,
175
311
  # body), with an optional <title>.
176
312
  def create_html_document(title = nil)
@@ -190,7 +326,13 @@ module Dommy
190
326
  when "createDocument"
191
327
  create_document(args[0], args[1], args[2])
192
328
  when "createHTMLDocument"
193
- create_html_document(args[0])
329
+ # title is an OPTIONAL DOMString: a missing or undefined argument leaves
330
+ # the document title-less, but an explicit null coerces to "null".
331
+ if args.empty? || args[0].equal?(Bridge::UNDEFINED)
332
+ create_html_document
333
+ else
334
+ create_html_document(args[0].nil? ? "null" : args[0])
335
+ end
194
336
  when "hasFeature"
195
337
  has_feature
196
338
  end
@@ -341,6 +483,10 @@ module Dommy
341
483
  # public/system identifier) is no-quirks. (The full quirks algorithm keys off
342
484
  # specific legacy public ids; this covers the common cases.)
343
485
  def compat_mode
486
+ # Only HTML documents can be in quirks mode; an XML document
487
+ # (createDocument / DOMParser XML) is always no-quirks.
488
+ return "CSS1Compat" unless html_document?
489
+
344
490
  dt = @backend_doc.internal_subset
345
491
  return "BackCompat" unless dt
346
492
  return "CSS1Compat" if dt.name.to_s.downcase == "html" && dt.external_id.nil?
@@ -364,7 +510,16 @@ module Dommy
364
510
  end
365
511
 
366
512
  def head
367
- wrap_node(@backend_doc.at_css("head"))
513
+ # The first `head` element child of the document element in the HTML
514
+ # namespace (readonly — assignment is a no-op, see __js_set__). Not just
515
+ # `at_css("head")`, which searches the whole tree and ignores namespace.
516
+ root = document_element
517
+ return nil unless root
518
+
519
+ root.child_nodes.to_a.find do |c|
520
+ c.respond_to?(:local_name) && c.local_name == "head" &&
521
+ c.respond_to?(:namespace_uri) && c.namespace_uri == "http://www.w3.org/1999/xhtml"
522
+ end
368
523
  end
369
524
 
370
525
  # Resolve `body` fresh from the tree (not memoized) so it tracks a swapped
@@ -404,10 +559,12 @@ module Dommy
404
559
  end
405
560
 
406
561
  # `document.URL` / `documentURI` — both return location.href in
407
- # real browsers (legacy aliases of the same field).
562
+ # real browsers (legacy aliases of the same field). A document with no
563
+ # browsing context (createDocument / new Document / DOMParser) has the URL
564
+ # "about:blank", not the empty string.
408
565
  def url
409
566
  view = @default_view
410
- view&.location ? view.location.href : ""
567
+ view&.location ? view.location.href : "about:blank"
411
568
  end
412
569
 
413
570
  alias document_uri url
@@ -524,8 +681,12 @@ module Dommy
524
681
  return true if other.equal?(self)
525
682
  return false unless other.respond_to?(:__dommy_backend_node__)
526
683
 
684
+ # Walk parents up to the backend document node. (The backend's #ancestors
685
+ # stops below the document, so it can't test document membership; the
686
+ # doctype in particular reports an empty ancestor list.)
527
687
  node = other.__dommy_backend_node__
528
- node.document == @backend_doc && node.ancestors.include?(@backend_doc)
688
+ node = node.parent while node && !node.equal?(@backend_doc)
689
+ !node.nil?
529
690
  end
530
691
 
531
692
  def __internal_set_active_element__(el)
@@ -608,6 +769,9 @@ module Dommy
608
769
  def import_node(node, deep = false)
609
770
  return nil unless node.respond_to?(:__dommy_backend_node__)
610
771
 
772
+ # WebIDL `optional boolean deep = false`: a missing / undefined argument
773
+ # is the default (false / shallow), not a truthy sentinel.
774
+ deep = false if deep.nil? || deep.equal?(Bridge::UNDEFINED)
611
775
  copy = clone_into_doc(node.__dommy_backend_node__, deep)
612
776
  wrap_node(copy)
613
777
  end
@@ -616,6 +780,10 @@ module Dommy
616
780
  # node is detached from its previous owner and its ownerDocument
617
781
  # becomes this. Returns the (possibly re-wrapped) node.
618
782
  def adopt_node(node)
783
+ # WHATWG adopt: a Document can't be adopted into another document.
784
+ if node.is_a?(Dommy::Document)
785
+ raise DOMException::NotSupportedError, "A Document node cannot be adopted."
786
+ end
619
787
  return nil unless node.respond_to?(:__dommy_backend_node__)
620
788
 
621
789
  src = node.__dommy_backend_node__
@@ -624,6 +792,27 @@ module Dommy
624
792
  # Same document: just return the wrapper after the detach above.
625
793
  return wrap_node(src) if src.document == @backend_doc
626
794
 
795
+ # Cross-document DocumentType: Makiri can't import a doctype node between
796
+ # arenas, so re-create it in this document's backend from its public
797
+ # name / publicId / systemId (as createDocument does), then reseat the
798
+ # caller's wrapper onto the new node so JS identity survives the move.
799
+ if node.is_a?(DocumentType)
800
+ adopted = begin
801
+ Backend.create_document_type(node.name, node.public_id, node.system_id, @backend_doc)
802
+ rescue StandardError
803
+ nil
804
+ end
805
+ return node unless adopted
806
+
807
+ src_doc_wrapper = node.instance_variable_get(:@document)
808
+ src_doc_wrapper.__internal_reset_wrapper__(src) if src_doc_wrapper.respond_to?(:__internal_reset_wrapper__)
809
+ node.instance_variable_set(:@document, self)
810
+ node.instance_variable_set(:@owner_document, self)
811
+ node.instance_variable_set(:@__node__, adopted)
812
+ @node_wrapper_cache.register(adopted, node)
813
+ return node
814
+ end
815
+
627
816
  # Cross-document: hand the detached source to the backend, which
628
817
  # returns the node now owned by this document — an imported copy for
629
818
  # Makiri (a node can't move between arenas). Drop the stale source
@@ -638,9 +827,69 @@ module Dommy
638
827
  node.instance_variable_set(:@document, self)
639
828
  node.instance_variable_set(:@__node__, adopted)
640
829
  @node_wrapper_cache.register(adopted, node)
830
+
831
+ # A deep adopt imports a fresh copy of the whole subtree, so any live
832
+ # descendant wrapper (held by page script, e.g. an aria element reference)
833
+ # must be reseated onto its corresponding copy — otherwise it stays bound to
834
+ # the old document. Import preserves document order, so walk both subtrees in
835
+ # lockstep.
836
+ reseat_descendant_wrappers(src, adopted, src_doc_wrapper)
641
837
  node
642
838
  end
643
839
 
840
+ # Move each live wrapper for a descendant of `src_root` onto the matching node
841
+ # in `dst_root` (the imported copy), pruning it from the source document.
842
+ def reseat_descendant_wrappers(src_root, dst_root, src_doc)
843
+ return unless src_doc.respond_to?(:__internal_peek_wrapper__)
844
+
845
+ src_nodes = collect_subtree_nodes(src_root)
846
+ dst_nodes = collect_subtree_nodes(dst_root)
847
+ return unless src_nodes.length == dst_nodes.length
848
+
849
+ src_nodes.zip(dst_nodes).each do |orig, copy|
850
+ next if orig.equal?(src_root) # the root wrapper is reseated by the caller
851
+
852
+ wrapper = src_doc.__internal_peek_wrapper__(orig)
853
+ next unless wrapper
854
+
855
+ src_doc.__internal_reset_wrapper__(orig)
856
+ wrapper.instance_variable_set(:@document, self)
857
+ wrapper.instance_variable_set(:@__node__, copy)
858
+ @node_wrapper_cache.register(copy, wrapper)
859
+ end
860
+ end
861
+
862
+ # HTML "cloning steps": a cloned node copies interface-specific live state
863
+ # that the content attributes don't capture — an input's dirty value and
864
+ # checkedness, a textarea's dirty value, etc. Dommy keeps that state on the
865
+ # Ruby wrapper (not the backend node), and a deep backend clone never calls
866
+ # the descendants' clone_node, so walk the original and cloned subtrees in
867
+ # lockstep and copy each live wrapper's cloning state onto its copy. `deep`
868
+ # false processes only the root (a shallow clone has no children).
869
+ def __internal_apply_cloning_steps__(src_root_bn, clone_root_bn, deep)
870
+ src_nodes = deep ? collect_subtree_nodes(src_root_bn) : [src_root_bn]
871
+ clone_nodes = deep ? collect_subtree_nodes(clone_root_bn) : [clone_root_bn]
872
+ return unless src_nodes.length == clone_nodes.length
873
+
874
+ src_nodes.zip(clone_nodes).each do |orig, copy|
875
+ wrapper = @node_wrapper_cache.peek(orig)
876
+ next unless wrapper.respond_to?(:__cloning_state__)
877
+
878
+ state = wrapper.__cloning_state__
879
+ next if state.nil?
880
+
881
+ @node_wrapper_cache.wrap(copy).__apply_cloning_state__(state)
882
+ end
883
+ end
884
+
885
+ # A subtree's nodes in document (depth-first) order — the order Backend.adopt
886
+ # preserves — so a source node and its imported copy line up by index.
887
+ def collect_subtree_nodes(root)
888
+ nodes = [root]
889
+ root.children.each { |child| nodes.concat(collect_subtree_nodes(child)) } if root.respond_to?(:children)
890
+ nodes
891
+ end
892
+
644
893
  # Legacy `document.createEvent("EventName")` factory. Returns an
645
894
  # Event subclass instance whose init still has to be called
646
895
  # (`event.initEvent(type, bubbles, cancelable)`). Matches the
@@ -738,14 +987,13 @@ module Dommy
738
987
  @node_iterators << iterator
739
988
  end
740
989
 
741
- # Minimal DocumentType represents the `<!doctype html>` line.
742
- # Always present in HTML5 documents we parse, so we synthesize a
743
- # stub object whose only useful field is `name`. Tests just need
744
- # `nodeType == 10`.
990
+ # `document.doctype` — the node-backed DocumentType wrapping the parsed
991
+ # `<!DOCTYPE …>` node, or nil when the document declares none (or the doctype
992
+ # was removed, which unlinks the backend node). Shares wrapper identity with
993
+ # the same node in `childNodes`, since both wrap the same backend node.
745
994
  def doctype
746
- return nil if @doctype_removed
747
-
748
- @doctype ||= DocumentType.new("html", owner_document: self)
995
+ node = Backend.internal_subset(@backend_doc)
996
+ node ? wrap_node(node) : nil
749
997
  end
750
998
 
751
999
  def implementation
@@ -756,9 +1004,88 @@ module Dommy
756
1004
  @node_wrapper_cache.create_processing_instruction(target, data)
757
1005
  end
758
1006
 
1007
+ # WHATWG "ensure pre-insertion validity", step 6 — the Document-parent
1008
+ # constraints an element-like parent doesn't have. `args` is the set of nodes
1009
+ # (and DOMStrings) being inserted; per "converting nodes into a node" they're
1010
+ # summed as one insertion. `child_bn` is the backend reference child (nil for
1011
+ # append). `ignore_existing` (replaceChildren) drops the current children
1012
+ # from the counts, since replace-all removes them first. `exclude` (replace)
1013
+ # is a current child to disregard. Raises HierarchyRequestError on violation.
1014
+ def ensure_document_insertion_validity!(args, child_bn, ignore_existing: false, exclude: nil)
1015
+ elements = 0
1016
+ doctypes = 0
1017
+ has_text = false
1018
+ args.each do |arg|
1019
+ case arg
1020
+ when String
1021
+ has_text = true
1022
+ when Dommy::Fragment
1023
+ arg.child_nodes.each do |c|
1024
+ if c.is_a?(Dommy::Element) then elements += 1
1025
+ elsif c.is_a?(Dommy::DocumentType) then doctypes += 1
1026
+ elsif c.is_a?(Dommy::TextNode) then has_text = true
1027
+ end
1028
+ end
1029
+ when Dommy::DocumentType then doctypes += 1
1030
+ when Dommy::Element then elements += 1
1031
+ when Dommy::CharacterDataNode
1032
+ # Comment (8) / PI (7) are valid document children; Text (3) is not.
1033
+ has_text = true if arg.node_type == 3
1034
+ when Dommy::Node
1035
+ # A Document / Attr / anything else is not an insertable node type.
1036
+ raise DOMException::HierarchyRequestError, "This node type cannot be inserted here."
1037
+ end
1038
+ end
1039
+
1040
+ # `existing` (with positions) drives the "doctype following / element
1041
+ # preceding child" checks; those use the FULL child list, since `child`
1042
+ # (the reference / node being replaced) is still in the tree. The
1043
+ # "already has an element/doctype child" checks, however, disregard the
1044
+ # node being replaced (`exclude`) — WHATWG replace's "...child that is not
1045
+ # child" wording.
1046
+ existing = ignore_existing ? [] : @backend_doc.children.to_a
1047
+ has_element = existing.any? { |c| c.node_type == 1 && !(exclude && c == exclude) }
1048
+ has_doctype = existing.any? { |c| c.node_type == 10 && !(exclude && c == exclude) }
1049
+
1050
+ # Step 6, Text/DocumentFragment-with-text: no text under a document.
1051
+ raise DOMException::HierarchyRequestError, "A Text node cannot be a child of a document." if has_text
1052
+
1053
+ if doctypes.positive?
1054
+ if doctypes > 1 || elements.positive? || has_doctype ||
1055
+ element_before_child?(existing, child_bn) ||
1056
+ (child_bn.nil? && has_element)
1057
+ raise DOMException::HierarchyRequestError, "A doctype cannot be inserted here."
1058
+ end
1059
+ end
1060
+
1061
+ if elements > 1
1062
+ raise DOMException::HierarchyRequestError, "Only one element may be a child of a document."
1063
+ elsif elements == 1 &&
1064
+ (has_element || (child_bn && child_bn.node_type == 10) || doctype_after_child?(existing, child_bn))
1065
+ raise DOMException::HierarchyRequestError, "An element cannot be inserted here."
1066
+ end
1067
+ end
1068
+
1069
+ # Whether any element child precedes `child_bn` in the document's child list.
1070
+ def element_before_child?(existing, child_bn)
1071
+ idx = child_bn && existing.index { |c| c == child_bn }
1072
+ return false unless idx
1073
+
1074
+ existing[0...idx].any? { |c| c.node_type == 1 }
1075
+ end
1076
+
1077
+ # Whether any doctype child follows `child_bn` in the document's child list.
1078
+ def doctype_after_child?(existing, child_bn)
1079
+ idx = child_bn && existing.index { |c| c == child_bn }
1080
+ return false unless idx
1081
+
1082
+ existing[idx..].any? { |c| c.node_type == 10 }
1083
+ end
1084
+
759
1085
  # Append a node as a child of the document itself (e.g. a comment alongside
760
1086
  # the document element). Adopts the node into this document.
761
1087
  def append_child(node)
1088
+ ensure_document_insertion_validity!([node], nil)
762
1089
  return node unless node.respond_to?(:__dommy_backend_node__)
763
1090
 
764
1091
  # appendChild adopts a node from another document (per spec). Only needed on
@@ -773,7 +1100,9 @@ module Dommy
773
1100
  # ParentNode / Node mutation on the document's direct children (the doctype
774
1101
  # and the document element).
775
1102
  def document_insert(args, prepend:)
776
- nodes = args.filter_map { |a| backend_node(a) }
1103
+ ref_bn = prepend ? @backend_doc.children.first : nil
1104
+ ensure_document_insertion_validity!(args, ref_bn)
1105
+ nodes = args.filter_map { |a| adopted_backend_node(a) }
777
1106
  if prepend && (first = @backend_doc.children.first)
778
1107
  nodes.reverse_each { |n| first.add_previous_sibling(n) }
779
1108
  else
@@ -783,8 +1112,11 @@ module Dommy
783
1112
  end
784
1113
 
785
1114
  def document_replace_children(args)
1115
+ # replaceChildren removes the current children first, so the validity
1116
+ # checks ignore them (whatwg/dom#1045).
1117
+ ensure_document_insertion_validity!(args, nil, ignore_existing: true)
786
1118
  @backend_doc.children.each(&:unlink)
787
- args.filter_map { |a| backend_node(a) }.each { |n| @backend_doc.add_child(n) }
1119
+ args.filter_map { |a| adopted_backend_node(a) }.each { |n| @backend_doc.add_child(n) }
788
1120
  nil
789
1121
  end
790
1122
 
@@ -794,12 +1126,23 @@ module Dommy
794
1126
  bn = backend_node(node)
795
1127
  raise DOMException::NotFoundError, "node is not a child of this document" unless bn && bn.parent == @backend_doc
796
1128
 
1129
+ run_node_iterator_pre_remove(bn)
797
1130
  bn.unlink
798
1131
  node
799
1132
  end
800
1133
 
801
1134
  def document_insert_before(node, ref)
802
- bn = backend_node(node)
1135
+ # WHATWG pre-insert order: the reference child must be a child of the
1136
+ # parent (step 3, NotFoundError) BEFORE the node-type / document-hierarchy
1137
+ # checks (steps 4-6).
1138
+ ref_present = !(ref.nil? || (defined?(Bridge::UNDEFINED) && ref.equal?(Bridge::UNDEFINED)))
1139
+ ref_bn = ref_present ? backend_node(ref) : nil
1140
+ if ref_present && !(ref_bn && ref_bn.parent == @backend_doc)
1141
+ raise DOMException::NotFoundError, "The reference child is not a child of this document."
1142
+ end
1143
+
1144
+ ensure_document_insertion_validity!([node], ref_bn)
1145
+ bn = adopted_backend_node(node)
803
1146
  return node unless bn
804
1147
 
805
1148
  ref_node = ref && backend_node(ref)
@@ -815,17 +1158,33 @@ module Dommy
815
1158
  old_bn = backend_node(old_child)
816
1159
  raise DOMException::NotFoundError, "node is not a child of this document" unless old_bn && old_bn.parent == @backend_doc
817
1160
 
818
- new_bn = backend_node(new_child)
819
- old_bn.add_previous_sibling(new_bn) if new_bn
1161
+ # replaceChild's validity disregards the node being replaced when counting
1162
+ # the document's existing element / doctype children.
1163
+ ensure_document_insertion_validity!([new_child], old_bn, exclude: old_bn)
1164
+
1165
+ # Remove old FIRST, then adopt the incoming node. A cross-document doctype
1166
+ # is re-created in this backend, and Makiri's fail-closed guard refuses a
1167
+ # second doctype — so the old one must be gone before the new is made.
1168
+ ref = old_bn.next
820
1169
  old_bn.unlink
1170
+ if !Backend.moves_nodes_across_documents? && new_child.respond_to?(:document) && !new_child.document.equal?(self)
1171
+ new_child = adopt_node(new_child)
1172
+ end
1173
+ new_bn = backend_node(new_child)
1174
+ if new_bn
1175
+ ref && ref.parent == @backend_doc ? ref.add_previous_sibling(new_bn) : @backend_doc.add_child(new_bn)
1176
+ end
821
1177
  old_child
822
1178
  end
823
1179
 
824
- # Called by DocumentType#remove — the doctype is synthesized from the DTD, so
825
- # remove the internal subset and mark it gone.
826
- def __internal_remove_doctype__(_doctype)
827
- @doctype_removed = true
828
- @backend_doc.internal_subset&.unlink
1180
+ # Called by DocumentType#remove — unlink the backend doctype node so the tree
1181
+ # (and `document.doctype`, which re-derives from the tree) no longer sees it.
1182
+ def __internal_remove_doctype__(doctype)
1183
+ node = backend_node(doctype) || Backend.internal_subset(@backend_doc)
1184
+ return nil unless node
1185
+
1186
+ run_node_iterator_pre_remove(node)
1187
+ node.unlink
829
1188
  nil
830
1189
  end
831
1190
 
@@ -854,6 +1213,21 @@ module Dommy
854
1213
  node.respond_to?(:__dommy_backend_node__) ? node.__dommy_backend_node__ : nil
855
1214
  end
856
1215
 
1216
+ # Like `backend_node`, but first adopts a node that belongs to another
1217
+ # document (per the insert steps) — the document-child insert paths
1218
+ # (append / prepend / replaceChildren / insertBefore) need this exactly as
1219
+ # `append_child` does, otherwise a cross-document node's backend node comes
1220
+ # from a foreign arena and the insertion silently drops it on a backend that
1221
+ # can't move nodes across documents (Makiri).
1222
+ def adopted_backend_node(node)
1223
+ return nil unless node.respond_to?(:__dommy_backend_node__)
1224
+
1225
+ if !Backend.moves_nodes_across_documents? && node.respond_to?(:document) && !node.document.equal?(self)
1226
+ node = adopt_node(node)
1227
+ end
1228
+ node.__dommy_backend_node__
1229
+ end
1230
+
857
1231
  # Delegate to CookieJar
858
1232
 
859
1233
  def cookie
@@ -916,7 +1290,8 @@ module Dommy
916
1290
  # Create a Comment node. Wraps the Makiri comment so it flows
917
1291
  # through the same wrap_node identity machinery as Element / TextNode.
918
1292
  def create_comment(text)
919
- @node_wrapper_cache.create_comment(text)
1293
+ # WebIDL DOMString: JS null coerces to "null" (undefined -> "undefined").
1294
+ @node_wrapper_cache.create_comment(text.nil? ? "null" : text)
920
1295
  end
921
1296
 
922
1297
  def create_cdata_section(text)
@@ -966,6 +1341,10 @@ module Dommy
966
1341
  cookie
967
1342
  when "nodeType"
968
1343
  9
1344
+ when "nodeValue", "textContent"
1345
+ # A Document's nodeValue and textContent are null (not the concatenated
1346
+ # descendant text) per the DOM.
1347
+ nil
969
1348
  when "activeElement"
970
1349
  active_element
971
1350
  when "URL", "documentURI"
@@ -1050,10 +1429,65 @@ module Dommy
1050
1429
  when "nodeName"
1051
1430
  "#document"
1052
1431
  else
1053
- Bridge::ABSENT # unknown property: JS undefined, `in` reports absent
1432
+ # WebIDL named getter: `document.someName` exposes a named embed / form /
1433
+ # iframe / img / object element (or an img/object by id). Unknown
1434
+ # otherwise → JS undefined.
1435
+ named = document_named_property(key.to_s)
1436
+ named.nil? ? Bridge::ABSENT : named
1437
+ end
1438
+ end
1439
+
1440
+ # The document's supported property names (for `"name" in document`): the
1441
+ # `name` of each exposed element, plus the `id` of id-exposed img/object.
1442
+ def __js_named_props__
1443
+ names = []
1444
+ named_getter_nodes.each do |node|
1445
+ n = node["name"].to_s
1446
+ names << n unless n.empty?
1447
+ id = node["id"].to_s
1448
+ names << id if !id.empty? && %w[img object].include?(node.name.to_s.downcase) && !n.empty?
1054
1449
  end
1450
+ names.uniq
1451
+ end
1452
+
1453
+ # Resolve a document named-getter property: nil when unsupported, a single
1454
+ # element (a named iframe yields its content window), or an HTMLCollection
1455
+ # when several elements share the name.
1456
+ def document_named_property(name)
1457
+ return nil if name.empty?
1458
+
1459
+ matches = named_getter_nodes.select do |node|
1460
+ node["name"] == name ||
1461
+ (node["id"] == name && %w[img object].include?(node.name.to_s.downcase) && !node["name"].to_s.empty?)
1462
+ end
1463
+ wrapped = matches.map { |node| wrap_node(node) }.compact
1464
+ return nil if wrapped.empty?
1465
+
1466
+ if wrapped.length == 1
1467
+ el = wrapped.first
1468
+ cw = el.respond_to?(:content_window) ? el.content_window : nil
1469
+ cw || el
1470
+ else
1471
+ HTMLCollection.new { document_named_property_nodes(name) }
1472
+ end
1473
+ end
1474
+
1475
+ private
1476
+
1477
+ # Elements the document's named getter exposes, in tree order.
1478
+ def named_getter_nodes
1479
+ @backend_doc.css("embed, form, iframe, img, object")
1480
+ end
1481
+
1482
+ def document_named_property_nodes(name)
1483
+ named_getter_nodes.select do |node|
1484
+ node["name"] == name ||
1485
+ (node["id"] == name && %w[img object].include?(node.name.to_s.downcase) && !node["name"].to_s.empty?)
1486
+ end.map { |node| wrap_node(node) }.compact
1055
1487
  end
1056
1488
 
1489
+ public
1490
+
1057
1491
  def __js_set__(key, value)
1058
1492
  case key
1059
1493
  when "title"
@@ -1070,6 +1504,10 @@ module Dommy
1070
1504
  # `document.location = url` navigates, same as `location.href = url`.
1071
1505
  loc = @default_view&.__js_get__("location")
1072
1506
  loc&.__js_set__("href", value)
1507
+ when "head", "documentElement"
1508
+ # Readonly attributes: assignment is a silent no-op. Handle it here so the
1509
+ # bridge doesn't store a JS-side expando that would shadow the getter.
1510
+ nil
1073
1511
  else
1074
1512
  return Bridge::UNHANDLED
1075
1513
  end
@@ -1084,12 +1522,19 @@ module Dommy
1084
1522
  getElementsByClassName getElementsByTagName getElementsByTagNameNS getElementsByName createAttribute
1085
1523
  createAttributeNS createTreeWalker createNodeIterator createRange createEvent importNode
1086
1524
  adoptNode hasFocus getSelection elementFromPoint queryCommandSupported addEventListener
1087
- removeEventListener dispatchEvent write writeln open close isEqualNode appendChild
1525
+ removeEventListener dispatchEvent write writeln open close isEqualNode isSameNode appendChild
1088
1526
  hasChildNodes contains append prepend replaceChildren removeChild insertBefore replaceChild
1089
1527
  cloneNode normalize compareDocumentPosition getRootNode
1528
+ lookupNamespaceURI lookupPrefix isDefaultNamespace
1090
1529
  ]
1091
1530
  def __js_call__(method, args)
1092
1531
  case method
1532
+ when "lookupNamespaceURI"
1533
+ lookup_namespace_uri(args[0])
1534
+ when "lookupPrefix"
1535
+ lookup_prefix(args[0])
1536
+ when "isDefaultNamespace"
1537
+ is_default_namespace(args[0])
1093
1538
  when "getRootNode"
1094
1539
  # A document is its own root (no shadow tree above it), for any options.
1095
1540
  # Exposing this is load-bearing: React's resource hoisting computes its
@@ -1104,6 +1549,8 @@ module Dommy
1104
1549
  contains?(args[0])
1105
1550
  when "isEqualNode"
1106
1551
  is_equal_node(args[0])
1552
+ when "isSameNode"
1553
+ is_same_node(args[0])
1107
1554
  when "appendChild"
1108
1555
  append_child(args[0])
1109
1556
  when "append"
@@ -1115,6 +1562,11 @@ module Dommy
1115
1562
  when "removeChild"
1116
1563
  document_remove_child(args[0])
1117
1564
  when "insertBefore"
1565
+ raise Bridge::TypeError, "insertBefore requires 2 arguments." if args.length < 2
1566
+ unless args[1].nil? || args[1].equal?(Bridge::UNDEFINED) || args[1].is_a?(Dommy::Node)
1567
+ raise Bridge::TypeError, "The reference child is not a Node."
1568
+ end
1569
+
1118
1570
  document_insert_before(args[0], args[1])
1119
1571
  when "replaceChild"
1120
1572
  document_replace_child(args[0], args[1])
@@ -1246,6 +1698,10 @@ module Dommy
1246
1698
  @node_wrapper_cache.wrap(node)
1247
1699
  end
1248
1700
 
1701
+ def wrap_cloned_element_ns(node, namespace, prefix, local, qualified_name)
1702
+ @node_wrapper_cache.wrap_cloned_element_ns(node, namespace, prefix, local, qualified_name)
1703
+ end
1704
+
1249
1705
  # Clear the cached wrapper so the next `wrap_node` creates a new
1250
1706
  # one. Used by `customElements.define` to upgrade nodes that were
1251
1707
  # constructed before the registration landed.
@@ -1253,6 +1709,10 @@ module Dommy
1253
1709
  @node_wrapper_cache.reset_wrapper(nokogiri_node)
1254
1710
  end
1255
1711
 
1712
+ def __internal_peek_wrapper__(nokogiri_node)
1713
+ @node_wrapper_cache.peek(nokogiri_node)
1714
+ end
1715
+
1256
1716
  # ShadowRoot identity registry: map a Nokogiri DocumentFragment
1257
1717
  # (the shadow tree's backing node) to the wrapping ShadowRoot so
1258
1718
  # slot assignment and event composition can walk from any inner
@@ -1380,7 +1840,8 @@ module Dommy
1380
1840
  end
1381
1841
 
1382
1842
  def create_text_node(text)
1383
- @node_wrapper_cache.create_text_node(text)
1843
+ # WebIDL DOMString: JS null coerces to "null" (undefined -> "undefined").
1844
+ @node_wrapper_cache.create_text_node(text.nil? ? "null" : text)
1384
1845
  end
1385
1846
 
1386
1847
  def query_selector(selector)
@@ -1402,7 +1863,9 @@ module Dommy
1402
1863
  end
1403
1864
 
1404
1865
  def get_element_by_id(id)
1405
- @node_wrapper_cache.get_element_by_id(id)
1866
+ # WebIDL DOMString: a null argument coerces to "null" (so it can match an
1867
+ # element with id="null"); undefined already stringifies to "undefined".
1868
+ @node_wrapper_cache.get_element_by_id(id.nil? ? "null" : id)
1406
1869
  end
1407
1870
 
1408
1871
  # ----- template content helpers (called from Element) -----
@@ -1485,9 +1948,14 @@ module Dommy
1485
1948
  end
1486
1949
 
1487
1950
  def read_title
1488
- head = @backend_doc.at_css("head")
1489
- title = head&.at_css("title")
1490
- title ? title.text : ""
1951
+ # The first title element in tree order (usually the head's), with its
1952
+ # child text content stripped and collapsed of ASCII whitespace per WHATWG.
1953
+ # ASCII whitespace is exactly tab/LF/FF/CR/space — NOT Ruby's String#strip
1954
+ # set, which also removes U+000B (vertical tab) and must be left intact.
1955
+ title = @backend_doc.at_css("title")
1956
+ return "" unless title
1957
+
1958
+ title.text.gsub(/[\t\n\f\r ]+/, " ").gsub(/\A[\t\n\f\r ]+|[\t\n\f\r ]+\z/, "")
1491
1959
  end
1492
1960
 
1493
1961
  def write_title(value)