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

Instance Method Details

#after_interactionObject

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

#debugObject

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_interactorObject



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_not_found_message(role, name, level) if matches.empty?
  raise AmbiguousElementError, "#{matches.size} elements with role #{role.to_s.inspect}" if matches.size > 1

  matches.first
end

#finderObject

--- Finding ---



51
52
53
# File 'lib/dommy/interaction/driver.rb', line 51

def finder
  Locator.new(scope_root)
end

#has_button?(locator) ⇒ Boolean

Returns:

  • (Boolean)


271
# File 'lib/dommy/interaction/driver.rb', line 271

def has_button?(locator) = element_present? { finder.find_button(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.

Returns:

  • (Boolean)


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

Returns:

  • (Boolean)


272
# File 'lib/dommy/interaction/driver.rb', line 272

def has_field?(locator) = element_present? { finder.find_field(locator) }

#has_link?(locator) ⇒ Boolean

Returns:

  • (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

Returns:

  • (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

Returns:

  • (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

Returns:

  • (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

Returns:

  • (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).

Returns:

  • (Boolean)


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_rootObject

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_textObject

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