Class: Capybara::Simulated::Browser

Inherits:
Object
  • Object
show all
Defined in:
lib/capybara/simulated/browser.rb

Constant Summary collapse

VENDOR_DIR =
File.expand_path('../../../../vendor/js', __FILE__)
DEFAULT_HOST =
'http://www.example.com'.freeze
EXTERNAL_SCRIPT_DENYLIST =

The V8 isolate is shared across the lifetime of a Browser instance —only the happy-dom Window is recreated per visit. Reset between specs is via ‘reset!` on the Driver, which closes the Window and clears tracked DOM handles, but keeps the (expensive) isolate alive. On mini_racer + happy-dom the previously-denylisted scripts (notably jQuery UI) load without hanging the runtime, and ready callbacks depend on them succeeding so we no longer skip anything by default.

/\A\z/.freeze
ASYNC_POLL_STEP_MS =

Capybara’s async-script contract: the user script receives a callback as its final ‘arguments` and must invoke it (possibly after a setTimeout) with the result. We have no real event loop, so after kicking the script off we drive the virtual clock in small slices until the callback fires or the wait budget is up.

50

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(app) ⇒ Browser

Returns a new instance of Browser.



46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
# File 'lib/capybara/simulated/browser.rb', line 46

def initialize(app)
  @app = app
  @ctx = MiniRacer::Context.new
  @ctx.eval(File.read(File.join(VENDOR_DIR, 'prelude.js')))
  @ctx.eval(File.read(File.join(VENDOR_DIR, 'csim.bundle.js')))
  @ctx.eval(File.read(File.join(VENDOR_DIR, 'runtime.js')))
  @ctx.attach('__csim_fetch', method(:js_fetch))

  @history          = []
  @history_index    = -1
  @current_url      = nil
  @status_code      = nil
  @response_headers = {}
  @modal_responses  = {alert: [], confirm: [], prompt: []}
  @cookie_jar       = []
end

Instance Attribute Details

#appObject (readonly)

Returns the value of attribute app.



26
27
28
# File 'lib/capybara/simulated/browser.rb', line 26

def app
  @app
end

#driver_for_resultsObject

Returns the value of attribute driver_for_results.



44
45
46
# File 'lib/capybara/simulated/browser.rb', line 44

def driver_for_results
  @driver_for_results
end

#response_headersObject (readonly)

Returns the value of attribute response_headers.



26
27
28
# File 'lib/capybara/simulated/browser.rb', line 26

def response_headers
  @response_headers
end

#status_codeObject (readonly)

Returns the value of attribute status_code.



26
27
28
# File 'lib/capybara/simulated/browser.rb', line 26

def status_code
  @status_code
end

Instance Method Details

#active_elementObject



204
# File 'lib/capybara/simulated/browser.rb', line 204

def active_element   = call_runtime('activeElement')

#add_modal_handler(type:, text: nil, response: true) ⇒ Object

Append a text-matched handler used by ‘accept_modal` / `dismiss_modal`. JS-side modal stubs scan the handler stack in registration order and pick the first whose text predicate matches the firing message — which is what enables nested `dismiss_confirm { accept_confirm { … } }`.



263
264
265
266
# File 'lib/capybara/simulated/browser.rb', line 263

def add_modal_handler(type:, text: nil, response: true)
  encoded = encode_modal_handler(type, text, response)
  call_runtime('pushModalHandler', encoded)
end

#advance_virtual_clock_step(ms) ⇒ Object

Forced advance for callers (e.g. accept_modal) that need to age out async setTimeout-driven side effects without waiting on the wall-clock heuristic in advance_virtual_clock.



315
316
317
# File 'lib/capybara/simulated/browser.rb', line 315

def advance_virtual_clock_step(ms)
  @ctx.call('__csim.drainTimers', ms.to_i) rescue nil
end

#all_text(id) ⇒ Object



128
# File 'lib/capybara/simulated/browser.rb', line 128

def all_text(id)     = call_runtime('allText', id).to_s

#attr(id, name) ⇒ Object



133
# File 'lib/capybara/simulated/browser.rb', line 133

def attr(id, name)   = call_runtime('attr', id, name.to_s)

#blur(id) ⇒ Object



203
# File 'lib/capybara/simulated/browser.rb', line 203

def blur(id)         = call_runtime('blur', id)

#capture_pending_modalsObject

Snapshot the JS-side modalQueue before a navigate wipes it, so a subsequent ‘drain_modal_queue` still sees alerts that fired synchronously from inside the click handler.



