capybara-dommy 0.9.0 → 0.11.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: 288082f09fb0e1151ba15dfa7bce080997855de9ac4cbced143238b8fbc6db6b
4
- data.tar.gz: 8c7c68b114eb0526071f15ae2675a94bc38492161fadbf29f3b5cf934d810f09
3
+ metadata.gz: f6479bfca855b3003e8b6b7876981e9b59b5a2fa870b96b2528e387966986d90
4
+ data.tar.gz: 54d05d18a6bd90fd2a795543130c54e8bc62d438a21c8a14f0fd06f83f5918d4
5
5
  SHA512:
6
- metadata.gz: a6544c835b5721f26d77ab97c8278dc7a722c859a86c32ca06b81c263c8cb5a2d9062b318b92e731dd646795cb634bc69c91bbb229da6de3dbaf719f1bec2887
7
- data.tar.gz: 707be8eeb45ba4a7ca871f1aef7160755bc0fb8a33f587b9bd37ea10fc69c9095c164684d765a5884ca7c1f79d69db650bc0fe758cdc5b752c3d61715abee610
6
+ metadata.gz: 588846fe1ca9d36119c237d71349d1af04fe5082fc73d64dbb48f8b7cc8b097b607d25bbb1af888ec58157547bd03793947faacf22106df80dc96d2a17a767bf
7
+ data.tar.gz: 48594055d5a1a63e80634fee2bc57c566ce8f33363c7163c58c6dc0b9df9aa6cfd612a9feec5321f3d326baa63a3e715072f6c50f3f75a5676f2d659d6975ce6
data/CHANGELOG.md CHANGED
@@ -1,5 +1,22 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.11.0 — 2026-09-11
4
+
5
+ ### Added
6
+ - **Native dialog helpers in JS mode.** `accept_confirm`, `dismiss_confirm`, `accept_alert`, `accept_prompt` (with `with:`) and `dismiss_prompt` work against the page's real `confirm` / `alert` / `prompt`, including a `text:` string or Regexp to say which dialog is expected. Nested helper blocks answer the dialogs in the order the page opens them, and a dialog nobody is waiting for falls back to the headless default, so a stray `confirm` cannot silently accept. A block that opens no matching dialog raises `Capybara::ModalNotFound`, naming what turned up instead.
7
+
8
+ ### Fixed
9
+ - Turbo-driven navigation in JS apps is followed, so `have_current_path` and the matchers after a Turbo visit see the page the app actually moved to.
10
+ - `current_url` advances the virtual clock like the other queries do, so a `have_current_path` poll converges when the navigation settles in a scheduled task rather than a microtask.
11
+
12
+ ## 0.10.0 — 2026-07-13
13
+
14
+ ### Added
15
+ - **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`.
16
+
17
+ ### Fixed
18
+ - 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.
19
+
3
20
  ## 0.9.0 — 2026-06-22
4
21
 
5
22
  Versioned in lockstep with [`dommy`](https://github.com/takahashim/dommy) 0.9.0.
data/README.md CHANGED
@@ -19,15 +19,24 @@ speed and simplicity of a Rack-style driver.
19
19
  - Preserves session state such as cookies, follows redirects by default, and
20
20
  supports browser-like back, forward, and refresh navigation.
21
21
  - Implements HTML-level visibility through `dommy-rack`.
22
+ - In JavaScript-enabled mode, supports Capybara's `accept_alert`,
23
+ `accept_confirm`, `dismiss_confirm`, `accept_prompt`, and `dismiss_prompt`
24
+ helpers with deterministic responses.
22
25
  - Provides a Rails convenience require for `driven_by :dommy`.
23
26
 
24
27
  ## Limitations
25
28
 
26
29
  `capybara-dommy` is intentionally not a browser automation driver.
27
30
 
28
- - JavaScript is not executed.
29
- - Screenshots, browser windows, alerts, confirms, prompts, and other real
30
- browser features are not supported.
31
+ - The default driver does not execute JavaScript. Use the JavaScript-enabled
32
+ variant for embedded QuickJS execution.
33
+ - Screenshots and browser windows are not supported. Native alerts, confirms,
34
+ and prompts are supported by the JavaScript-enabled variant through
35
+ Capybara's modal helpers.
36
+ - Constructable stylesheets can be built (`new CSSStyleSheet()`), but
37
+ `adoptedStyleSheets` is not implemented — assigning one applies no style.
38
+ Component libraries that feature-detect (`'adoptedStyleSheets' in
39
+ Document.prototype`) fall back to injecting a `<style>`, which is handled.
31
40
  - CSS layout is not calculated. Visibility is based on HTML-level rules such as
32
41
  `hidden`, `type="hidden"`, and inline `display: none` handling provided by
33
42
  `dommy-rack`.
@@ -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
 
@@ -4,9 +4,10 @@ module Capybara
4
4
  module Dommy
5
5
  # A Capybara driver backed by Dommy::Rack::Session. Implements the
6
6
  # navigation / query / reset! parts of the Capybara::Driver::Base contract;
7
- # element interaction lives in Capybara::Dommy::Node. JavaScript, screenshot,
8
- # window, and modal methods are left to Driver::Base (which raises
9
- # Capybara::NotSupportedByDriverError).
7
+ # element interaction lives in Capybara::Dommy::Node. The JS-enabled mode
8
+ # additionally supplies deterministic native dialog responses for Capybara's
9
+ # alert/confirm/prompt helpers. Screenshot and window methods remain with
10
+ # Driver::Base (which raises Capybara::NotSupportedByDriverError).
10
11
  class Driver < Capybara::Driver::Base
11
12
  VISIBILITY_MODES = %i[all html none].freeze
12
13
 
@@ -28,10 +29,12 @@ module Capybara
28
29
  default_host: nil,
29
30
  follow_redirects: nil,
30
31
  max_redirects: nil,
31
- visibility: nil)
32
+ visibility: nil,
33
+ javascript: nil)
32
34
  super()
33
35
  config = Capybara::Dommy.configuration
34
36
  @app = app
37
+ @javascript = javascript.nil? ? config.javascript : javascript
35
38
  @visibility = visibility || config.visibility
36
39
  unless VISIBILITY_MODES.include?(@visibility)
37
40
  raise ArgumentError,
@@ -46,6 +49,24 @@ module Capybara
46
49
  # hosts (e.g. app_host / multi-server specs), so don't enforce origin.
47
50
  enforce_same_origin: false
48
51
  }
52
+ @session_options[:javascript] = true if @javascript
53
+ # A JS session needs the virtual clock pumped inside Capybara's
54
+ # synchronize loop, so waiting expectations converge on timer/fetch
55
+ # driven updates. A host-installed pump (the documented seam) wins.
56
+ @time_pump ||= -> { @rack_session&.advance_time(16) } if @javascript
57
+ end
58
+
59
+ # Whether this driver runs page JavaScript (`javascript: true`, backed by
60
+ # a `Dommy::Rack::Session.new(app, javascript: true)`). Node interactions
61
+ # then dispatch real DOM events (Turbo/Stimulus handlers run) instead of
62
+ # the HTML-only fast paths.
63
+ def javascript? = @javascript
64
+
65
+ # Drain the JS runtime after an interaction's events (promise reactions
66
+ # settle before the next Capybara step). No-op without JavaScript.
67
+ def drain_js
68
+ rack_session.after_interaction if @javascript
69
+ nil
49
70
  end
50
71
 
51
72
  # The dommy-rack session. Named `rack_session` to avoid colliding with
@@ -55,6 +76,7 @@ module Capybara
55
76
  def rack_session
56
77
  host = effective_host
57
78
  if @rack_session.nil? || @rack_session_host != host
79
+ @rack_session&.dispose
58
80
  @rack_session = ::Dommy::Rack::Session.new(@app, **@session_options.merge(default_host: host))
59
81
  @rack_session_host = host
60
82
  end
@@ -73,6 +95,12 @@ module Capybara
73
95
  end
74
96
 
75
97
  def current_url
98
+ # Capybara polls current_url for have_current_path. In JS mode that poll
99
+ # must advance the virtual clock too: Turbo/fetch continuations often
100
+ # settle in a scheduled task rather than the interaction's microtask
101
+ # drain. The Rack session's History hook then reflects pushState in its
102
+ # current URL.
103
+ pump!
76
104
  rack_session.current_url.to_s
77
105
  end
78
106
 
@@ -179,6 +207,7 @@ module Capybara
179
207
  # --- Lifecycle ---
180
208
 
181
209
  def reset!
210
+ @rack_session&.dispose
182
211
  @rack_session = nil
183
212
  @frame_stack = []
184
213
  end
@@ -200,18 +229,40 @@ module Capybara
200
229
  # When raise_on_unsupported_js is false these become no-ops, so tests
201
230
  # that incidentally call them don't fail.
202
231
 
203
- def execute_script(_script, *_args)
204
- unsupported_js!("execute_script")
232
+ def execute_script(script, *args)
233
+ return unsupported_js!("execute_script") unless @javascript
234
+ raise ArgumentError, "script arguments are not supported" unless args.empty?
235
+
236
+ rack_session.execute_script(script)
205
237
  end
206
238
 
207
- def evaluate_script(_script, *_args)
208
- unsupported_js!("evaluate_script")
239
+ def evaluate_script(script, *args)
240
+ return unsupported_js!("evaluate_script") unless @javascript
241
+ raise ArgumentError, "script arguments are not supported" unless args.empty?
242
+
243
+ rack_session.evaluate_script(script)
209
244
  end
210
245
 
211
246
  def evaluate_async_script(_script, *_args)
212
247
  unsupported_js!("evaluate_async_script")
213
248
  end
214
249
 
250
+ # --- Native dialogs ---
251
+ #
252
+ # A native dialog is synchronous in Dommy, so installing the expected
253
+ # answer before the triggering block runs is sufficient. What is expected,
254
+ # what it answers and what it saw instead live in ModalExpectation; the
255
+ # stack they sit on, which is the session's dialog handler while a helper
256
+ # block is running, is ModalStack.
257
+
258
+ def accept_modal(type, **options, &block)
259
+ respond_to_modal(type, accept: true, **options, &block)
260
+ end
261
+
262
+ def dismiss_modal(type, **options, &block)
263
+ respond_to_modal(type, accept: false, **options, &block)
264
+ end
265
+
215
266
  # Visibility decision used by Node#visible?. :all / :none treat every
216
267
  # element as visible; :html defers to dommy-rack's HTML-level check.
217
268
  def visible?(element)
@@ -295,6 +346,26 @@ module Capybara
295
346
  raise Capybara::NotSupportedByDriverError,
296
347
  "capybara-dommy does not support JavaScript (#{name})"
297
348
  end
349
+
350
+ def respond_to_modal(type, accept:, text: nil, with: nil, **_options)
351
+ expectation = ModalExpectation.new(type: type, accept: accept, text: text, with: with)
352
+ modals.push(expectation)
353
+ rack_session.dialog_handler = modals
354
+ yield if block_given?
355
+
356
+ raise Capybara::ModalNotFound, expectation.not_found_message unless expectation.answered?
357
+
358
+ expectation.message
359
+ ensure
360
+ if expectation
361
+ modals.delete(expectation)
362
+ rack_session.dialog_handler = nil if modals.empty? && @rack_session
363
+ end
364
+ end
365
+
366
+ def modals
367
+ @modals ||= ModalStack.new
368
+ end
298
369
  end
299
370
  end
300
371
  end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Capybara
4
+ module Dommy
5
+ # What one `accept_modal` / `dismiss_modal` block is waiting for, and what
6
+ # it saw. A native dialog is synchronous in Dommy, so the expectation is
7
+ # installed before the triggering block runs and answers the dialog the
8
+ # block opens.
9
+ class ModalExpectation
10
+ attr_reader :type, :text
11
+ # The message of the dialog this expectation answered — nil while it is
12
+ # still waiting, which is what makes it "not found" at the end of the
13
+ # block.
14
+ attr_reader :message
15
+
16
+ def initialize(type:, accept:, text: nil, with: nil)
17
+ @type = type.to_sym
18
+ @accept = accept
19
+ @text = text
20
+ @with = with
21
+ @message = nil
22
+ @actual = nil
23
+ end
24
+
25
+ def matches?(type, message)
26
+ @type == type && text_matches?(message)
27
+ end
28
+
29
+ def answered? = !@message.nil?
30
+
31
+ # Record that this dialog is the one, and answer it: a confirm with the
32
+ # accept/dismiss decision, a prompt with the text to type (`with:`, else
33
+ # what the page offered) or nil when dismissed. An alert has no answer.
34
+ def answer(message, default_value)
35
+ @message = message
36
+ case @type
37
+ when :confirm then @accept
38
+ when :prompt then @accept ? (@with || default_value) : nil
39
+ end
40
+ end
41
+
42
+ # A dialog this expectation did not want. Remembered for the error at the
43
+ # end of the block, which says what turned up instead.
44
+ def saw(type, message)
45
+ @actual = {type: type, message: message}
46
+ nil
47
+ end
48
+
49
+ def not_found_message
50
+ if @actual
51
+ "Unable to find #{@type} dialog with #{@text.inspect} - found " \
52
+ "#{@actual[:type]} dialog with #{@actual[:message].inspect} instead."
53
+ else
54
+ "Unable to find #{@type} dialog#{@text ? " with #{@text.inspect}" : ""}"
55
+ end
56
+ end
57
+
58
+ private
59
+
60
+ def text_matches?(message)
61
+ return true if @text.nil?
62
+
63
+ pattern = @text.is_a?(Regexp) ? @text : Regexp.new(Regexp.escape(@text.to_s))
64
+ pattern.match?(message)
65
+ end
66
+ end
67
+
68
+ # The expectations currently in scope, innermost first, as the dialog
69
+ # handler a Dommy Window asks (it calls `#call`).
70
+ #
71
+ # A stack, because Capybara expresses nested confirms as nested helper
72
+ # blocks while the page opens them sequentially in one JS call stack:
73
+ # consuming the inner expectation exposes the outer answer to the next
74
+ # confirm immediately.
75
+ class ModalStack
76
+ def initialize
77
+ @expectations = []
78
+ end
79
+
80
+ def empty? = @expectations.empty?
81
+
82
+ def push(expectation)
83
+ @expectations << expectation
84
+ expectation
85
+ end
86
+
87
+ # By identity: two helper blocks with the same arguments build EQUAL
88
+ # expectations, and removing by value would drop the caller's alongside
89
+ # this one.
90
+ def delete(expectation)
91
+ @expectations.delete_if { |e| e.equal?(expectation) }
92
+ nil
93
+ end
94
+
95
+ # The dialog handler protocol: answer the dialog, or return
96
+ # DIALOG_UNANSWERED to leave it to the Window's headless default. An
97
+ # expectation that does not want this dialog remembers it (so the block
98
+ # can say what turned up instead) and declines rather than answering.
99
+ def call(type, message, default_value)
100
+ expectation = @expectations.last
101
+ return ::Dommy::Window::DIALOG_UNANSWERED if expectation.nil?
102
+
103
+ unless expectation.matches?(type, message)
104
+ expectation.saw(type, message)
105
+ return ::Dommy::Window::DIALOG_UNANSWERED
106
+ end
107
+
108
+ answer = expectation.answer(message, default_value)
109
+ delete(expectation)
110
+ answer
111
+ end
112
+ end
113
+ end
114
+ end
@@ -107,6 +107,12 @@ module Capybara
107
107
  # Capybara's non-JS send_keys specs use. Key *events* are not dispatched
