capybara-dommy 0.8.0 → 0.10.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 +15 -0
- data/lib/capybara/dommy/configuration.rb +2 -1
- data/lib/capybara/dommy/driver.rb +145 -8
- data/lib/capybara/dommy/node.rb +153 -19
- data/lib/capybara/dommy/rails.rb +7 -0
- 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: ce693099e9f737e726914acb5cf9129ad87a96978209f5b1d9cc27132925b652
|
|
4
|
+
data.tar.gz: a69e70e2f0878025fd8d8c5a9411aaa990d6ebd7f812bd623cd3f27c4614e3bd
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1f351cf3f7989d3bfa1a984dc425cf895790350ed699431f1e6fb8a7d6708c8b1abc05ab20df20f129839a0c926074d6b64774b9885a97b593ad41b8354b0b2a
|
|
7
|
+
data.tar.gz: 4246e91a681b995f8cc2fb2633eac92a1d6e5b1fc8dad67954d099124cc999055575597a59a94965ea6d37b201ed04d9ec2271a6dbd0146d7eede6b634a416cc
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.10.0 — 2026-07-13
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- **JavaScript-enabled driver variant** (`:dommy_js`, or `Driver.new(app, javascript: true)` / `config.javascript`): pages run their real Turbo/Stimulus/React bundles in the embedded QuickJS runtime, no browser process. Node interactions behave like a browser instead of the HTML-only fast paths — `click` dispatches the full pointer/mouse/click sequence before the default action (Turbo can `preventDefault` and take over), `set` types with focus + `input` / `change` events, `select_option` fires `input` / `change`, and `send_keys` dispatches real keyboard events. `execute_script` / `evaluate_script` run for real, and a time pump advances the virtual clock inside Capybara's synchronize loop so waiting matchers converge. Requires `dommy-js-quickjs`.
|
|
7
|
+
|
|
8
|
+
### Fixed
|
|
9
|
+
- The dommy-rack session is fully disposed on `reset!` and when the effective host changes, so JS runtimes and open WebSocket transports no longer leak across tests.
|
|
10
|
+
|
|
11
|
+
## 0.9.0 — 2026-06-22
|
|
12
|
+
|
|
13
|
+
Versioned in lockstep with [`dommy`](https://github.com/takahashim/dommy) 0.9.0.
|
|
14
|
+
No functional changes to capybara-dommy itself; it inherits dommy 0.9.0's CSS
|
|
15
|
+
cascade, computed styles, and accessibility tree, and now drives check/choose
|
|
16
|
+
through native click activation.
|
|
17
|
+
|
|
3
18
|
## 0.8.0 — 2026-05-31
|
|
4
19
|
|
|
5
20
|
Versioned in lockstep with [`dommy`](https://github.com/takahashim/dommy) 0.8.0.
|
|
@@ -6,7 +6,7 @@ module Capybara
|
|
|
6
6
|
# when a keyword argument is omitted.
|
|
7
7
|
class Configuration
|
|
8
8
|
attr_accessor :default_host, :follow_redirects, :max_redirects, :visibility,
|
|
9
|
-
:raise_on_unsupported_js
|
|
9
|
+
:raise_on_unsupported_js, :javascript
|
|
10
10
|
|
|
11
11
|
def initialize
|
|
12
12
|
@default_host = "http://example.org"
|
|
@@ -14,6 +14,7 @@ module Capybara
|
|
|
14
14
|
@max_redirects = 5
|
|
15
15
|
@visibility = :html
|
|
16
16
|
@raise_on_unsupported_js = true
|
|
17
|
+
@javascript = false
|
|
17
18
|
end
|
|
18
19
|
end
|
|
19
20
|
|
|
@@ -12,14 +12,28 @@ 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,
|
|
18
30
|
max_redirects: nil,
|
|
19
|
-
visibility: nil
|
|
31
|
+
visibility: nil,
|
|
32
|
+
javascript: nil)
|
|
20
33
|
super()
|
|
21
34
|
config = Capybara::Dommy.configuration
|
|
22
35
|
@app = app
|
|
36
|
+
@javascript = javascript.nil? ? config.javascript : javascript
|
|
23
37
|
@visibility = visibility || config.visibility
|
|
24
38
|
unless VISIBILITY_MODES.include?(@visibility)
|
|
25
39
|
raise ArgumentError,
|
|
@@ -34,6 +48,24 @@ module Capybara
|
|
|
34
48
|
# hosts (e.g. app_host / multi-server specs), so don't enforce origin.
|
|
35
49
|
enforce_same_origin: false
|
|
36
50
|
}
|
|
51
|
+
@session_options[:javascript] = true if @javascript
|
|
52
|
+
# A JS session needs the virtual clock pumped inside Capybara's
|
|
53
|
+
# synchronize loop, so waiting expectations converge on timer/fetch
|
|
54
|
+
# driven updates. A host-installed pump (the documented seam) wins.
|
|
55
|
+
@time_pump ||= -> { @rack_session&.advance_time(16) } if @javascript
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Whether this driver runs page JavaScript (`javascript: true`, backed by
|
|
59
|
+
# a `Dommy::Rack::Session.new(app, javascript: true)`). Node interactions
|
|
60
|
+
# then dispatch real DOM events (Turbo/Stimulus handlers run) instead of
|
|
61
|
+
# the HTML-only fast paths.
|
|
62
|
+
def javascript? = @javascript
|
|
63
|
+
|
|
64
|
+
# Drain the JS runtime after an interaction's events (promise reactions
|
|
65
|
+
# settle before the next Capybara step). No-op without JavaScript.
|
|
66
|
+
def drain_js
|
|
67
|
+
rack_session.after_interaction if @javascript
|
|
68
|
+
nil
|
|
37
69
|
end
|
|
38
70
|
|
|
39
71
|
# The dommy-rack session. Named `rack_session` to avoid colliding with
|
|
@@ -43,6 +75,7 @@ module Capybara
|
|
|
43
75
|
def rack_session
|
|
44
76
|
host = effective_host
|
|
45
77
|
if @rack_session.nil? || @rack_session_host != host
|
|
78
|
+
@rack_session&.dispose
|
|
46
79
|
@rack_session = ::Dommy::Rack::Session.new(@app, **@session_options.merge(default_host: host))
|
|
47
80
|
@rack_session_host = host
|
|
48
81
|
end
|
|
@@ -52,6 +85,7 @@ module Capybara
|
|
|
52
85
|
# --- Navigation ---
|
|
53
86
|
|
|
54
87
|
def visit(path)
|
|
88
|
+
@frame_stack = []
|
|
55
89
|
# A fresh visit resolves a relative path against the host root (not the
|
|
56
90
|
# current page's directory), matching browser address-bar semantics.
|
|
57
91
|
rack_session.visit(::URI.join("#{effective_host}/", path.to_s).to_s)
|
|
@@ -78,11 +112,15 @@ module Capybara
|
|
|
78
112
|
# --- Page state ---
|
|
79
113
|
|
|
80
114
|
def html
|
|
115
|
+
pump!
|
|
81
116
|
rack_session.html
|
|
82
117
|
end
|
|
83
118
|
|
|
119
|
+
# The title of the top-level browsing context, even inside a frame
|
|
120
|
+
# (Capybara's #title contract); the current frame's title is #frame_title.
|
|
84
121
|
def title
|
|
85
|
-
|
|
122
|
+
pump!
|
|
123
|
+
rack_session.document&.title
|
|
86
124
|
end
|
|
87
125
|
|
|
88
126
|
def status_code
|
|
@@ -96,17 +134,59 @@ module Capybara
|
|
|
96
134
|
# --- Query (returns Capybara::Dommy::Node arrays) ---
|
|
97
135
|
|
|
98
136
|
def find_css(query, **_options)
|
|
137
|
+
pump!
|
|
99
138
|
wrap(document&.query_selector_all(query))
|
|
100
139
|
end
|
|
101
140
|
|
|
102
141
|
def find_xpath(query, **_options)
|
|
142
|
+
pump!
|
|
103
143
|
wrap(document&.xpath(query))
|
|
104
144
|
end
|
|
105
145
|
|
|
106
146
|
# --- Node-facing seam (keeps the dommy-rack Session API in one place) ---
|
|
107
147
|
|
|
148
|
+
# The document queries run against: the innermost switched-to frame's
|
|
149
|
+
# document, or the top-level page when no frame is active.
|
|
108
150
|
def document
|
|
109
|
-
rack_session.document
|
|
151
|
+
frame_stack.empty? ? rack_session.document : frame_stack.last[:document]
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# --- Frames ---
|
|
155
|
+
# Capybara::Session#switch_to_frame drives these with an iframe element
|
|
156
|
+
# node, :parent, or :top. Frame documents are fetched through the
|
|
157
|
+
# dommy-rack session (sharing cookies); nothing here touches the
|
|
158
|
+
# top-level page state, so current_url / title stay top-level.
|
|
159
|
+
|
|
160
|
+
def switch_to_frame(frame)
|
|
161
|
+
case frame
|
|
162
|
+
when :top
|
|
163
|
+
@frame_stack = []
|
|
164
|
+
when :parent
|
|
165
|
+
frame_stack.pop
|
|
166
|
+
else
|
|
167
|
+
frame_stack.push(load_frame(frame.native))
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def frame_url
|
|
172
|
+
frame_stack.empty? ? rack_session.current_url.to_s : frame_stack.last[:url]
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def frame_title
|
|
176
|
+
document&.title
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# --- Focus / keyboard ---
|
|
180
|
+
|
|
181
|
+
def active_element
|
|
182
|
+
Node.new(self, document.active_element)
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Session-level send_keys. Without JavaScript only focus navigation is
|
|
186
|
+
# meaningful, so :tab (the key Capybara's focused: specs use) moves
|
|
187
|
+
# focus through the tab order; other keys are ignored.
|
|
188
|
+
def send_keys(*keys)
|
|
189
|
+
keys.each { |key| focus_next_tabbable if key == :tab }
|
|
110
190
|
end
|
|
111
191
|
|
|
112
192
|
def follow_link(element)
|
|
@@ -120,11 +200,13 @@ module Capybara
|
|
|
120
200
|
# --- Lifecycle ---
|
|
121
201
|
|
|
122
202
|
def reset!
|
|
203
|
+
@rack_session&.dispose
|
|
123
204
|
@rack_session = nil
|
|
205
|
+
@frame_stack = []
|
|
124
206
|
end
|
|
125
207
|
|
|
126
208
|
def wait?
|
|
127
|
-
|
|
209
|
+
!@time_pump.nil?
|
|
128
210
|
end
|
|
129
211
|
|
|
130
212
|
def needs_server?
|
|
@@ -140,12 +222,18 @@ module Capybara
|
|
|
140
222
|
# When raise_on_unsupported_js is false these become no-ops, so tests
|
|
141
223
|
# that incidentally call them don't fail.
|
|
142
224
|
|
|
143
|
-
def execute_script(
|
|
144
|
-
unsupported_js!("execute_script")
|
|
225
|
+
def execute_script(script, *args)
|
|
226
|
+
return unsupported_js!("execute_script") unless @javascript
|
|
227
|
+
raise ArgumentError, "script arguments are not supported" unless args.empty?
|
|
228
|
+
|
|
229
|
+
rack_session.execute_script(script)
|
|
145
230
|
end
|
|
146
231
|
|
|
147
|
-
def evaluate_script(
|
|
148
|
-
unsupported_js!("evaluate_script")
|
|
232
|
+
def evaluate_script(script, *args)
|
|
233
|
+
return unsupported_js!("evaluate_script") unless @javascript
|
|
234
|
+
raise ArgumentError, "script arguments are not supported" unless args.empty?
|
|
235
|
+
|
|
236
|
+
rack_session.evaluate_script(script)
|
|
149
237
|
end
|
|
150
238
|
|
|
151
239
|
def evaluate_async_script(_script, *_args)
|
|
@@ -162,6 +250,55 @@ module Capybara
|
|
|
162
250
|
|
|
163
251
|
private
|
|
164
252
|
|
|
253
|
+
def pump!
|
|
254
|
+
@time_pump&.call
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def frame_stack
|
|
258
|
+
@frame_stack ||= []
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# Fetch an iframe's document, resolving its src against the enclosing
|
|
262
|
+
# frame's URL so nested frames with relative srcs load correctly.
|
|
263
|
+
def load_frame(iframe_element)
|
|
264
|
+
src = iframe_element.get_attribute("src").to_s
|
|
265
|
+
raise Capybara::Dommy::Error, "iframe has no src" if src.empty?
|
|
266
|
+
|
|
267
|
+
url = ::URI.join(frame_url, src).to_s
|
|
268
|
+
response = rack_session.fetch(url, headers: {"Referer" => frame_url})
|
|
269
|
+
doc = response.document
|
|
270
|
+
raise Capybara::Dommy::Error, "iframe did not return an HTML document" unless doc
|
|
271
|
+
|
|
272
|
+
{document: doc, url: url}
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
# Sequential focus navigation: elements with a positive tabindex first
|
|
276
|
+
# (ascending, document order within a value), then the remaining
|
|
277
|
+
# focusables in document order. The page's tab cycle starts over when
|
|
278
|
+
# the current active element is not in the order (e.g. body).
|
|
279
|
+
FOCUSABLE_SELECTOR = "a[href], button, input, select, textarea, [tabindex]"
|
|
280
|
+
|
|
281
|
+
def focus_next_tabbable
|
|
282
|
+
ordered = tab_order
|
|
283
|
+
return if ordered.empty?
|
|
284
|
+
|
|
285
|
+
current = document.active_element
|
|
286
|
+
index = ordered.index { |el| el == current }
|
|
287
|
+
target = ordered[index ? index + 1 : 0]
|
|
288
|
+
target&.focus
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
def tab_order
|
|
292
|
+
candidates = document.query_selector_all(FOCUSABLE_SELECTOR).to_a.reject do |el|
|
|
293
|
+
el.get_attribute("tabindex").to_s.start_with?("-") ||
|
|
294
|
+
el.has_attribute?("disabled") ||
|
|
295
|
+
el.get_attribute("type").to_s.downcase == "hidden" ||
|
|
296
|
+
!visible?(el)
|
|
297
|
+
end
|
|
298
|
+
positive, natural = candidates.each_with_index.partition { |el, _i| el.get_attribute("tabindex").to_i.positive? }
|
|
299
|
+
positive.sort_by { |el, i| [el.get_attribute("tabindex").to_i, i] }.map(&:first) + natural.map(&:first)
|
|
300
|
+
end
|
|
301
|
+
|
|
165
302
|
# Capybara's app_host (set per-example) wins over default_host; falls
|
|
166
303
|
# back to the host this driver was configured with. Guarded so a
|
|
167
304
|
# 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,16 +94,56 @@ 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
|
+
# Under JavaScript, dispatch real keyboard events (keydown/keypress/
|
|
111
|
+
# input/keyup with browser default actions) through the dommy driver,
|
|
112
|
+
# so keyboard handlers (arrow navigation, Enter selection) run.
|
|
113
|
+
# Key chords ([:shift, "o"]) are not supported on this path.
|
|
114
|
+
return driver.rack_session.send_keys_to(native, *args) if driver.javascript?
|
|
115
|
+
|
|
116
|
+
return unless native.respond_to?(:value=)
|
|
117
|
+
|
|
118
|
+
state = {chars: native.value.to_s.chars, caret: native.value.to_s.length, shift: false}
|
|
119
|
+
args.each do |arg|
|
|
120
|
+
if arg.is_a?(Array)
|
|
121
|
+
# A chord like [:shift, 'o'] holds its modifiers only for the
|
|
122
|
+
# duration of the array.
|
|
123
|
+
held = state[:shift]
|
|
124
|
+
arg.each { |key| apply_key(state, key) }
|
|
125
|
+
state[:shift] = held
|
|
126
|
+
else
|
|
127
|
+
apply_key(state, arg)
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
native.focus if native.respond_to?(:focus)
|
|
131
|
+
native.value = state[:chars].join
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Computed styles for Capybara's matches_style? / style: filters,
|
|
135
|
+
# served by Dommy's CSS cascade (values come back in the computed
|
|
136
|
+
# serialization, e.g. colors as rgb()).
|
|
137
|
+
def style(styles)
|
|
138
|
+
computed = ::Dommy::Internal::CSS::Cascade.computed_style(native)
|
|
139
|
+
Array(styles).flatten.to_h { |name| [name.to_s, computed[name.to_s].to_s] }
|
|
85
140
|
end
|
|
86
141
|
|
|
87
142
|
# --- Interaction ---
|
|
88
143
|
|
|
89
144
|
def click(_keys = [], **_options)
|
|
145
|
+
return js_click if driver.javascript?
|
|
146
|
+
|
|
90
147
|
if link?
|
|
91
148
|
click_link_node
|
|
92
149
|
elsif submits?
|
|
@@ -122,6 +179,7 @@ module Capybara
|
|
|
122
179
|
select_el = select_node
|
|
123
180
|
deselect_all(select_el) unless select_el&.multiple
|
|
124
181
|
native.selected = true
|
|
182
|
+
notify_select_changed(select_el)
|
|
125
183
|
end
|
|
126
184
|
|
|
127
185
|
def unselect_option
|
|
@@ -132,6 +190,15 @@ module Capybara
|
|
|
132
190
|
native.selected = false
|
|
133
191
|
end
|
|
134
192
|
|
|
193
|
+
# Move the (virtual) pointer over this element: :hover rules and
|
|
194
|
+
# `matches(":hover")` then apply to it and its ancestors. No
|
|
195
|
+
# mouseover/mouseout events are dispatched (nothing observes them
|
|
196
|
+
# without JavaScript).
|
|
197
|
+
def hover
|
|
198
|
+
native.owner_document.__internal_set_hovered_element__(native)
|
|
199
|
+
nil
|
|
200
|
+
end
|
|
201
|
+
|
|
135
202
|
# --- Scoped queries (for `within`) ---
|
|
136
203
|
|
|
137
204
|
def find_css(locator, **_options)
|
|
@@ -157,8 +224,34 @@ module Capybara
|
|
|
157
224
|
RUBY
|
|
158
225
|
end
|
|
159
226
|
|
|
227
|
+
# Identity matters to Capybara (e.g. the focused: filter compares a
|
|
228
|
+
# candidate against session.active_element). Dommy's wrapper cache hands
|
|
229
|
+
# out one wrapper per DOM node, so native equality is node identity.
|
|
230
|
+
def ==(other)
|
|
231
|
+
other.is_a?(Node) && native == other.native
|
|
232
|
+
end
|
|
233
|
+
|
|
160
234
|
private
|
|
161
235
|
|
|
236
|
+
def apply_key(state, key)
|
|
237
|
+
case key
|
|
238
|
+
when String
|
|
239
|
+
key.each_char do |ch|
|
|
240
|
+
state[:chars].insert(state[:caret], state[:shift] ? ch.upcase : ch)
|
|
241
|
+
state[:caret] += 1
|
|
242
|
+
end
|
|
243
|
+
when :space
|
|
244
|
+
state[:chars].insert(state[:caret], " ")
|
|
245
|
+
state[:caret] += 1
|
|
246
|
+
when :left
|
|
247
|
+
state[:caret] = [state[:caret] - 1, 0].max
|
|
248
|
+
when :right
|
|
249
|
+
state[:caret] = [state[:caret] + 1, state[:chars].length].min
|
|
250
|
+
when :shift
|
|
251
|
+
state[:shift] = true
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
|
|
162
255
|
def stale_check
|
|
163
256
|
return if native.document.equal?(driver.document)
|
|
164
257
|
|
|
@@ -173,6 +266,40 @@ module Capybara
|
|
|
173
266
|
# javascript: links are no-ops in Capybara (a policy decision); every
|
|
174
267
|
# other link delegates to dommy-rack, which handles fragment / same-page
|
|
175
268
|
# / blank-href semantics and raises on genuinely unsupported schemes.
|
|
269
|
+
# A JavaScript click: dispatch the full pointer/mouse/click sequence
|
|
270
|
+
# (Stimulus actions, Turbo's link/submit interception run like in a
|
|
271
|
+
# browser), then perform the un-prevented default action ourselves —
|
|
272
|
+
# link navigation, or the form submission algorithm (a cancelable
|
|
273
|
+
# submit event, then the real submission if nothing canceled it;
|
|
274
|
+
# Turbo cancels and takes over). Checkbox/radio toggling already ran
|
|
275
|
+
# as the click event's activation behavior.
|
|
276
|
+
def js_click
|
|
277
|
+
prevented = ::Dommy::Interaction::EventSynthesis.click(native)
|
|
278
|
+
unless prevented
|
|
279
|
+
# Link navigation and form submission are the elements' own activation
|
|
280
|
+
# behavior — run inside EventSynthesis.click above, routing through the
|
|
281
|
+
# session's navigation delegate (performed when driver.drain_js drains).
|
|
282
|
+
# Only the non-navigating default actions remain here.
|
|
283
|
+
if tag_name == "label"
|
|
284
|
+
click_label
|
|
285
|
+
elsif (details = native.closest("details"))
|
|
286
|
+
toggle_details(details)
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
driver.drain_js
|
|
290
|
+
nil
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
# Selecting an option through the UI fires input + change on the select
|
|
294
|
+
# (under JavaScript; nothing listens without it).
|
|
295
|
+
def notify_select_changed(select_el)
|
|
296
|
+
return unless driver.javascript? && select_el
|
|
297
|
+
|
|
298
|
+
::Dommy::Interaction::EventSynthesis.input(select_el)
|
|
299
|
+
::Dommy::Interaction::EventSynthesis.change(select_el)
|
|
300
|
+
driver.drain_js
|
|
301
|
+
end
|
|
302
|
+
|
|
176
303
|
def click_link_node
|
|
177
304
|
scheme = native.get_attribute("href").to_s.split(":", 2).first.to_s.downcase
|
|
178
305
|
return if scheme == "javascript"
|
|
@@ -219,11 +346,28 @@ module Capybara
|
|
|
219
346
|
# There is no submitter button in this case.
|
|
220
347
|
form = single_field_form
|
|
221
348
|
if input_field? && string.end_with?("\n") && form
|
|
222
|
-
|
|
349
|
+
write_text(string.chomp)
|
|
223
350
|
driver.submit_form(form, submitter: nil)
|
|
224
351
|
else
|
|
352
|
+
write_text(string)
|
|
353
|
+
end
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
# Set a text field's value. Under JavaScript this types like a user:
|
|
357
|
+
# focus, value, then input + change events, so Stimulus/React handlers
|
|
358
|
+
# observe the edit; the JS-less driver keeps the bare value write
|
|
359
|
+
# (nothing listens).
|
|
360
|
+
def write_text(string)
|
|
361
|
+
unless driver.javascript?
|
|
225
362
|
native.value = string
|
|
363
|
+
return
|
|
226
364
|
end
|
|
365
|
+
|
|
366
|
+
::Dommy::Interaction::EventSynthesis.focus(native)
|
|
367
|
+
native.value = string
|
|
368
|
+
::Dommy::Interaction::EventSynthesis.input(native, string)
|
|
369
|
+
::Dommy::Interaction::EventSynthesis.change(native)
|
|
370
|
+
driver.drain_js
|
|
227
371
|
end
|
|
228
372
|
|
|
229
373
|
def single_field_form
|
|
@@ -248,25 +392,15 @@ module Capybara
|
|
|
248
392
|
value && !value.empty?
|
|
249
393
|
end
|
|
250
394
|
|
|
251
|
-
#
|
|
252
|
-
#
|
|
395
|
+
# Toggle through the control's native activation behavior (which fires
|
|
396
|
+
# input/change and, for a radio, maintains its group) — only when it
|
|
397
|
+
# isn't already in the requested state, matching a real click.
|
|
253
398
|
def set_checkbox(value)
|
|
254
|
-
if value
|
|
255
|
-
native.set_attribute("checked", "checked")
|
|
256
|
-
else
|
|
257
|
-
native.remove_attribute("checked")
|
|
258
|
-
end
|
|
399
|
+
native.click if checked? != !!value
|
|
259
400
|
end
|
|
260
401
|
|
|
261
402
|
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")
|
|
403
|
+
native.click unless checked?
|
|
270
404
|
end
|
|
271
405
|
|
|
272
406
|
def set_file(value)
|
data/lib/capybara/dommy/rails.rb
CHANGED
|
@@ -8,3 +8,10 @@ require "capybara/dommy"
|
|
|
8
8
|
Capybara.register_driver(:dommy) do |app|
|
|
9
9
|
Capybara::Dommy::Driver.new(app)
|
|
10
10
|
end
|
|
11
|
+
|
|
12
|
+
# The JavaScript-enabled variant (`driven_by :dommy_js`): pages run their real
|
|
13
|
+
# Turbo/Stimulus/React bundles in the embedded QuickJS runtime, no browser
|
|
14
|
+
# process. Requires dommy-js-quickjs.
|
|
15
|
+
Capybara.register_driver(:dommy_js) do |app|
|
|
16
|
+
Capybara::Dommy::Driver.new(app, javascript: true)
|
|
17
|
+
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.
|
|
4
|
+
version: 0.10.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.10.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.10.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.10.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.10.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:
|
|
97
|
+
rubygems_version: 4.0.10
|
|
98
98
|
specification_version: 4
|
|
99
99
|
summary: A Dommy-backed Capybara driver
|
|
100
100
|
test_files: []
|