dommy 0.9.0 → 0.11.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 (68) hide show
  1. checksums.yaml +4 -4
  2. data/lib/dommy/attr.rb +50 -13
  3. data/lib/dommy/backend/makiri_adapter.rb +56 -0
  4. data/lib/dommy/backend.rb +47 -0
  5. data/lib/dommy/blob.rb +48 -6
  6. data/lib/dommy/browser.rb +246 -36
  7. data/lib/dommy/callable_invoker.rb +16 -4
  8. data/lib/dommy/css.rb +33 -10
  9. data/lib/dommy/custom_elements.rb +42 -2
  10. data/lib/dommy/document.rb +1299 -152
  11. data/lib/dommy/dom_parser.rb +12 -1
  12. data/lib/dommy/element.rb +824 -413
  13. data/lib/dommy/event.rb +744 -115
  14. data/lib/dommy/fetch.rb +431 -25
  15. data/lib/dommy/history.rb +24 -3
  16. data/lib/dommy/html_collection.rb +148 -21
  17. data/lib/dommy/html_elements.rb +2307 -317
  18. data/lib/dommy/interaction/driver.rb +177 -14
  19. data/lib/dommy/interaction/event_synthesis.rb +111 -6
  20. data/lib/dommy/interaction/field_interactor.rb +73 -7
  21. data/lib/dommy/internal/accessible_name.rb +45 -8
  22. data/lib/dommy/internal/aria_role.rb +5 -3
  23. data/lib/dommy/internal/child_node.rb +313 -0
  24. data/lib/dommy/internal/css/cascade.rb +47 -3
  25. data/lib/dommy/internal/css/parser.rb +97 -14
  26. data/lib/dommy/internal/css/rule_index.rb +288 -18
  27. data/lib/dommy/internal/css_priority.rb +33 -0
  28. data/lib/dommy/internal/dom_matching.rb +1 -1
  29. data/lib/dommy/internal/global_functions.rb +23 -10
  30. data/lib/dommy/internal/leaf_node.rb +51 -0
  31. data/lib/dommy/internal/mutation_coordinator.rb +161 -34
  32. data/lib/dommy/internal/node_adopter.rb +190 -0
  33. data/lib/dommy/internal/node_traversal.rb +10 -0
  34. data/lib/dommy/internal/node_wrapper_cache.rb +109 -26
  35. data/lib/dommy/internal/observer_manager.rb +24 -0
  36. data/lib/dommy/internal/observer_matcher.rb +39 -8
  37. data/lib/dommy/internal/parent_node.rb +345 -82
  38. data/lib/dommy/internal/range_text_serializer.rb +5 -4
  39. data/lib/dommy/internal/selector_index.rb +24 -8
  40. data/lib/dommy/internal/selector_matcher.rb +221 -16
  41. data/lib/dommy/internal/selector_parser.rb +44 -7
  42. data/lib/dommy/internal/template_content_registry.rb +23 -7
  43. data/lib/dommy/internal/utf16.rb +45 -0
  44. data/lib/dommy/internal/xml_serialization.rb +18 -5
  45. data/lib/dommy/js/custom_element_bridge.rb +13 -2
  46. data/lib/dommy/js/dom_interfaces.rb +91 -13
  47. data/lib/dommy/js/host_bridge.rb +86 -15
  48. data/lib/dommy/js/host_runtime.js +1444 -98
  49. data/lib/dommy/js/marshaller.rb +12 -7
  50. data/lib/dommy/js/script_boot.rb +81 -0
  51. data/lib/dommy/location.rb +76 -13
  52. data/lib/dommy/mutation_observer.rb +109 -20
  53. data/lib/dommy/navigation.rb +263 -0
  54. data/lib/dommy/node.rb +207 -27
  55. data/lib/dommy/parser.rb +14 -0
  56. data/lib/dommy/promise.rb +4 -4
  57. data/lib/dommy/range.rb +373 -65
  58. data/lib/dommy/scheduler.rb +16 -0
  59. data/lib/dommy/shadow_root.rb +65 -17
  60. data/lib/dommy/storage.rb +61 -10
  61. data/lib/dommy/tree_walker.rb +25 -39
  62. data/lib/dommy/url.rb +4 -2
  63. data/lib/dommy/version.rb +1 -1
  64. data/lib/dommy/web_socket.rb +45 -2
  65. data/lib/dommy/window.rb +200 -11
  66. data/lib/dommy/xml_http_request.rb +30 -4
  67. data/lib/dommy.rb +5 -0
  68. metadata +9 -9
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ab840b2c863131b5f20d7be40f28028bec227fb490688b4b0f26157058c0ea68
4
- data.tar.gz: a87acf4824167a08eae696de0ffe504cdf808d9085176e084b3e4f3d225e5c7a
3
+ metadata.gz: 47c59ce209a6e63313db2b6685126910463abfc26020ec5f24a7b7494b67b10f
4
+ data.tar.gz: 43980f4f5eb7b2c5bfe76e41bd2786e9c8cad571df1eb882e0e6d5107fbe04c6
5
5
  SHA512:
