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.
Files changed (115) 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 +11 -5
  5. data/lib/dommy/backend/makiri_adapter.rb +330 -0
  6. data/lib/dommy/backend.rb +114 -33
  7. data/lib/dommy/blob.rb +2 -0
  8. data/lib/dommy/bridge.rb +11 -0
  9. data/lib/dommy/browser.rb +217 -0
  10. data/lib/dommy/compression_streams.rb +4 -0
  11. data/lib/dommy/crypto.rb +4 -0
  12. data/lib/dommy/css.rb +487 -50
  13. data/lib/dommy/custom_elements.rb +2 -2
  14. data/lib/dommy/data_transfer.rb +2 -0
  15. data/lib/dommy/data_uri.rb +35 -0
  16. data/lib/dommy/deferred_response.rb +59 -0
  17. data/lib/dommy/document.rb +386 -228
  18. data/lib/dommy/dom_exception.rb +2 -0
  19. data/lib/dommy/dom_parser.rb +7 -17
  20. data/lib/dommy/element.rb +502 -155
  21. data/lib/dommy/event.rb +240 -9
  22. data/lib/dommy/fetch.rb +152 -34
  23. data/lib/dommy/form_data.rb +2 -0
  24. data/lib/dommy/history.rb +2 -0
  25. data/lib/dommy/html_canvas_element.rb +230 -0
  26. data/lib/dommy/html_collection.rb +5 -6
  27. data/lib/dommy/html_elements.rb +304 -27
  28. data/lib/dommy/interaction/debug.rb +35 -0
  29. data/lib/dommy/interaction/dom_summary.rb +131 -0
  30. data/lib/dommy/interaction/driver.rb +244 -0
  31. data/lib/dommy/interaction/event_synthesis.rb +56 -0
  32. data/lib/dommy/interaction/field_interactor.rb +117 -0
  33. data/lib/dommy/interaction/form_submission.rb +268 -0
  34. data/lib/dommy/interaction/locator.rb +158 -0
  35. data/lib/dommy/interaction/role_query.rb +58 -0
  36. data/lib/dommy/interaction.rb +32 -0
  37. data/lib/dommy/internal/accessibility_tree.rb +215 -0
  38. data/lib/dommy/internal/accessible_description.rb +38 -0
  39. data/lib/dommy/internal/accessible_name.rb +301 -0
  40. data/lib/dommy/internal/aria_role.rb +252 -0
  41. data/lib/dommy/internal/aria_snapshot.rb +64 -0
  42. data/lib/dommy/internal/aria_state.rb +151 -0
  43. data/lib/dommy/internal/css/calc.rb +242 -0
  44. data/lib/dommy/internal/css/cascade.rb +430 -0
  45. data/lib/dommy/internal/css/color.rb +381 -0
  46. data/lib/dommy/internal/css/computed_style_declaration.rb +130 -0
  47. data/lib/dommy/internal/css/counters.rb +227 -0
  48. data/lib/dommy/internal/css/custom_properties.rb +183 -0
  49. data/lib/dommy/internal/css/media_query.rb +302 -0
  50. data/lib/dommy/internal/css/parser.rb +265 -0
  51. data/lib/dommy/internal/css/property_registry.rb +512 -0
  52. data/lib/dommy/internal/css/rule_index.rb +494 -0
  53. data/lib/dommy/internal/css/supports.rb +158 -0
  54. data/lib/dommy/internal/css/ua_stylesheet.rb +53 -0
  55. data/lib/dommy/internal/css_pseudo_handlers.rb +283 -42
  56. data/lib/dommy/internal/css_rule_text.rb +160 -0
  57. data/lib/dommy/internal/dom_matching.rb +80 -9
  58. data/lib/dommy/internal/element_matching.rb +109 -0
  59. data/lib/dommy/internal/global_functions.rb +33 -0
  60. data/lib/dommy/internal/mutation_coordinator.rb +95 -4
  61. data/lib/dommy/internal/namespaces.rb +49 -5
  62. data/lib/dommy/internal/node_wrapper_cache.rb +163 -26
  63. data/lib/dommy/internal/parent_node.rb +82 -5
  64. data/lib/dommy/internal/selector_ast.rb +124 -0
  65. data/lib/dommy/internal/selector_index.rb +146 -0
  66. data/lib/dommy/internal/selector_matcher.rb +756 -0
  67. data/lib/dommy/internal/selector_parser.rb +283 -131
  68. data/lib/dommy/internal/shadow_root_registry.rb +9 -2
  69. data/lib/dommy/internal/template_content_registry.rb +26 -18
  70. data/lib/dommy/internal/xml_serialization.rb +344 -0
  71. data/lib/dommy/intersection_observer.rb +2 -0
  72. data/lib/dommy/js/bridge_conformance.rb +80 -0
  73. data/lib/dommy/js/constructor_resolver.rb +44 -0
  74. data/lib/dommy/js/custom_element_bridge.rb +90 -0
  75. data/lib/dommy/js/dom_interfaces.rb +162 -0
  76. data/lib/dommy/js/handle_table.rb +60 -0
  77. data/lib/dommy/js/host_bridge.rb +517 -0
  78. data/lib/dommy/js/host_runtime.js +1495 -0
  79. data/lib/dommy/js/import_map.rb +58 -0
  80. data/lib/dommy/js/marshaller.rb +240 -0
  81. data/lib/dommy/js/module_loader.rb +99 -0
  82. data/lib/dommy/js/observable_runtime.js +742 -0
  83. data/lib/dommy/js/runtime.rb +115 -0
  84. data/lib/dommy/js/script_boot.rb +221 -0
  85. data/lib/dommy/js/wire_tags.rb +62 -0
  86. data/lib/dommy/location.rb +2 -0
  87. data/lib/dommy/media_query_list.rb +50 -14
  88. data/lib/dommy/message_channel.rb +22 -6
  89. data/lib/dommy/minitest/assertions.rb +27 -0
  90. data/lib/dommy/mutation_observer.rb +89 -4
  91. data/lib/dommy/navigator.rb +34 -2
  92. data/lib/dommy/node.rb +24 -14
  93. data/lib/dommy/notification.rb +2 -0
  94. data/lib/dommy/parser.rb +1 -1
  95. data/lib/dommy/performance.rb +21 -1
  96. data/lib/dommy/promise.rb +94 -10
  97. data/lib/dommy/range.rb +173 -31
  98. data/lib/dommy/resources.rb +178 -0
  99. data/lib/dommy/rspec/capy_style_matchers.rb +126 -0
  100. data/lib/dommy/scheduler.rb +149 -13
  101. data/lib/dommy/screen.rb +91 -0
  102. data/lib/dommy/shadow_root.rb +76 -13
  103. data/lib/dommy/storage.rb +2 -1
  104. data/lib/dommy/streams.rb +6 -0
  105. data/lib/dommy/text_codec.rb +7 -1
  106. data/lib/dommy/tree_walker.rb +33 -10
  107. data/lib/dommy/url.rb +13 -1
  108. data/lib/dommy/version.rb +1 -1
  109. data/lib/dommy/window.rb +199 -11
  110. data/lib/dommy/worker.rb +8 -4
  111. data/lib/dommy/xml_http_request.rb +47 -6
  112. data/lib/dommy.rb +36 -1
  113. metadata +96 -10
  114. data/lib/dommy/backend/nokogiri_adapter.rb +0 -127
  115. 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
- collect_nodes_in_range.each do |node|
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
- collapse(true)
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-node containers we split;
169
- # for element containers we insert at child index.
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
- idx += 1 if @start_offset >= length_of(sc)
177
- insert_into_parent_at(parent, idx, node)
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` must be one of the four named constants, else NotSupportedError.
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
- if text_node?(node)
372
- node.data.to_s.length
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
- 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
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. 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.
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
- return a_offset <=> (b_idx + 1) if a_offset > b_idx
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