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.
Files changed (121) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +3 -3
  3. data/lib/dommy/animation.rb +4 -0
  4. data/lib/dommy/attr.rb +57 -13
  5. data/lib/dommy/backend/makiri_adapter.rb +364 -0
  6. data/lib/dommy/backend.rb +143 -33
  7. data/lib/dommy/blob.rb +50 -6
  8. data/lib/dommy/bridge.rb +11 -0
  9. data/lib/dommy/browser.rb +420 -0
  10. data/lib/dommy/callable_invoker.rb +6 -2
  11. data/lib/dommy/compression_streams.rb +4 -0
  12. data/lib/dommy/crypto.rb +4 -0
  13. data/lib/dommy/css.rb +487 -50
  14. data/lib/dommy/custom_elements.rb +2 -2
  15. data/lib/dommy/data_transfer.rb +2 -0
  16. data/lib/dommy/data_uri.rb +35 -0
  17. data/lib/dommy/deferred_response.rb +59 -0
  18. data/lib/dommy/document.rb +901 -275
  19. data/lib/dommy/dom_exception.rb +2 -0
  20. data/lib/dommy/dom_parser.rb +7 -17
  21. data/lib/dommy/element.rb +940 -353
  22. data/lib/dommy/event.rb +529 -17
  23. data/lib/dommy/fetch.rb +577 -53
  24. data/lib/dommy/form_data.rb +2 -0
  25. data/lib/dommy/history.rb +26 -3
  26. data/lib/dommy/html_canvas_element.rb +230 -0
  27. data/lib/dommy/html_collection.rb +150 -27
  28. data/lib/dommy/html_elements.rb +1968 -282
  29. data/lib/dommy/interaction/debug.rb +35 -0
  30. data/lib/dommy/interaction/dom_summary.rb +131 -0
  31. data/lib/dommy/interaction/driver.rb +385 -0
  32. data/lib/dommy/interaction/event_synthesis.rb +164 -0
  33. data/lib/dommy/interaction/field_interactor.rb +177 -0
  34. data/lib/dommy/interaction/form_submission.rb +268 -0
  35. data/lib/dommy/interaction/locator.rb +158 -0
  36. data/lib/dommy/interaction/role_query.rb +58 -0
  37. data/lib/dommy/interaction.rb +32 -0
  38. data/lib/dommy/internal/accessibility_tree.rb +215 -0
  39. data/lib/dommy/internal/accessible_description.rb +38 -0
  40. data/lib/dommy/internal/accessible_name.rb +301 -0
  41. data/lib/dommy/internal/aria_role.rb +252 -0
  42. data/lib/dommy/internal/aria_snapshot.rb +64 -0
  43. data/lib/dommy/internal/aria_state.rb +151 -0
  44. data/lib/dommy/internal/child_node.rb +199 -0
  45. data/lib/dommy/internal/css/calc.rb +242 -0
  46. data/lib/dommy/internal/css/cascade.rb +472 -0
  47. data/lib/dommy/internal/css/color.rb +381 -0
  48. data/lib/dommy/internal/css/computed_style_declaration.rb +130 -0
  49. data/lib/dommy/internal/css/counters.rb +227 -0
  50. data/lib/dommy/internal/css/custom_properties.rb +183 -0
  51. data/lib/dommy/internal/css/media_query.rb +302 -0
  52. data/lib/dommy/internal/css/parser.rb +265 -0
  53. data/lib/dommy/internal/css/property_registry.rb +512 -0
  54. data/lib/dommy/internal/css/rule_index.rb +494 -0
  55. data/lib/dommy/internal/css/supports.rb +158 -0
  56. data/lib/dommy/internal/css/ua_stylesheet.rb +53 -0
  57. data/lib/dommy/internal/css_pseudo_handlers.rb +283 -42
  58. data/lib/dommy/internal/css_rule_text.rb +160 -0
  59. data/lib/dommy/internal/dom_matching.rb +80 -9
  60. data/lib/dommy/internal/element_matching.rb +109 -0
  61. data/lib/dommy/internal/global_functions.rb +55 -9
  62. data/lib/dommy/internal/mutation_coordinator.rb +122 -11
  63. data/lib/dommy/internal/namespaces.rb +49 -5
  64. data/lib/dommy/internal/node_wrapper_cache.rb +217 -38
  65. data/lib/dommy/internal/observer_manager.rb +6 -0
  66. data/lib/dommy/internal/observer_matcher.rb +6 -8
  67. data/lib/dommy/internal/parent_node.rb +98 -57
  68. data/lib/dommy/internal/selector_ast.rb +124 -0
  69. data/lib/dommy/internal/selector_index.rb +146 -0
  70. data/lib/dommy/internal/selector_matcher.rb +845 -0
  71. data/lib/dommy/internal/selector_parser.rb +321 -132
  72. data/lib/dommy/internal/shadow_root_registry.rb +9 -2
  73. data/lib/dommy/internal/template_content_registry.rb +26 -18
  74. data/lib/dommy/internal/xml_serialization.rb +344 -0
  75. data/lib/dommy/intersection_observer.rb +2 -0
  76. data/lib/dommy/js/bridge_conformance.rb +80 -0
  77. data/lib/dommy/js/constructor_resolver.rb +44 -0
  78. data/lib/dommy/js/custom_element_bridge.rb +101 -0
  79. data/lib/dommy/js/dom_interfaces.rb +177 -0
  80. data/lib/dommy/js/handle_table.rb +60 -0
  81. data/lib/dommy/js/host_bridge.rb +538 -0
  82. data/lib/dommy/js/host_runtime.js +2357 -0
  83. data/lib/dommy/js/import_map.rb +58 -0
  84. data/lib/dommy/js/marshaller.rb +240 -0
  85. data/lib/dommy/js/module_loader.rb +99 -0
  86. data/lib/dommy/js/observable_runtime.js +742 -0
  87. data/lib/dommy/js/runtime.rb +115 -0
  88. data/lib/dommy/js/script_boot.rb +301 -0
  89. data/lib/dommy/js/wire_tags.rb +62 -0
  90. data/lib/dommy/location.rb +78 -13
  91. data/lib/dommy/media_query_list.rb +50 -14
  92. data/lib/dommy/message_channel.rb +22 -6
  93. data/lib/dommy/minitest/assertions.rb +27 -0
  94. data/lib/dommy/mutation_observer.rb +89 -5
  95. data/lib/dommy/navigation.rb +263 -0
  96. data/lib/dommy/navigator.rb +34 -2
  97. data/lib/dommy/node.rb +198 -35
  98. data/lib/dommy/notification.rb +2 -0
  99. data/lib/dommy/parser.rb +1 -1
  100. data/lib/dommy/performance.rb +21 -1
  101. data/lib/dommy/promise.rb +94 -10
  102. data/lib/dommy/range.rb +187 -33
  103. data/lib/dommy/resources.rb +178 -0
  104. data/lib/dommy/rspec/capy_style_matchers.rb +126 -0
  105. data/lib/dommy/scheduler.rb +165 -13
  106. data/lib/dommy/screen.rb +91 -0
  107. data/lib/dommy/shadow_root.rb +109 -13
  108. data/lib/dommy/storage.rb +63 -11
  109. data/lib/dommy/streams.rb +6 -0
  110. data/lib/dommy/text_codec.rb +7 -1
  111. data/lib/dommy/tree_walker.rb +51 -46
  112. data/lib/dommy/url.rb +17 -3
  113. data/lib/dommy/version.rb +1 -1
  114. data/lib/dommy/web_socket.rb +45 -2
  115. data/lib/dommy/window.rb +322 -15
  116. data/lib/dommy/worker.rb +8 -4
  117. data/lib/dommy/xml_http_request.rb +76 -9
  118. data/lib/dommy.rb +38 -1
  119. metadata +82 -14
  120. data/lib/dommy/backend/nokogiri_adapter.rb +0 -127
  121. 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
- collect_nodes_in_range.each do |node|
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
- collapse(true)
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-node containers we split;
169
- # for element containers we insert at child index.
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
- idx += 1 if @start_offset >= length_of(sc)
177
- insert_into_parent_at(parent, idx, node)
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` must be one of the four named constants, else NotSupportedError.
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 and node intersect iff node's "position relative to range"
211
- # is not entirely before or entirely after.
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
- if text_node?(node)
372
- node.data.to_s.length
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
- anchor = children[idx]
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. a sits at
510
- # offset a_offset; b's branch sits at child index b_idx. If
511
- # a_offset > b_idx, a comes after; otherwise a precedes b.
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
- return a_offset <=> (b_idx + 1) if a_offset > b_idx
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