6
- metadata.gz: db29082c6b8b686eb762aa53f3ae213b40da69210732c99a53f15a1f18318a326fd6bcf4c65fcfa4abc27dc41bebff0b16164682b7d18e7fed7a44415a9bb7e6
7
- data.tar.gz: 71669401a09832106ec301e0b2d4747fb7782b2ac2514fbf4cfbf625358695f0c498561cbd3efb565b7b2abbaf43b4b2068dbe5f1831523a74ec25dc2c2e1435
6
+ metadata.gz: b2627fa8969554f7ecb7a04ed16c331a77e604b44a9ca9e95412a89e48412744a3dd606892255e93eb56988cfda5214000a15821bbc5eb6ed7d18701740b3262
7
+ data.tar.gz: 632bddbea02093111566d46ba9fc44af35ed17879bf9921c4ad477a138d44b83e2c2761337d34f5bdfa31cc150353a4b7afe47b0cca8d817e79eda95d7b556ad
data/lib/dommy/attr.rb CHANGED
@@ -22,9 +22,12 @@ module Dommy
22
22
  nil
23
23
  end
24
24
 
25
- def initialize(name, owner: nil, value: "", namespace_uri: nil, prefix: nil, local_name: nil)
25
+ def initialize(name, owner: nil, value: "", namespace_uri: nil, prefix: nil, local_name: nil, document: nil)
26
26
  qname = name.to_s
27
27
  @owner = owner
28
+ # The node document (for baseURI/ownerDocument when detached from an
29
+ # element). Owned attrs derive it from their owner instead.
30
+ @document = document
28
31
  @detached_value = value.to_s
29
32
  if namespace_uri && !namespace_uri.to_s.empty?
30
33
  # Namespaced attributes preserve case and carry prefix / localName.
@@ -50,13 +53,28 @@ module Dommy
50
53
  @owner
51
54
  end
52
55
 
56
+ # Node.baseURI — the node document's base URL. Derived from the owner
57
+ # element when attached, else the document the attr was created in.
58
+ def base_uri
59
+ return @owner.base_uri if @owner.respond_to?(:base_uri)
60
+
61
+ @document&.base_uri
62
+ end
63
+
64
+ # Node.ownerDocument — the owner element's current document when attached (so
65
+ # it follows the element across adoptNode), else the creation document.
66
+ def owner_document
67
+ return @owner.document if @owner.respond_to?(:document)
68
+
69
+ @document
70
+ end
71
+
53
72
  def value
54
73
  if @owner
55
- if @namespace_uri
56
- Backend.get_attribute_ns(@owner.__dommy_backend_node__, @namespace_uri, @local_name).to_s
57
- else
58
- @owner.__dommy_backend_node__[@name].to_s
59
- end
74
+ # Both branches go through the namespace-aware read: the backend's
75
+ # `node[name]` indexes by local name, so a null-namespace `b` would read
76
+ # back the value of a prefixed `xml:b` sitting on the same element.
77
+ Backend.get_attribute_ns(@owner.__dommy_backend_node__, @namespace_uri, @local_name).to_s
60
78
  else
61
79
  @detached_value
62
80
  end
@@ -89,6 +107,8 @@ module Dommy
89
107
  value
90
108
  when "ownerElement"