305
306
307
308
309
310
# File 'lib/capybara/simulated/browser.rb', line 305

def capture_pending_modals
  pending = Array(@ctx.call('__csim.drainModalQueue')) rescue []
  return if pending.empty?
  @captured_modals ||= []
  @captured_modals.concat(pending)
end

#checked?(id) ⇒ Boolean

Returns:

  • (Boolean)


137
# File 'lib/capybara/simulated/browser.rb', line 137

def checked?(id)     = !!call_runtime('checked', id)

#click(id, modifiers = {}) ⇒ Object



168
169
170
171
172
173
174
175
176
177
178
179
# File 'lib/capybara/simulated/browser.rb', line 168

def click(id, modifiers = {})
  delay = modifiers.delete('delay')
  if delay&.positive?
    call_runtime('mouseDown', id, 0, modifiers)
    sleep(delay)
    result = follow(call_runtime('click', id, 0, modifiers, true))
  else
    result = follow(call_runtime('click', id, 0, modifiers))
  end
  drain_async_timers
  result
end

#current_urlObject

window.history.pushState/replaceState only updates the JS-side location; mirror it onto the Ruby @current_url so Capybara reads the new path. Before any real visit (or after reset_session!), we have no @current_url, in which case we report ‘nil` rather than leaking the synthetic happy-dom Window URL.



33
34
35
36
37
38
39
40
41
42
43
# File 'lib/capybara/simulated/browser.rb', line 33

def current_url
  return '' unless @current_url
  # Capybara's synchronize loop calls current_path/current_url while
  # waiting for navigation. Drive the virtual clock so timer-based
  # location updates (window.location.pathname = '...') eventually
  # fire under the wait budget.
  advance_virtual_clock
  check_location_change unless @in_navigate
  loc = (@ctx.eval('window && window.location ? window.location.href : null') rescue nil)
  loc && !loc.empty? ? loc.to_s : @current_url
end

#disabled?(id) ⇒ Boolean

Returns:

  • (Boolean)


139
# File 'lib/capybara/simulated/browser.rb', line 139

def disabled?(id)    = !!call_runtime('disabled', id)

#double_click(id, modifiers = {}) ⇒ Object



191
192
193
194
195
# File 'lib/capybara/simulated/browser.rb', line 191

def double_click(id, modifiers = {})
  result = call_runtime('doubleClick', id, modifiers)
  drain_async_timers
  result
end

#drain_modal_queueObject



287
288
289
290
291
292
# File 'lib/capybara/simulated/browser.rb', line 287

def drain_modal_queue
  captured = @captured_modals
  @captured_modals = nil
  out = Array(call_runtime('drainModalQueue'))
  captured ? captured + out : out
end

#drop(id, items) ⇒ Object



206
207
208
# File 'lib/capybara/simulated/browser.rb', line 206

def drop(id, items)
  call_runtime('drop', id, items).tap { drain_async_timers }
end

#encode_modal_handler(type, text, response) ⇒ Object



272
273
274
275
276
277
278
# File 'lib/capybara/simulated/browser.rb', line 272

def encode_modal_handler(type, text, response)
  {
    'type'     => type.to_s,
    'text'     => encode_modal_text(text),
    'response' => response.is_a?(Symbol) ? response.to_s : response
  }
end

#encode_modal_text(text) ⇒ Object



280
281
282
283
284
285
286
# File 'lib/capybara/simulated/browser.rb', line 280

def encode_modal_text(text)
  case text
  when nil      then nil
  when Regexp   then {'regexp' => text.source, 'flags' => text.options}
  else               text.to_s
  end
end

#evaluate_async_script(code, args = []) ⇒ Object

Raises:

  • (Capybara::ScriptTimeoutError)


237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
# File 'lib/capybara/simulated/browser.rb', line 237

def evaluate_async_script(code, args = [])
  call_runtime('startAsync', code.to_s, encode_script_args(args))
  budget = (Capybara.default_max_wait_time || 2).to_f
  deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + budget
  loop do
    status = @ctx.call('__csim.pollAsync')
    if status['done']
      raise MiniRacer::RuntimeError, status['error'] if status['error']
      return decode_script_result(status['value'])
    end
    break if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
    @ctx.call('__csim.drainTimers', ASYNC_POLL_STEP_MS) rescue nil
  end
  raise Capybara::ScriptTimeoutError, 'evaluate_async_script timed out'
end

#evaluate_script(code, args = []) ⇒ Object



227
228
229
# File 'lib/capybara/simulated/browser.rb', line 227

def evaluate_script(code, args = [])
  decode_script_result(call_runtime('evaluate', code.to_s, encode_script_args(args)))
end

#execute_script(code, args = []) ⇒ Object



223
224
225
226
# File 'lib/capybara/simulated/browser.rb', line 223

def execute_script(code, args = [])
  call_runtime('executeScript', code.to_s, encode_script_args(args))
  nil
end

#find_css(css, context_id = nil) ⇒ Object



126
# File 'lib/capybara/simulated/browser.rb', line 126

def find_css(css, context_id = nil)     = Array(call_runtime('findCSS', css, context_id))

#find_xpath(xpath, context_id = nil) ⇒ Object



125
# File 'lib/capybara/simulated/browser.rb', line 125

def find_xpath(xpath, context_id = nil) = Array(call_runtime('findXPath', xpath, context_id))

#focus(id) ⇒ Object



202
# File 'lib/capybara/simulated/browser.rb', line 202

def focus(id)        = call_runtime('focus', id)

#format_set_value(value) ⇒ Object

Capybara hands Date/DateTime/Time objects through to the driver as-is but mini_racer can’t serialise them. Pre-format them to the strings an HTML5 input expects so they round-trip via JS.



156
157
158
159
160
161
162
163
164
# File 'lib/capybara/simulated/browser.rb', line 156

def format_set_value(value)
  case value
  when Array      then value.map {|v| format_set_value(v) }
  when Date       then value.strftime('%Y-%m-%d')
  when DateTime   then value.strftime('%Y-%m-%dT%H:%M')
  when Time       then value.strftime('%Y-%m-%dT%H:%M')
  else                 value
  end
end

#go_backObject



111
112
113
114
115
# File 'lib/capybara/simulated/browser.rb', line 111

def go_back
  return if @history_index <= 0
  @history_index -= 1
  navigate(:get, @history[@history_index], [], history_move: true)
end

#go_forwardObject



116
117
118
119
120
# File 'lib/capybara/simulated/browser.rb', line 116

def go_forward
  return if @history_index >= @history.size - 1
  @history_index += 1
  navigate(:get, @history[@history_index], [], history_move: true)
end

#hover(id) ⇒ Object



196
197
198
# File 'lib/capybara/simulated/browser.rb', line 196

def hover(id)
  call_runtime('hover', id).tap { drain_async_timers }
end

#htmlObject



122
# File 'lib/capybara/simulated/browser.rb', line 122

def html  = call_runtime('html')

#inner_html(id) ⇒ Object



130
# File 'lib/capybara/simulated/browser.rb', line 130

def inner_html(id)   = call_runtime('innerHTML', id).to_s

Long-lived inbox shared by nested ‘accept_modal` / `dismiss_modal` blocks. Each driver call drains the JS-side queue once and stashes unmatched modals here so the OUTER block can still find its message after the inner one consumes its own.



