Module: Dommy::Interaction::Driver
- Included in:
- Browser
- Defined in:
- lib/dommy/interaction/driver.rb
Overview
The shared interaction surface mixed into both Dommy::Rack::Session and
Dommy::Browser: element finding, scoping, field interaction, generic
click, and query matchers — Capybara's vocabulary, in one place. The
includer must provide #document; it may override #after_interaction
(called after each interaction's events are dispatched) to settle a JS
runtime. Navigation verbs (click_link / click_button that issue
requests) stay in the host, which reuses #finder / EventSynthesis.
Instance Method Summary collapse
-
#after_interaction ⇒ Object
A no-op the includer overrides to settle a JS runtime after events.
-
#all(selector, text: nil) ⇒ Object
All elements matching
selectorin scope (possibly empty), optionally filtered bytext:. -
#all_by_role(role, name: nil, level: nil, exact: false) ⇒ Object
Elements in scope whose computed ARIA role is
role, optionally filtered by accessiblename:(substring;exact: trueor a Regexp for precision) andlevel:. - #attach_file(locator, path) ⇒ Object
- #check(locator) ⇒ Object
- #choose(locator) ⇒ Object
-
#click(selector) ⇒ Object
Click any element matched by a CSS selector, firing the full browser event sequence so JS click handlers run.
-
#debug ⇒ Object
A read-only debugging view (forms / links / buttons / fields / summary) over the current scope.
- #field_interactor ⇒ Object
- #fill_in(locator, with:) ⇒ Object
-
#find(selector, text: nil) ⇒ Object
The single element matching
selectorin scope (raises if none). -
#find_by_role(role, name: nil, level: nil, exact: false) ⇒ Object
The single element with role
role(+ optional name/level). -
#finder ⇒ Object
--- Finding ---.
- #has_button?(locator) ⇒ Boolean
-
#has_css?(selector, text: nil, count: nil) ⇒ Boolean
True when an element matches
selectorin scope. - #has_field?(locator) ⇒ Boolean
- #has_link?(locator) ⇒ Boolean
- #has_no_css?(selector, text: nil, count: nil) ⇒ Boolean
- #has_no_role?(role, name: nil, level: nil, exact: false) ⇒ Boolean
- #has_no_text?(text) ⇒ Boolean
- #has_role?(role, name: nil, level: nil, exact: false) ⇒ Boolean
-
#has_text?(text) ⇒ Boolean
True when the current scope's text contains
text(a String substring) or matches it (a Regexp). -
#ime_input(selector, text, updates: nil, commit: true) ⇒ Object
Compose text through an IME, with the canonical (Chrome-like) event sequence an input method produces.
-
#scope_root ⇒ Object
The node finds and matchers run against: the innermost active scope, or the document when none is open.
-
#scope_text ⇒ Object
Visible text of the current scope (document via
, else the node). - #select(value, from:) ⇒ Object
-
#send_keys(selector, *keys) ⇒ Object
Type into the element matching
selector(focusing it first). -
#send_keys_to(element, *keys) ⇒ Object
Element-based variant of #send_keys, for hosts that already hold the element (capybara-dommy's Node#send_keys).
- #uncheck(locator) ⇒ Object
- #unselect(value, from:) ⇒ Object
-
#with_scope(node) ⇒ Object
Push
nodeas the active scope for the block, restoring afterward. -
#within(selector, &block) ⇒ Object
Scope finds/matchers to the element matched by
selectorfor the block.
Instance Method Details
#after_interaction ⇒ Object
A no-op the includer overrides to settle a JS runtime after events.
275 276 277 |
# File 'lib/dommy/interaction/driver.rb', line 275 def after_interaction nil end |
#all(selector, text: nil) ⇒ Object
All elements matching selector in scope (possibly empty), optionally
filtered by text:.
76 77 78 79 |
# File 'lib/dommy/interaction/driver.rb', line 76 def all(selector, text: nil) nodes = scope_root ? scope_root.query_selector_all(selector).to_a : [] text.nil? ? nodes : nodes.select { |node| text_matches?(node, text) } end |
#all_by_role(role, name: nil, level: nil, exact: false) ⇒ Object
Elements in scope whose computed ARIA role is role, optionally filtered
by accessible name: (substring; exact: true or a Regexp for
precision) and level:. Walks the accessibility tree, so aria-hidden /
invisible / presentational nodes are already excluded. Possibly empty.
87 88 89 |
# File 'lib/dommy/interaction/driver.rb', line 87 def all_by_role(role, name: nil, level: nil, exact: false) RoleQuery.match(scope_root, role: role, name: name, level: level, exact: exact) end |
#attach_file(locator, path) ⇒ Object
161 162 163 164 165 |
# File 'lib/dommy/interaction/driver.rb', line 161 def attach_file(locator, path) result = field_interactor.attach_file(locator, path) after_interaction result end |
#check(locator) ⇒ Object
137 138 139 140 141 |
# File 'lib/dommy/interaction/driver.rb', line 137 def check(locator) result = field_interactor.check(locator) after_interaction result end |
#choose(locator) ⇒ Object
131 132 133 134 135 |
# File 'lib/dommy/interaction/driver.rb', line 131 def choose(locator) result = field_interactor.choose(locator) after_interaction result end |
#click(selector) ⇒ Object
Click any element matched by a CSS selector, firing the full browser event sequence so JS click handlers run.
114 115 116 117 118 119 120 121 122 123 |
# File 'lib/dommy/interaction/driver.rb', line 114 def click(selector) element = find(selector) # An un-prevented click runs the element's activation behavior — a submit # button submits its owning form (SubmitButtonActivation), an anchor # follows its href — so `click "button[type=submit]"` / `click "a"` behave # like a real click with no driver-level special-casing. EventSynthesis.click(element) after_interaction element end |
#debug ⇒ Object
A read-only debugging view (forms / links / buttons / fields / summary) over the current scope. See Dommy::Interaction::Debug.
57 58 59 |
# File 'lib/dommy/interaction/driver.rb', line 57 def debug Debug.new(scope_root) end |
#field_interactor ⇒ Object
61 62 63 |
# File 'lib/dommy/interaction/driver.rb', line 61 def field_interactor FieldInteractor.new(finder, document) end |
#fill_in(locator, with:) ⇒ Object
125 126 127 128 129 |
# File 'lib/dommy/interaction/driver.rb', line 125 def fill_in(locator, with:) result = field_interactor.fill_in(locator, with: with) after_interaction result end |
#find(selector, text: nil) ⇒ Object
The single element matching selector in scope (raises if none).
The first element matching selector in scope (raises if none). text:
keeps only elements whose text contains the String (or matches the
Regexp).
69 70 71 72 |
# File 'lib/dommy/interaction/driver.rb', line 69 def find(selector, text: nil) all(selector, text: text).first || raise(ElementNotFoundError, "no element matching #{selector.inspect}#{" with text #{text.inspect}" if text}") end |
#find_by_role(role, name: nil, level: nil, exact: false) ⇒ Object
The single element with role role (+ optional name/level). Raises
ElementNotFoundError when none (listing the roles that ARE present) and
AmbiguousElementError when more than one.
94 95 96 97 98 99 100 |
# File 'lib/dommy/interaction/driver.rb', line 94 def find_by_role(role, name: nil, level: nil, exact: false) matches = all_by_role(role, name: name, level: level, exact: exact) raise ElementNotFoundError, (role, name, level) if matches.empty? raise AmbiguousElementError, "#{matches.size} elements with role #{role.to_s.inspect}" if matches.size > 1 matches.first end |
#finder ⇒ Object
--- Finding ---
51 52 53 |
# File 'lib/dommy/interaction/driver.rb', line 51 def finder Locator.new(scope_root) end |
#has_button?(locator) ⇒ Boolean
271 |
# File 'lib/dommy/interaction/driver.rb', line 271 def (locator) = element_present? { finder.(locator) } |
#has_css?(selector, text: nil, count: nil) ⇒ Boolean
True when an element matches selector in scope. text: keeps only
elements whose text contains the String (or matches the Regexp);
count: requires an exact number of matches.
253 254 255 256 257 |
# File 'lib/dommy/interaction/driver.rb', line 253 def has_css?(selector, text: nil, count: nil) nodes = scope_root ? scope_root.query_selector_all(selector).to_a : [] nodes = nodes.select { |node| text_matches?(node, text) } unless text.nil? count ? nodes.size == count : !nodes.empty? end |
#has_field?(locator) ⇒ Boolean
272 |
# File 'lib/dommy/interaction/driver.rb', line 272 def has_field?(locator) = element_present? { finder.find_field(locator) } |
#has_link?(locator) ⇒ Boolean
270 |
# File 'lib/dommy/interaction/driver.rb', line 270 def has_link?(locator) = element_present? { finder.find_link(locator) } |
#has_no_css?(selector, text: nil, count: nil) ⇒ Boolean
259 |
# File 'lib/dommy/interaction/driver.rb', line 259 def has_no_css?(selector, text: nil, count: nil) = !has_css?(selector, text: text, count: count) |
#has_no_role?(role, name: nil, level: nil, exact: false) ⇒ Boolean
106 107 108 |
# File 'lib/dommy/interaction/driver.rb', line 106 def has_no_role?(role, name: nil, level: nil, exact: false) all_by_role(role, name: name, level: level, exact: exact).empty? end |
#has_no_text?(text) ⇒ Boolean
268 |
# File 'lib/dommy/interaction/driver.rb', line 268 def has_no_text?(text) = !has_text?(text) |
#has_role?(role, name: nil, level: nil, exact: false) ⇒ Boolean
102 103 104 |
# File 'lib/dommy/interaction/driver.rb', line 102 def has_role?(role, name: nil, level: nil, exact: false) !all_by_role(role, name: name, level: level, exact: exact).empty? end |
#has_text?(text) ⇒ Boolean
True when the current scope's text contains text (a String substring)
or matches it (a Regexp).
263 264 265 266 |
# File 'lib/dommy/interaction/driver.rb', line 263 def has_text?(text) content = scope_text text.is_a?(Regexp) ? content.match?(text) : content.include?(text.to_s) end |
#ime_input(selector, text, updates: nil, commit: true) ⇒ Object
Compose text through an IME, with the canonical (Chrome-like) event
sequence an input method produces. updates are the visible
composition states (e.g. ["に", "にほんご"] while converting to
日本語); each dispatches a Process keydown (keyCode 229),
compositionupdate, the insertCompositionText beforeinput/input pair
(isComposing: true, the field's value shows the composing text),
and keyup. commit: true (default) then fires compositionend with
text and leaves it in the field; commit: false cancels the
composition, restoring the field's prior value.
browser.ime_input "#q", "日本語", updates: ["に", "にほんご"]
browser.ime_input "#q", "", updates: ["に"], commit: false
A block runs after each composition state (with the update string),
so a test can let virtual time pass mid-composition — e.g. prove a
debounced handler that guards on event.isComposing stays quiet
while the user pauses to pick a conversion candidate:
browser.ime_input "#q", "日本語", updates: ["に", "にほんご"] do
browser.advance_time(400) # longer than the debounce
end
Handlers that guard on event.isComposing (deferring work until
compositionend) therefore behave exactly as they would under a real
IME — something a real-browser test cannot drive deterministically.
217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 |
# File 'lib/dommy/interaction/driver.rb', line 217 def ime_input(selector, text, updates: nil, commit: true) element = find(selector) EventSynthesis.focus(element) text = text.to_s updates = Array(updates || (text.empty? ? [] : [text])).map(&:to_s) base = element.respond_to?(:value) ? element.value.to_s : "" EventSynthesis.keydown(element, "Process", "", {"keyCode" => 229}) EventSynthesis.compositionstart(element) updates.each_with_index do |update, i| EventSynthesis.keydown(element, "Process", "", {"keyCode" => 229, "isComposing" => true}) if i.positive? EventSynthesis.compositionupdate(element, update) field_interactor.set_composition_text(element, base, update) EventSynthesis.keyup(element, "Process", "", {"isComposing" => true}) yield update if block_given? end if commit unless text == updates.last EventSynthesis.compositionupdate(element, text) field_interactor.set_composition_text(element, base, text) end EventSynthesis.compositionend(element, text) else field_interactor.cancel_composition_text(element, base) EventSynthesis.compositionend(element, "") end after_interaction element end |
#scope_root ⇒ Object
The node finds and matchers run against: the innermost active scope, or the document when none is open.
17 18 19 |
# File 'lib/dommy/interaction/driver.rb', line 17 def scope_root (@scope_stack ||= []).last || document end |
#scope_text ⇒ Object
Visible text of the current scope (document via
, else the node).
41 42 43 44 45 46 47 |
# File 'lib/dommy/interaction/driver.rb', line 41 def scope_text root = scope_root return "" unless root return Internal::DomMatching.rendered_text(root.body) if root.respond_to?(:body) root.respond_to?(:child_nodes) ? Internal::DomMatching.rendered_text(root) : "" end |
#select(value, from:) ⇒ Object
149 150 151 152 153 |
# File 'lib/dommy/interaction/driver.rb', line 149 def select(value, from:) result = field_interactor.select(value, from: from) after_interaction result end |
#send_keys(selector, *keys) ⇒ Object
Type into the element matching selector (focusing it first). Each
key is a Symbol for a named key (:enter, :arrow_down, :escape, … see
EventSynthesis::NAMED_KEYS) or a String typed character by character
(keydown -> keypress -> beforeinput -> insertion + input -> keyup).
Default actions mirror a browser's: characters insert into a text
field, Backspace deletes, Enter inserts a newline in a textarea and
triggers the owning form's implicit submission elsewhere. Preventing
keydown / keypress / beforeinput suppresses the default action, so
SPA keyboard handlers behave as they would in a browser.
browser.send_keys "#q", :arrow_down, :enter
browser.send_keys "#q", "hello"
179 180 181 |
# File 'lib/dommy/interaction/driver.rb', line 179 def send_keys(selector, *keys) send_keys_to(find(selector), *keys) end |
#send_keys_to(element, *keys) ⇒ Object
Element-based variant of #send_keys, for hosts that already hold the element (capybara-dommy's Node#send_keys). Same semantics.
185 186 187 188 189 190 |
# File 'lib/dommy/interaction/driver.rb', line 185 def send_keys_to(element, *keys) EventSynthesis.focus(element) keys.each { |key| dispatch_send_key(element, key) } after_interaction element end |
#uncheck(locator) ⇒ Object
143 144 145 146 147 |
# File 'lib/dommy/interaction/driver.rb', line 143 def uncheck(locator) result = field_interactor.uncheck(locator) after_interaction result end |
#unselect(value, from:) ⇒ Object
155 156 157 158 159 |
# File 'lib/dommy/interaction/driver.rb', line 155 def unselect(value, from:) result = field_interactor.unselect(value, from: from) after_interaction result end |
#with_scope(node) ⇒ Object
Push node as the active scope for the block, restoring afterward.
Returns the block value, or the node when no block is given.
23 24 25 26 27 28 29 30 |
# File 'lib/dommy/interaction/driver.rb', line 23 def with_scope(node) (@scope_stack ||= []).push(node) begin block_given? ? yield(self) : node ensure @scope_stack.pop end end |
#within(selector, &block) ⇒ Object
Scope finds/matchers to the element matched by selector for the block.
33 34 35 36 37 38 |
# File 'lib/dommy/interaction/driver.rb', line 33 def within(selector, &block) node = scope_root&.query_selector(selector) raise ElementNotFoundError, "no element matching #{selector.inspect}" unless node with_scope(node, &block) end |