91
109
  @owner
110
+ when "ownerDocument"
111
+ owner_document
92
112
  when "localName"
93
113
  @local_name
94
114
  when "namespaceURI"
@@ -97,6 +117,8 @@ module Dommy
97
117
  @prefix
98
118
  when "nodeType"
99
119
  2
120
+ when "baseURI"
121
+ base_uri
100
122
  when "specified"
101
123
  # Legacy/useless attribute — always true (WHATWG DOM).
102
124
  true
@@ -119,12 +141,20 @@ module Dommy
119
141
  include Bridge::Methods
120
142
  js_methods %w[cloneNode isSameNode getRootNode hasChildNodes normalize compareDocumentPosition
121
143
  appendChild insertBefore removeChild replaceChild
144
+ lookupNamespaceURI lookupPrefix isDefaultNamespace
122
145
  addEventListener removeEventListener dispatchEvent]
123
146
  def __js_call__(method, args)
124
147
  case method
148
+ when "lookupNamespaceURI"
149
+ lookup_namespace_uri(args[0])
150
+ when "lookupPrefix"
151
+ lookup_prefix(args[0])
152
+ when "isDefaultNamespace"
153
+ is_default_namespace(args[0])
125
154
  when "cloneNode"
126
155
  Attr.new(@name, owner: nil, value: value,
127
- namespace_uri: @namespace_uri, prefix: @prefix, local_name: @local_name)
156
+ namespace_uri: @namespace_uri, prefix: @prefix, local_name: @local_name,
157
+ document: @document || (@owner.respond_to?(:document) ? @owner.document : nil))
128
158
  when "isSameNode"
129
159
  is_same_node(args[0])
130
160
  when "getRootNode"
@@ -209,7 +239,9 @@ module Dommy
209
239
  end
210
240
 
211
241
  def get_named_item(name)
212
- key = name.to_s.downcase
242
+ # getNamedItem / getAttribute lowercase the qualified name only for an HTML
243
+ # element in an HTML document; other elements match case-sensitively.
244
+ key = @element.__internal_normalize_attr_key__(name)
213
245
  node = Backend.attribute_nodes(@element.__dommy_backend_node__).find do |a|
214
246
  Backend.attribute_ns_info(a)[:qualified_name] == key
215
247
  end
@@ -221,7 +253,7 @@ module Dommy
221
253
  end
222
254
 
223
255
  def remove_named_item(name)
224
- key = name.to_s.downcase
256
+ key = @element.__internal_normalize_attr_key__(name)
225
257
  node = Backend.attribute_nodes(@element.__dommy_backend_node__).find do |a|
226
258
  Backend.attribute_ns_info(a)[:qualified_name] == key
227
259
  end
@@ -329,12 +361,17 @@ module Dommy
329
361
  end
330
362
  end
331
363
 
332
- # WebIDL "supported property names" for NamedNodeMap: the qualified name of
333
- # each attribute, in order (the indexed names are reflected separately).
364
+ # WebIDL "supported property names" for NamedNodeMap: each attribute's
365
+ # qualified name, in order with duplicates omitted (the indexed names are
366
+ # reflected separately). For an HTML element in an HTML document, names
367
+ # containing an ASCII upper alpha are excluded (they can't be reached by the
368
+ # case-insensitive named getter).
334
369
  def __js_named_props__
335
- Backend.attribute_nodes(@element.__dommy_backend_node__).map do |a|
370
+ names = Backend.attribute_nodes(@element.__dommy_backend_node__).map do |a|
336
371
  Backend.attribute_ns_info(a)[:qualified_name]
337
- end
372
+ end.uniq
373
+ names.reject! { |n| n.match?(/[A-Z]/) } unless @element.__internal_case_sensitive_attribute_names__?
374
+ names
338
375
  end
339
376
 
340
377
  include Bridge::Methods
@@ -21,6 +21,7 @@ module Dommy
21
21
  CDATASection = ::Makiri::CDATASection
22
22
  ProcessingInstruction = ::Makiri::ProcessingInstruction
23
23
  DocumentFragment = ::Makiri::DocumentFragment
24
+ DocumentType = ::Makiri::DocumentType
24
25
  Node = ::Makiri::Node