298
299
300
# File 'lib/capybara/simulated/browser.rb', line 298

def modal_inbox
  @modal_inbox ||= []
end

#multiple?(id) ⇒ Boolean

Returns:

  • (Boolean)


141
# File 'lib/capybara/simulated/browser.rb', line 141

def multiple?(id)    = !!call_runtime('multiple', id)

#outer_html(id) ⇒ Object



131
# File 'lib/capybara/simulated/browser.rb', line 131

def outer_html(id)   = call_runtime('outerHTML', id).to_s

#path(id) ⇒ Object



142
# File 'lib/capybara/simulated/browser.rb', line 142

def path(id)         = call_runtime('path', id).to_s

#prop(id, name) ⇒ Object



134
# File 'lib/capybara/simulated/browser.rb', line 134

def prop(id, name)   = call_runtime('prop', id, name.to_s)

#readonly?(id) ⇒ Boolean

Returns:

  • (Boolean)


140
# File 'lib/capybara/simulated/browser.rb', line 140

def readonly?(id)    = !!call_runtime('readonly', id)

#rect(id) ⇒ Object



143
# File 'lib/capybara/simulated/browser.rb', line 143

def rect(id)         = call_runtime('rect', id) || {}

#refreshObject



102
103
104
105
106
107
108
109
110
# File 'lib/capybara/simulated/browser.rb', line 102

