capybara-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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5c7a62f13890b2fa82ad1270440924e9a1723a20975d71d91aa1bf24938922ae
4
- data.tar.gz: 8045f008aaca496cab19488fc3b551cc2a955880d08bdc24034e60981d791ffe
3
+ metadata.gz: 288082f09fb0e1151ba15dfa7bce080997855de9ac4cbced143238b8fbc6db6b
4
+ data.tar.gz: 8c7c68b114eb0526071f15ae2675a94bc38492161fadbf29f3b5cf934d810f09
5
5
  SHA512:
6
- metadata.gz: e337cfa551b1a24a3f06554d5638260f5b2c2c19c09cad1b90aa3bfe55bd7363b1671f329070eabe32ab489ebcf035b06240cf79d1dea6ce364ff1541b2b9b03
7
- data.tar.gz: 05ac1cd5fdda2ba28b907adbac630d3d139e2e33dfaf26f06478bd4fb7a7ca668b662fb4b6726da46818cae9aa09b5f95dc7a0390d81e9915bf64f1ad3ace1a0
6
+ metadata.gz: a6544c835b5721f26d77ab97c8278dc7a722c859a86c32ca06b81c263c8cb5a2d9062b318b92e731dd646795cb634bc69c91bbb229da6de3dbaf719f1bec2887
7
+ data.tar.gz: 707be8eeb45ba4a7ca871f1aef7160755bc0fb8a33f587b9bd37ea10fc69c9095c164684d765a5884ca7c1f79d69db650bc0fe758cdc5b752c3d61715abee610
data/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.9.0 — 2026-06-22
4
+
5
+ Versioned in lockstep with [`dommy`](https://github.com/takahashim/dommy) 0.9.0.
6
+ No functional changes to capybara-dommy itself; it inherits dommy 0.9.0's CSS
7
+ cascade, computed styles, and accessibility tree, and now drives check/choose
8
+ through native click activation.
9
+
3
10
  ## 0.8.0 — 2026-05-31
4
11
 
5
12
  Versioned in lockstep with [`dommy`](https://github.com/takahashim/dommy) 0.8.0.
@@ -12,6 +12,18 @@ module Capybara
12
12
 
13
13
  attr_reader :app, :visibility
14
14
 
15
+ # --- Deterministic-time seam (used by JS runtimes) ---
16
+ #
17
+ # A JS runtime assigns a callable here; the driver invokes it before
18
+ # each DOM read Capybara polls in its synchronize loop (find_css /
19
+ # find_xpath / html / title). The pump is expected to advance Dommy's
20
+ # virtual scheduler a small slice and drain microtasks, so "content
21
+ # appears after a timeout" specs converge without wall-clock sleeps.
22
+ # Installing a pump also flips `wait?` to true, making Capybara retry
23
+ # failed expectations instead of raising immediately. Survives
24
+ # `reset!` (it belongs to the runtime, not to one page session).
25
+ attr_accessor :time_pump
26
+
15
27
  def initialize(app,
16
28
  default_host: nil,
17
29
  follow_redirects: nil,
@@ -52,6 +64,7 @@ module Capybara
52
64
  # --- Navigation ---
53
65
 
54
66
  def visit(path)
67
+ @frame_stack = []
55
68
  # A fresh visit resolves a relative path against the host root (not the
56
69
  # current page's directory), matching browser address-bar semantics.
57
70
  rack_session.visit(::URI.join("#{effective_host}/", path.to_s).to_s)
@@ -78,11 +91,15 @@ module Capybara
78
91
  # --- Page state ---
79
92
 
80
93
  def html
94
+ pump!
81
95
  rack_session.html
82
96
  end
83
97
 
98
+ # The title of the top-level browsing context, even inside a frame
99
+ # (Capybara's #title contract); the current frame's title is #frame_title.
84
100
  def title
85
- document&.title
101
+ pump!
102
+ rack_session.document&.title
86
103
  end
87
104
 
88
105
  def status_code
@@ -96,17 +113,59 @@ module Capybara
96
113
  # --- Query (returns Capybara::Dommy::Node arrays) ---
97
114
 
98
115
  def find_css(query, **_options)
116
+ pump!
99
117
  wrap(document&.query_selector_all(query))
100
118
  end
101
119
 
102
120
  def find_xpath(query, **_options)
121
+ pump!
103
122
  wrap(document&.xpath(query))
104
123
  end
105
124
 
106
125
  # --- Node-facing seam (keeps the dommy-rack Session API in one place) ---
107
126
 
127
+ # The document queries run against: the innermost switched-to frame's
128
+ # document, or the top-level page when no frame is active.
108
129
  def document
109
- rack_session.document
130
+ frame_stack.empty? ? rack_session.document : frame_stack.last[:document]
131
+ end
132
+
133
+ # --- Frames ---
134
+ # Capybara::Session#switch_to_frame drives these with an iframe element
135
+ # node, :parent, or :top. Frame documents are fetched through the
136
+ # dommy-rack session (sharing cookies); nothing here touches the
137
+ # top-level page state, so current_url / title stay top-level.
138
+
139
+ def switch_to_frame(frame)
140
+ case frame
141
+ when :top
142
+ @frame_stack = []
143
+ when :parent
144
+ frame_stack.pop
145
+ else
146
+ frame_stack.push(load_frame(frame.native))
147
+ end
148
+ end
149
+
150
+ def frame_url
151
+ frame_stack.empty? ? rack_session.current_url.to_s : frame_stack.last[:url]
152
+ end
153
+
154
+ def frame_title
155
+ document&.title
156
+ end
157
+
158
+ # --- Focus / keyboard ---
159
+
160
+ def active_element
161
+ Node.new(self, document.active_element)
162
+ end
163
+
164
+ # Session-level send_keys. Without JavaScript only focus navigation is
165
+ # meaningful, so :tab (the key Capybara's focused: specs use) moves
166
+ # focus through the tab order; other keys are ignored.
167
+ def send_keys(*keys)
168
+ keys.each { |key| focus_next_tabbable if key == :tab }
110
169
  end
111
170
 
112
171
  def follow_link(element)
@@ -121,10 +180,11 @@ module Capybara
121
180
 
122
181
  def reset!
123
182
  @rack_session = nil
183
+ @frame_stack = []
124
184
  end
125
185
 
126
186
  def wait?
127
- false
187
+ !@time_pump.nil?
128
188
  end
129
189
 
130
190
  def needs_server?
@@ -162,6 +222,55 @@ module Capybara
162
222
 
163
223
  private
164
224
 
225
+ def pump!
226
+ @time_pump&.call
227
+ end
228
+
229
+ def frame_stack
230
+ @frame_stack ||= []
231
+ end
232
+
233
+ # Fetch an iframe's document, resolving its src against the enclosing
234
+ # frame's URL so nested frames with relative srcs load correctly.
235
+ def load_frame(iframe_element)
236
+ src = iframe_element.get_attribute("src").to_s
237
+ raise Capybara::Dommy::Error, "iframe has no src" if src.empty?
238
+
239
+ url = ::URI.join(frame_url, src).to_s
240
+ response = rack_session.fetch(url, headers: {"Referer" => frame_url})
241
+ doc = response.document
242
+ raise Capybara::Dommy::Error, "iframe did not return an HTML document" unless doc
243
+
244
+ {document: doc, url: url}
245
+ end
246
+
247
+ # Sequential focus navigation: elements with a positive tabindex first
248
+ # (ascending, document order within a value), then the remaining
249
+ # focusables in document order. The page's tab cycle starts over when
250
+ # the current active element is not in the order (e.g. body).
251
+ FOCUSABLE_SELECTOR = "a[href], button, input, select, textarea, [tabindex]"
252
+
253
+ def focus_next_tabbable
254
+ ordered = tab_order
255
+ return if ordered.empty?
256
+
257
+ current = document.active_element
258
+ index = ordered.index { |el| el == current }
259
+ target = ordered[index ? index + 1 : 0]
260
+ target&.focus
261
+ end
262
+
263
+ def tab_order
264
+ candidates = document.query_selector_all(FOCUSABLE_SELECTOR).to_a.reject do |el|
265
+ el.get_attribute("tabindex").to_s.start_with?("-") ||
266
+ el.has_attribute?("disabled") ||
267
+ el.get_attribute("type").to_s.downcase == "hidden" ||
268
+ !visible?(el)
269
+ end
270
+ positive, natural = candidates.each_with_index.partition { |el, _i| el.get_attribute("tabindex").to_i.positive? }
271
+ positive.sort_by { |el, i| [el.get_attribute("tabindex").to_i, i] }.map(&:first) + natural.map(&:first)
272
+ end
273
+
165
274
  # Capybara's app_host (set per-example) wins over default_host; falls
166
275
  # back to the host this driver was configured with. Guarded so a
167
276
  # standalone driver (no owning Capybara session) still works.
@@ -17,7 +17,24 @@ module Capybara
17
17
  end
18
18
 
19
19
  def [](name)
20
- native.get_attribute(name.to_s)
20
+ key = name.to_s
21
+ # Capybara's validation_message filter reads the constraint-validation
22
+ # DOM property, which never appears as a markup attribute.
23
+ if key == "validationMessage" && native.respond_to?(:validation_message)
24
+ return native.validation_message
25
+ end
26
+
27
+ # `checked` / `selected` reflect the live IDL property (current state),
28
+ # not the content attribute (which is the *default*) — as WebDriver's
29
+ # getAttribute does for these boolean attributes.
30
+ if key == "checked" && native.respond_to?(:checked)
31
+ return native.checked ? "true" : nil
32
+ end
33
+ if key == "selected" && native.respond_to?(:selected)
34
+ return native.selected ? "true" : nil
35
+ end
36
+
37
+ native.get_attribute(key)
21
38
  end
22
39
 
23
40
  def value
@@ -77,11 +94,43 @@ module Capybara
77
94
  end
78
95
 
79
96
  def path
97
+ # Capybara's documented placeholder: a shadow tree has no XPath.
98
+ if native.respond_to?(:get_root_node) && native.get_root_node.is_a?(::Dommy::ShadowRoot)
99
+ return "(: Shadow DOM element - no XPath :)"
100
+ end
101
+
80
102
  native.path
81
103
  end
82
104
 
83
- def style(_styles)
84
- {}
105
+ # Keyboard input without a JS engine: maintains a caret over the field's
106
+ # value and applies printable keys plus the position/modifier keys
107
+ # Capybara's non-JS send_keys specs use. Key *events* are not dispatched
108
+ # (nothing here can observe them without JavaScript).
109
+ def send_keys(*args)
110
+ return unless native.respond_to?(:value=)
111
+
112
+ state = {chars: native.value.to_s.chars, caret: native.value.to_s.length, shift: false}
113
+ args.each do |arg|
114
+ if arg.is_a?(Array)
115
+ # A chord like [:shift, 'o'] holds its modifiers only for the
116
+ # duration of the array.
117
+ held = state[:shift]
118
+ arg.each { |key| apply_key(state, key) }
119
+ state[:shift] = held
120
+ else
121
+ apply_key(state, arg)
122
+ end
123
+ end
124
+ native.focus if native.respond_to?(:focus)
125
+ native.value = state[:chars].join
126
+ end
127
+
128
+ # Computed styles for Capybara's matches_style? / style: filters,
129
+ # served by Dommy's CSS cascade (values come back in the computed
130
+ # serialization, e.g. colors as rgb()).
131
+ def style(styles)
132
+ computed = ::Dommy::Internal::CSS::Cascade.computed_style(native)
133
+ Array(styles).flatten.to_h { |name| [name.to_s, computed[name.to_s].to_s] }
85
134
  end
86
135
 
87
136
  # --- Interaction ---
@@ -132,6 +181,15 @@ module Capybara
132
181
  native.selected = false
133
182
  end
134
183
 
184
+ # Move the (virtual) pointer over this element: :hover rules and
185
+ # `matches(":hover")` then apply to it and its ancestors. No
186
+ # mouseover/mouseout events are dispatched (nothing observes them
187
+ # without JavaScript).
188
+ def hover
189
+ native.owner_document.__internal_set_hovered_element__(native)
190
+ nil
191
+ end
192
+
135
193
  # --- Scoped queries (for `within`) ---
136
194
 
137
195
  def find_css(locator, **_options)
@@ -157,8 +215,34 @@ module Capybara
157
215
  RUBY
158
216
  end
159
217
 
218
+ # Identity matters to Capybara (e.g. the focused: filter compares a
219
+ # candidate against session.active_element). Dommy's wrapper cache hands
220
+ # out one wrapper per DOM node, so native equality is node identity.
221
+ def ==(other)
222
+ other.is_a?(Node) && native == other.native
223
+ end
224
+
160
225
  private
161
226
 
227
+ def apply_key(state, key)
228
+ case key
229
+ when String
230
+ key.each_char do |ch|
231
+ state[:chars].insert(state[:caret], state[:shift] ? ch.upcase : ch)
232
+ state[:caret] += 1
233
+ end
234
+ when :space
235
+ state[:chars].insert(state[:caret], " ")
236
+ state[:caret] += 1
237
+ when :left
238
+ state[:caret] = [state[:caret] - 1, 0].max
239
+ when :right
240
+ state[:caret] = [state[:caret] + 1, state[:chars].length].min
241
+ when :shift
242
+ state[:shift] = true
243
+ end
244
+ end
245
+
162
246
  def stale_check
163
247
  return if native.document.equal?(driver.document)
164
248
 
@@ -248,25 +332,15 @@ module Capybara
248
332
  value && !value.empty?
249
333
  end
250
334
 
251
- # Reflect checked state on the attribute so node[:checked] and form
252
- # submission both observe it (Dommy's `checked=` only sets the property).
335
+ # Toggle through the control's native activation behavior (which fires
336
+ # input/change and, for a radio, maintains its group) — only when it
337
+ # isn't already in the requested state, matching a real click.
253
338
  def set_checkbox(value)
254
- if value
255
- native.set_attribute("checked", "checked")
256
- else
257
- native.remove_attribute("checked")
258
- end
339
+ native.click if checked? != !!value
259
340
  end
260
341
 
261
342
  def set_radio
262
- name = native.get_attribute("name")
263
- scope = native.closest("form") || document
264
- if name && scope
265
- scope.query_selector_all("input[type='radio']").each do |radio|
266
- radio.remove_attribute("checked") if radio.get_attribute("name") == name
267
- end
268
- end
269
- native.set_attribute("checked", "checked")
343
+ native.click unless checked?
270
344
  end
271
345
 
272
346
  def set_file(value)
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Capybara
4
4
  module Dommy
5
- VERSION = "0.8.0"
5
+ VERSION = "0.9.0"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: capybara-dommy
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.0
4
+ version: 0.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - takahashim
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-05-31 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: capybara
@@ -29,28 +29,28 @@ dependencies:
29
29
  requirements:
30
30
  - - "~>"
31
31
  - !ruby/object:Gem::Version
32
- version: 0.8.0
32
+ version: 0.9.0
33
33
  type: :runtime
34
34
  prerelease: false
35
35
  version_requirements: !ruby/object:Gem::Requirement
36
36
  requirements:
37
37
  - - "~>"
38
38
  - !ruby/object:Gem::Version
39
- version: 0.8.0
39
+ version: 0.9.0
40
40
  - !ruby/object:Gem::Dependency
41
41
  name: dommy-rack
42
42
  requirement: !ruby/object:Gem::Requirement
43
43
  requirements:
44
44
  - - "~>"
45
45
  - !ruby/object:Gem::Version
46
- version: 0.8.0
46
+ version: 0.9.0
47
47
  type: :runtime
48
48
  prerelease: false
49
49
  version_requirements: !ruby/object:Gem::Requirement
50
50
  requirements:
51
51
  - - "~>"
52
52
  - !ruby/object:Gem::Version
53
- version: 0.8.0
53
+ version: 0.9.0
54
54
  description: |
55
55
  capybara-dommy is a Capybara driver backed by Dommy and dommy-rack. It drives
56
56
  Rack/Rails apps through the Capybara DSL without a real browser or JavaScript,
@@ -94,7 +94,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
94
94
  - !ruby/object:Gem::Version
95
95
  version: '0'
96
96
  requirements: []
97
- rubygems_version: 3.6.2
97
+ rubygems_version: 3.6.9
98
98
  specification_version: 4
99
99
  summary: A Dommy-backed Capybara driver
100
100
  test_files: []