25
26
 
26
27
  # A minimal namespace wrapper exposing the same `href` API that Nokogiri's
@@ -168,6 +169,39 @@ module Dommy
168
169
  doc.create_element(name)
169
170
  end
170
171
 
172
+ # An XML-backed document rejects a qualified name that DOM allows (e.g.
173
+ # "f}oo" — an invalid char in the local part), so createElementNS uses
174
+ # Makiri's loose creator, which builds it verbatim (case/prefix preserved).
175
+ # nil for a non-XML backend → the caller uses the strict #create_element.
176
+ def create_element_loose(qualified_name, prefix, local, namespace, doc)
177
+ return nil unless doc.is_a?(::Makiri::XML::Document) && doc.respond_to?(:create_loose_dom_element)
178
+
179
+ doc.create_loose_dom_element(qualified_name, prefix, local, namespace)
180
+ end
181
+
182
+ # A detached DocumentType node owned by `doc`, for
183
+ # DOMImplementation.createDocumentType. Only the HTML backend ships the
184
+ # factory (`create_document_type`); nil signals the caller to fall back to a
185
+ # synthetic (non-tree) DocumentType. Raises ArgumentError for a name the
186
+ # factory rejects.
187
+ def create_document_type(name, public_id, system_id, doc)
188
+ return nil unless doc.respond_to?(:create_document_type)
189
+
190
+ doc.create_document_type(name.to_s, public_id.to_s, system_id.to_s)
191
+ end
192
+
193
+ # The parsed document's DocumentType node (`<!DOCTYPE …>`), or nil when the
194
+ # document declares none.
195
+ def internal_subset(doc)
196
+ doc.respond_to?(:internal_subset) ? doc.internal_subset : nil
197
+ end
198
+
199
+ # The backend class for a DocumentType node, so the wrapper routes it to
200
+ # Dommy::DocumentType (node-backed).
201
+ def document_type_class
202
+ ::Makiri::DocumentType
203
+ end
204
+
171
205
  def create_text(content, doc)
172
206
  doc.create_text_node(content)
173
207
  end
@@ -242,6 +276,12 @@ module Dommy
242
276
 
243
277
  node["xmlns:#{prefix}"] = href.to_s
244
278
  nil
279
+ rescue ArgumentError
280
+ # DOM validates a qualified name against the Name production, which
281
+ # admits prefixes an XML backend cannot spell as an `xmlns:` attribute
282
+ # ("0:a", ";:a"). The element is still valid — its prefix and namespace
283
+ # live on the wrapper — so the declaration is simply not written.
284
+ nil
245
285
  end
246
286
 
247
287
  def namespace_definitions(_node)
@@ -296,6 +336,22 @@ module Dommy
296
336
  node.attribute_nodes
297
337
  end
298
338
 
339
+ # Attribute node matching the qualified name exactly. `node[name]` would
340
+ # also answer for a prefixed attribute with that local name.
341
+ #
342
+ # This sits under `getAttribute` / `hasAttribute` / `setAttribute` /
343
+ # `removeAttribute`, so it runs on the hottest path in the library — every
344
+ # CSS match, every reflected IDL attribute — and Makiri does the scan
345
+ # natively (0.9.0). The value-returning spelling is what the two readers
346
+ # that only want the value ask for: it wraps no Attr at all.
347
+ def attr_by_qualified_name(node, qualified_name)
348
+ node.attribute_by_qualified_name(qualified_name.to_s)
349
+ end
350
+
351
+ def attr_value_by_qualified_name(node, qualified_name)
352
+ node.attribute_value_by_qualified_name(qualified_name.to_s)
353
+ end
354
+
299
355
  # Attribute node matching (namespace, local name) case-sensitively; a
300
356
  # null/empty namespace matches a null-namespace attribute.
301
357
  def attr_by_ns(node, namespace, local_name)
data/lib/dommy/backend.rb CHANGED
@@ -130,6 +130,35 @@ module Dommy
130
130
  current.create_element(name, doc)
131
131
  end
132
132
 
