File: driver.rb

package info (click to toggle)
ruby-capybara 3.40.0%2Bds-2
  • links: PTS, VCS
  • area: main
  • in suites: forky, sid, trixie
  • size: 2,368 kB
  • sloc: ruby: 23,988; javascript: 752; makefile: 11
file content (517 lines) | stat: -rw-r--r-- 14,977 bytes parent folder | download
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
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
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
# frozen_string_literal: true

require 'uri'
require 'English'

class Capybara::Selenium::Driver < Capybara::Driver::Base
  include Capybara::Selenium::Find

  DEFAULT_OPTIONS = {
    browser: :firefox,
    clear_local_storage: nil,
    clear_session_storage: nil
  }.freeze
  SPECIAL_OPTIONS = %i[browser clear_local_storage clear_session_storage timeout native_displayed].freeze
  CAPS_VERSION = Gem::Requirement.new('< 4.8.0')

  attr_reader :app, :options

  class << self
    attr_reader :selenium_webdriver_version

    def load_selenium
      require 'selenium-webdriver'
      require 'capybara/selenium/patches/atoms'
      require 'capybara/selenium/patches/is_displayed'

      # Look up the version of `selenium-webdriver` to
      # see if it's a version we support.
      #
      # By default, we use Gem.loaded_specs to determine
      # the version number. However, in some cases, such
      # as when loading `selenium-webdriver` outside of
      # Rubygems, we fall back to referencing
      # Selenium::WebDriver::VERSION. Ideally we'd
      # use the constant in all cases, but earlier versions
      # of `selenium-webdriver` didn't provide the constant.
      @selenium_webdriver_version =
        if Gem.loaded_specs['selenium-webdriver']
          Gem.loaded_specs['selenium-webdriver'].version
        else
          Gem::Version.new(Selenium::WebDriver::VERSION)
        end

      unless Gem::Requirement.new('>= 4.8').satisfied_by? @selenium_webdriver_version
        warn "Warning: You're using an unsupported version of selenium-webdriver, please upgrade to 4.8+."
      end

      @selenium_webdriver_version
    rescue LoadError => e
      raise e unless e.message.include?('selenium-webdriver')

      raise LoadError, "Capybara's selenium driver is unable to load `selenium-webdriver`, please install the gem and add `gem 'selenium-webdriver'` to your Gemfile if you are using bundler."
    end

    attr_reader :specializations

    def register_specialization(browser_name, specialization)
      @specializations ||= {}
      @specializations[browser_name] = specialization
    end
  end

  def browser
    unless @browser
      options[:http_client] ||= begin
        require 'capybara/selenium/patches/persistent_client'
        if options[:timeout]
          ::Capybara::Selenium::PersistentClient.new(read_timeout: options[:timeout])
        else
          ::Capybara::Selenium::PersistentClient.new
        end
      end
      processed_options = options.except(*SPECIAL_OPTIONS)

      @browser = Selenium::WebDriver.for(options[:browser], processed_options)

      specialize_driver
      setup_exit_handler
    end
    @browser
  end

  def initialize(app, **options)
    super()
    self.class.load_selenium
    @app = app
    @browser = nil
    @exit_status = nil
    @frame_handles = Hash.new { |hash, handle| hash[handle] = [] }
    @options = DEFAULT_OPTIONS.merge(options)
    @node_class = ::Capybara::Selenium::Node
  end

  def visit(path)
    browser.navigate.to(path)
  end

  def refresh
    browser.navigate.refresh
  end

  def go_back
    browser.navigate.back
  end

  def go_forward
    browser.navigate.forward
  end

  def html
    browser.page_source
  rescue Selenium::WebDriver::Error::JavascriptError => e
    raise unless e.message.include?('documentElement is null')
  end

  def title
    browser.title
  end

  def current_url
    browser.current_url
  end

  def wait?; true; end
  def needs_server?; true; end

  def execute_script(script, *args)
    browser.execute_script(script, *native_args(args))
  end

  def evaluate_script(script, *args)
    result = execute_script("return #{script}", *args)
    unwrap_script_result(result)
  end

  def evaluate_async_script(script, *args)
    browser.manage.timeouts.script_timeout = Capybara.default_max_wait_time
    result = browser.execute_async_script(script, *native_args(args))
    unwrap_script_result(result)
  end

  def active_element
    build_node(native_active_element)
  end

  def send_keys(*args)
    # Should this call the specialized nodes rather than native???
    native_active_element.send_keys(*args)
  end

  def save_screenshot(path, **options)
    browser.save_screenshot(path, **options)
  end

  def reset!
    # Use instance variable directly so we avoid starting the browser just to reset the session
    return unless @browser

    navigated = false
    timer = Capybara::Helpers.timer(expire_in: 10)
    begin
      # Only trigger a navigation if we haven't done it already, otherwise it
      # can trigger an endless series of unload modals
      reset_browser_state unless navigated
      navigated = true
      # Ensure the page is empty and trigger an UnhandledAlertError for any modals that appear during unload
      wait_for_empty_page(timer)
    rescue *unhandled_alert_errors
      # This error is thrown if an unhandled alert is on the page
      # Firefox appears to automatically dismiss this alert, chrome does not
      # We'll try to accept it
      accept_unhandled_reset_alert
      # try cleaning up the browser again
      retry
    end
  end

  def frame_obscured_at?(x:, y:)
    frame = @frame_handles[current_window_handle].last
    return false unless frame

    switch_to_frame(:parent)
    begin
      frame.base.obscured?(x: x, y: y)
    ensure
      switch_to_frame(frame)
    end
  end

  def switch_to_frame(frame)
    handles = @frame_handles[current_window_handle]
    case frame
    when :top
      handles.clear
      browser.switch_to.default_content
    when :parent
      handles.pop
      browser.switch_to.parent_frame
    else
      handles << frame
      browser.switch_to.frame(frame.native)
    end
  end

  def current_window_handle
    browser.window_handle
  end

  def window_size(handle)
    within_given_window(handle) do
      size = browser.manage.window.size
      [size.width, size.height]
    end
  end

  def resize_window_to(handle, width, height)
    within_given_window(handle) do
      browser.manage.window.resize_to(width, height)
    end
  end

  def maximize_window(handle)
    within_given_window(handle) do
      browser.manage.window.maximize
    end
    sleep 0.1 # work around for https://code.google.com/p/selenium/issues/detail?id=7405
  end

  def fullscreen_window(handle)
    within_given_window(handle) do
      browser.manage.window.full_screen
    end
  end

  def close_window(handle)
    raise ArgumentError, 'Not allowed to close the primary window' if handle == window_handles.first

    within_given_window(handle) do
      browser.close
    end
  end

  def window_handles
    browser.window_handles
  end

  def open_new_window(kind = :tab)
    if browser.switch_to.respond_to?(:new_window)
      handle = current_window_handle
      browser.switch_to.new_window(kind)
      switch_to_window(handle)
    else
      browser.manage.new_window(kind)
    end
  rescue NoMethodError, Selenium::WebDriver::Error::WebDriverError
    # If not supported by the driver or browser default to using JS
    browser.execute_script('window.open();')
  end

  def switch_to_window(handle)
    browser.switch_to.window handle
  end

  def accept_modal(_type, **options)
    yield if block_given?
    modal = find_modal(**options)

    modal.send_keys options[:with] if options[:with]

    message = modal.text
    modal.accept
    message
  end

  def dismiss_modal(_type, **options)
    yield if block_given?
    modal = find_modal(**options)
    message = modal.text
    modal.dismiss
    message
  end

  def quit
    @browser&.quit
  rescue Selenium::WebDriver::Error::SessionNotCreatedError, Errno::ECONNREFUSED,
         Selenium::WebDriver::Error::InvalidSessionIdError
    # Browser must have already gone
  rescue Selenium::WebDriver::Error::UnknownError => e
    unless silenced_unknown_error_message?(e.message) # Most likely already gone
      # probably already gone but not sure - so warn
      warn "Ignoring Selenium UnknownError during driver quit: #{e.message}"
    end
  ensure
    @browser = nil
  end

  def invalid_element_errors
    @invalid_element_errors ||=
      [
        ::Selenium::WebDriver::Error::StaleElementReferenceError,
        ::Selenium::WebDriver::Error::ElementNotInteractableError,
        ::Selenium::WebDriver::Error::InvalidSelectorError, # Work around chromedriver go_back/go_forward race condition
        ::Selenium::WebDriver::Error::ElementClickInterceptedError,
        ::Selenium::WebDriver::Error::NoSuchElementError, # IE
        ::Selenium::WebDriver::Error::InvalidArgumentError # IE
      ].tap do |errors|
        if defined?(::Selenium::WebDriver::Error::DetachedShadowRootError)
          errors.push(::Selenium::WebDriver::Error::DetachedShadowRootError)
        end
      end
  end

  def no_such_window_error
    Selenium::WebDriver::Error::NoSuchWindowError
  end

