dommy 0.5.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 (48) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +213 -0
  3. data/lib/dommy/attr.rb +200 -0
  4. data/lib/dommy/blob.rb +182 -0
  5. data/lib/dommy/bridge.rb +141 -0
  6. data/lib/dommy/css.rb +283 -0
  7. data/lib/dommy/custom_elements.rb +125 -0
  8. data/lib/dommy/data_transfer.rb +98 -0
  9. data/lib/dommy/document.rb +674 -0
  10. data/lib/dommy/dom_exception.rb +258 -0
  11. data/lib/dommy/dom_parser.rb +88 -0
  12. data/lib/dommy/element.rb +1975 -0
  13. data/lib/dommy/event.rb +589 -0
  14. data/lib/dommy/fetch.rb +241 -0
  15. data/lib/dommy/form_data.rb +208 -0
  16. data/lib/dommy/html_collection.rb +207 -0
  17. data/lib/dommy/html_elements.rb +4455 -0
  18. data/lib/dommy/internal/cookie_jar.rb +27 -0
  19. data/lib/dommy/internal/dom_matching.rb +141 -0
  20. data/lib/dommy/internal/mutation_coordinator.rb +172 -0
  21. data/lib/dommy/internal/node_traversal.rb +36 -0
  22. data/lib/dommy/internal/node_wrapper_cache.rb +179 -0
  23. data/lib/dommy/internal/observer_manager.rb +31 -0
  24. data/lib/dommy/internal/observer_matcher.rb +31 -0
  25. data/lib/dommy/internal/scope_resolution.rb +27 -0
  26. data/lib/dommy/internal/shadow_root_registry.rb +35 -0
  27. data/lib/dommy/internal/template_content_registry.rb +97 -0
  28. data/lib/dommy/minitest/assertions.rb +105 -0
  29. data/lib/dommy/minitest.rb +17 -0
  30. data/lib/dommy/navigator.rb +271 -0
  31. data/lib/dommy/node.rb +218 -0
  32. data/lib/dommy/observer.rb +199 -0
  33. data/lib/dommy/parser.rb +29 -0
  34. data/lib/dommy/promise.rb +199 -0
  35. data/lib/dommy/router.rb +275 -0
  36. data/lib/dommy/rspec/capy_style_matchers.rb +356 -0
  37. data/lib/dommy/rspec/matchers.rb +230 -0
  38. data/lib/dommy/rspec.rb +18 -0
  39. data/lib/dommy/scheduler.rb +135 -0
  40. data/lib/dommy/shadow_root.rb +255 -0
  41. data/lib/dommy/storage.rb +112 -0
  42. data/lib/dommy/test_helpers.rb +78 -0
  43. data/lib/dommy/tree_walker.rb +425 -0
  44. data/lib/dommy/url.rb +479 -0
  45. data/lib/dommy/version.rb +5 -0
  46. data/lib/dommy/world.rb +209 -0
  47. data/lib/dommy.rb +119 -0
  48. metadata +110 -0
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dommy
4
+ module Internal
5
+ # Manages document cookie storage (in-memory, not persisted).
6
+ # Implements the simple document.cookie key=value; key=value interface.
7
+ class CookieJar
8
+ def initialize
9
+ @cookies = {}
10
+ end
11
+
12
+ # Return all cookies as "name=value; name=value" string
13
+ def to_cookie_string
14
+ @cookies.map { |k, v| "#{k}=#{v}" }.join("; ")
15
+ end
16
+
17
+ # Parse and store a cookie from a Set-Cookie-style string
18
+ def set_cookie(value)
19
+ pair = value.to_s.split(";", 2).first.to_s.strip
20
+ return if pair.empty?
21
+
22
+ key, val = pair.split("=", 2)
23
+ @cookies[key.to_s.strip] = val.to_s.strip if key
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dommy
4
+ module Internal
5
+ # Shared matching primitives used by both RSpec matchers and
6
+ # Minitest assertions. Centralizes selector / text / count
7
+ # interpretation so the two frameworks behave identically.
8
+ module DomMatching
9
+ module_function
10
+
11
+ # Find elements in scope matching selector, optionally filtered
12
+ # by text content.
13
+ #
14
+ # @param scope [#query_selector_all] Document / Element / ShadowRoot / Fragment
15
+ # @param selector [String]
16
+ # @param text [String, Regexp, nil]
17
+ # @return [Array<Dommy::Element>]
18
+ def filter(scope, selector, text: nil)
19
+ elements = scope.query_selector_all(selector).to_a
20
+ return elements if text.nil?
21
+
22
+ elements.select { |el| text_matches?(el.text_content, text) }
23
+ end
24
+
25
+ # @param actual [String]
26
+ # @param expected [String, Regexp]
27
+ # @param exact [Boolean] when true, require exact equality (string)
28
+ # or full-string regexp match.
29
+ def text_matches?(actual, expected, exact: false)
30
+ actual = actual.to_s
31
+ case expected
32
+ when Regexp
33
+ exact ? actual.match?(expected) && actual == actual[expected] : actual.match?(expected)
34
+ else
35
+ exact ? actual.strip == expected.to_s : actual.include?(expected.to_s)
36
+ end
37
+ end
38
+
39
+ # @param actual [Integer]
40
+ # @param expected [Integer, Range, nil] — nil means "at least one"
41
+ def count_matches?(actual, expected)
42
+ case expected
43
+ when nil
44
+ actual.positive?
45
+ when Integer
46
+ actual == expected
47
+ when Range
48
+ expected.cover?(actual)
49
+ else
50
+ false
51
+ end
52
+ end
53
+
54
+ # Normalize an HTML string for structural comparison.
55
+ # Re-parses through Nokogiri and re-serializes, which collapses
56
+ # whitespace differences and attribute ordering quirks.
57
+ #
58
+ # @param html [String]
59
+ def normalize_html(html)
60
+ Nokogiri::HTML5.fragment(html.to_s).to_html.gsub(/\s+/, " ").strip
61
+ end
62
+
63
+ # Get the text_content of a scope, handling Document (which has
64
+ # no text_content directly — its body does).
65
+ def text_of(scope)
66
+ if scope.respond_to?(:text_content)
67
+ scope.text_content.to_s
68
+ elsif scope.respond_to?(:body) && scope.body
69
+ scope.body.text_content.to_s
70
+ else
71
+ scope.to_s
72
+ end
73
+ end
74
+
75
+ # Get the inner_html of a scope, falling back to body for Document.
76
+ def html_of(scope)
77
+ if scope.respond_to?(:inner_html)
78
+ scope.inner_html.to_s
79
+ elsif scope.respond_to?(:body) && scope.body
80
+ scope.body.inner_html.to_s
81
+ else
82
+ scope.to_s
83
+ end
84
+ end
85
+
86
+ # Best-effort visibility check using HTML-level signals only.
87
+ # Does NOT evaluate CSS stylesheets — `display: none` via class
88
+ # is NOT detected. See README for details and workarounds.
89
+ #
90
+ # Detects: `hidden` attribute, `<input type=hidden>`, non-rendering
91
+ # ancestors (head/script/style/template), inline `display:none` /
92
+ # `visibility:hidden` on element or any ancestor.
93
+ def visible?(element)
94
+ return true unless element.respond_to?(:__node__)
95
+
96
+ node = element.__node__
97
+ return false if node_invisible_self?(node)
98
+
99
+ NodeTraversal.each_ancestor(node) do |ancestor|
100
+ return false if non_rendering_tag?(ancestor)
101
+ return false if node_invisible_self?(ancestor)
102
+ end
103
+
104
+ true
105
+ end
106
+
107
+ # Filter elements by Capybara-style :visible option.
108
+ # @param elements [Array]
109
+ # @param visible [:visible, :all, :hidden, true, false, nil]
110
+ def filter_by_visibility(elements, visible)
111
+ case visible
112
+ when nil, :all, false
113
+ elements
114
+ when :hidden
115
+ elements.reject { |el| visible?(el) }
116
+ else
117
+ elements.select { |el| visible?(el) }
118
+ end
119
+ end
120
+
121
+ # ----- Implementation details for visible? -----
122
+ # @api private (kept module-level only because visible? calls them)
123
+
124
+ def node_invisible_self?(node)
125
+ return false unless node.respond_to?(:[])
126
+
127
+ return true if node["hidden"]
128
+ return true if node.respond_to?(:name) && node.name == "input" && node["type"] == "hidden"
129
+
130
+ style = node["style"].to_s
131
+ style.match?(/display\s*:\s*none/i) || style.match?(/visibility\s*:\s*hidden/i)
132
+ end
133
+
134
+ def non_rendering_tag?(node)
135
+ node.respond_to?(:name) && %w[head script style template].include?(node.name)
136
+ end
137
+
138
+ private_class_method :node_invisible_self?, :non_rendering_tag?
139
+ end
140
+ end
141
+ end
@@ -0,0 +1,172 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dommy
4
+ module Internal
5
+ # Coordinates mutation notification to observers and custom element lifecycle callbacks.
6
+ # Isolates mutation observation and custom element logic from Document's public API.
7
+ class MutationCoordinator
8
+ def initialize(document, observer_manager)
9
+ @document = document
10
+ @observer_manager = observer_manager
11
+ end
12
+
13
+ def register_observer(observer)
14
+ @observer_manager.register(observer)
15
+ nil
16
+ end
17
+
18
+ def unregister_observer(observer)
19
+ @observer_manager.unregister(observer)
20
+ nil
21
+ end
22
+
23
+ # Fire CustomElement lifecycle: connected (synchronous, before mutation delivery)
24
+ def notify_connected(element)
25
+ return unless element&.respond_to?(:connected_callback)
26
+
27
+ element.connected_callback
28
+ rescue StandardError
29
+ nil
30
+ end
31
+
32
+ def notify_disconnected(element)
33
+ return unless element&.respond_to?(:disconnected_callback)
34
+
35
+ element.disconnected_callback
36
+ rescue StandardError
37
+ nil
38
+ end
39
+
40
+ # Walk a subtree and fire connected/disconnected callbacks for all elements
41
+ def notify_connected_subtree(nk)
42
+ return unless nk.respond_to?(:element?)
43
+
44
+ if nk.element?
45
+ wrapped = @document.wrap_node(nk)
46
+ notify_connected(wrapped) if wrapped
47
+ end
48
+
49
+ nk.children.each { |c| notify_connected_subtree(c) } if nk.respond_to?(:children)
50
+ end
51
+
52
+ def notify_disconnected_subtree(nk)
53
+ return unless nk.respond_to?(:element?)
54
+
55
+ if nk.element?
56
+ wrapped = @document.wrap_node(nk)
57
+ notify_disconnected(wrapped) if wrapped
58
+ end
59
+
60
+ nk.children.each { |c| notify_disconnected_subtree(c) } if nk.respond_to?(:children)
61
+ end
62
+
63
+ def notify_attribute_changed(element, name, old_value, new_value)
64
+ return unless element&.respond_to?(:attribute_changed_callback)
65
+
66
+ klass = element.class
67
+ return unless klass.respond_to?(:observed_attributes)
68
+ return unless klass.observed_attributes.include?(name.to_s.downcase)
69
+
70
+ element.attribute_changed_callback(name, old_value, new_value)
71
+ rescue StandardError
72
+ nil
73
+ end
74
+
75
+ # Fire MutationObserver childList records
76
+ def notify_child_list_mutation(
77
+ target_node:,
78
+ added_nodes:,
79
+ removed_nodes:,
80
+ previous_sibling: nil,
81
+ next_sibling: nil
82
+ )
83
+ target = @document.wrap_node(target_node)
84
+ return nil unless target
85
+ return nil if added_nodes.empty? && removed_nodes.empty?
86
+
87
+ wrapped_added = added_nodes.map { |node| @document.wrap_node(node) }.compact
88
+ wrapped_removed = removed_nodes.map { |node| @document.wrap_node(node) }.compact
89
+
90
+ # Fire Custom Element lifecycle callbacks (synchronous, before MutationObserver microtask)
91
+ added_nodes.each { |nk| notify_connected_subtree(nk) }
92
+ removed_nodes.each { |nk| notify_disconnected_subtree(nk) }
93
+
94
+ # Capture previousSibling / nextSibling (the position within target)
95
+ prev_w = previous_sibling
96
+ next_w = next_sibling
97
+ if (prev_w.nil? && next_w.nil?) && !added_nodes.empty?
98
+ first_nk = added_nodes.first
99
+ last_nk = added_nodes.last
100
+ prev_w ||= @document.wrap_node(first_nk.previous) if first_nk.respond_to?(:previous)
101
+ next_w ||= @document.wrap_node(last_nk.next) if last_nk.respond_to?(:next)
102
+ end
103
+
104
+ record = MutationRecord.new(
105
+ type: "childList",
106
+ target: target,
107
+ added_nodes: wrapped_added,
108
+ removed_nodes: wrapped_removed,
109
+ previous_sibling: prev_w,
110
+ next_sibling: next_w
111
+ )
112
+ @observer_manager.observers_matching(target).each do |observer|
113
+ observer.enqueue(record)
114
+ end
115
+
116
+ nil
117
+ end
118
+
119
+ # Fire MutationObserver attribute records
120
+ def notify_attribute_mutation(target_node:, attribute_name:, old_value:)
121
+ target = @document.wrap_node(target_node)
122
+ return nil unless target
123
+
124
+ attr = attribute_name.to_s.downcase
125
+ new_value = target_node[attr]
126
+
127
+ # Custom Element attributeChangedCallback (synchronous)
128
+ notify_attribute_changed(target, attr, old_value, new_value)
129
+
130
+ @observer_manager.observers_matching(target).each do |observer|
131
+ entry = observer.find_matching_entry(target)
132
+ next unless entry && entry[:attributes]
133
+
134
+ filter = entry[:attribute_filter]
135
+ next if filter && !filter.include?(attr)
136
+
137
+ observer.enqueue(
138
+ MutationRecord.new(
139
+ type: "attributes",
140
+ target: target,
141
+ attribute_name: attr,
142
+ old_value: entry[:attribute_old_value] ? old_value : nil
143
+ )
144
+ )
145
+ end
146
+
147
+ nil
148
+ end
149
+
150
+ # Fire MutationObserver characterData records
151
+ def notify_character_data_mutation(target_node:, old_value:)
152
+ target = @document.wrap_node(target_node)
153
+ return nil unless target
154
+
155
+ @observer_manager.observers_matching(target).each do |observer|
156
+ entry = observer.find_matching_entry(target)
157
+ next unless entry && entry[:character_data]
158
+
159
+ observer.enqueue(
160
+ MutationRecord.new(
161
+ type: "characterData",
162
+ target: target,
163
+ old_value: entry[:character_data_old_value] ? old_value : nil
164
+ )
165
+ )
166
+ end
167
+
168
+ nil
169
+ end
170
+ end
171
+ end
172
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dommy
4
+ module Internal
5
+ # Node tree traversal utilities.
6
+ # Centralizes ancestor walking logic to hide Nokogiri implementation details.
7
+ # Prevents duplication of tree traversal code across Observer, EventTarget, etc.
8
+ module NodeTraversal
9
+ # Walk from a node up to document, yielding each ancestor.
10
+ # Stops at Nokogiri::XML::Document (the root).
11
+ def self.each_ancestor(node)
12
+ current = node.respond_to?(:parent) ? node.parent : nil
13
+ while current && !current.is_a?(Nokogiri::XML::Document)
14
+ yield current
15
+ current = current.respond_to?(:parent) ? current.parent : nil
16
+ end
17
+ end
18
+
19
+ # Check if ancestor is an ancestor of node.
20
+ def self.ancestor_of?(ancestor, node)
21
+ each_ancestor(node) { |n| return true if n == ancestor }
22
+ false
23
+ end
24
+
25
+ # Find the first ancestor matching a predicate.
26
+ # Returns the result of the block, not the node itself.
27
+ def self.find_ancestor(node)
28
+ each_ancestor(node) { |n|
29
+ result = yield(n)
30
+ return result if result
31
+ }
32
+ nil
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,179 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dommy
4
+ module Internal
5
+ # Manages DOM node identity via wrapper caching.
6
+ # Ensures that wrap_node(nokogiri_node) always returns the same Ruby object.
7
+ # Separates identity/caching management from Document's public DOM API.
8
+ class NodeWrapperCache
9
+ NAME_RE = /\A[a-z][\w.\-]*\z/i
10
+
11
+ def initialize(document)
12
+ @document = document
13
+ @wrappers = {}
14
+ end
15
+
16
+ # Returns the wrapped node, creating and caching if needed.
17
+ # Maintains DOM identity across repeated traversals.
18
+ def wrap(node)
19
+ return nil unless node
20
+
21
+ cached = @wrappers[node.object_id]
22
+ return cached if cached
23
+
24
+ wrapper = build_wrapper_for(node)
25
+ @wrappers[node.object_id] = wrapper if wrapper
26
+ wrapper
27
+ end
28
+
29
+ # Factory methods
30
+
31
+ def create_element(name)
32
+ str = name.to_s
33
+ raise DOMException::InvalidCharacterError, "name must not be empty" if str.empty?
34
+ raise DOMException::InvalidCharacterError, "invalid element name: #{str.inspect}" unless str.match?(NAME_RE)
35
+
36
+ wrap_node(Nokogiri::XML::Node.new(str.downcase, @document.nokogiri_doc))
37
+ end
38
+
39
+ def create_text_node(text)
40
+ wrap_node(Nokogiri::XML::Text.new(text.to_s, @document.nokogiri_doc))
41
+ end
42
+
43
+ def create_comment(text)
44
+ wrap_node(Nokogiri::XML::Comment.new(@document.nokogiri_doc, text.to_s))
45
+ end
46
+
47
+ def create_document_fragment
48
+ wrap_node(@document.nokogiri_doc.fragment(""))
49
+ end
50
+
51
+ def create_attribute(name)
52
+ str = name.to_s
53
+ raise DOMException::InvalidCharacterError, "name must not be empty" if str.empty?
54
+ raise DOMException::InvalidCharacterError, "invalid attribute name: #{str.inspect}" unless str.match?(NAME_RE)
55
+
56
+ Attr.new(str)
57
+ end
58
+
59
+ def create_attribute_ns(_namespace_uri, qualified_name)
60
+ str = qualified_name.to_s
61
+ raise DOMException::InvalidCharacterError, "name must not be empty" if str.empty?
62
+ raise DOMException::InvalidCharacterError, "invalid qualified name: #{str.inspect}" unless str.match?(NAME_RE)
63
+
64
+ Attr.new(str)
65
+ end
66
+
67
+ def create_element_ns(namespace_uri, qualified_name)
68
+ str = qualified_name.to_s
69
+ raise DOMException::InvalidCharacterError, "name must not be empty" if str.empty?
70
+ raise DOMException::InvalidCharacterError, "invalid qualified name: #{str.inspect}" unless str.match?(NAME_RE)
71
+
72
+ el = Nokogiri::XML::Node.new(str, @document.nokogiri_doc)
73
+ el.add_namespace_definition(nil, namespace_uri.to_s) if namespace_uri && !namespace_uri.to_s.empty?
74
+ wrap(el)
75
+ end
76
+
77
+ # Query methods
78
+
79
+ def query_selector(selector)
80
+ return nil if selector.nil? || selector.to_s.empty?
81
+
82
+ wrap(@document.nokogiri_doc.at_css(selector.to_s))
83
+ end
84
+
85
+ def query_selector_all(selector)
86
+ return NodeList.new if selector.nil? || selector.to_s.empty?
87
+
88
+ NodeList.new(@document.nokogiri_doc.css(selector.to_s).map { |node| wrap(node) }.compact)
89
+ end
90
+
91
+ def get_element_by_id(id)
92
+ return nil if id.nil? || id.to_s.empty?
93
+
94
+ wrap(@document.nokogiri_doc.at_css("##{id}"))
95
+ end
96
+
97
+ def get_elements_by_tag_name(name)
98
+ n = name.to_s.downcase
99
+ doc = @document.nokogiri_doc
100
+ cache = self
101
+ if n == "*"
102
+ HTMLCollection.new { doc.css("*").map { |x| cache.wrap(x) }.compact }
103
+ else
104
+ HTMLCollection.new { doc.css(n).map { |x| cache.wrap(x) }.compact }
105
+ end
106
+ end
107
+
108
+ def get_elements_by_name(name)
109
+ doc = @document.nokogiri_doc
110
+ cache = self
111
+ key = name.to_s
112
+ HTMLCollection.new do
113
+ doc.css("[name='#{key}']").map { |x| cache.wrap(x) }.compact
114
+ end
115
+ end
116
+
117
+ def get_elements_by_class_name(name)
118
+ tokens = name.to_s.split(/\s+/).reject(&:empty?)
119
+ doc = @document.nokogiri_doc
120
+ cache = self
121
+ HTMLCollection.new do
122
+ next [] if tokens.empty?
123
+
124
+ selector = tokens.map { |t| ".#{t}" }.join("")
125
+ doc.css(selector).map { |n| cache.wrap(n) }.compact
126
+ end
127
+ end
128
+
129
+ # Clear cached wrapper (used by customElements.define for upgrades)
130
+ def reset_wrapper(nokogiri_node)
131
+ @wrappers.delete(nokogiri_node.object_id)
132
+ end
133
+
134
+ private
135
+
136
+ def wrap_node(node)
137
+ wrap(node)
138
+ end
139
+
140
+ def build_wrapper_for(node)
141
+ case node
142
+ when Nokogiri::XML::Element
143
+ build_element_wrapper(node)
144
+ when Nokogiri::XML::Text
145
+ TextNode.new(@document, node)
146
+ when Nokogiri::XML::Comment
147
+ CommentNode.new(@document, node)
148
+ when Nokogiri::XML::DocumentFragment
149
+ Fragment.new(@document, node)
150
+ end
151
+ end
152
+
153
+ def build_element_wrapper(node)
154
+ custom_klass = custom_element_class_for(node.name)
155
+ klass = custom_klass || Dommy.element_class_for(node.name)
156
+ instance = klass.new(@document, node)
157
+
158
+ @wrappers[node.object_id] = instance
159
+
160
+ if custom_klass && instance.respond_to?(:construct)
161
+ begin
162
+ instance.construct
163
+ rescue StandardError
164
+ nil
165
+ end
166
+ end
167
+
168
+ instance
169
+ end
170
+
171
+ def custom_element_class_for(tag_name)
172
+ # Custom elements are registered on window, not document.
173
+ # Access via default_view if available.
174
+ default_view = @document.default_view
175
+ default_view&.custom_elements&.get(tag_name)
176
+ end
177
+ end
178
+ end
179
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dommy
4
+ module Internal
5
+ # Manages MutationObserver registration and matching.
6
+ # Filters observers based on mutation target to avoid unnecessary lookups.
7
+ class ObserverManager
8
+ def initialize
9
+ @observers = []
10
+ end
11
+
12
+ def register(observer)
13
+ @observers << observer unless @observers.include?(observer)
14
+ end
15
+
16
+ def unregister(observer)
17
+ @observers.delete(observer)
18
+ end
19
+
20
+ # Returns all observers that match the given wrapped target.
21
+ # Delegates to each observer's matches_wrapped? method.
22
+ def observers_matching(target_wrapped)
23
+ @observers.select { |observer| observer.matches_wrapped?(target_wrapped) }
24
+ end
25
+
26
+ def all
27
+ @observers.dup
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dommy
4
+ module Internal
5
+ # Matches a mutation target against an observed node based on observer options.
6
+ # Works exclusively with wrapped DOM nodes (not Nokogiri internals).
7
+ class ObserverMatcher
8
+ def initialize
9
+ end
10
+
11
+ # Does this observer's target scope match the mutation target?
12
+ # Returns true if:
13
+ # - target == observed (exact match), OR
14
+ # - subtree=true AND target is descendant of observed
15
+ def matches?(observed_wrapped, target_wrapped, subtree:)
16
+ return true if target_wrapped.equal?(observed_wrapped)
17
+ return false unless subtree
18
+ return false unless observed_wrapped.respond_to?(:contains?)
19
+
20
+ observed_wrapped.contains?(target_wrapped)
21
+ end
22
+
23
+ # Special case: Document observation
24
+ # Matches if subtree=false (never) or target is in document (always)
25
+ def matches_document?(target_wrapped, subtree:)
26
+ return true if subtree
27
+ false
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dommy
4
+ module Internal
5
+ # Resolve various subject types into a uniform DOM scope for
6
+ # matchers and assertions to operate on.
7
+ #
8
+ # Test helpers want to let callers pass whatever they have on hand
9
+ # — `rendered`, `response.body`, a Document, an Element — and have
10
+ # it Just Work. This module centralizes that conversion so the
11
+ # matchers themselves stay focused on matching logic.
12
+ module ScopeResolution
13
+ module_function
14
+
15
+ # Convert the given subject into a scope usable by query_selector
16
+ # / text_content / inner_html.
17
+ #
18
+ # - String: parsed as HTML (full document or body fragment, per
19
+ # `Dommy.parse`)
20
+ # - Anything else: returned as-is (Document, Element, Fragment,
21
+ # ShadowRoot, etc.)
22
+ def resolve(scope)
23
+ scope.is_a?(String) ? Dommy.parse(scope).document : scope
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "node_traversal"
4
+
5
+ module Dommy
6
+ module Internal
7
+ # Manages ShadowRoot identity and shadow boundary traversal.
8
+ # Maps Nokogiri DocumentFragment (shadow tree backing) to ShadowRoot wrapper.
9
+ class ShadowRootRegistry
10
+ def initialize
11
+ @shadow_roots = {}
12
+ end
13
+
14
+ # Register a shadow root by its backing fragment node
15
+ def register(fragment_node, shadow_root)
16
+ @shadow_roots[fragment_node.object_id] = shadow_root
17
+ end
18
+
19
+ # Find the ShadowRoot for a given fragment (if any)
20
+ def find_for_fragment(fragment_node)
21
+ return nil unless fragment_node
22
+ @shadow_roots[fragment_node.object_id]
23
+ end
24
+
25
+ # Find the enclosing ShadowRoot for a given node.
26
+ # Walks up the ancestor chain looking for a shadow root.
27
+ # Uses NodeTraversal to avoid duplication of tree walking logic.
28
+ def find_enclosing(nokogiri_node)
29
+ NodeTraversal.find_ancestor(nokogiri_node) do |ancestor|
30
+ find_for_fragment(ancestor)
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end