dommy 0.8.0 → 0.9.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 +11 -5
- data/lib/dommy/backend/makiri_adapter.rb +330 -0
- data/lib/dommy/backend.rb +114 -33
- data/lib/dommy/blob.rb +2 -0
- data/lib/dommy/bridge.rb +11 -0
- data/lib/dommy/browser.rb +217 -0
- 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 +386 -228
- data/lib/dommy/dom_exception.rb +2 -0
- data/lib/dommy/dom_parser.rb +7 -17
- data/lib/dommy/element.rb +502 -155
- data/lib/dommy/event.rb +240 -9
- data/lib/dommy/fetch.rb +152 -34
- data/lib/dommy/form_data.rb +2 -0
- data/lib/dommy/history.rb +2 -0
- data/lib/dommy/html_canvas_element.rb +230 -0
- data/lib/dommy/html_collection.rb +5 -6
- data/lib/dommy/html_elements.rb +304 -27
- data/lib/dommy/interaction/debug.rb +35 -0
- data/lib/dommy/interaction/dom_summary.rb +131 -0
- data/lib/dommy/interaction/driver.rb +244 -0
- data/lib/dommy/interaction/event_synthesis.rb +56 -0
- data/lib/dommy/interaction/field_interactor.rb +117 -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/css/calc.rb +242 -0
- data/lib/dommy/internal/css/cascade.rb +430 -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 +33 -0
- data/lib/dommy/internal/mutation_coordinator.rb +95 -4
- data/lib/dommy/internal/namespaces.rb +49 -5
- data/lib/dommy/internal/node_wrapper_cache.rb +163 -26
- data/lib/dommy/internal/parent_node.rb +82 -5
- 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 +756 -0
- data/lib/dommy/internal/selector_parser.rb +283 -131
- 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 +90 -0
- data/lib/dommy/js/dom_interfaces.rb +162 -0
- data/lib/dommy/js/handle_table.rb +60 -0
- data/lib/dommy/js/host_bridge.rb +517 -0
- data/lib/dommy/js/host_runtime.js +1495 -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 +221 -0
- data/lib/dommy/js/wire_tags.rb +62 -0
- data/lib/dommy/location.rb +2 -0
- 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 -4
- data/lib/dommy/navigator.rb +34 -2
- data/lib/dommy/node.rb +24 -14
- 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 +173 -31
- data/lib/dommy/resources.rb +178 -0
- data/lib/dommy/rspec/capy_style_matchers.rb +126 -0
- data/lib/dommy/scheduler.rb +149 -13
- data/lib/dommy/screen.rb +91 -0
- data/lib/dommy/shadow_root.rb +76 -13
- data/lib/dommy/storage.rb +2 -1
- data/lib/dommy/streams.rb +6 -0
- data/lib/dommy/text_codec.rb +7 -1
- data/lib/dommy/tree_walker.rb +33 -10
- data/lib/dommy/url.rb +13 -1
- data/lib/dommy/version.rb +1 -1
- data/lib/dommy/window.rb +199 -11
- data/lib/dommy/worker.rb +8 -4
- data/lib/dommy/xml_http_request.rb +47 -6
- data/lib/dommy.rb +36 -1
- metadata +96 -10
- 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,18 @@ 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
|
+
context.inner_html = html.to_s # fragment-parses in the context element
|
|
121
|
+
fragment = @document.create_document_fragment
|
|
122
|
+
context.child_nodes.to_a.each { |child| fragment.append_child(child) }
|
|
123
|
+
fragment
|
|
124
|
+
end
|
|
125
|
+
|
|
114
126
|
# --- Content extraction ----------------------------------------
|
|
115
127
|
|
|
116
128
|
def to_s
|
|
@@ -138,21 +150,87 @@ module Dommy
|
|
|
138
150
|
fragment
|
|
139
151
|
end
|
|
140
152
|
|
|
153
|
+
# WHATWG Range.deleteContents. Removes the fully-contained nodes (childList
|
|
154
|
+
# records) and trims the partially-contained boundary CharacterData nodes
|
|
155
|
+
# (characterData records via `data=`), rather than removing boundary text
|
|
156
|
+
# nodes whole — so a deletion like `"abc"[1]…"def"[1]` leaves `"a"…"ef"`.
|
|
141
157
|
def delete_contents
|
|
142
|
-
|
|
158
|
+
return nil if collapsed?
|
|
159
|
+
|
|
160
|
+
sc = @start_container
|
|
161
|
+
so = @start_offset
|
|
162
|
+
ec = @end_container
|
|
163
|
+
eo = @end_offset
|
|
164
|
+
|
|
165
|
+
# Both boundaries in the same text node: just delete the substring.
|
|
166
|
+
if sc.equal?(ec) && text_node?(sc)
|
|
167
|
+
d = sc.data.to_s
|
|
168
|
+
sc.data = d[0, so].to_s + (d[eo..] || "")
|
|
169
|
+
return nil
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
to_remove = nodes_to_remove
|
|
173
|
+
new_node, new_offset = deletion_collapse_point(sc, so, ec, eo)
|
|
174
|
+
|
|
175
|
+
# Trim the start boundary text node's tail, remove the contained nodes,
|
|
176
|
+
# then trim the end boundary text node's head (order matters for records).
|
|
177
|
+
sc.data = sc.data.to_s[0, so].to_s if text_node?(sc)
|
|
178
|
+
to_remove.each do |node|
|
|
143
179
|
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
180
|
@document.remove_node_with_notify(node.__dommy_backend_node__)
|
|
147
181
|
elsif node.respond_to?(:remove)
|
|
148
182
|
node.remove
|
|
149
183
|
end
|
|
150
184
|
end
|
|
185
|
+
ec.data = (ec.data.to_s[eo..] || "") if text_node?(ec)
|
|
151
186
|
|
|
152
|
-
|
|
187
|
+
@start_container = @end_container = new_node
|
|
188
|
+
@start_offset = @end_offset = new_offset
|
|
153
189
|
nil
|
|
154
190
|
end
|
|
155
191
|
|
|
192
|
+
# Top-level nodes fully contained in the range (their parent isn't), removed
|
|
193
|
+
# whole. Deeper contained nodes go with their removed ancestor.
|
|
194
|
+
def nodes_to_remove
|
|
195
|
+
ancestor = common_ancestor_container
|
|
196
|
+
return [] unless ancestor.respond_to?(:child_nodes)
|
|
197
|
+
|
|
198
|
+
ancestor.child_nodes.to_a.select { |child| node_fully_contained?(child) }
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# A node is contained in the range when its start position is at or after the
|
|
202
|
+
# range start and its end position is at or before the range end.
|
|
203
|
+
def node_fully_contained?(node)
|
|
204
|
+
parent = parent_of(node)
|
|
205
|
+
return false unless parent
|
|
206
|
+
|
|
207
|
+
idx = child_index_of(parent, node)
|
|
208
|
+
compare_points(parent, idx, @start_container, @start_offset) >= 0 &&
|
|
209
|
+
compare_points(parent, idx + 1, @end_container, @end_offset) <= 0
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# The (node, offset) the range collapses to after deletion (WHATWG step 5):
|
|
213
|
+
# the start boundary if it contains the end, else the start's highest
|
|
214
|
+
# ancestor that doesn't contain the end, positioned just after it.
|
|
215
|
+
def deletion_collapse_point(sc, so, ec, eo)
|
|
216
|
+
return [sc, so] if inclusive_ancestor?(sc, ec)
|
|
217
|
+
|
|
218
|
+
ref = sc
|
|
219
|
+
ref = parent_of(ref) while (p = parent_of(ref)) && !inclusive_ancestor?(p, ec)
|
|
220
|
+
parent = parent_of(ref)
|
|
221
|
+
[parent, child_index_of(parent, ref) + 1]
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def inclusive_ancestor?(maybe_ancestor, node)
|
|
225
|
+
current = node
|
|
226
|
+
while current
|
|
227
|
+
return true if current.equal?(maybe_ancestor)
|
|
228
|
+
|
|
229
|
+
current = parent_of(current)
|
|
230
|
+
end
|
|
231
|
+
false
|
|
232
|
+
end
|
|
233
|
+
|
|
156
234
|
# surroundContents(newParent) — wraps the range contents in
|
|
157
235
|
# newParent (which must be an element).
|
|
158
236
|
def surround_contents(new_parent)
|
|
@@ -165,16 +243,21 @@ module Dommy
|
|
|
165
243
|
end
|
|
166
244
|
|
|
167
245
|
def insert_node(node)
|
|
168
|
-
# Insert at the range start. For text
|
|
169
|
-
#
|
|
246
|
+
# Insert at the range start. For a text container, split it at the offset
|
|
247
|
+
# (when interior) and insert before the split-off tail — matching the spec,
|
|
248
|
+
# which produces a childList record for the split plus one for the insert.
|
|
170
249
|
sc = @start_container
|
|
171
250
|
if text_node?(sc)
|
|
172
|
-
# Splitting is out of spec-perfect scope; insert before/after
|
|
173
|
-
# the text node based on offset.
|
|
174
251
|
parent = parent_of(sc)
|
|
175
252
|
idx = child_index_of(parent, sc)
|
|
176
|
-
|
|
177
|
-
|
|
253
|
+
if @start_offset.zero?
|
|
254
|
+
insert_into_parent_at(parent, idx, node)
|
|
255
|
+
elsif @start_offset >= length_of(sc)
|
|
256
|
+
insert_into_parent_at(parent, idx + 1, node)
|
|
257
|
+
else
|
|
258
|
+
tail = sc.split_text(@start_offset)
|
|
259
|
+
parent.insert_before(node, tail)
|
|
260
|
+
end
|
|
178
261
|
else
|
|
179
262
|
insert_into_parent_at(sc, @start_offset, node)
|
|
180
263
|
end
|
|
@@ -185,7 +268,10 @@ module Dommy
|
|
|
185
268
|
# --- Ordering / containment ------------------------------------
|
|
186
269
|
|
|
187
270
|
def compare_boundary_points(how, other)
|
|
188
|
-
# `how`
|
|
271
|
+
# `how` is a WebIDL `unsigned short`: coerce first (NaN/±0/±Infinity → 0,
|
|
272
|
+
# otherwise truncate toward zero and take modulo 2^16), then require one of
|
|
273
|
+
# the four named constants, else NotSupportedError.
|
|
274
|
+
how = to_unsigned_short(how)
|
|
189
275
|
unless [START_TO_START, START_TO_END, END_TO_END, END_TO_START].include?(how)
|
|
190
276
|
raise DOMException::NotSupportedError, "invalid comparison type: #{how}"
|
|
191
277
|
end
|
|
@@ -288,6 +374,16 @@ module Dommy
|
|
|
288
374
|
collapsed?
|
|
289
375
|
when "commonAncestorContainer"
|
|
290
376
|
common_ancestor_container
|
|
377
|
+
when "START_TO_START"
|
|
378
|
+
START_TO_START
|
|
379
|
+
when "START_TO_END"
|
|
380
|
+
START_TO_END
|
|
381
|
+
when "END_TO_END"
|
|
382
|
+
END_TO_END
|
|
383
|
+
when "END_TO_START"
|
|
384
|
+
END_TO_START
|
|
385
|
+
else
|
|
386
|
+
Bridge::ABSENT
|
|
291
387
|
end
|
|
292
388
|
end
|
|
293
389
|
|
|
@@ -296,7 +392,7 @@ module Dommy
|
|
|
296
392
|
setStart setEnd setStartBefore setStartAfter setEndBefore setEndAfter collapse selectNode
|
|
297
393
|
selectNodeContents toString cloneContents extractContents deleteContents surroundContents
|
|
298
394
|
insertNode compareBoundaryPoints intersectsNode containsNode cloneRange detach
|
|
299
|
-
comparePoint isPointInRange getBoundingClientRect getClientRects
|
|
395
|
+
comparePoint isPointInRange getBoundingClientRect getClientRects createContextualFragment
|
|
300
396
|
]
|
|
301
397
|
def __js_call__(method, args)
|
|
302
398
|
case method
|
|
@@ -318,6 +414,8 @@ module Dommy
|
|
|
318
414
|
select_node(args[0])
|
|
319
415
|
when "selectNodeContents"
|
|
320
416
|
select_node_contents(args[0])
|
|
417
|
+
when "createContextualFragment"
|
|
418
|
+
create_contextual_fragment(args[0])
|
|
321
419
|
when "toString"
|
|
322
420
|
to_s
|
|
323
421
|
when "cloneContents"
|
|
@@ -353,6 +451,16 @@ module Dommy
|
|
|
353
451
|
|
|
354
452
|
private
|
|
355
453
|
|
|
454
|
+
# The local name of the element to fragment-parse in: the start node if it
|
|
455
|
+
# is an element, else its nearest element ancestor; falling back to "body"
|
|
456
|
+
# (the HTML fragment-parsing context) for a document/fragment/`<html>` start.
|
|
457
|
+
def contextual_local_name
|
|
458
|
+
node = @start_container
|
|
459
|
+
el = node.respond_to?(:node_type) && node.node_type == 1 ? node : (node.respond_to?(:parent_element) ? node.parent_element : nil)
|
|
460
|
+
name = el&.local_name
|
|
461
|
+
name.nil? || name.casecmp?("html") ? "body" : name
|
|
462
|
+
end
|
|
463
|
+
|
|
356
464
|
def collapse_to_start
|
|
357
465
|
@end_container = @start_container
|
|
358
466
|
@end_offset = @start_offset
|
|
@@ -367,13 +475,17 @@ module Dommy
|
|
|
367
475
|
node.respond_to?(:node_type) && node.node_type == 3
|
|
368
476
|
end
|
|
369
477
|
|
|
478
|
+
# WHATWG "length of a node": a DocumentType is 0; a CharacterData node
|
|
479
|
+
# (Text / CDATASection / ProcessingInstruction / Comment) is its data
|
|
480
|
+
# length; any other node is its number of children.
|
|
370
481
|
def length_of(node)
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
elsif node.respond_to?(:child_nodes)
|
|
374
|
-
node.child_nodes.length
|
|
375
|
-
else
|
|
482
|
+
case node.respond_to?(:node_type) ? node.node_type : nil
|
|
483
|
+
when 10 # DocumentType
|
|
376
484
|
0
|
|
485
|
+
when 3, 4, 7, 8 # Text, CDATASection, ProcessingInstruction, Comment
|
|
486
|
+
node.respond_to?(:data) ? node.data.to_s.length : 0
|
|
487
|
+
else
|
|
488
|
+
node.respond_to?(:child_nodes) ? node.child_nodes.length : 0
|
|
377
489
|
end
|
|
378
490
|
end
|
|
379
491
|
|
|
@@ -382,6 +494,35 @@ module Dommy
|
|
|
382
494
|
value.to_i % (2**32)
|
|
383
495
|
end
|
|
384
496
|
|
|
497
|
+
# WebIDL `unsigned short` conversion: ToNumber, then NaN/±0/±Infinity → 0,
|
|
498
|
+
# otherwise truncate toward zero and take modulo 2^16.
|
|
499
|
+
def to_unsigned_short(value)
|
|
500
|
+
num = web_to_number(value)
|
|
501
|
+
return 0 if num.nan? || num.zero? || num.infinite?
|
|
502
|
+
|
|
503
|
+
((num.negative? ? -1 : 1) * num.abs.floor) % 65536
|
|
504
|
+
end
|
|
505
|
+
|
|
506
|
+
# WebIDL ToNumber for the values that reach a bridged argument.
|
|
507
|
+
def web_to_number(value)
|
|
508
|
+
case value
|
|
509
|
+
when Numeric then value.to_f
|
|
510
|
+
when nil, false then 0.0
|
|
511
|
+
when true then 1.0
|
|
512
|
+
when String
|
|
513
|
+
stripped = value.strip
|
|
514
|
+
return 0.0 if stripped.empty?
|
|
515
|
+
|
|
516
|
+
begin
|
|
517
|
+
Float(stripped)
|
|
518
|
+
rescue ArgumentError
|
|
519
|
+
Float::NAN
|
|
520
|
+
end
|
|
521
|
+
else
|
|
522
|
+
Float::NAN
|
|
523
|
+
end
|
|
524
|
+
end
|
|
525
|
+
|
|
385
526
|
# Two nodes share a root iff their topmost ancestors are the same node.
|
|
386
527
|
def same_root?(node)
|
|
387
528
|
ancestor_chain(node).last.equal?(ancestor_chain(@start_container).last)
|
|
@@ -398,15 +539,15 @@ module Dommy
|
|
|
398
539
|
children = parent.respond_to?(:child_nodes) ? parent.child_nodes.to_a : []
|
|
399
540
|
if idx >= children.length
|
|
400
541
|
parent.append_child(node) if parent.respond_to?(:append_child)
|
|
542
|
+
elsif parent.respond_to?(:insert_before)
|
|
543
|
+
# insert_before extracts a DocumentFragment's children in order and fires
|
|
544
|
+
# the fragment-removal + target-addition records; `anchor.before` coerces
|
|
545
|
+
# the fragment to a single node and reverses multi-node order.
|
|
546
|
+
parent.insert_before(node, children[idx])
|
|
547
|
+
elsif children[idx].respond_to?(:before)
|
|
548
|
+
children[idx].before(node)
|
|
401
549
|
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
|
|
550
|
+
parent.append_child(node) if parent.respond_to?(:append_child)
|
|
410
551
|
end
|
|
411
552
|
end
|
|
412
553
|
|
|
@@ -506,14 +647,13 @@ module Dommy
|
|
|
506
647
|
chain.find { |n| parent_of(n)&.equal?(container) }
|
|
507
648
|
end
|
|
508
649
|
|
|
509
|
-
# Case 2a: a_container is an ancestor of b_container.
|
|
510
|
-
#
|
|
511
|
-
#
|
|
650
|
+
# Case 2a: a_container is an ancestor of b_container. b sits *inside* the
|
|
651
|
+
# child of a_container at index b_idx (so its exact offset is irrelevant):
|
|
652
|
+
# if that index is before a_offset, a comes after b; otherwise a precedes b
|
|
653
|
+
# (WHATWG boundary-point position, ancestor case).
|
|
512
654
|
def compare_offset_to_branch(a_offset, a_container, b_branch, ahead:)
|
|
513
655
|
b_idx = child_index_of(a_container, b_branch)
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
ahead
|
|
656
|
+
b_idx < a_offset ? 1 : ahead
|
|
517
657
|
end
|
|
518
658
|
|
|
519
659
|
# Case 2b: b_container is an ancestor of a_container.
|
|
@@ -635,6 +775,8 @@ module Dommy
|
|
|
635
775
|
is_collapsed
|
|
636
776
|
when "type"
|
|
637
777
|
is_collapsed ? "Caret" : "Range"
|
|
778
|
+
else
|
|
779
|
+
Bridge::ABSENT
|
|
638
780
|
end
|
|
639
781
|
end
|
|
640
782
|
|
|
@@ -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
|