108
108
  # (nothing here can observe them without JavaScript).
109
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
+
110
116
  return unless native.respond_to?(:value=)
111
117
 
112
118
  state = {chars: native.value.to_s.chars, caret: native.value.to_s.length, shift: false}
@@ -136,6 +142,8 @@ module Capybara
136
142
  # --- Interaction ---
137
143
 
138
144
  def click(_keys = [], **_options)
145
+ return js_click if driver.javascript?
146
+
139
147
  if link?
140
148
  click_link_node
141
149
  elsif submits?
@@ -171,6 +179,7 @@ module Capybara
171
179
  select_el = select_node
172
180
  deselect_all(select_el) unless select_el&.multiple
173
181
  native.selected = true
182
+ notify_select_changed(select_el)
174
183
  end
175
184
 
176
185
  def unselect_option
@@ -257,6 +266,40 @@ module Capybara
257
266
  # javascript: links are no-ops in Capybara (a policy decision); every
258
267
  # other link delegates to dommy-rack, which handles fragment / same-page
259
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
+
260
303
  def click_link_node
261
304
  scheme = native.get_attribute("href").to_s.split(":", 2).first.to_s.downcase
262
305
  return if scheme == "javascript"
@@ -303,11 +346,28 @@ module Capybara
303
346
  # There is no submitter button in this case.
