capybara-dommy 0.8.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 5c7a62f13890b2fa82ad1270440924e9a1723a20975d71d91aa1bf24938922ae
4
+ data.tar.gz: 8045f008aaca496cab19488fc3b551cc2a955880d08bdc24034e60981d791ffe
5
+ SHA512:
6
+ metadata.gz: e337cfa551b1a24a3f06554d5638260f5b2c2c19c09cad1b90aa3bfe55bd7363b1671f329070eabe32ab489ebcf035b06240cf79d1dea6ce364ff1541b2b9b03
7
+ data.tar.gz: 05ac1cd5fdda2ba28b907adbac630d3d139e2e33dfaf26f06478bd4fb7a7ca668b662fb4b6726da46818cae9aa09b5f95dc7a0390d81e9915bf64f1ad3ace1a0
data/CHANGELOG.md ADDED
@@ -0,0 +1,16 @@
1
+ # Changelog
2
+
3
+ ## 0.8.0 — 2026-05-31
4
+
5
+ Versioned in lockstep with [`dommy`](https://github.com/takahashim/dommy) 0.8.0.
6
+ No functional changes to capybara-dommy itself.
7
+
8
+ ## 0.7.0 — 2026-05-30
9
+
10
+ Initial release.
11
+
12
+ Versioned in lockstep with the [`dommy`](https://github.com/takahashim/dommy)
13
+ gem. capybara-dommy is a Capybara driver backed by `dommy` and `dommy-rack`. It
14
+ drives Rack/Rails apps through the Capybara DSL without a real browser or
15
+ JavaScript, keeping the page as a `Dommy::Document` (RackTest-like, with
16
+ HTML-level visibility).
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Masayoshi Takahashi
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,190 @@
1
+ # Capybara::Dommy
2
+
3
+ `capybara-dommy` is a [Capybara](https://github.com/teamcapybara/capybara) driver backed by
4
+ [Dommy](https://github.com/takahashim/dommy) and `dommy-rack`.
5
+
6
+ It drives Rack and Rails applications through the normal Capybara DSL without
7
+ starting a real browser. The current page is kept as a `Dommy::Document`, so
8
+ tests can inspect and interact with parsed HTML while staying close to the
9
+ speed and simplicity of a Rack-style driver.
10
+
11
+ ## Features
12
+
13
+ - Visits Rack endpoints without a browser process or JavaScript runtime.
14
+ - Supports Capybara navigation, CSS/XPath queries, scoped `within` queries,
15
+ status codes, response headers, page title, and serialized HTML.
16
+ - Supports common HTML interactions: links, buttons, form submission, text
17
+ fields, textareas, checkboxes, radios, selects, ranges, labels, details, and
18
+ file uploads.
19
+ - Preserves session state such as cookies, follows redirects by default, and
20
+ supports browser-like back, forward, and refresh navigation.
21
+ - Implements HTML-level visibility through `dommy-rack`.
22
+ - Provides a Rails convenience require for `driven_by :dommy`.
23
+
24
+ ## Limitations
25
+
26
+ `capybara-dommy` is intentionally not a browser automation driver.
27
+
28
+ - JavaScript is not executed.
29
+ - Screenshots, browser windows, alerts, confirms, prompts, and other real
30
+ browser features are not supported.
31
+ - CSS layout is not calculated. Visibility is based on HTML-level rules such as
32
+ `hidden`, `type="hidden"`, and inline `display: none` handling provided by
33
+ `dommy-rack`.
34
+
35
+ By default, `execute_script`, `evaluate_script`, and
36
+ `evaluate_async_script` raise `Capybara::NotSupportedByDriverError`. You can
37
+ turn those calls into no-ops with configuration when migrating tests that call
38
+ JavaScript helpers incidentally.
39
+
40
+ ## Installation
41
+
42
+ Add the gem to your application's Gemfile:
43
+
44
+ ```ruby
45
+ gem "capybara-dommy"
46
+ ```
47
+
48
+ Then run:
49
+
50
+ ```bash
51
+ bundle install
52
+ ```
53
+
54
+ Until the gem is available from RubyGems, install it from GitHub:
55
+
56
+ ```ruby
57
+ gem "capybara-dommy", github: "takahashim/capybara-dommy"
58
+ ```
59
+
60
+ `capybara-dommy` requires Ruby 3.2 or newer.
61
+
62
+ ## Usage
63
+
64
+ For a plain Rack app, require the gem and register a Capybara driver:
65
+
66
+ ```ruby
67
+ require "capybara/dommy"
68
+
69
+ Capybara.register_driver(:dommy) do |app|
70
+ Capybara::Dommy::Driver.new(app)
71
+ end
72
+
73
+ Capybara.default_driver = :dommy
74
+ ```
75
+
76
+ You can then use the normal Capybara DSL:
77
+
78
+ ```ruby
79
+ visit "/"
80
+ click_link "New post"
81
+ fill_in "Title", with: "Hello"
82
+ click_button "Create"
83
+
84
+ expect(page).to have_text("Created")
85
+ ```
86
+
87
+ ### Rails System Tests
88
+
89
+ For Rails system tests, require the Rails integration and use
90
+ `driven_by :dommy`:
91
+
92
+ ```ruby
93
+ # test/application_system_test_case.rb or spec/rails_helper.rb
94
+ require "capybara/dommy/rails"
95
+
96
+ class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
97
+ driven_by :dommy
98
+ end
99
+ ```
100
+
101
+ The Rails integration only registers the driver. Driver defaults still come from
102
+ `Capybara::Dommy.configuration`.
103
+
104
+ ## Configuration
105
+
106
+ Configure process-wide defaults before creating sessions:
107
+
108
+ ```ruby
109
+ Capybara::Dommy.configure do |config|
110
+ config.default_host = "http://example.org"
111
+ config.follow_redirects = true
112
+ config.max_redirects = 5
113
+ config.visibility = :html
114
+ config.raise_on_unsupported_js = true
115
+ end
116
+ ```
117
+
118
+ Available options:
119
+
120
+ - `default_host`: host used for relative visits. Defaults to
121
+ `"http://example.org"`.
122
+ - `follow_redirects`: whether Rack redirects are followed automatically.
123
+ Defaults to `true`.
124
+ - `max_redirects`: maximum redirect count. Defaults to `5`.
125
+ - `visibility`: one of `:html`, `:all`, or `:none`. `:html` uses
126
+ `dommy-rack` visibility checks. `:all` and `:none` treat every element as
127
+ visible.
128
+ - `raise_on_unsupported_js`: when `true`, JavaScript methods raise
129
+ `Capybara::NotSupportedByDriverError`; when `false`, they return `nil`.
130
+
131
+ You can also override driver options per registration:
132
+
133
+ ```ruby
134
+ Capybara.register_driver(:dommy) do |app|
135
+ Capybara::Dommy::Driver.new(
136
+ app,
137
+ default_host: "http://test.example",
138
+ follow_redirects: true,
139
+ max_redirects: 10,
140
+ visibility: :html
141
+ )
142
+ end
143
+ ```
144
+
145
+ ## Development
146
+
147
+ After checking out the repository, install dependencies:
148
+
149
+ ```bash
150
+ bin/setup
151
+ ```
152
+
153
+ Run the full test suite:
154
+
155
+ ```bash
156
+ bundle exec rake spec
157
+ ```
158
+
159
+ Run only the fast unit specs:
160
+
161
+ ```bash
162
+ bundle exec rake spec:unit
163
+ ```
164
+
165
+ Run only Capybara's shared driver compliance suite:
166
+
167
+ ```bash
168
+ bundle exec rake spec:compliance
169
+ ```
170
+
171
+ Open an interactive console:
172
+
173
+ ```bash
174
+ bin/console
175
+ ```
176
+
177
+ Install the gem locally:
178
+
179
+ ```bash
180
+ bundle exec rake install
181
+ ```
182
+
183
+ ## Contributing
184
+
185
+ Bug reports and pull requests are welcome on GitHub:
186
+ <https://github.com/takahashim/capybara-dommy>.
187
+
188
+ ## License
189
+
190
+ The gem is available as open source under the terms of the MIT License.
data/Rakefile ADDED
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ # Everything (fast unit specs + Capybara compliance suite).
7
+ RSpec::Core::RakeTask.new(:spec)
8
+
9
+ namespace :spec do
10
+ desc "Run only the fast unit specs (no compliance harness / TestApp)"
11
+ RSpec::Core::RakeTask.new(:unit) do |t|
12
+ t.pattern = "spec/unit/**/*_spec.rb"
13
+ end
14
+
15
+ desc "Run only the Capybara driver compliance suite"
16
+ RSpec::Core::RakeTask.new(:compliance) do |t|
17
+ t.pattern = "spec/compliance/**/*_spec.rb"
18
+ end
19
+ end
20
+
21
+ task default: :spec
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Capybara
4
+ module Dommy
5
+ # Process-wide defaults for new drivers. `Driver.new` falls back to these
6
+ # when a keyword argument is omitted.
7
+ class Configuration
8
+ attr_accessor :default_host, :follow_redirects, :max_redirects, :visibility,
9
+ :raise_on_unsupported_js
10
+
11
+ def initialize
12
+ @default_host = "http://example.org"
13
+ @follow_redirects = true
14
+ @max_redirects = 5
15
+ @visibility = :html
16
+ @raise_on_unsupported_js = true
17
+ end
18
+ end
19
+
20
+ class << self
21
+ def configuration
22
+ @configuration ||= Configuration.new
23
+ end
24
+
25
+ def configure
26
+ yield configuration
27
+ end
28
+
29
+ def reset_configuration!
30
+ @configuration = Configuration.new
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,191 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Capybara
4
+ module Dommy
5
+ # A Capybara driver backed by Dommy::Rack::Session. Implements the
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).
10
+ class Driver < Capybara::Driver::Base
11
+ VISIBILITY_MODES = %i[all html none].freeze
12
+
13
+ attr_reader :app, :visibility
14
+
15
+ def initialize(app,
16
+ default_host: nil,
17
+ follow_redirects: nil,
18
+ max_redirects: nil,
19
+ visibility: nil)
20
+ super()
21
+ config = Capybara::Dommy.configuration
22
+ @app = app
23
+ @visibility = visibility || config.visibility
24
+ unless VISIBILITY_MODES.include?(@visibility)
25
+ raise ArgumentError,
26
+ "unknown visibility mode #{@visibility.inspect} (expected one of #{VISIBILITY_MODES.join(", ")})"
27
+ end
28
+ @raise_on_unsupported_js = config.raise_on_unsupported_js
29
+ @session_options = {
30
+ default_host: default_host || config.default_host,
31
+ follow_redirects: follow_redirects.nil? ? config.follow_redirects : follow_redirects,
32
+ max_redirects: max_redirects || config.max_redirects,
33
+ # Capybara drives a trusted app and legitimately visits multiple
34
+ # hosts (e.g. app_host / multi-server specs), so don't enforce origin.
35
+ enforce_same_origin: false
36
+ }
37
+ end
38
+
39
+ # The dommy-rack session. Named `rack_session` to avoid colliding with
40
+ # Capybara::Driver::Base#session (the owning Capybara::Session). Rebuilt
41
+ # when the effective host (Capybara app_host / default_host) changes so
42
+ # current_url reflects it and same-origin checks pass.
43
+ def rack_session
44
+ host = effective_host
45
+ if @rack_session.nil? || @rack_session_host != host
46
+ @rack_session = ::Dommy::Rack::Session.new(@app, **@session_options.merge(default_host: host))
47
+ @rack_session_host = host
48
+ end
49
+ @rack_session
50
+ end
51
+
52
+ # --- Navigation ---
53
+
54
+ def visit(path)
55
+ # A fresh visit resolves a relative path against the host root (not the
56
+ # current page's directory), matching browser address-bar semantics.
57
+ rack_session.visit(::URI.join("#{effective_host}/", path.to_s).to_s)
58
+ rescue URI::InvalidURIError
59
+ rack_session.visit(path)
60
+ end
61
+
62
+ def current_url
63
+ rack_session.current_url.to_s
64
+ end
65
+
66
+ def refresh
67
+ rack_session.reload
68
+ end
69
+
70
+ def go_back
71
+ rack_session.back
72
+ end
73
+
74
+ def go_forward
75
+ rack_session.forward
76
+ end
77
+
78
+ # --- Page state ---
79
+
80
+ def html
81
+ rack_session.html
82
+ end
83
+
84
+ def title
85
+ document&.title
86
+ end
87
+
88
+ def status_code
89
+ rack_session.status
90
+ end
91
+
92
+ def response_headers
93
+ rack_session.headers || {}
94
+ end
95
+
96
+ # --- Query (returns Capybara::Dommy::Node arrays) ---
97
+
98
+ def find_css(query, **_options)
99
+ wrap(document&.query_selector_all(query))
100
+ end
101
+
102
+ def find_xpath(query, **_options)
103
+ wrap(document&.xpath(query))
104
+ end
105
+
106
+ # --- Node-facing seam (keeps the dommy-rack Session API in one place) ---
107
+
108
+ def document
109
+ rack_session.document
110
+ end
111
+
112
+ def follow_link(element)
113
+ rack_session.click_link_element(element)
114
+ end
115
+
116
+ def submit_form(form, submitter:)
117
+ rack_session.submit_form(form, submitter: submitter)
118
+ end
119
+
120
+ # --- Lifecycle ---
121
+
122
+ def reset!
123
+ @rack_session = nil
124
+ end
125
+
126
+ def wait?
127
+ false
128
+ end
129
+
130
+ def needs_server?
131
+ false
132
+ end
133
+
134
+ # Lets Capybara reload a node when it goes stale (after navigation).
135
+ def invalid_element_errors
136
+ [Capybara::Dommy::StaleElementReferenceError]
137
+ end
138
+
139
+ # --- JavaScript (unsupported) ---
140
+ # When raise_on_unsupported_js is false these become no-ops, so tests
141
+ # that incidentally call them don't fail.
142
+
143
+ def execute_script(_script, *_args)
144
+ unsupported_js!("execute_script")
145
+ end
146
+
147
+ def evaluate_script(_script, *_args)
148
+ unsupported_js!("evaluate_script")
149
+ end
150
+
151
+ def evaluate_async_script(_script, *_args)
152
+ unsupported_js!("evaluate_async_script")
153
+ end
154
+
155
+ # Visibility decision used by Node#visible?. :all / :none treat every
156
+ # element as visible; :html defers to dommy-rack's HTML-level check.
157
+ def visible?(element)
158
+ return true if @visibility == :all || @visibility == :none
159
+
160
+ ::Dommy::Rack.visible?(element)
161
+ end
162
+
163
+ private
164
+
165
+ # Capybara's app_host (set per-example) wins over default_host; falls
166
+ # back to the host this driver was configured with. Guarded so a
167
+ # standalone driver (no owning Capybara session) still works.
168
+ def effective_host
169
+ options = owning_session_options
170
+ (options && (options.app_host || options.default_host)) || @session_options[:default_host]
171
+ end
172
+
173
+ def owning_session_options
174
+ session_options if session
175
+ rescue StandardError
176
+ nil
177
+ end
178
+
179
+ def wrap(elements)
180
+ (elements || []).map { |element| Node.new(self, element) }
181
+ end
182
+
183
+ def unsupported_js!(name)
184
+ return nil unless @raise_on_unsupported_js
185
+
186
+ raise Capybara::NotSupportedByDriverError,
187
+ "capybara-dommy does not support JavaScript (#{name})"
188
+ end
189
+ end
190
+ end
191
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Capybara
4
+ module Dommy
5
+ # Base error for capybara-dommy. Element lookup failures surface as
6
+ # Capybara's own ElementNotFound / Ambiguous from the finder layer, and
7
+ # unsupported-URL / cross-origin errors propagate from dommy-rack.
8
+ class Error < StandardError; end
9
+
10
+ # Raised when a node is used after its element left the current document
11
+ # (e.g. after navigation). Listed in Driver#invalid_element_errors so
12
+ # Capybara reloads and retries.
13
+ class StaleElementReferenceError < Error; end
14
+ end
15
+ end
@@ -0,0 +1,334 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Capybara
4
+ module Dommy
5
+ # Wraps a Dommy::Element as a Capybara driver node. Modeled on
6
+ # Capybara::RackTest::Node: field setting mutates the Dommy::Element in the
7
+ # live document, while link clicks and form submission delegate to the
8
+ # dommy-rack session (which re-reads the same document at submit time).
9
+ class Node < Capybara::Driver::Node
10
+ OPTION_OWNER_XPATH = "./parent::*[self::optgroup | self::select | self::datalist]"
11
+ DISABLED_BY_FIELDSET_XPATH =
12
+ "./parent::fieldset[./@disabled] | " \
13
+ "./ancestor::*[(not(./self::legend) or ./preceding-sibling::legend)][./parent::fieldset[./@disabled]]"
14
+
15
+ def tag_name
16
+ native.tag_name.downcase
17
+ end
18
+
19
+ def [](name)
20
+ native.get_attribute(name.to_s)
21
+ end
22
+
23
+ def value
24
+ if select?
25
+ native.multiple ? native.selected_options.map(&:value) : native.value
26
+ elsif checkable?
27
+ native.has_attribute?("value") ? native.get_attribute("value") : "on"
28
+ elsif native.respond_to?(:value)
29
+ native.value
30
+ end
31
+ end
32
+
33
+ def all_text
34
+ text_extractor.all_text(native)
35
+ end
36
+
37
+ def visible_text
38
+ text_extractor.visible_text(native)
39
+ end
40
+
41
+ def visible?
42
+ driver.visible?(native)
43
+ end
44
+
45
+ def checked?
46
+ native.respond_to?(:checked) && native.checked
47
+ end
48
+
49
+ def selected?
50
+ native.respond_to?(:selected) && native.selected
51
+ end
52
+
53
+ # `disabled` only applies to form-associated elements; on anything else
54
+ # (e.g. a link that incorrectly carries the attribute) it has no effect.
55
+ DISABLEABLE_ELEMENTS = %w[button fieldset input optgroup option select textarea].freeze
56
+
57
+ def disabled?
58
+ return false unless DISABLEABLE_ELEMENTS.include?(tag_name)
59
+ return true if native.has_attribute?("disabled")
60
+
61
+ if %w[option optgroup].include?(tag_name)
62
+ owner = native.xpath(OPTION_OWNER_XPATH).first
63
+ owner ? self.class.new(driver, owner).disabled? : false
64
+ else
65
+ !native.xpath(DISABLED_BY_FIELDSET_XPATH).empty?
66
+ end
67
+ end
68
+
69
+ # readonly does not apply to these input types, so they are never
70
+ # readonly even if the attribute is present (matches RackTest).
71
+ NON_READONLY_TYPES = %w[hidden range color checkbox radio file submit image reset button].freeze
72
+
73
+ def readonly?
74
+ return false if input_field? && NON_READONLY_TYPES.include?(field_type)
75
+
76
+ native.has_attribute?("readonly")
77
+ end
78
+
79
+ def path
80
+ native.path
81
+ end
82
+
83
+ def style(_styles)
84
+ {}
85
+ end
86
+
87
+ # --- Interaction ---
88
+
89
+ def click(_keys = [], **_options)
90
+ if link?
91
+ click_link_node
92
+ elsif submits?
93
+ submit_owning_form
94
+ elsif checkable?
95
+ set(!checked?)
96
+ elsif tag_name == "label"
97
+ click_label
98
+ elsif (details = native.closest("details"))
99
+ toggle_details(details)
100
+ end
101
+ end
102
+
103
+ def set(value, **_options)
104
+ return if disabled? || readonly?
105
+
106
+ if radio?
107
+ set_radio
108
+ elsif checkbox?
109
+ set_checkbox(value)
110
+ elsif range?
111
+ set_range(value)
112
+ elsif file?
113
+ set_file(value)
114
+ elsif input_field? || textarea?
115
+ set_text_value(value)
116
+ end
117
+ end
118
+
119
+ def select_option
120
+ return if disabled?
121
+
122
+ select_el = select_node
123
+ deselect_all(select_el) unless select_el&.multiple
124
+ native.selected = true
125
+ end
126
+
127
+ def unselect_option
128
+ unless select_node&.multiple
129
+ raise Capybara::UnselectNotAllowed, "Cannot unselect option from a non-multiple select box"
130
+ end
131
+
132
+ native.selected = false
133
+ end
134
+
135
+ # --- Scoped queries (for `within`) ---
136
+
137
+ def find_css(locator, **_options)
138
+ native.query_selector_all(locator).map { |element| self.class.new(driver, element) }
139
+ end
140
+
141
+ def find_xpath(locator, **_options)
142
+ native.xpath(locator).map { |element| self.class.new(driver, element) }
143
+ end
144
+
145
+ # Guard every public method with a staleness check so Capybara can
146
+ # reload a node whose element left the current document (after
147
+ # navigation). Mirrors Capybara::RackTest::Node.
148
+ public_instance_methods(false).each do |meth_name|
149
+ alias_method "unchecked_#{meth_name}", meth_name
150
+ private "unchecked_#{meth_name}"
151
+
152
+ class_eval <<~RUBY, __FILE__, __LINE__ + 1
153
+ def #{meth_name}(...)
154
+ stale_check
155
+ send(:"unchecked_#{meth_name}", ...)
156
+ end
157
+ RUBY
158
+ end
159
+
160
+ private
161
+
162
+ def stale_check
163
+ return if native.document.equal?(driver.document)
164
+
165
+ raise StaleElementReferenceError, "element is no longer attached to the document"
166
+ end
167
+
168
+ def submit_owning_form
169
+ form = form_for(native)
170
+ driver.submit_form(form, submitter: native) if form
171
+ end
172
+
173
+ # javascript: links are no-ops in Capybara (a policy decision); every
174
+ # other link delegates to dommy-rack, which handles fragment / same-page
175
+ # / blank-href semantics and raises on genuinely unsupported schemes.
176
+ def click_link_node
177
+ scheme = native.get_attribute("href").to_s.split(":", 2).first.to_s.downcase
178
+ return if scheme == "javascript"
179
+
180
+ driver.follow_link(native)
181
+ end
182
+
183
+ def text_extractor
184
+ TextExtractor.new(driver)
185
+ end
186
+
187
+ def toggle_details(details)
188
+ if details.has_attribute?("open")
189
+ details.remove_attribute("open")
190
+ else
191
+ details.set_attribute("open", "open")
192
+ end
193
+ end
194
+
195
+ def click_label
196
+ control = labelled_control
197
+ return unless control
198
+
199
+ node = self.class.new(driver, control)
200
+ node.set(!node.checked?) if node.send(:checkable?)
201
+ end
202
+
203
+ def labelled_control
204
+ for_id = native.get_attribute("for")
205
+ if for_id && !for_id.empty?
206
+ document.get_element_by_id(for_id)
207
+ else
208
+ native.query_selector("input, textarea, select")
209
+ end
210
+ end
211
+
212
+ def set_text_value(value)
213
+ string = value.to_s
214
+ if text_or_password? && attribute_present?("maxlength")
215
+ string = string[0, native.get_attribute("maxlength").to_i].to_s
216
+ end
217
+
218
+ # An <input> value ending in a newline submits a single-field form.
219
+ # There is no submitter button in this case.
220
+ form = single_field_form
221
+ if input_field? && string.end_with?("\n") && form
222
+ native.value = string.chomp
223
+ driver.submit_form(form, submitter: nil)
224
+ else
225
+ native.value = string
226
+ end
227
+ end
228
+
229
+ def single_field_form
230
+ form = form_for(native)
231
+ return nil unless form && form.query_selector_all("input, textarea").length == 1
232
+
233
+ form
234
+ end
235
+
236
+ def set_range(value)
237
+ min = (native.get_attribute("min") || 0).to_f
238
+ max = (native.get_attribute("max") || 100).to_f
239
+ step = (native.get_attribute("step") || 1).to_f
240
+ v = value.to_f.clamp(min, max)
241
+ v = (((v - min) / step).round * step) + min
242
+ v = v.clamp(min, max)
243
+ native.value = (v == v.to_i ? v.to_i : v).to_s
244
+ end
245
+
246
+ def attribute_present?(name)
247
+ value = native.get_attribute(name)
248
+ value && !value.empty?
249
+ end
250
+
251
+ # Reflect checked state on the attribute so node[:checked] and form
252
+ # submission both observe it (Dommy's `checked=` only sets the property).
253
+ def set_checkbox(value)
254
+ if value
255
+ native.set_attribute("checked", "checked")
256
+ else
257
+ native.remove_attribute("checked")
258
+ end
259
+ end
260
+
261
+ 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")
270
+ end
271
+
272
+ def set_file(value)
273
+ files = Array(value).map do |path|
274
+ path = path.to_s
275
+ raise Capybara::FileNotFound, "cannot attach file, #{path} does not exist" unless ::File.exist?(path)
276
+
277
+ ::Dommy::File.new(
278
+ [::File.binread(path)], ::File.basename(path),
279
+ "type" => ::Dommy::Rack::FileUpload.mime_type_for(path)
280
+ )
281
+ end
282
+ native.__driver_set_files__(files)
283
+ end
284
+
285
+ def deselect_all(select_el)
286
+ return unless select_el
287
+
288
+ select_el.options.each { |option| option.selected = false }
289
+ end
290
+
291
+ def form_for(element)
292
+ form_id = element.get_attribute("form")
293
+ if form_id && !form_id.empty?
294
+ document.get_element_by_id(form_id)
295
+ else
296
+ element.closest("form")
297
+ end
298
+ end
299
+
300
+ def select_node
301
+ native.closest("select")
302
+ end
303
+
304
+ def document
305
+ driver.document
306
+ end
307
+
308
+ def field_type
309
+ native.respond_to?(:type) ? native.type : nil
310
+ end
311
+
312
+ def input_field? = tag_name == "input"
313
+ def textarea? = tag_name == "textarea"
314
+ def select? = tag_name == "select"
315
+ def radio? = input_field? && field_type == "radio"
316
+ def checkbox? = input_field? && field_type == "checkbox"
317
+ def range? = input_field? && field_type == "range"
318
+ def file? = input_field? && field_type == "file"
319
+ def text_or_password? = input_field? && %w[text password].include?(field_type)
320
+ def checkable? = radio? || checkbox?
321
+ def link? = tag_name == "a" && !native.get_attribute("href").nil?
322
+
323
+ def submits?
324
+ if input_field?
325
+ %w[submit image].include?(field_type)
326
+ elsif tag_name == "button"
327
+ field_type == "submit"
328
+ else
329
+ false
330
+ end
331
+ end
332
+ end
333
+ end
334
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "capybara/dommy"
4
+
5
+ # Thin convenience layer: registers the :dommy driver so Rails system tests
6
+ # can use `driven_by :dommy`. Driver defaults come from
7
+ # Capybara::Dommy.configuration.
8
+ Capybara.register_driver(:dommy) do |app|
9
+ Capybara::Dommy::Driver.new(app)
10
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Capybara
4
+ module Dommy
5
+ # Extracts text from a Dommy subtree, reusing Capybara's own whitespace
6
+ # normalization so results match the matchers' expectations. `all_text` is
7
+ # the raw textContent; `visible_text` excludes element subtrees that are
8
+ # hidden under the driver's visibility mode or are non-rendered
9
+ # (script/style/head), and inserts breaks at block boundaries.
10
+ class TextExtractor
11
+ include Capybara::Node::WhitespaceNormalizer
12
+
13
+ BLOCK_ELEMENTS = %w[
14
+ p h1 h2 h3 h4 h5 h6 ol ul pre address blockquote dl div fieldset form hr noscript table
15
+ ].freeze
16
+ NON_DISPLAYED_ELEMENTS = %w[script style head title].freeze
17
+
18
+ def initialize(driver)
19
+ @driver = driver
20
+ end
21
+
22
+ def all_text(element)
23
+ normalize_spacing(element.text_content)
24
+ end
25
+
26
+ def visible_text(element)
27
+ return "" unless @driver.visible?(element)
28
+
29
+ normalize_visible_spacing(displayed_text(element))
30
+ end
31
+
32
+ private
33
+
34
+ def displayed_text(element)
35
+ element.child_nodes.map do |child|
36
+ if text_node?(child)
37
+ # Whitespace inside a text node (incl. newlines) collapses to
38
+ # spaces; only block boundaries introduce line breaks.
39
+ child.text_content.tr(SQUEEZED_SPACES, " ")
40
+ elsif element_node?(child)
41
+ next "" if non_displayed?(child) || !@driver.visible?(child)
42
+
43
+ inner = displayed_text(child)
44
+ block_element?(child) ? "\n#{inner}\n" : inner
45
+ else
46
+ ""
47
+ end
48
+ end.join
49
+ end
50
+
51
+ def non_displayed?(node)
52
+ NON_DISPLAYED_ELEMENTS.include?(node.tag_name.downcase)
53
+ end
54
+
55
+ def block_element?(node)
56
+ BLOCK_ELEMENTS.include?(node.tag_name.downcase)
57
+ end
58
+
59
+ def text_node?(node)
60
+ node.is_a?(::Dommy::TextNode)
61
+ end
62
+
63
+ def element_node?(node)
64
+ node.is_a?(::Dommy::Element)
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Capybara
4
+ module Dommy
5
+ VERSION = "0.8.0"
6
+ end
7
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "capybara"
4
+ require "dommy"
5
+ require "dommy/rack"
6
+
7
+ require_relative "dommy/version"
8
+ require_relative "dommy/errors"
9
+ require_relative "dommy/configuration"
10
+ require_relative "dommy/text_extractor"
11
+ require_relative "dommy/node"
12
+ require_relative "dommy/driver"
13
+
14
+ module Capybara
15
+ module Dommy
16
+ end
17
+ end
@@ -0,0 +1,6 @@
1
+ module Capybara
2
+ module Dommy
3
+ VERSION: String
4
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
5
+ end
6
+ end
metadata ADDED
@@ -0,0 +1,100 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: capybara-dommy
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.8.0
5
+ platform: ruby
6
+ authors:
7
+ - takahashim
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 2026-05-31 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: capybara
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '3.40'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '3.40'
26
+ - !ruby/object:Gem::Dependency
27
+ name: dommy
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: 0.8.0
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: 0.8.0
40
+ - !ruby/object:Gem::Dependency
41
+ name: dommy-rack
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: 0.8.0
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: 0.8.0
54
+ description: |
55
+ capybara-dommy is a Capybara driver backed by Dommy and dommy-rack. It drives
56
+ Rack/Rails apps through the Capybara DSL without a real browser or JavaScript,
57
+ keeping the page as a Dommy::Document. RackTest-like, with HTML-level visibility.
58
+ email:
59
+ - takahashimm@gmail.com
60
+ executables: []
61
+ extensions: []
62
+ extra_rdoc_files: []
63
+ files:
64
+ - CHANGELOG.md
65
+ - LICENSE.txt
66
+ - README.md
67
+ - Rakefile
68
+ - lib/capybara/dommy.rb
69
+ - lib/capybara/dommy/configuration.rb
70
+ - lib/capybara/dommy/driver.rb
71
+ - lib/capybara/dommy/errors.rb
72
+ - lib/capybara/dommy/node.rb
73
+ - lib/capybara/dommy/rails.rb
74
+ - lib/capybara/dommy/text_extractor.rb
75
+ - lib/capybara/dommy/version.rb
76
+ - sig/capybara/dommy.rbs
77
+ homepage: https://github.com/takahashim/dommy
78
+ licenses:
79
+ - MIT
80
+ metadata:
81
+ homepage_uri: https://github.com/takahashim/dommy
82
+ source_code_uri: https://github.com/takahashim/dommy/tree/main/gems/capybara-dommy
83
+ rdoc_options: []
84
+ require_paths:
85
+ - lib
86
+ required_ruby_version: !ruby/object:Gem::Requirement
87
+ requirements:
88
+ - - ">="
89
+ - !ruby/object:Gem::Version
90
+ version: 3.2.0
91
+ required_rubygems_version: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - ">="
94
+ - !ruby/object:Gem::Version
95
+ version: '0'
96
+ requirements: []
97
+ rubygems_version: 3.6.2
98
+ specification_version: 4
99
+ summary: A Dommy-backed Capybara driver
100
+ test_files: []