133
+ # Create a namespaced element permitting a DOM-valid qualified name that a
134
+ # strict XML backend would reject (an internal invalid char like "f}oo"),
135
+ # preserving case/prefix. Returns nil when the backend has no loose path
136
+ # (fall back to #create_element); raises ArgumentError for a genuinely
137
+ # invalid name (the caller maps it to InvalidCharacterError).
138
+ def create_element_loose(qualified_name, prefix, local, namespace, doc)
139
+ current.create_element_loose(qualified_name, prefix, local, namespace, doc)
140
+ end
141
+
142
+ # A detached DocumentType node owned by `doc` (for
143
+ # DOMImplementation.createDocumentType). Returns nil when the backend has no
144
+ # doctype factory (the caller falls back to a synthetic DocumentType); raises
145
+ # ArgumentError for a name the factory rejects (the caller then also falls
146
+ # back, since createDocumentType is permissive).
147
+ def create_document_type(name, public_id, system_id, doc)
148
+ current.respond_to?(:create_document_type) ? current.create_document_type(name, public_id, system_id, doc) : nil
149
+ end
150
+
151
+ # The parsed document's DocumentType node, or nil when it declares none.
152
+ def internal_subset(doc)
153
+ current.respond_to?(:internal_subset) ? current.internal_subset(doc) : nil
154
+ end
155
+
156
+ # The backend class for a DocumentType node (nil if unsupported), so the
157
+ # wrapper cache can route it to Dommy::DocumentType.
158
+ def document_type_class
159
+ current.respond_to?(:document_type_class) ? current.document_type_class : nil
160
+ end
161
+
133
162
  def create_text(content, doc)
134
163
  current.create_text(content, doc)
135
164
  end
@@ -204,6 +233,24 @@ module Dommy
204
233
  current.attribute_ns_info(attr_node)
205
234
  end
206
235
 
236
+ # The attribute node whose QUALIFIED name is `qualified_name`, or nil.
237
+ #
238
+ # WHATWG's by-name family ("get an attribute by name", `setAttribute`,
239
+ # `removeAttribute`) matches on the qualified name. A lookup by local name
240
+ # confuses `b` with a prefixed `xml:b`, so those paths must come through
241
+ # here rather than through `node[name]`.
242
+ def attr_by_qualified_name(node, qualified_name)
243
+ current.attr_by_qualified_name(node, qualified_name)
244
+ end
245
+
246
+ # That attribute's VALUE, or nil when there is no such attribute. The same
247
+ # match without an attribute node in hand, for the two readers that only
248
+ # ever wanted the value — `getAttribute` and `hasAttribute` — which run on
249
+ # every CSS match and every reflected IDL attribute.
250
+ def attr_value_by_qualified_name(node, qualified_name)
251
+ current.attr_value_by_qualified_name(node, qualified_name)
252
+ end
253
+
207
254
  # The element's attribute nodes (each readable via attribute_ns_info).
208
255
  # The single choke point so DOM code doesn't touch parser internals.
209
256
  def attribute_nodes(node)
data/lib/dommy/blob.rb CHANGED
@@ -21,19 +21,26 @@ module Dommy
21
21
  # Promises (they need a scheduler). A window-less Blob falls back to a
22
22
  # synchronous result, which `await` still handles.
23
23
  def initialize(parts = [], options = {}, window = nil)
24
+ # WebIDL: an omitted / `undefined` blobParts argument defaults to an empty
25
+ # sequence (`new Blob()` / `new Blob(undefined)` is a zero-length Blob), so
26
+ # it must not be coerced to the string "undefined".
27
+ parts = [] if parts.nil? || (defined?(Bridge::UNDEFINED) && parts.equal?(Bridge::UNDEFINED))
24
28
  parts = [parts] unless parts.is_a?(Array)
25
29
  @data = collect_bytes(parts)
26
30
  @size = @data.bytesize
27
- raw_type = options["type"] || options[:type] || ""
28
- @type = raw_type.to_s.downcase
31
+ raw_type = (options["type"] || options[:type] || "").to_s
32
+ # A type string with any code point outside U+0020..U+007E is discarded
33
+ # (→ ""); otherwise it is ASCII-lowercased (FileAPI "parse a MIME type"
34
+ # gate, applied to both the constructor and slice's contentType).
35
+ @type = raw_type.match?(/[^ -~]/) ? "" : raw_type.downcase
29
36
  @window = window