304
347
  form = single_field_form
305
348
  if input_field? && string.end_with?("\n") && form
306
- native.value = string.chomp
349
+ write_text(string.chomp)
307
350
  driver.submit_form(form, submitter: nil)
308
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?
309
362
  native.value = string
363
+ return
310
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
311
371
  end
312
372
 
313
373
  def single_field_form
@@ -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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Capybara
4
4
  module Dommy
5
- VERSION = "0.9.0"
5
+ VERSION = "0.11.0"
6
6
  end
7
7
  end
@@ -8,6 +8,7 @@ require_relative "dommy/version"
8
8
  require_relative "dommy/errors"
9
9
  require_relative "dommy/configuration"
10
10
  require_relative "dommy/text_extractor"
11
+ require_relative "dommy/modal"
11
12
  require_relative "dommy/node"
12
13
  require_relative "dommy/driver"
13
14
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: capybara-dommy
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.9.0
4
+ version: 0.11.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - takahashim
@@ -29,28 +29,28 @@ dependencies:
29
29
  requirements:
30
30
  - - "~>"
31
31
  - !ruby/object:Gem::Version
32
- version: 0.9.0
32
+ version: 0.11.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.9.0
39
+ version: 0.11.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.9.0
46
+ version: 0.11.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.9.0
53
+ version: 0.11.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,
@@ -69,6 +69,7 @@ files:
69
69
  - lib/capybara/dommy/configuration.rb
70
70
  - lib/capybara/dommy/driver.rb
71
71
  - lib/capybara/dommy/errors.rb
72
+ - lib/capybara/dommy/modal.rb
72
73
  - lib/capybara/dommy/node.rb
73
74
  - lib/capybara/dommy/rails.rb
74
75
  - lib/capybara/dommy/text_extractor.rb