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 +4 -4
- data/CHANGELOG.md +7 -0
- data/lib/capybara/dommy/driver.rb +112 -3
- data/lib/capybara/dommy/node.rb +92 -18
- data/lib/capybara/dommy/version.rb +1 -1
- metadata +7 -7
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 288082f09fb0e1151ba15dfa7bce080997855de9ac4cbced143238b8fbc6db6b
|
|
4
|
+
data.tar.gz: 8c7c68b114eb0526071f15ae2675a94bc38492161fadbf29f3b5cf934d810f09
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
data/lib/capybara/dommy/node.rb
CHANGED
|
@@ -17,7 +17,24 @@ module Capybara
|
|
|
17
17
|
end
|
|
18
18
|
|
|
19
19
|
def [](name)
|
|
20
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
252
|
-
#
|
|
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
|
-
|
|
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)
|
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.
|
|
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:
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
97
|
+
rubygems_version: 3.6.9
|
|
98
98
|
specification_version: 4
|
|
99
99
|
summary: A Dommy-backed Capybara driver
|
|
100
100
|
test_files: []
|