def refresh
  return unless @current_url
  # Browsers re-issue the previous request as-is on F5 (after a
  # confirmation prompt for non-GETs, which our driver skips).
  method = @last_method || :get
  navigate(method, @current_url, @last_fields || [],
           enctype: @last_enctype || 'application/x-www-form-urlencoded',
           replace_history: true)
end

#remove_modal_handler(type:, text: nil) ⇒ Object



268
269
270
# File 'lib/capybara/simulated/browser.rb', line 268

def remove_modal_handler(type:, text: nil)
  call_runtime('popModalHandler', type.to_s, encode_modal_text(text))
end

#reset_state!Object

Tear down all per-page state without throwing away the V8 isolate. Capybara calls this between specs via Driver#reset!.



65
66
67
68
69
70
71
72
73
74
75
76
77
78
# File 'lib/capybara/simulated/browser.rb', line 65

def reset_state!
  @history          = []
  @history_index    = -1
  @current_url      = nil
  @status_code      = nil
  @response_headers = {}
  @modal_responses  = {alert: [], confirm: [], prompt: []}
  @last_call_at     = nil
  @cookie_jar       = []
  # `loadHTML` clears all tracked node handles and recreates the
  # happy-dom Window for an empty document.
  call_runtime('loadHTML', '<!doctype html><html><body></body></html>', DEFAULT_HOST)
  call_runtime('setModalResponses', stringify_keys(@modal_responses))
end

#resolve_visit_url(url) ⇒ Object



88
89
90
91
92
93
94
95
96
97
98
99
100
101
# File 'lib/capybara/simulated/browser.rb', line 88

def resolve_visit_url(url)
  return @current_url if !url.nil? && !url.empty? && @current_url && url == @current_url
  url = '/' if url.nil? || url.empty?
  return url if url.start_with?('http://', 'https://')
  # Resolve relative paths against the app host's root (not the
  # currently displayed page), matching Capybara's `visit` semantics
  # for rack_test-style drivers.
  host = Capybara.app_host
  if host.nil?
    uri = URI.parse(@current_url || DEFAULT_HOST)
    host = "#{uri.scheme}://#{uri.host}#{uri.port ? ":#{uri.port}" : ''}/"
  end
  URI.join(host, url).to_s
end

#right_click(id, modifiers = {}) ⇒ Object



