dommy 0.8.1 → 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.
- checksums.yaml +4 -4
- data/README.md +3 -3
- data/lib/dommy/animation.rb +4 -0
- data/lib/dommy/attr.rb +57 -13
- data/lib/dommy/backend/makiri_adapter.rb +364 -0
- data/lib/dommy/backend.rb +143 -33
- data/lib/dommy/blob.rb +50 -6
- data/lib/dommy/bridge.rb +11 -0
- data/lib/dommy/browser.rb +420 -0
- data/lib/dommy/callable_invoker.rb +6 -2
- data/lib/dommy/compression_streams.rb +4 -0
- data/lib/dommy/crypto.rb +4 -0
- data/lib/dommy/css.rb +487 -50
- data/lib/dommy/custom_elements.rb +2 -2
- data/lib/dommy/data_transfer.rb +2 -0
- data/lib/dommy/data_uri.rb +35 -0
- data/lib/dommy/deferred_response.rb +59 -0
- data/lib/dommy/document.rb +901 -275
- data/lib/dommy/dom_exception.rb +2 -0
- data/lib/dommy/dom_parser.rb +7 -17
- data/lib/dommy/element.rb +940 -353
- data/lib/dommy/event.rb +529 -17
- data/lib/dommy/fetch.rb +577 -53
- data/lib/dommy/form_data.rb +2 -0
- data/lib/dommy/history.rb +26 -3
- data/lib/dommy/html_canvas_element.rb +230 -0
- data/lib/dommy/html_collection.rb +150 -27
- data/lib/dommy/html_elements.rb +1968 -282
- data/lib/dommy/interaction/debug.rb +35 -0
- data/lib/dommy/interaction/dom_summary.rb +131 -0
- data/lib/dommy/interaction/driver.rb +385 -0
- data/lib/dommy/interaction/event_synthesis.rb +164 -0
- data/lib/dommy/interaction/field_interactor.rb +177 -0
- data/lib/dommy/interaction/form_submission.rb +268 -0
- data/lib/dommy/interaction/locator.rb +158 -0
- data/lib/dommy/interaction/role_query.rb +58 -0
- data/lib/dommy/interaction.rb +32 -0
- data/lib/dommy/internal/accessibility_tree.rb +215 -0
- data/lib/dommy/internal/accessible_description.rb +38 -0
- data/lib/dommy/internal/accessible_name.rb +301 -0
- data/lib/dommy/internal/aria_role.rb +252 -0
- data/lib/dommy/internal/aria_snapshot.rb +64 -0
- data/lib/dommy/internal/aria_state.rb +151 -0
- data/lib/dommy/internal/child_node.rb +199 -0
- data/lib/dommy/internal/css/calc.rb +242 -0
- data/lib/dommy/internal/css/cascade.rb +472 -0
- data/lib/dommy/internal/css/color.rb +381 -0
- data/lib/dommy/internal/css/computed_style_declaration.rb +130 -0
- data/lib/dommy/internal/css/counters.rb +227 -0
- data/lib/dommy/internal/css/custom_properties.rb +183 -0
- data/lib/dommy/internal/css/media_query.rb +302 -0
- data/lib/dommy/internal/css/parser.rb +265 -0
- data/lib/dommy/internal/css/property_registry.rb +512 -0
- data/lib/dommy/internal/css/rule_index.rb +494 -0
- data/lib/dommy/internal/css/supports.rb +158 -0
- data/lib/dommy/internal/css/ua_stylesheet.rb +53 -0
- data/lib/dommy/internal/css_pseudo_handlers.rb +283 -42
- data/lib/dommy/internal/css_rule_text.rb +160 -0
- data/lib/dommy/internal/dom_matching.rb +80 -9
- data/lib/dommy/internal/element_matching.rb +109 -0
- data/lib/dommy/internal/global_functions.rb +55 -9
- data/lib/dommy/internal/mutation_coordinator.rb +122 -11
- data/lib/dommy/internal/namespaces.rb +49 -5
- data/lib/dommy/internal/node_wrapper_cache.rb +217 -38
- data/lib/dommy/internal/observer_manager.rb +6 -0
- data/lib/dommy/internal/observer_matcher.rb +6 -8
- data/lib/dommy/internal/parent_node.rb +98 -57
- data/lib/dommy/internal/selector_ast.rb +124 -0
- data/lib/dommy/internal/selector_index.rb +146 -0
- data/lib/dommy/internal/selector_matcher.rb +845 -0
- data/lib/dommy/internal/selector_parser.rb +321 -132
- data/lib/dommy/internal/shadow_root_registry.rb +9 -2
- data/lib/dommy/internal/template_content_registry.rb +26 -18
- data/lib/dommy/internal/xml_serialization.rb +344 -0
- data/lib/dommy/intersection_observer.rb +2 -0
- data/lib/dommy/js/bridge_conformance.rb +80 -0
- data/lib/dommy/js/constructor_resolver.rb +44 -0
- data/lib/dommy/js/custom_element_bridge.rb +101 -0
- data/lib/dommy/js/dom_interfaces.rb +177 -0
- data/lib/dommy/js/handle_table.rb +60 -0
- data/lib/dommy/js/host_bridge.rb +538 -0
- data/lib/dommy/js/host_runtime.js +2357 -0
- data/lib/dommy/js/import_map.rb +58 -0
- data/lib/dommy/js/marshaller.rb +240 -0
- data/lib/dommy/js/module_loader.rb +99 -0
- data/lib/dommy/js/observable_runtime.js +742 -0
- data/lib/dommy/js/runtime.rb +115 -0
- data/lib/dommy/js/script_boot.rb +301 -0
- data/lib/dommy/js/wire_tags.rb +62 -0
- data/lib/dommy/location.rb +78 -13
- data/lib/dommy/media_query_list.rb +50 -14
- data/lib/dommy/message_channel.rb +22 -6
- data/lib/dommy/minitest/assertions.rb +27 -0
- data/lib/dommy/mutation_observer.rb +89 -5
- data/lib/dommy/navigation.rb +263 -0
- data/lib/dommy/navigator.rb +34 -2
- data/lib/dommy/node.rb +198 -35
- data/lib/dommy/notification.rb +2 -0
- data/lib/dommy/parser.rb +1 -1
- data/lib/dommy/performance.rb +21 -1
- data/lib/dommy/promise.rb +94 -10
- data/lib/dommy/range.rb +187 -33
- data/lib/dommy/resources.rb +178 -0
- data/lib/dommy/rspec/capy_style_matchers.rb +126 -0
- data/lib/dommy/scheduler.rb +165 -13
- data/lib/dommy/screen.rb +91 -0
- data/lib/dommy/shadow_root.rb +109 -13
- data/lib/dommy/storage.rb +63 -11
- data/lib/dommy/streams.rb +6 -0
- data/lib/dommy/text_codec.rb +7 -1
- data/lib/dommy/tree_walker.rb +51 -46
- data/lib/dommy/url.rb +17 -3
- data/lib/dommy/version.rb +1 -1
- data/lib/dommy/web_socket.rb +45 -2
- data/lib/dommy/window.rb +322 -15
- data/lib/dommy/worker.rb +8 -4
- data/lib/dommy/xml_http_request.rb +76 -9
- data/lib/dommy.rb +38 -1
- metadata +82 -14
- data/lib/dommy/backend/nokogiri_adapter.rb +0 -127
- data/lib/dommy/backend/nokolexbor_adapter.rb +0 -117
data/lib/dommy/range.rb
CHANGED
|
@@ -111,6 +111,20 @@ module Dommy
|
|
|
111
111
|
nil
|
|
112
112
|
end
|
|
113
113
|
|
|
114
|
+
# `createContextualFragment(html)` — parse an HTML string into a
|
|
115
|
+
# DocumentFragment using the range's start node as the parsing context
|
|
116
|
+
# (DOM Parsing & Serialization). Frameworks use it to turn an HTML string
|
|
117
|
+
# into nodes (Nuxt's DOM hydration / `<slot>` helpers call it via a Range).
|
|
118
|
+
def create_contextual_fragment(html)
|
|
119
|
+
context = @document.create_element(contextual_local_name)
|
|
120
|
+
# WebIDL DOMString coercion: JS null stringifies to "null" (no
|
|
121
|
+
# [LegacyNullToEmptyString] here), the UNDEFINED sentinel to "undefined".
|
|
122
|
+
context.inner_html = html.nil? ? "null" : html.to_s # fragment-parses in the context element
|
|
123
|
+
fragment = @document.create_document_fragment
|
|
124
|
+
context.child_nodes.to_a.each { |child| fragment.append_child(child) }
|
|
125
|
+
fragment
|
|
126
|
+
end
|
|
127
|
+
|
|
114
128
|
# --- Content extraction ----------------------------------------
|
|
115
129
|
|
|
116
130
|
def to_s
|
|
@@ -138,21 +152,87 @@ module Dommy
|
|
|
138
152
|
fragment
|
|
139
153
|
end
|
|
140
154
|
|
|
155
|
+
# WHATWG Range.deleteContents. Removes the fully-contained nodes (childList
|
|
156
|
+
# records) and trims the partially-contained boundary CharacterData nodes
|
|
157
|
+
# (characterData records via `data=`), rather than removing boundary text
|
|
158
|
+
# nodes whole — so a deletion like `"abc"[1]…"def"[1]` leaves `"a"…"ef"`.
|
|
141
159
|
def delete_contents
|
|
142
|
-
|
|
160
|
+
return nil if collapsed?
|
|
161
|
+
|
|
162
|
+
sc = @start_container
|
|
163
|
+
so = @start_offset
|
|
164
|
+
ec = @end_container
|
|
165
|
+
eo = @end_offset
|
|
166
|
+
|
|
167
|
+
# Both boundaries in the same text node: just delete the substring.
|
|
168
|
+
if sc.equal?(ec) && text_node?(sc)
|
|
169
|
+
d = sc.data.to_s
|
|
170
|
+
sc.data = d[0, so].to_s + (d[eo..] || "")
|
|
171
|
+
return nil
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
to_remove = nodes_to_remove
|
|
175
|
+
new_node, new_offset = deletion_collapse_point(sc, so, ec, eo)
|
|
176
|
+
|
|
177
|
+
# Trim the start boundary text node's tail, remove the contained nodes,
|
|
178
|
+
# then trim the end boundary text node's head (order matters for records).
|
|
179
|
+
sc.data = sc.data.to_s[0, so].to_s if text_node?(sc)
|
|
180
|
+
to_remove.each do |node|
|
|
143
181
|
if node.respond_to?(:__dommy_backend_node__)
|
|
144
|
-
# Fire a childList removal record (with correct sibling context) on the
|
|
145
|
-
# node's parent, like any other tree removal.
|
|
146
182
|
@document.remove_node_with_notify(node.__dommy_backend_node__)
|
|
147
183
|
elsif node.respond_to?(:remove)
|
|
148
184
|
node.remove
|
|
149
185
|
end
|
|
150
186
|
end
|
|
187
|
+
ec.data = (ec.data.to_s[eo..] || "") if text_node?(ec)
|
|
151
188
|
|
|
152
|
-
|
|
189
|
+
@start_container = @end_container = new_node
|
|
190
|
+
@start_offset = @end_offset = new_offset
|
|
153
191
|
nil
|
|
154
192
|
end
|
|
155
193
|
|
|
194
|
+
# Top-level nodes fully contained in the range (their parent isn't), removed
|
|
195
|
+
# whole. Deeper contained nodes go with their removed ancestor.
|
|
196
|
+
def nodes_to_remove
|
|
197
|
+
ancestor = common_ancestor_container
|
|
198
|
+
return [] unless ancestor.respond_to?(:child_nodes)
|
|
199
|
+
|
|
200
|
+
ancestor.child_nodes.to_a.select { |child| node_fully_contained?(child) }
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# A node is contained in the range when its start position is at or after the
|
|
204
|
+
# range start and its end position is at or before the range end.
|
|
205
|
+
def node_fully_contained?(node)
|
|
206
|
+
parent = parent_of(node)
|
|
207
|
+
return false unless parent
|
|
208
|
+
|
|
209
|
+
idx = child_index_of(parent, node)
|
|
210
|
+
compare_points(parent, idx, @start_container, @start_offset) >= 0 &&
|
|
211
|
+
compare_points(parent, idx + 1, @end_container, @end_offset) <= 0
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# The (node, offset) the range collapses to after deletion (WHATWG step 5):
|
|
215
|
+
# the start boundary if it contains the end, else the start's highest
|
|
216
|
+
# ancestor that doesn't contain the end, positioned just after it.
|
|
217
|
+
def deletion_collapse_point(sc, so, ec, eo)
|
|
218
|
+
return [sc, so] if inclusive_ancestor?(sc, ec)
|
|
219
|
+
|
|
220
|
+
ref = sc
|
|
221
|
+
ref = parent_of(ref) while (p = parent_of(ref)) && !inclusive_ancestor?(p, ec)
|
|
222
|
+
parent = parent_of(ref)
|
|
223
|
+
[parent, child_index_of(parent, ref) + 1]
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def inclusive_ancestor?(maybe_ancestor, node)
|
|
227
|
+
current = node
|
|
228
|
+
while current
|
|
229
|
+
return true if current.equal?(maybe_ancestor)
|
|
230
|
+
|
|
231
|
+
current = parent_of(current)
|
|
232
|
+
end
|
|
233
|
+
false
|
|
234
|
+
end
|
|
235
|
+
|
|
156
236
|
# surroundContents(newParent) — wraps the range contents in
|
|
157
237
|
# newParent (which must be an element).
|
|
158
238
|
def surround_contents(new_parent)
|
|
@@ -165,16 +245,21 @@ module Dommy
|
|
|
165
245
|
end
|
|
166
246
|
|
|
167
247
|
def insert_node(node)
|
|
168
|
-
# Insert at the range start. For text
|
|
169
|
-
#
|
|
248
|
+
# Insert at the range start. For a text container, split it at the offset
|
|
249
|
+
# (when interior) and insert before the split-off tail — matching the spec,
|
|
250
|
+
# which produces a childList record for the split plus one for the insert.
|
|
170
251
|
sc = @start_container
|
|
171
252
|
if text_node?(sc)
|
|
172
|
-
# Splitting is out of spec-perfect scope; insert before/after
|
|
173
|
-
# the text node based on offset.
|
|
174
253
|
parent = parent_of(sc)
|
|
175
254
|
idx = child_index_of(parent, sc)
|
|
176
|
-
|
|
177
|
-
|
|
255
|
+
if @start_offset.zero?
|
|
256
|
+
insert_into_parent_at(parent, idx, node)
|
|
257
|
+
elsif @start_offset >= length_of(sc)
|
|
258
|
+
insert_into_parent_at(parent, idx + 1, node)
|
|
259
|
+
else
|
|
260
|
+
tail = sc.split_text(@start_offset)
|
|
261
|
+
parent.insert_before(node, tail)
|
|
262
|
+
end
|
|
178
263
|
else
|
|
179
264
|
insert_into_parent_at(sc, @start_offset, node)
|
|
180
265
|
end
|
|
@@ -185,7 +270,10 @@ module Dommy
|
|
|
185
270
|
# --- Ordering / containment ------------------------------------
|
|
186
271
|
|
|
187
272
|
def compare_boundary_points(how, other)
|
|
188
|
-
# `how`
|
|
273
|
+
# `how` is a WebIDL `unsigned short`: coerce first (NaN/±0/±Infinity → 0,
|
|
274
|
+
# otherwise truncate toward zero and take modulo 2^16), then require one of
|
|
275
|
+
# the four named constants, else NotSupportedError.
|
|
276
|
+
how = to_unsigned_short(how)
|
|
189
277
|
unless [START_TO_START, START_TO_END, END_TO_END, END_TO_START].include?(how)
|
|
190
278
|
raise DOMException::NotSupportedError, "invalid comparison type: #{how}"
|
|
191
279
|
end
|
|
@@ -207,8 +295,13 @@ module Dommy
|
|
|
207
295
|
end
|
|
208
296
|
|
|
209
297
|
def intersects_node(node)
|
|
210
|
-
# Range
|
|
211
|
-
#
|
|
298
|
+
# WHATWG Range.intersectsNode: a node in a different tree never intersects;
|
|
299
|
+
# a node with no parent (a tree root, e.g. the document) always does.
|
|
300
|
+
return false unless same_root?(node)
|
|
301
|
+
return true if parent_of(node).nil?
|
|
302
|
+
|
|
303
|
+
# Otherwise range and node intersect iff node's position relative to the
|
|
304
|
+
# range is not entirely before or entirely after it.
|
|
212
305
|
return false if before?(node)
|
|
213
306
|
return false if after?(node)
|
|
214
307
|
|
|
@@ -219,6 +312,8 @@ module Dommy
|
|
|
219
312
|
# range, 0 if inside, 1 if after. offset is a WebIDL unsigned long (so -1
|
|
220
313
|
# wraps to a huge value > length → IndexSizeError).
|
|
221
314
|
def compare_point(node, offset)
|
|
315
|
+
raise Bridge::TypeError, "argument is not a Node" unless node.is_a?(Dommy::Node)
|
|
316
|
+
|
|
222
317
|
off = unsigned_long(offset)
|
|
223
318
|
raise DOMException::WrongDocumentError, "node is in a different tree" unless same_root?(node)
|
|
224
319
|
raise DOMException::InvalidNodeTypeError, "node is a doctype" if doctype?(node)
|
|
@@ -288,6 +383,16 @@ module Dommy
|
|
|
288
383
|
collapsed?
|
|
289
384
|
when "commonAncestorContainer"
|
|
290
385
|
common_ancestor_container
|
|
386
|
+
when "START_TO_START"
|
|
387
|
+
START_TO_START
|
|
388
|
+
when "START_TO_END"
|
|
389
|
+
START_TO_END
|
|
390
|
+
when "END_TO_END"
|
|
391
|
+
END_TO_END
|
|
392
|
+
when "END_TO_START"
|
|
393
|
+
END_TO_START
|
|
394
|
+
else
|
|
395
|
+
Bridge::ABSENT
|
|
291
396
|
end
|
|
292
397
|
end
|
|
293
398
|
|
|
@@ -296,7 +401,7 @@ module Dommy
|
|
|
296
401
|
setStart setEnd setStartBefore setStartAfter setEndBefore setEndAfter collapse selectNode
|
|
297
402
|
selectNodeContents toString cloneContents extractContents deleteContents surroundContents
|
|
298
403
|
insertNode compareBoundaryPoints intersectsNode containsNode cloneRange detach
|
|
299
|
-
comparePoint isPointInRange getBoundingClientRect getClientRects
|
|
404
|
+
comparePoint isPointInRange getBoundingClientRect getClientRects createContextualFragment
|
|
300
405
|
]
|
|
301
406
|
def __js_call__(method, args)
|
|
302
407
|
case method
|
|
@@ -318,6 +423,11 @@ module Dommy
|
|
|
318
423
|
select_node(args[0])
|
|
319
424
|
when "selectNodeContents"
|
|
320
425
|
select_node_contents(args[0])
|
|
426
|
+
when "createContextualFragment"
|
|
427
|
+
# WebIDL required argument: a bare call (0 args) throws TypeError.
|
|
428
|
+
raise Bridge::TypeError, "createContextualFragment requires 1 argument, but only 0 present" if args.empty?
|
|
429
|
+
|
|
430
|
+
create_contextual_fragment(args[0])
|
|
321
431
|
when "toString"
|
|
322
432
|
to_s
|
|
323
433
|
when "cloneContents"
|
|
@@ -353,6 +463,16 @@ module Dommy
|
|
|
353
463
|
|
|
354
464
|
private
|
|
355
465
|
|
|
466
|
+
# The local name of the element to fragment-parse in: the start node if it
|
|
467
|
+
# is an element, else its nearest element ancestor; falling back to "body"
|
|
468
|
+
# (the HTML fragment-parsing context) for a document/fragment/`<html>` start.
|
|
469
|
+
def contextual_local_name
|
|
470
|
+
node = @start_container
|
|
471
|
+
el = node.respond_to?(:node_type) && node.node_type == 1 ? node : (node.respond_to?(:parent_element) ? node.parent_element : nil)
|
|
472
|
+
name = el&.local_name
|
|
473
|
+
name.nil? || name.casecmp?("html") ? "body" : name
|
|
474
|
+
end
|
|
475
|
+
|
|
356
476
|
def collapse_to_start
|
|
357
477
|
@end_container = @start_container
|
|
358
478
|
@end_offset = @start_offset
|
|
@@ -367,13 +487,17 @@ module Dommy
|
|
|
367
487
|
node.respond_to?(:node_type) && node.node_type == 3
|
|
368
488
|
end
|
|
369
489
|
|
|
490
|
+
# WHATWG "length of a node": a DocumentType is 0; a CharacterData node
|
|
491
|
+
# (Text / CDATASection / ProcessingInstruction / Comment) is its data
|
|
492
|
+
# length; any other node is its number of children.
|
|
370
493
|
def length_of(node)
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
elsif node.respond_to?(:child_nodes)
|
|
374
|
-
node.child_nodes.length
|
|
375
|
-
else
|
|
494
|
+
case node.respond_to?(:node_type) ? node.node_type : nil
|
|
495
|
+
when 10 # DocumentType
|
|
376
496
|
0
|
|
497
|
+
when 3, 4, 7, 8 # Text, CDATASection, ProcessingInstruction, Comment
|
|
498
|
+
node.respond_to?(:data) ? node.data.to_s.length : 0
|
|
499
|
+
else
|
|
500
|
+
node.respond_to?(:child_nodes) ? node.child_nodes.length : 0
|
|
377
501
|
end
|
|
378
502
|
end
|
|
379
503
|
|
|
@@ -382,6 +506,35 @@ module Dommy
|
|
|
382
506
|
value.to_i % (2**32)
|
|
383
507
|
end
|
|
384
508
|
|
|
509
|
+
# WebIDL `unsigned short` conversion: ToNumber, then NaN/±0/±Infinity → 0,
|
|
510
|
+
# otherwise truncate toward zero and take modulo 2^16.
|
|
511
|
+
def to_unsigned_short(value)
|
|
512
|
+
num = web_to_number(value)
|
|
513
|
+
return 0 if num.nan? || num.zero? || num.infinite?
|
|
514
|
+
|
|
515
|
+
((num.negative? ? -1 : 1) * num.abs.floor) % 65536
|
|
516
|
+
end
|
|
517
|
+
|
|
518
|
+
# WebIDL ToNumber for the values that reach a bridged argument.
|
|
519
|
+
def web_to_number(value)
|
|
520
|
+
case value
|
|
521
|
+
when Numeric then value.to_f
|
|
522
|
+
when nil, false then 0.0
|
|
523
|
+
when true then 1.0
|
|
524
|
+
when String
|
|
525
|
+
stripped = value.strip
|
|
526
|
+
return 0.0 if stripped.empty?
|
|
527
|
+
|
|
528
|
+
begin
|
|
529
|
+
Float(stripped)
|
|
530
|
+
rescue ArgumentError
|
|
531
|
+
Float::NAN
|
|
532
|
+
end
|
|
533
|
+
else
|
|
534
|
+
Float::NAN
|
|
535
|
+
end
|
|
536
|
+
end
|
|
537
|
+
|
|
385
538
|
# Two nodes share a root iff their topmost ancestors are the same node.
|
|
386
539
|
def same_root?(node)
|
|
387
540
|
ancestor_chain(node).last.equal?(ancestor_chain(@start_container).last)
|
|
@@ -398,15 +551,15 @@ module Dommy
|
|
|
398
551
|
children = parent.respond_to?(:child_nodes) ? parent.child_nodes.to_a : []
|
|
399
552
|
if idx >= children.length
|
|
400
553
|
parent.append_child(node) if parent.respond_to?(:append_child)
|
|
554
|
+
elsif parent.respond_to?(:insert_before)
|
|
555
|
+
# insert_before extracts a DocumentFragment's children in order and fires
|
|
556
|
+
# the fragment-removal + target-addition records; `anchor.before` coerces
|
|
557
|
+
# the fragment to a single node and reverses multi-node order.
|
|
558
|
+
parent.insert_before(node, children[idx])
|
|
559
|
+
elsif children[idx].respond_to?(:before)
|
|
560
|
+
children[idx].before(node)
|
|
401
561
|
else
|
|
402
|
-
|
|
403
|
-
if anchor.respond_to?(:before)
|
|
404
|
-
anchor.before(node)
|
|
405
|
-
elsif parent.respond_to?(:insert_before)
|
|
406
|
-
parent.insert_before(node, anchor)
|
|
407
|
-
else
|
|
408
|
-
parent.append_child(node) if parent.respond_to?(:append_child)
|
|
409
|
-
end
|
|
562
|
+
parent.append_child(node) if parent.respond_to?(:append_child)
|
|
410
563
|
end
|
|
411
564
|
end
|
|
412
565
|
|
|
@@ -506,14 +659,13 @@ module Dommy
|
|
|
506
659
|
chain.find { |n| parent_of(n)&.equal?(container) }
|
|
507
660
|
end
|
|
508
661
|
|
|
509
|
-
# Case 2a: a_container is an ancestor of b_container.
|
|
510
|
-
#
|
|
511
|
-
#
|
|
662
|
+
# Case 2a: a_container is an ancestor of b_container. b sits *inside* the
|
|
663
|
+
# child of a_container at index b_idx (so its exact offset is irrelevant):
|
|
664
|
+
# if that index is before a_offset, a comes after b; otherwise a precedes b
|
|
665
|
+
# (WHATWG boundary-point position, ancestor case).
|
|
512
666
|
def compare_offset_to_branch(a_offset, a_container, b_branch, ahead:)
|
|
513
667
|
b_idx = child_index_of(a_container, b_branch)
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
ahead
|
|
668
|
+
b_idx < a_offset ? 1 : ahead
|
|
517
669
|
end
|
|
518
670
|
|
|
519
671
|
# Case 2b: b_container is an ancestor of a_container.
|
|
@@ -635,6 +787,8 @@ module Dommy
|
|
|
635
787
|
is_collapsed
|
|
636
788
|
when "type"
|
|
637
789
|
is_collapsed ? "Caret" : "Range"
|
|
790
|
+
else
|
|
791
|
+
Bridge::ABSENT
|
|
638
792
|
end
|
|
639
793
|
end
|
|
640
794
|
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module Dommy
|
|
6
|
+
# The single interface through which the lightweight test browser resolves
|
|
7
|
+
# external resources — both `<script src>` loads and `fetch` / XHR. A real
|
|
8
|
+
# browser routes both through one network layer, so Dommy does too: a
|
|
9
|
+
# `Resources` adapter answers `#get` / `#request` with a `Response`, and the
|
|
10
|
+
# same adapter can be installed as the window's fetch handler.
|
|
11
|
+
#
|
|
12
|
+
# An adapter returns `nil` from `#request` when it does not serve a URL, so
|
|
13
|
+
# callers fall through (to the next adapter in a `chain`, or to the stub maps
|
|
14
|
+
# behind `window.fetch`). Built-in adapters: `static`, `file_system`, `chain`.
|
|
15
|
+
# `Dommy::Rack::Resources` (in dommy-rack) serves a Rack app.
|
|
16
|
+
module Resources
|
|
17
|
+
# A resolved resource. `status_text` is filled by whichever adapter built
|
|
18
|
+
# it (the Rack adapter uses Rack::Utils; the core adapters leave it blank or
|
|
19
|
+
# derive a minimal default) so the core stays Rails/Rack-independent.
|
|
20
|
+
Response = Struct.new(:status, :status_text, :headers, :body, :url, :redirected, keyword_init: true) do
|
|
21
|
+
def success? = (200..299).cover?(status.to_i)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
class << self
|
|
25
|
+
# An in-memory adapter. `map` keys are matched against the request URL as
|
|
26
|
+
# given AND its path component, so `{ "/app.js" => "..." }` serves both
|
|
27
|
+
# `/app.js` and `http://host/app.js`. A value is either a body String or a
|
|
28
|
+
# Hash with "status" / "headers" / "body" / "content_type".
|
|
29
|
+
def static(map) = Static.new(map)
|
|
30
|
+
|
|
31
|
+
# Serve files under `root` for URLs whose path starts with `base_url`.
|
|
32
|
+
# `file_system(root: "dist", base_url: "/assets")` maps the request path
|
|
33
|
+
# `/assets/app.js` to the file `dist/app.js`. Missing files return nil.
|
|
34
|
+
def file_system(root:, base_url: "/") = FileSystem.new(root, base_url)
|
|
35
|
+
|
|
36
|
+
# Try each adapter in order; the first non-nil Response wins.
|
|
37
|
+
def chain(*adapters) = Chain.new(adapters)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Maps a Resources adapter onto the `__fetch_handler__` callable contract
|
|
41
|
+
# (`call(url, init) -> entry Hash | nil`) consumed by FetchFn / XHR, so the
|
|
42
|
+
# same Resources resolves window.fetch. A nil Response passes through to the
|
|
43
|
+
# stub maps.
|
|
44
|
+
class FetchHandler
|
|
45
|
+
# `executor` (responds to `submit(job) { |result| }`) and `scheduler` opt
|
|
46
|
+
# this handler into off-thread network: when both are present and the
|
|
47
|
+
# adapter supports `request_job`, fetch / XHR run the request on a worker
|
|
48
|
+
# and resolve through a DeferredResponse. Without them everything stays
|
|
49
|
+
# synchronous (the deterministic default the tests rely on).
|
|
50
|
+
def initialize(resources, executor: nil, scheduler: nil)
|
|
51
|
+
@resources = resources
|
|
52
|
+
@executor = executor
|
|
53
|
+
@scheduler = scheduler
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def call(url, init = nil)
|
|
57
|
+
init = {} unless init.is_a?(Hash)
|
|
58
|
+
method = (init["method"] || "GET").to_s.upcase
|
|
59
|
+
headers = init["headers"].is_a?(Hash) ? init["headers"] : {}
|
|
60
|
+
body = init["body"]&.to_s
|
|
61
|
+
|
|
62
|
+
return call_async(method, url, headers, body) if async?
|
|
63
|
+
|
|
64
|
+
response = @resources.request(method: method, url: url.to_s, headers: headers, body: body)
|
|
65
|
+
response && to_entry(response, url)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
def async?
|
|
71
|
+
!@executor.nil? && @resources.respond_to?(:request_job)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# The page-thread serve/decline decision still runs synchronously (so an
|
|
75
|
+
# unserved URL falls through to the stub maps exactly as in the sync path);
|
|
76
|
+
# only a served request is handed to a worker. The DeferredResponse
|
|
77
|
+
# completes — on the page thread, via the scheduler inbox — with the same
|
|
78
|
+
# entry Hash the sync path returns (or nil for a network miss).
|
|
79
|
+
def call_async(method, url, headers, body)
|
|
80
|
+
job = @resources.request_job(method: method, url: url.to_s, headers: headers, body: body)
|
|
81
|
+
return nil unless job
|
|
82
|
+
|
|
83
|
+
deferred = Dommy::DeferredResponse.new(@scheduler)
|
|
84
|
+
@executor.submit(job) do |response|
|
|
85
|
+
deferred.complete(response && to_entry(response, url))
|
|
86
|
+
end
|
|
87
|
+
deferred
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def to_entry(response, url)
|
|
91
|
+
{
|
|
92
|
+
"status" => response.status,
|
|
93
|
+
"statusText" => response.status_text.to_s,
|
|
94
|
+
"body" => response.body.to_s,
|
|
95
|
+
"headers" => response.headers || {},
|
|
96
|
+
"url" => response.url || url.to_s,
|
|
97
|
+
"redirected" => response.redirected ? true : false
|
|
98
|
+
}
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Common helpers for the built-in adapters.
|
|
103
|
+
module Pathing
|
|
104
|
+
module_function
|
|
105
|
+
|
|
106
|
+
def path_of(url)
|
|
107
|
+
URI.parse(url.to_s).path
|
|
108
|
+
rescue URI::InvalidURIError
|
|
109
|
+
url.to_s
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
class Static
|
|
114
|
+
def initialize(map)
|
|
115
|
+
@map = map || {}
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def get(url, headers: {}) = request(method: "GET", url: url, headers: headers)
|
|
119
|
+
|
|
120
|
+
def request(method:, url:, headers: {}, body: nil)
|
|
121
|
+
entry = @map[url.to_s] || @map[Pathing.path_of(url)]
|
|
122
|
+
return nil unless entry
|
|
123
|
+
|
|
124
|
+
if entry.is_a?(Hash)
|
|
125
|
+
ct = entry["content_type"] || entry["contentType"]
|
|
126
|
+
Response.new(
|
|
127
|
+
status: (entry["status"] || 200).to_i,
|
|
128
|
+
status_text: entry["status_text"].to_s,
|
|
129
|
+
headers: entry["headers"] || (ct ? {"Content-Type" => ct} : {}),
|
|
130
|
+
body: entry["body"].to_s,
|
|
131
|
+
url: url.to_s,
|
|
132
|
+
redirected: false
|
|
133
|
+
)
|
|
134
|
+
else
|
|
135
|
+
Response.new(status: 200, status_text: "OK", headers: {}, body: entry.to_s, url: url.to_s, redirected: false)
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
class FileSystem
|
|
141
|
+
def initialize(root, base_url)
|
|
142
|
+
@root = ::File.expand_path(root.to_s)
|
|
143
|
+
@base = base_url.to_s.sub(%r{/\z}, "")
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def get(url, headers: {}) = request(method: "GET", url: url, headers: headers)
|
|
147
|
+
|
|
148
|
+
def request(method:, url:, headers: {}, body: nil)
|
|
149
|
+
path = Pathing.path_of(url)
|
|
150
|
+
return nil unless @base.empty? || path.start_with?("#{@base}/") || path == @base
|
|
151
|
+
|
|
152
|
+
rel = @base.empty? ? path : path[@base.length..]
|
|
153
|
+
file = ::File.expand_path(rel.sub(%r{\A/}, ""), @root)
|
|
154
|
+
# Containment guard: never escape `root`.
|
|
155
|
+
return nil unless file == @root || file.start_with?("#{@root}/")
|
|
156
|
+
return nil unless ::File.file?(file)
|
|
157
|
+
|
|
158
|
+
Response.new(status: 200, status_text: "OK", headers: {}, body: ::File.read(file), url: url.to_s, redirected: false)
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
class Chain
|
|
163
|
+
def initialize(adapters)
|
|
164
|
+
@adapters = adapters.compact
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def get(url, headers: {}) = request(method: "GET", url: url, headers: headers)
|
|
168
|
+
|
|
169
|
+
def request(method:, url:, headers: {}, body: nil)
|
|
170
|
+
@adapters.each do |adapter|
|
|
171
|
+
response = adapter.request(method: method, url: url, headers: headers, body: body)
|
|
172
|
+
return response if response
|
|
173
|
+
end
|
|
174
|
+
nil
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
@@ -76,6 +76,14 @@ module Dommy
|
|
|
76
76
|
HaveNoField.new(name_or_label, **opts)
|
|
77
77
|
end
|
|
78
78
|
|
|
79
|
+
def have_role(role, name: nil, level: nil, count: nil, exact: false)
|
|
80
|
+
HaveRole.new(role, name: name, level: level, count: count, exact: exact)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def have_no_role(role, name: nil, level: nil, exact: false)
|
|
84
|
+
HaveNoRole.new(role, name: name, level: level, exact: exact)
|
|
85
|
+
end
|
|
86
|
+
|
|
79
87
|
# ----- Base behavior shared across element-finding matchers -----
|
|
80
88
|
|
|
81
89
|
# @api private
|
|
@@ -351,6 +359,124 @@ module Dommy
|
|
|
351
359
|
class HaveNoField < HaveField
|
|
352
360
|
include Negated
|
|
353
361
|
end
|
|
362
|
+
|
|
363
|
+
# @api private
|
|
364
|
+
# Matches elements by computed ARIA role (+ optional accessible name /
|
|
365
|
+
# level), walking the accessibility tree via Interaction::RoleQuery.
|
|
366
|
+
class HaveRole
|
|
367
|
+
def initialize(role, name: nil, level: nil, count: nil, exact: false)
|
|
368
|
+
@role = role
|
|
369
|
+
@name = name
|
|
370
|
+
@level = level
|
|
371
|
+
@count = count
|
|
372
|
+
@exact = exact
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
def matches?(scope)
|
|
376
|
+
@matched = Interaction::RoleQuery.match(
|
|
377
|
+
Internal::ScopeResolution.resolve(scope),
|
|
378
|
+
role: @role, name: @name, level: @level, exact: @exact
|
|
379
|
+
)
|
|
380
|
+
@count ? @matched.size == @count : !@matched.empty?
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
def does_not_match?(scope)
|
|
384
|
+
matches?(scope)
|
|
385
|
+
@count ? @matched.size != @count : @matched.empty?
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
def description
|
|
389
|
+
"have #{describe_target}"
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
def failure_message
|
|
393
|
+
"expected to find #{describe_target}, found #{@matched.size}"
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
def failure_message_when_negated
|
|
397
|
+
"expected NOT to find #{describe_target}, found #{@matched.size}"
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
private
|
|
401
|
+
|
|
402
|
+
def describe_target
|
|
403
|
+
parts = ["role #{@role.to_s.inspect}"]
|
|
404
|
+
parts << "named #{@name.inspect}" if @name
|
|
405
|
+
parts << "at level #{@level}" if @level
|
|
406
|
+
parts << "(count: #{@count.inspect})" if @count
|
|
407
|
+
parts.join(" ")
|
|
408
|
+
end
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
# @api private
|
|
412
|
+
class HaveNoRole < HaveRole
|
|
413
|
+
def matches?(scope)
|
|
414
|
+
!super
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
def does_not_match?(scope)
|
|
418
|
+
!matches?(scope)
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
def failure_message
|
|
422
|
+
"expected NOT to find #{describe_target}, found #{@matched.size}"
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
def failure_message_when_negated
|
|
426
|
+
"expected to find #{describe_target}, found #{@matched.size}"
|
|
427
|
+
end
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
# Prepended to every concrete matcher so it sits first in the method
|
|
431
|
+
# resolution order: it remembers the matched subject and wraps the
|
|
432
|
+
# matcher's own `failure_message` (via `super`) with any extra context a
|
|
433
|
+
# host registered through `Dommy::RSpec.failure_context` (e.g. dommy-rails
|
|
434
|
+
# appends a recent trace for a trace-enabled session). With no context
|
|
435
|
+
# registered the message is returned unchanged, so existing behavior — and
|
|
436
|
+
# existing message assertions — are preserved.
|
|
437
|
+
module FailureContext
|
|
438
|
+
def matches?(scope)
|
|
439
|
+
@__dommy_subject = scope
|
|
440
|
+
super
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
def does_not_match?(scope)
|
|
444
|
+
@__dommy_subject = scope
|
|
445
|
+
super
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
def failure_message
|
|
449
|
+
Dommy::RSpec.__decorate_failure(super, @__dommy_subject)
|
|
450
|
+
end
|
|
451
|
+
|
|
452
|
+
def failure_message_when_negated
|
|
453
|
+
Dommy::RSpec.__decorate_failure(super, @__dommy_subject)
|
|
454
|
+
end
|
|
455
|
+
end
|
|
456
|
+
|
|
457
|
+
[HaveSelector, HaveNoSelector, HaveContent, HaveNoContent,
|
|
458
|
+
HaveLink, HaveNoLink, HaveButton, HaveNoButton,
|
|
459
|
+
HaveField, HaveNoField, HaveRole, HaveNoRole].each { |matcher| matcher.prepend(FailureContext) }
|
|
460
|
+
end
|
|
461
|
+
|
|
462
|
+
class << self
|
|
463
|
+
# A `->(subject) { String | nil }` consulted when a matcher fails: its
|
|
464
|
+
# return value is appended to the failure message. Hosts set this to add
|
|
465
|
+
# context (dommy-rails registers a recent-trace summary for trace-enabled
|
|
466
|
+
# sessions). nil (the default) leaves every message untouched.
|
|
467
|
+
attr_accessor :failure_context
|
|
468
|
+
end
|
|
469
|
+
|
|
470
|
+
# Append the host's failure context to `message` for `subject`, or return
|
|
471
|
+
# `message` unchanged. Never raises out of a failure path: a misbehaving
|
|
472
|
+
# context proc must not mask the real assertion failure.
|
|
473
|
+
def self.__decorate_failure(message, subject)
|
|
474
|
+
return message unless failure_context && subject
|
|
475
|
+
|
|
476
|
+
extra = failure_context.call(subject)
|
|
477
|
+
extra ? "#{message}\n\n#{extra}" : message
|
|
478
|
+
rescue StandardError
|
|
479
|
+
message
|
|
354
480
|
end
|
|
355
481
|
end
|
|
356
482
|
end
|