private

  def native_args(args)
    args.map { |arg| arg.is_a?(Capybara::Selenium::Node) ? arg.native : arg }
  end

  def native_active_element
    browser.switch_to.active_element
  end

  def clear_browser_state
    delete_all_cookies
    clear_storage
  rescue *clear_browser_state_errors
    # delete_all_cookies fails when we've previously gone
    # to about:blank, so we rescue this error and do nothing
    # instead.
  end

  def clear_browser_state_errors
    @clear_browser_state_errors ||= [Selenium::WebDriver::Error::UnknownError]
  end

  def unhandled_alert_errors
    @unhandled_alert_errors ||= [Selenium::WebDriver::Error::UnexpectedAlertOpenError]
  end

  def delete_all_cookies
    @browser.manage.delete_all_cookies
  end

  def clear_storage
    clear_session_storage unless options[:clear_session_storage] == false
    clear_local_storage unless options[:clear_local_storage] == false
  rescue Selenium::WebDriver::Error::JavascriptError
    # session/local storage may not be available if on non-http pages (e.g. about:blank)
  end

  def clear_session_storage
    if @browser.respond_to? :session_storage
      @browser.session_storage.clear
    else
      begin
        @browser&.execute_script('window.sessionStorage.clear()')
      rescue # rubocop:disable Style/RescueStandardError
        unless options[:clear_session_storage].nil?
          warn 'sessionStorage clear requested but is not supported by this driver'
        end
      end
    end
  end

  def clear_local_storage
    if @browser.respond_to? :local_storage
      @browser.local_storage.clear
    else
      begin
        @browser&.execute_script('window.localStorage.clear()')
      rescue # rubocop:disable Style/RescueStandardError
        unless options[:clear_local_storage].nil?
          warn 'localStorage clear requested but is not supported by this driver'
        end
      end
    end
  end

  def navigate_with_accept(url)
    @browser.navigate.to(url)
    sleep 0.1 # slight wait for alert
    @browser.switch_to.alert.accept
  rescue modal_error
    # alert now gone, should mean navigation happened
  end

  def modal_error
    Selenium::WebDriver::Error::NoSuchAlertError
  end

  def within_given_window(handle)
    original_handle = current_window_handle
    if handle == original_handle
      yield
    else
      switch_to_window(handle)
      result = yield
      switch_to_window(original_handle)
      result
    end
  end

  def find_modal(text: nil, **options)
    # Selenium has its own built in wait (2 seconds)for a modal to show up, so this wait is really the minimum time
    # Actual wait time may be longer than specified
    wait = Selenium::WebDriver::Wait.new(
      timeout: options.fetch(:wait, session_options.default_max_wait_time) || 0,
      ignore: modal_error
    )
    begin
      wait.until do
        alert = @browser.switch_to.alert
        regexp = text.is_a?(Regexp) ? text : Regexp.new(Regexp.escape(text.to_s))
        matched = alert.text.match?(regexp)
        unless matched
          raise Capybara::ModalNotFound, "Unable to find modal dialog with #{text} - found '#{alert.text}' instead."
        end

        alert
      end
    rescue *find_modal_errors
      raise Capybara::ModalNotFound, "Unable to find modal dialog#{" with #{text}" if text}"
    end
  end

  def find_modal_errors
    @find_modal_errors ||= [Selenium::WebDriver::Error::TimeoutError]
  end

  def silenced_unknown_error_message?(msg)
    silenced_unknown_error_messages.any? { |regex| msg.match? regex }
  end

  def silenced_unknown_error_messages
    [/Error communicating with the remote browser/]
  end

  def unwrap_script_result(arg)
    case arg
    when Array
      arg.map { |arr| unwrap_script_result(arr) }
    when Hash
      arg.transform_values! { |value| unwrap_script_result(value) }
    when Selenium::WebDriver::Element, Selenium::WebDriver::ShadowRoot
      build_node(arg)
    else
      arg
    end
  end

  def find_context
    browser
  end

  def build_node(native_node, initial_cache = {})
    ::Capybara::Selenium::Node.new(self, native_node, initial_cache)
  end

  def bridge
    browser.send(:bridge)
  end

  def specialize_driver
    browser_type = browser.browser
    Capybara::Selenium::Driver.specializations.select { |k, _v| k === browser_type }.each_value do |specialization| # rubocop:disable Style/CaseEquality
      extend specialization
    end
  end

  def setup_exit_handler
    main = Process.pid
    at_exit do
      # Store the exit status of the test run since it goes away after calling the at_exit proc...
      @exit_status = $ERROR_INFO.status if $ERROR_INFO.is_a?(SystemExit)
      quit if Process.pid == main
      exit @exit_status if @exit_status # Force exit with stored status
    end
  end

  def reset_browser_state
    clear_browser_state
    @browser.navigate.to('about:blank')
  end

  def wait_for_empty_page(timer)
    until find_xpath('/html/body/*').empty?
      raise Capybara::ExpectationNotMet, 'Timed out waiting for Selenium session reset' if timer.expired?

      sleep 0.01

      # It has been observed that it is possible that asynchronous JS code in
      # the application under test can navigate the browser away from about:blank
      # if the timing is just right. Ensure we are still at about:blank...
      @browser.navigate.to('about:blank') unless current_url == 'about:blank'
    end
  end

  def accept_unhandled_reset_alert
    @browser.switch_to.alert.accept
    sleep 0.25 # allow time for the modal to be handled
  rescue modal_error
    # The alert is now gone.
    # If navigation has not occurred attempt again and accept alert
    # since FF may have dismissed the alert at first attempt.
    navigate_with_accept('about:blank') if current_url != 'about:blank'
  end
end

require 'capybara/selenium/driver_specializations/chrome_driver'
require 'capybara/selenium/driver_specializations/firefox_driver'
require 'capybara/selenium/driver_specializations/internet_explorer_driver'
require 'capybara/selenium/driver_specializations/safari_driver'
require 'capybara/selenium/driver_specializations/edge_driver'