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,356 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../internal/dom_matching"
4
+ require_relative "../internal/scope_resolution"
5
+
6
+ module Dommy
7
+ module RSpec
8
+ # Capybara-style RSpec matchers backed by Dommy.
9
+ #
10
+ # These mirror Capybara's matcher names (`have_selector`, `have_content`,
11
+ # `have_link`, etc.) so existing Capybara test suites can be migrated
12
+ # to a pure-Ruby DOM stack with minimal code changes.
13
+ #
14
+ # Because the names collide with Capybara::RSpecMatchers, this module
15
+ # should be included into specs that DO NOT also include Capybara —
16
+ # typically view / component / request specs, with feature specs
17
+ # left to Capybara. See the README for a `type:` split example.
18
+ #
19
+ # @example
20
+ # require "dommy/rspec/capy_style_matchers"
21
+ #
22
+ # RSpec.configure do |c|
23
+ # c.include Dommy::TestHelpers, type: :view
24
+ # c.include Dommy::RSpec::CapyStyleMatchers, type: :view
25
+ # end
26
+ #
27
+ # expect(dom).to have_selector("button.primary")
28
+ # expect(dom).to have_content("Welcome")
29
+ # expect(dom).to have_link("Sign up", href: "/signup")
30
+ module CapyStyleMatchers
31
+ def have_selector(selector, **opts)
32
+ HaveSelector.new(selector, **opts)
33
+ end
34
+
35
+ alias_method :have_css, :have_selector
36
+
37
+ def have_no_selector(selector, **opts)
38
+ HaveNoSelector.new(selector, **opts)
39
+ end
40
+
41
+ alias_method :have_no_css, :have_no_selector
42
+
43
+ def have_content(text, **opts)
44
+ HaveContent.new(text, **opts)
45
+ end
46
+
47
+ alias_method :have_text, :have_content
48
+
49
+ def have_no_content(text, **opts)
50
+ HaveNoContent.new(text, **opts)
51
+ end
52
+
53
+ alias_method :have_no_text, :have_no_content
54
+
55
+ def have_link(text = nil, **opts)
56
+ HaveLink.new(text, **opts)
57
+ end
58
+
59
+ def have_no_link(text = nil, **opts)
60
+ HaveNoLink.new(text, **opts)
61
+ end
62
+
63
+ def have_button(text = nil, **opts)
64
+ HaveButton.new(text, **opts)
65
+ end
66
+
67
+ def have_no_button(text = nil, **opts)
68
+ HaveNoButton.new(text, **opts)
69
+ end
70
+
71
+ def have_field(name_or_label = nil, **opts)
72
+ HaveField.new(name_or_label, **opts)
73
+ end
74
+
75
+ def have_no_field(name_or_label = nil, **opts)
76
+ HaveNoField.new(name_or_label, **opts)
77
+ end
78
+
79
+ # ----- Base behavior shared across element-finding matchers -----
80
+
81
+ # @api private
82
+ class Base
83
+ def initialize(selector, **opts)
84
+ @selector = selector
85
+ @text = opts[:text] || opts[:content]
86
+ @count = opts[:count]
87
+ @exact = opts[:exact] || opts[:exact_text]
88
+ @visible = opts.fetch(:visible, :visible)
89
+ # :wait is accepted for Capybara compatibility but ignored
90
+ # (Dommy is synchronous).
91
+ end
92
+
93
+ def matches?(scope)
94
+ @scope = Internal::ScopeResolution.resolve(scope)
95
+ @matched = find_matches(@scope)
96
+ count_ok?(@matched.size)
97
+ end
98
+
99
+ def does_not_match?(scope)
100
+ @scope = Internal::ScopeResolution.resolve(scope)
101
+ @matched = find_matches(@scope)
102
+ if @count
103
+ !count_ok?(@matched.size)
104
+ else
105
+ @matched.empty?
106
+ end
107
+ end
108
+
109
+ def description
110
+ "have #{describe_target}"
111
+ end
112
+
113
+ def failure_message
114
+ "expected to find #{describe_target}, found #{@matched.size}"
115
+ end
116
+
117
+ def failure_message_when_negated
118
+ "expected NOT to find #{describe_target}, found #{@matched.size}"
119
+ end
120
+
121
+ private
122
+
123
+ def find_matches(scope)
124
+ elements = scope.query_selector_all(query_selector).to_a
125
+ elements = filter_by_text(elements) if @text
126
+ Internal::DomMatching.filter_by_visibility(elements, @visible)
127
+ end
128
+
129
+ def filter_by_text(elements)
130
+ elements.select { |el| Internal::DomMatching.text_matches?(el.text_content, @text, exact: @exact) }
131
+ end
132
+
133
+ def query_selector
134
+ @selector
135
+ end
136
+
137
+ def count_ok?(actual)
138
+ Internal::DomMatching.count_matches?(actual, @count)
139
+ end
140
+
141
+ def describe_target
142
+ parts = [describe_what]
143
+ parts << "with text #{@text.inspect}" if @text
144
+ parts << "(count: #{@count.inspect})" if @count
145
+ parts.join(" ")
146
+ end
147
+
148
+ def describe_what
149
+ "elements matching #{@selector.inspect}"
150
+ end
151
+ end
152
+
153
+ # @api private
154
+ # Mixin that flips matches? / does_not_match? for the "no_*" matchers,
155
+ # so each negative matcher reads identically to its positive twin
156
+ # but with inverted assertions.
157
+ module Negated
158
+ def matches?(scope)
159
+ !super
160
+ end
161
+
162
+ def does_not_match?(scope)
163
+ !matches?(scope)
164
+ end
165
+
166
+ def failure_message
167
+ "expected NOT to find #{describe_target}, found #{@matched.size}"
168
+ end
169
+
170
+ def failure_message_when_negated
171
+ "expected to find #{describe_target}, found #{@matched.size}"
172
+ end
173
+ end
174
+
175
+ class HaveSelector < Base
176
+ end
177
+
178
+ class HaveNoSelector < HaveSelector
179
+ include Negated
180
+ end
181
+
182
+ # @api private
183
+ class HaveContent
184
+ def initialize(text, **opts)
185
+ @text = text
186
+ @exact = opts[:exact] || opts[:exact_text]
187
+ end
188
+
189
+ def matches?(scope)
190
+ @scope = Internal::ScopeResolution.resolve(scope)
191
+ @actual = Internal::DomMatching.text_of(@scope)
192
+ Internal::DomMatching.text_matches?(@actual, @text, exact: @exact)
193
+ end
194
+
195
+ def does_not_match?(scope)
196
+ !matches?(scope)
197
+ end
198
+
199
+ def description
200
+ "have content #{@text.inspect}"
201
+ end
202
+
203
+ def failure_message
204
+ "expected text to include #{@text.inspect}, got #{@actual.inspect}"
205
+ end
206
+
207
+ def failure_message_when_negated
208
+ "expected text NOT to include #{@text.inspect}, got #{@actual.inspect}"
209
+ end
210
+ end
211
+
212
+ # @api private
213
+ class HaveNoContent < HaveContent
214
+ def matches?(scope)
215
+ !super
216
+ end
217
+
218
+ def does_not_match?(scope)
219
+ !matches?(scope)
220
+ end
221
+
222
+ def failure_message
223
+ "expected text NOT to include #{@text.inspect}, got #{@actual.inspect}"
224
+ end
225
+
226
+ def failure_message_when_negated
227
+ "expected text to include #{@text.inspect}, got #{@actual.inspect}"
228
+ end
229
+ end
230
+
231
+ # @api private
232
+ class HaveLink < Base
233
+ def initialize(text, **opts)
234
+ super("a[href]", text: text, **opts.reject { |k, _| k == :href })
235
+ @href = opts[:href]
236
+ end
237
+
238
+ private
239
+
240
+ def find_matches(scope)
241
+ elements = super
242
+ return elements unless @href
243
+
244
+ elements.select do |a|
245
+ href = a.get_attribute("href").to_s
246
+ case @href
247
+ when Regexp
248
+ href.match?(@href)
249
+ else
250
+ href == @href.to_s
251
+ end
252
+ end
253
+ end
254
+
255
+ def describe_what
256
+ @href ? "link to #{@href.inspect}" : "link"
257
+ end
258
+ end
259
+
260
+ class HaveNoLink < HaveLink
261
+ include Negated
262
+ end
263
+
264
+ # @api private
265
+ class HaveButton < Base
266
+ def initialize(text, **opts)
267
+ # Buttons are <button> elements OR <input type="submit|button|reset">.
268
+ super("button, input[type='submit'], input[type='button'], input[type='reset']", text: text, **opts)
269
+ @type_filter = opts[:type]
270
+ end
271
+
272
+ private
273
+
274
+ def find_matches(scope)
275
+ elements = super
276
+ return elements unless @type_filter
277
+
278
+ elements.select { |el| el.get_attribute("type") == @type_filter.to_s }
279
+ end
280
+
281
+ # Override Base's text filter so <input type=submit> matches by
282
+ # its `value` attribute rather than text content.
283
+ def filter_by_text(elements)
284
+ elements.select do |el|
285
+ label = el.tag_name.downcase == "input" ? el.get_attribute("value").to_s : el.text_content.to_s
286
+ Internal::DomMatching.text_matches?(label, @text, exact: @exact)
287
+ end
288
+ end
289
+
290
+ def describe_what
291
+ "button"
292
+ end
293
+ end
294
+
295
+ class HaveNoButton < HaveButton
296
+ include Negated
297
+ end
298
+
299
+ # @api private
300
+ # have_field locates form fields by name attribute, id, or label text.
301
+ class HaveField < Base
302
+ def initialize(name_or_label, **opts)
303
+ super("input, textarea, select", text: nil, **opts.reject { |k, _| %i[with type].include?(k) })
304
+ @locator = name_or_label
305
+ @with_value = opts[:with]
306
+ @type_filter = opts[:type]
307
+ end
308
+
309
+ private
310
+
311
+ def find_matches(scope)
312
+ elements = scope.query_selector_all(query_selector).to_a
313
+ elements = elements.select { |el| matches_locator?(el, @locator) } if @locator
314
+ elements = elements.select { |el| el.get_attribute("type") == @type_filter.to_s } if @type_filter
315
+ elements = elements.select { |el| matches_value?(el, @with_value) } unless @with_value.nil?
316
+ Internal::DomMatching.filter_by_visibility(elements, @visible)
317
+ end
318
+
319
+ def matches_locator?(el, locator)
320
+ locator_str = locator.to_s
321
+ return true if el.get_attribute("name") == locator_str
322
+ return true if el.get_attribute("id") == locator_str
323
+ return true if matches_label?(el, locator_str)
324
+
325
+ false
326
+ end
327
+
328
+ # Find a <label> with matching text whose `for=` points to this element,
329
+ # or that wraps this element.
330
+ def matches_label?(el, label_text)
331
+ @scope.query_selector_all("label").any? do |label|
332
+ next false unless label.text_content.to_s.strip.include?(label_text)
333
+
334
+ for_attr = label.get_attribute("for")
335
+ next true if for_attr && el.get_attribute("id") == for_attr
336
+
337
+ label.contains?(el)
338
+ end
339
+ end
340
+
341
+ def matches_value?(el, expected_value)
342
+ actual = el.respond_to?(:value) ? el.value.to_s : el.get_attribute("value").to_s
343
+ actual == expected_value.to_s
344
+ end
345
+
346
+ def describe_what
347
+ @locator ? "field #{@locator.inspect}" : "field"
348
+ end
349
+ end
350
+
351
+ class HaveNoField < HaveField
352
+ include Negated
353
+ end
354
+ end
355
+ end
356
+ end
@@ -0,0 +1,230 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../internal/dom_matching"
4
+ require_relative "../internal/scope_resolution"
5
+
6
+ module Dommy
7
+ module RSpec
8
+ # Custom RSpec matchers for asserting against Dommy DOM objects.
9
+ #
10
+ # @example RSpec config
11
+ # require "dommy/rspec"
12
+ # RSpec.configure do |c|
13
+ # c.include Dommy::RSpec::Matchers
14
+ # end
15
+ #
16
+ # @example Usage
17
+ # expect(root).to contain_dom("button.primary")
18
+ # expect(root).to contain_dom("li", count: 3)
19
+ # expect(root).to contain_dom("h1", text: "Welcome")
20
+ # expect(root).to contain_dom_text("Submit")
21
+ # expect(button).to have_dom_attribute("type", "button")
22
+ # expect(button).to have_dom_class("primary")
23
+ # expect(root).to match_dom_html("<h1>Hello</h1>")
24
+ module Matchers
25
+ def contain_dom(selector, text: nil, count: nil)
26
+ ContainDom.new(selector, text: text, count: count)
27
+ end
28
+
29
+ def contain_dom_text(text)
30
+ ContainDomText.new(text)
31
+ end
32
+
33
+ def have_dom_attribute(name, value = UNSET)
34
+ HaveDomAttribute.new(name, value)
35
+ end
36
+
37
+ def have_dom_class(class_name)
38
+ HaveDomClass.new(class_name)
39
+ end
40
+
41
+ def match_dom_html(expected_html)
42
+ MatchDomHtml.new(expected_html)
43
+ end
44
+
45
+ # Sentinel for "value was not passed" — distinguishes
46
+ # `have_dom_attribute("disabled")` (existence only) from
47
+ # `have_dom_attribute("disabled", "true")` (value match).
48
+ UNSET = Object.new.freeze
49
+ private_constant :UNSET
50
+
51
+ # @api private
52
+ class ContainDom
53
+ def initialize(selector, text: nil, count: nil)
54
+ @selector = selector
55
+ @text = text
56
+ @count = count
57
+ end
58
+
59
+ def matches?(scope)
60
+ @scope = Internal::ScopeResolution.resolve(scope)
61
+ @matched = Internal::DomMatching.filter(@scope, @selector, text: @text)
62
+ Internal::DomMatching.count_matches?(@matched.size, @count)
63
+ end
64
+
65
+ def does_not_match?(scope)
66
+ !matches?(scope)
67
+ end
68
+
69
+ def description
70
+ parts = ["contain DOM matching #{@selector.inspect}"]
71
+ parts << "with text #{@text.inspect}" if @text
72
+ parts << "(count: #{@count.inspect})" if @count
73
+ parts.join(" ")
74
+ end
75
+
76
+ def failure_message
77
+ "expected #{describe_scope} to #{description} (found #{@matched.size})"
78
+ end
79
+
80
+ def failure_message_when_negated
81
+ "expected #{describe_scope} not to #{description} (found #{@matched.size})"
82
+ end
83
+
84
+ private
85
+
86
+ def describe_scope
87
+ @scope.respond_to?(:tag_name) ? "<#{@scope.tag_name.downcase}>" : @scope.class.name
88
+ end
89
+ end
90
+
91
+ # @api private
92
+ class ContainDomText
93
+ def initialize(text)
94
+ @text = text
95
+ end
96
+
97
+ def matches?(scope)
98
+ @scope = Internal::ScopeResolution.resolve(scope)
99
+ @actual_text = Internal::DomMatching.text_of(@scope)
100
+ Internal::DomMatching.text_matches?(@actual_text, @text)
101
+ end
102
+
103
+ def does_not_match?(scope)
104
+ !matches?(scope)
105
+ end
106
+
107
+ def description
108
+ "contain text #{@text.inspect}"
109
+ end
110
+
111
+ def failure_message
112
+ "expected text to include #{@text.inspect}, got #{@actual_text.inspect}"
113
+ end
114
+
115
+ def failure_message_when_negated
116
+ "expected text not to include #{@text.inspect}, got #{@actual_text.inspect}"
117
+ end
118
+ end
119
+
120
+ # @api private
121
+ class HaveDomAttribute
122
+ def initialize(name, value)
123
+ @name = name.to_s
124
+ @value = value
125
+ end
126
+
127
+ def matches?(element)
128
+ @element = element
129
+ return false unless element.has_attribute?(@name)
130
+
131
+ @actual = element.get_attribute(@name)
132
+ unset?(@value) ? true : @actual.to_s == @value.to_s
133
+ end
134
+
135
+ def does_not_match?(element)
136
+ !matches?(element)
137
+ end
138
+
139
+ def description
140
+ if unset?(@value)
141
+ "have DOM attribute #{@name.inspect}"
142
+ else
143
+ "have DOM attribute #{@name.inspect} = #{@value.inspect}"
144
+ end
145
+ end
146
+
147
+ def failure_message
148
+ if unset?(@value)
149
+ "expected element to have attribute #{@name.inspect}, but it was missing"
150
+ else
151
+ "expected attribute #{@name.inspect} to equal #{@value.inspect}, got #{@actual.inspect}"
152
+ end
153
+ end
154
+
155
+ def failure_message_when_negated
156
+ if unset?(@value)
157
+ "expected element NOT to have attribute #{@name.inspect}, but it was present (#{@actual.inspect})"
158
+ else
159
+ "expected attribute #{@name.inspect} not to equal #{@value.inspect}"
160
+ end
161
+ end
162
+
163
+ private
164
+
165
+ def unset?(value)
166
+ value.equal?(UNSET)
167
+ end
168
+ end
169
+
170
+ # @api private
171
+ class HaveDomClass
172
+ def initialize(class_name)
173
+ @class_name = class_name.to_s
174
+ end
175
+
176
+ def matches?(element)
177
+ @element = element
178
+ @actual_classes = element.class_list.value.to_s.split(/\s+/)
179
+ @actual_classes.include?(@class_name)
180
+ end
181
+
182
+ def does_not_match?(element)
183
+ !matches?(element)
184
+ end
185
+
186
+ def description
187
+ "have DOM class #{@class_name.inspect}"
188
+ end
189
+
190
+ def failure_message
191
+ "expected element to have class #{@class_name.inspect}, got #{@actual_classes.inspect}"
192
+ end
193
+
194
+ def failure_message_when_negated
195
+ "expected element NOT to have class #{@class_name.inspect}, got #{@actual_classes.inspect}"
196
+ end
197
+ end
198
+
199
+ # @api private
200
+ class MatchDomHtml
201
+ def initialize(expected_html)
202
+ @expected_html = expected_html
203
+ end
204
+
205
+ def matches?(scope)
206
+ @scope = Internal::ScopeResolution.resolve(scope)
207
+ @actual_normalized = Internal::DomMatching.normalize_html(Internal::DomMatching.html_of(@scope))
208
+ @expected_normalized = Internal::DomMatching.normalize_html(@expected_html)
209
+ @actual_normalized == @expected_normalized
210
+ end
211
+
212
+ def does_not_match?(scope)
213
+ !matches?(scope)
214
+ end
215
+
216
+ def description
217
+ "match DOM HTML #{@expected_html.inspect}"
218
+ end
219
+
220
+ def failure_message
221
+ "expected DOM HTML to match.\nExpected: #{@expected_normalized}\nActual: #{@actual_normalized}"
222
+ end
223
+
224
+ def failure_message_when_negated
225
+ "expected DOM HTML NOT to match #{@expected_normalized}"
226
+ end
227
+ end
228
+ end
229
+ end
230
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Entry point for using Dommy from RSpec test suites.
4
+ # Loads the test helpers and DOM matcher modules so users can
5
+ # `include` them into their RSpec config.
6
+ #
7
+ # @example
8
+ # require "dommy/rspec"
9
+ #
10
+ # RSpec.configure do |c|
11
+ # c.include Dommy::TestHelpers
12
+ # c.include Dommy::RSpec::Matchers
13
+ # end
14
+
15
+ require "dommy"
16
+ require "dommy/test_helpers"
17
+ require "dommy/rspec/matchers"
18
+ require "dommy/rspec/capy_style_matchers"