Module: Dommy::Internal::DomMatching

Defined in:
lib/dommy/internal/dom_matching.rb

Overview

Shared matching primitives used by both RSpec matchers and Minitest assertions. Centralizes selector / text / count interpretation so the two frameworks behave identically.

Constant Summary collapse

INLINE_ZERO_OPACITY =

opacity: 0 / 0.0 / .0 / 0% (and nothing else) as an inline value.

/opacity\s*:\s*\+?(?:0+(?:\.0*)?|\.0+)%?\s*(?:[;!]|\z)/i
NON_RENDERED_TEXT_TAGS =

Tags whose text never renders, so it must not count as page text.

%w[head script style template noscript].freeze

Class Method Summary collapse

Class Method Details

.count_matches?(actual, expected) ⇒ Boolean

Parameters:

  • actual (Integer)
  • expected (Integer, Range, nil)

    — nil means "at least one"

Returns:

  • (Boolean)


41
42
43
44
45
46
47
48
49
50
51
52
# File 'lib/dommy/internal/dom_matching.rb', line 41

def count_matches?(actual, expected)
  case expected
  when nil
    actual.positive?
  when Integer
    actual == expected
  when ::Range
    expected.cover?(actual)
  else
    false
  end
end

.css_visible?(element) ⇒ Boolean

CSS-aware extension of visible?, consulted only when the document has author CSS (Cascade.author_css? keeps sheetless documents on the fast path). display: none on the element or any ancestor hides; computed visibility: hidden/collapse (inherited, overridable by a descendant's visibility: visible) hides.

Returns:

  • (Boolean)


112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
# File 'lib/dommy/internal/dom_matching.rb', line 112

def css_visible?(element)
  document = element.respond_to?(:owner_document) ? element.owner_document : nil
  return true unless document && CSS::Cascade.author_css?(document)

  return false if %w[hidden collapse].include?(CSS::Cascade.computed_style(element)["visibility"])

  current = element
  while current
    styles = CSS::Cascade.computed_style(current)
    return false if styles["display"] == "none"
    # Zero effective opacity is invisible (Selenium's displayed
    # algorithm); a zero anywhere in the chain zeroes the product.
    return false if opacity_zero?(styles["opacity"])
    current = current.respond_to?(:parent_element) ? current.parent_element : nil
  end

  true
end

.filter(scope, selector, text: nil) ⇒ Array<Dommy::Element>

Find elements in scope matching selector, optionally filtered by text content.

Parameters:

  • scope (#query_selector_all)

    Document / Element / ShadowRoot / Fragment

  • selector (String)
  • text (String, Regexp, nil) (defaults to: nil)

Returns:



18
19
20
21
22
23
# File 'lib/dommy/internal/dom_matching.rb', line 18

def filter(scope, selector, text: nil)
  elements = scope.query_selector_all(selector).to_a
  return elements if text.nil?

  elements.select { |el| text_matches?(el.text_content, text) }
end

.filter_by_visibility(elements, visible) ⇒ Object

Filter elements by Capybara-style :visible option.

Parameters:

  • elements (Array)
  • visible (:visible, :all, :hidden, true, false, nil)


141
142
143
144
145
146
147
148
149
150
# File 'lib/dommy/internal/dom_matching.rb', line 141

def filter_by_visibility(elements, visible)
  case visible
  when nil, :all, false
    elements
  when :hidden
    elements.reject { |el| visible?(el) }
  else
    elements.select { |el| visible?(el) }
  end
end

.html_of(scope) ⇒ Object

Get the inner_html of a scope, falling back to body for Document.



76
77
78
79
80
81
82
83
84
# File 'lib/dommy/internal/dom_matching.rb', line 76

def html_of(scope)
  if scope.respond_to?(:inner_html)
    scope.inner_html.to_s
  elsif scope.respond_to?(:body) && scope.body
    scope.body.inner_html.to_s
  else
    scope.to_s
  end
end

.normalize_html(html) ⇒ Object

Normalize an HTML string for structural comparison. Re-parses through Nokogiri and re-serializes, which collapses whitespace differences and attribute ordering quirks.

Parameters:

  • html (String)


59
60
61
# File 'lib/dommy/internal/dom_matching.rb', line 59

def normalize_html(html)
  Backend.fragment(html.to_s, owner_doc: nil).to_html.gsub(/\s+/, " ").strip
end

.opacity_zero?(value) ⇒ Boolean

Returns:

  • (Boolean)


131
132
133
134
135
136
# File 'lib/dommy/internal/dom_matching.rb', line 131

def opacity_zero?(value)
  text = value.to_s.strip
  return false unless text.match?(/\A[+-]?(?:\d+(?:\.\d*)?|\.\d+)%?\z/)

  text.to_f <= 0
end

.rendered_text(node) ⇒ Object

Visible text of a node's subtree: like text_content, but excluding subtrees that never render (script/style/head/template/noscript). Mirrors what a browser exposes to has_text? / text filters, so JSON embedded in a <script data-page> (Inertia/Turbo) or inline CSS is not mistaken for visible page text.



184
185
186
187
188
189
190
# File 'lib/dommy/internal/dom_matching.rb', line 184

def rendered_text(node)
  return "" if node.nil?

  out = +""
  append_rendered_text(node, out)
  out
end

.text_matches?(actual, expected, exact: false) ⇒ Boolean

Parameters:

  • actual (String)
  • expected (String, Regexp)
  • exact (Boolean) (defaults to: false)

    when true, require exact equality (string) or full-string regexp match.

Returns:

  • (Boolean)


29
30
31
32
33
34
35
36
37
# File 'lib/dommy/internal/dom_matching.rb', line 29

def text_matches?(actual, expected, exact: false)
  actual = actual.to_s
  case expected
  when Regexp
    exact ? actual.match?(expected) && actual == actual[expected] : actual.match?(expected)
  else
    exact ? actual.strip == expected.to_s : actual.include?(expected.to_s)
  end
end

.text_of(scope) ⇒ Object

Get the text_content of a scope, handling Document (which has no text_content directly — its body does).



65
66
67
68
69
70
71
72
73
# File 'lib/dommy/internal/dom_matching.rb', line 65

def text_of(scope)
  if scope.respond_to?(:text_content)
    scope.text_content.to_s
  elsif scope.respond_to?(:body) && scope.body
    scope.body.text_content.to_s
  else
    scope.to_s
  end
end

.visible?(element) ⇒ Boolean

Visibility check. Fast HTML-level signals first (hidden attribute, <input type=hidden>, non-rendering ancestors, inline display:none / visibility:hidden); when the document carries author CSS and the makiri-backed parser is available, stylesheet- driven display:none / visibility:hidden (e.g. via a class) is detected through the computed style as well. No layout: geometry- dependent invisibility stays out of scope.

Returns:

  • (Boolean)


93
94
95
96
97
98
99
100
101
102
103
104
105
# File 'lib/dommy/internal/dom_matching.rb', line 93

def visible?(element)
  return true unless element.respond_to?(:__dommy_backend_node__)

  node = element.__dommy_backend_node__
  return false if node_invisible_self?(node)

  NodeTraversal.each_ancestor(node) do |ancestor|
    return false if non_rendering_tag?(ancestor)
    return false if node_invisible_self?(ancestor)
  end

  css_visible?(element)
end