180
181
182
183
184
185
186
187
188
189
190
# File 'lib/capybara/simulated/browser.rb', line 180

def right_click(id, modifiers = {})
  delay = modifiers.delete('delay')
  if delay&.positive?
    call_runtime('mouseDown', id, 2, modifiers)
    sleep(delay)
    call_runtime('rightClick', id, modifiers, true)
  else
    call_runtime('rightClick', id, modifiers)
  end
  drain_async_timers
end

#select_option(id) ⇒ Object



165
# File 'lib/capybara/simulated/browser.rb', line 165

def select_option(id)    = call_runtime('selectOption', id)

#selected?(id) ⇒ Boolean

Returns:

  • (Boolean)


138
# File 'lib/capybara/simulated/browser.rb', line 138

def selected?(id)    = !!call_runtime('selected', id)

#send_keys(id, keys) ⇒ Object



218
219
220
221
# File 'lib/capybara/simulated/browser.rb', line 218

def send_keys(id, keys)
  directive = call_runtime('sendKeys', id, encode_keys(keys))
  follow(directive)
end

#set_modal_responses(alerts: [], confirms: [], prompts: []) ⇒ Object



253
254
255
256
# File 'lib/capybara/simulated/browser.rb', line 253

def set_modal_responses(alerts: [], confirms: [], prompts: [])
  @modal_responses = {alert: Array(alerts), confirm: Array(confirms), prompt: Array(prompts)}
  call_runtime('setModalResponses', stringify_keys(@modal_responses))
end

#set_value(id, value) ⇒ Object



145
146
147
148
149
150
151
# File 'lib/capybara/simulated/browser.rb', line 145

def set_value(id, value)
  # setValue may return a submit-descriptor when the user typed `\n`
  # into the only text input of a form (HTML implicit submission).
  result = call_runtime('setValue', id, format_set_value(value))
  follow(result) if result.is_a?(Hash) && result['action']
  result
end

#shadow_root(id) ⇒ Object



216
# File 'lib/capybara/simulated/browser.rb', line 216

def shadow_root(id) = call_runtime('shadowRoot', id)

#submit(id) ⇒ Object



210
211
212
213
214
# File 'lib/capybara/simulated/browser.rb', line 210

def submit(id)
  result = follow(call_runtime('submit', id))
  drain_async_timers
  result
end

#tag_name(id) ⇒ Object



132
# File 'lib/capybara/simulated/browser.rb', line 132

def tag_name(id)     = call_runtime('tagName', id).to_s

#titleObject



123
# File 'lib/capybara/simulated/browser.rb', line 123

def title = call_runtime('title')

#trigger(id, evt) ⇒ Object



199
200
201
# File 'lib/capybara/simulated/browser.rb', line 199

def trigger(id, evt)
  call_runtime('trigger', id, evt.to_s).tap { drain_async_timers }
end

#unselect_option(id) ⇒ Object



166
# File 'lib/capybara/simulated/browser.rb', line 166

def unselect_option(id)  = call_runtime('unselectOption', id)

#value(id) ⇒ Object



135
# File 'lib/capybara/simulated/browser.rb', line 135

def value(id)        = call_runtime('value', id)

#visible?(id) ⇒ Boolean

Returns:

  • (Boolean)


136
# File 'lib/capybara/simulated/browser.rb', line 136

def visible?(id)     = !!call_runtime('visible', id)

#visible_text(id) ⇒ Object



129
# File 'lib/capybara/simulated/browser.rb', line 129

def visible_text(id) = call_runtime('visibleText', id).to_s

#visit(url) ⇒ Object

Explicit ‘visit` is a new navigation, not a follow-up — drop the referer the way browsers’ address-bar navigation does, and resolve the URL against the configured app host rather than the current page (which may have a stale ‘<base href>`).



84
85
86
# File 'lib/capybara/simulated/browser.rb', line 84

def visit(url)
  navigate(:get, resolve_visit_url(url), [], referer: false)
end