30
37
  end
31
38
 
32
39
  # Return a new Blob over a byte range of this one.
33
40
  # Negative indices are treated as offsets from the end (per spec).
34
41
  def slice(start = 0, last = @size, content_type = "")
35
- s = clamp_index(start.to_i, @size)
36
- e = clamp_index(last.to_i, @size)
42
+ s = clamp_index(clamp_long_long(start), @size)
43
+ e = clamp_index(clamp_long_long(last), @size)
37
44
  e = s if e < s
38
45
  Blob.new([@data.byteslice(s, e - s) || ""], {"type" => content_type.to_s}, @window)
39
46
  end
@@ -51,6 +58,13 @@ module Dommy
51
58
  Bridge::ArrayBuffer.new(@data.bytes)
52
59
  end
53
60
 
61
+ # Read the bytes as a Uint8Array (the spec return type). The DOM spec returns
62
+ # a Promise<Uint8Array>; Dommy is synchronous. `Bridge::Bytes` crosses the JS
63
+ # boundary as a Uint8Array (vs `ArrayBuffer` for #array_buffer).
64
+ def bytes
65
+ Bridge::Bytes.new(@data.bytes)
66
+ end
67
+
54
68
  # Raw binary bytes (Ruby ASCII-8BIT string). Used by FormData /
55
69
  # fetch when serializing multipart bodies.
56
70
  def __dommy_bytes__
@@ -71,17 +85,30 @@ module Dommy
71
85
  # Methods routed through __js_call__ (keep in sync with its when-arms).
72
86
  # File < Blob inherits these (it adds only properties).
73
87
  include Bridge::Methods
74
- js_methods %w[slice text arrayBuffer]
88
+ js_methods %w[slice text arrayBuffer bytes]
75
89
  def __js_call__(method, args)
76
90
  case method
77
91
  when "slice"
78
- slice(args[0] || 0, args[1] || @size, args[2] || "")
92
+ # An omitted / `undefined` start|end uses the default (0 / size); map
93
+ # UNDEFINED to nil so the `|| default` fallbacks apply (a bare UNDEFINED
94
+ # is truthy and has no #to_i). contentType is a plain DOMString: omitted
95
+ # / undefined → "" (default), but an explicit JS null coerces to "null".
96
+ a = args.map { |v| v.equal?(Bridge::UNDEFINED) ? nil : v }
97
+ ctype = if args.length < 3 || args[2].equal?(Bridge::UNDEFINED)
98
+ ""
99
+ else
100
+ args[2].nil? ? "null" : args[2].to_s
101
+ end
102
+ slice(a[0] || 0, a[1] || @size, ctype)
79
103
  when "text"
80
104
  # WHATWG: Blob.text() returns a Promise<string>.
81
105
  promise_or_value(text)
82
106
  when "arrayBuffer"
83
107
  # WHATWG: Blob.arrayBuffer() returns a Promise<ArrayBuffer>.
84
108
  promise_or_value(array_buffer)
109
+ when "bytes"
110
+ # WHATWG: Blob.bytes() returns a Promise<Uint8Array>.
111
+ promise_or_value(bytes)
85
112
  end
86
113
  end
87
114
 
@@ -115,6 +142,21 @@ module Dommy
115
142
  idx = length + idx if idx.negative?
116
143
  idx.clamp(0, length)
117
144
  end
145
+
146
+ # WebIDL `[Clamp] long long` conversion of a slice bound: a fractional value
147
+ # rounds to the nearest integer, ties to even (banker's rounding) — so
148
+ # `slice(1.5)` starts at 2 and `slice(3.5)` at 4, per the [Clamp] extended
149
+ # attribute on Blob.slice.
150
+ def clamp_long_long(value)
151
+ return value if value.is_a?(Integer)
152
+
153
+ f = value.to_f
154
+ return 0 if f.nan?
155
+
156
+ f.round(half: :even)
157
+ rescue StandardError
158
+ 0
159
+ end
118
160
  end
119
161
 
120
162
  # `File` — Blob with a filename and an optional last-modified