From a61790eac20fc60ce014eda02956ce84d74b9159 Mon Sep 17 00:00:00 2001 From: Thomas Walpole Date: Wed, 13 Mar 2019 18:21:53 -0700 Subject: [PATCH] Add test specs run with Safari, and workaround issues - it's still pretty useless on anything more than basic pages though (#2165) Add test specs run with Safari, and workaround some issues - it's still pretty useless on anything more than basic pages though --- Rakefile | 2 +- lib/capybara/selenium/driver.rb | 15 +- .../driver_specializations/safari_driver.rb | 15 ++ lib/capybara/selenium/nodes/firefox_node.rb | 2 +- lib/capybara/selenium/nodes/safari_node.rb | 145 +++++++++++++++++ lib/capybara/spec/session/attach_file_spec.rb | 4 +- lib/capybara/spec/session/fill_in_spec.rb | 6 +- lib/capybara/spec/views/with_html.erb | 2 +- spec/selenium_spec_safari.rb | 148 ++++++++++++++++++ spec/shared_selenium_session.rb | 18 ++- spec/spec_helper.rb | 4 + 11 files changed, 349 insertions(+), 12 deletions(-) create mode 100644 lib/capybara/selenium/driver_specializations/safari_driver.rb create mode 100644 lib/capybara/selenium/nodes/safari_node.rb create mode 100644 spec/selenium_spec_safari.rb diff --git a/Rakefile b/Rakefile index 9fc18320..cdc55d55 100644 --- a/Rakefile +++ b/Rakefile @@ -14,7 +14,7 @@ RSpec::Core::RakeTask.new(:spec_firefox) do |t| t.pattern = './spec{,/*/**}/*{_spec.rb,_spec_firefox.rb}' end -%w[chrome ie edge chrome_remote firefox_remote].each do |driver| +%w[chrome ie edge chrome_remote firefox_remote safari].each do |driver| RSpec::Core::RakeTask.new(:"spec_#{driver}") do |t| t.rspec_opts = rspec_opts t.pattern = "./spec/*{_spec_#{driver}.rb}" diff --git a/lib/capybara/selenium/driver.rb b/lib/capybara/selenium/driver.rb index b53371ad..65c11d08 100644 --- a/lib/capybara/selenium/driver.rb +++ b/lib/capybara/selenium/driver.rb @@ -267,7 +267,11 @@ private if @browser.respond_to? :session_storage @browser.session_storage.clear else - warn 'sessionStorage clear requested but is not supported by this driver' unless options[:clear_session_storage].nil? + begin + @browser&.execute_script('window.sessionStorage.clear()') + rescue # rubocop:disable Style/RescueStandardError + warn 'sessionStorage clear requested but is not supported by this driver' unless options[:clear_session_storage].nil? + end end end @@ -275,7 +279,11 @@ private if @browser.respond_to? :local_storage @browser.local_storage.clear else - warn 'localStorage clear requested but is not supported by this driver' unless options[:clear_local_storage].nil? + begin + @browser&.execute_script('window.localStorage.clear()') + rescue # rubocop:disable Style/RescueStandardError + warn 'localStorage clear requested but is not supported by this driver' unless options[:clear_local_storage].nil? + end end end @@ -359,6 +367,8 @@ private extend FirefoxDriver if sel_driver.capabilities.is_a?(::Selenium::WebDriver::Remote::W3C::Capabilities) when :ie, :internet_explorer extend InternetExplorerDriver + when :safari, :Safari_Technology_Preview + extend SafariDriver end end @@ -408,3 +418,4 @@ 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' diff --git a/lib/capybara/selenium/driver_specializations/safari_driver.rb b/lib/capybara/selenium/driver_specializations/safari_driver.rb new file mode 100644 index 00000000..4f567a0c --- /dev/null +++ b/lib/capybara/selenium/driver_specializations/safari_driver.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require 'capybara/selenium/nodes/safari_node' + +module Capybara::Selenium::Driver::SafariDriver +private + + def build_node(native_node, initial_cache = {}) + ::Capybara::Selenium::SafariNode.new(self, native_node, initial_cache) + end + + def bridge + browser.send(:bridge) + end +end diff --git a/lib/capybara/selenium/nodes/firefox_node.rb b/lib/capybara/selenium/nodes/firefox_node.rb index 7945c41d..5980f4c9 100644 --- a/lib/capybara/selenium/nodes/firefox_node.rb +++ b/lib/capybara/selenium/nodes/firefox_node.rb @@ -119,7 +119,7 @@ private x.parent(:fieldset)[ x.attr(:disabled) ] + x.ancestor[ - ~x.self(:legned) | + ~x.self(:legend) | x.preceding_sibling(:legend) ][ x.parent(:fieldset)[ diff --git a/lib/capybara/selenium/nodes/safari_node.rb b/lib/capybara/selenium/nodes/safari_node.rb new file mode 100644 index 00000000..c535631d --- /dev/null +++ b/lib/capybara/selenium/nodes/safari_node.rb @@ -0,0 +1,145 @@ +# frozen_string_literal: true + +# require 'capybara/selenium/extensions/html5_drag' + +class Capybara::Selenium::SafariNode < Capybara::Selenium::Node + # include Html5Drag + + def click(keys = [], **options) + # driver.execute_script('arguments[0].scrollIntoViewIfNeeded({block: "center"})', self) + super + rescue ::Selenium::WebDriver::Error::ElementNotInteractableError + if tag_name == 'tr' + warn 'You are attempting to click a table row which has issues in safaridriver - '\ + 'Your test should probably be clicking on a table cell like a user would. '\ + 'Clicking the first cell in the row instead.' + return find_css('th:first-child,td:first-child')[0].click(keys, options) + end + raise + end + + def select_option + driver.execute_script("arguments[0].closest('select').scrollIntoView()", self) + super + end + + def unselect_option + driver.execute_script("arguments[0].closest('select').scrollIntoView()", self) + super + end + + def visible_text + return '' unless visible? + + vis_text = driver.execute_script('return arguments[0].innerText', self) + vis_text.gsub(/\ +/, ' ') + .gsub(/[\ \n]*\n[\ \n]*/, "\n") + .gsub(/\A[[:space:]&&[^\u00a0]]+/, '') + .gsub(/[[:space:]&&[^\u00a0]]+\z/, '') + .tr("\u00a0", ' ') + end + + def disabled? + return true if super || (self[:disabled] == 'true') + + # workaround for safaridriver reporting elements as enabled when they are nested in disabling elements + if %w[option optgroup].include? tag_name + find_xpath('parent::*[self::optgroup or self::select]')[0].disabled? + else + !find_xpath(DISABLED_BY_FIELDSET_XPATH).empty? + end + end + + def set_file(value) # rubocop:disable Naming/AccessorMethodName + # By default files are appended so we have to clear here if its multiple and already set + native.clear if multiple? && driver.evaluate_script('arguments[0].files', self).any? + super + end + + def send_keys(*args) + return super(*args.map { |arg| arg == :space ? ' ' : arg }) if args.none? { |arg| arg.is_a? Array } + + native.click + _send_keys(args).perform + end + + def set_text(value, clear: nil, **_unused) + value = value.to_s + if clear == :backspace + # Clear field by sending the correct number of backspace keys. + backspaces = [:backspace] * self.value.to_s.length + send_keys(*([[:control, 'e']] + backspaces + [value])) + else + super.tap do + # React doesn't see the safaridriver element clear + send_keys(:space, :backspace) if value.to_s.empty? && clear.nil? + end + end + end + +private + + def bridge + driver.browser.send(:bridge) + end + + DISABLED_BY_FIELDSET_XPATH = XPath.generate do |x| + x.parent(:fieldset)[ + x.attr(:disabled) + ] + x.ancestor[ + ~x.self(:legend) | + x.preceding_sibling(:legend) + ][ + x.parent(:fieldset)[ + x.attr(:disabled) + ] + ] + end.to_s.freeze + + def _send_keys(keys, actions = browser_action, down_keys = ModifierKeysStack.new) + case keys + when :control, :left_control, :right_control, + :alt, :left_alt, :right_alt, + :shift, :left_shift, :right_shift, + :meta, :left_meta, :right_meta, + :command + down_keys.press(keys) + actions.key_down(keys) + when String + keys = keys.upcase if down_keys&.include?(:shift) + actions.send_keys(keys) + when Symbol + actions.send_keys(keys) + when Array + down_keys.push + keys.each { |sub_keys| _send_keys(sub_keys, actions, down_keys) } + down_keys.pop.reverse_each { |key| actions.key_up(key) } + else + raise ArgumentError, 'Unknown keys type' + end + actions + end + + class ModifierKeysStack + def initialize + @stack = [] + end + + def include?(key) + @stack.flatten.include?(key) + end + + def press(key) + @stack.last.push(key) + end + + def push + @stack.push [] + end + + def pop + @stack.pop + end + end + private_constant :ModifierKeysStack +end diff --git a/lib/capybara/spec/session/attach_file_spec.rb b/lib/capybara/spec/session/attach_file_spec.rb index e8f3b01f..650f955c 100644 --- a/lib/capybara/spec/session/attach_file_spec.rb +++ b/lib/capybara/spec/session/attach_file_spec.rb @@ -84,7 +84,7 @@ Capybara::SpecHelper.spec '#attach_file' do @session.attach_file('Multiple Documents', [test_file_path, another_test_file_path].map { |f| with_os_path_separators(f) }) @session.click_button('Upload Multiple') - expect(@session.body).to include('2 | ') # number of files + expect(@session).to have_content('2 | ') # number of files expect(@session.body).to include(File.read(test_file_path)) expect(@session.body).to include(File.read(another_test_file_path)) end @@ -98,7 +98,7 @@ Capybara::SpecHelper.spec '#attach_file' do @session.attach_file 'Multiple Documents', with_os_path_separators(test_file_path) @session.attach_file 'Multiple Documents', with_os_path_separators(another_test_file_path) @session.click_button('Upload Multiple') - expect(@session.body).to include('1 | ') # number of files + expect(@session).to have_content('1 | ') # number of files expect(@session.body).to include(File.read(another_test_file_path)) expect(@session.body).not_to include(File.read(test_file_path)) end diff --git a/lib/capybara/spec/session/fill_in_spec.rb b/lib/capybara/spec/session/fill_in_spec.rb index 8273b330..07f0c27f 100644 --- a/lib/capybara/spec/session/fill_in_spec.rb +++ b/lib/capybara/spec/session/fill_in_spec.rb @@ -178,10 +178,10 @@ Capybara::SpecHelper.spec '#fill_in' do it 'should only trigger onchange once' do @session.visit('/with_js') # Click somewhere on the page to ensure focus is acquired. Without this FF won't generate change events for some reason??? - @session.find(:css, 'body').click + @session.find(:css, 'h1', text: 'FooBar').click @session.fill_in('with_change_event', with: 'some value') # click outside the field to trigger the change event - @session.find(:css, 'body').click + @session.find(:css, 'h1', text: 'FooBar').click expect(@session.find(:css, '.change_event_triggered', match: :one)).to have_text 'some value' end @@ -189,7 +189,7 @@ Capybara::SpecHelper.spec '#fill_in' do @session.visit('/with_js') @session.fill_in('with_change_event', with: '') # click outside the field to trigger the change event - @session.find(:css, 'body').click + @session.find(:css, 'h1', text: 'FooBar').click expect(@session).to have_selector(:css, '.change_event_triggered', match: :one) end end diff --git a/lib/capybara/spec/views/with_html.erb b/lib/capybara/spec/views/with_html.erb index 58072b69..99238155 100644 --- a/lib/capybara/spec/views/with_html.erb +++ b/lib/capybara/spec/views/with_html.erb @@ -23,7 +23,7 @@ et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. - awesome image + awesome image

diff --git a/spec/selenium_spec_safari.rb b/spec/selenium_spec_safari.rb new file mode 100644 index 00000000..050f13b6 --- /dev/null +++ b/spec/selenium_spec_safari.rb @@ -0,0 +1,148 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'selenium-webdriver' +require 'shared_selenium_session' +require 'rspec/shared_spec_matchers' + +SAFARI_DRIVER = :selenium_safari + +::Selenium::WebDriver::Safari.driver_path = '/Applications/Safari Technology Preview.app/Contents/MacOS/safaridriver' + +browser_options = ::Selenium::WebDriver::Safari::Options.new +# browser_options.headless! if ENV['HEADLESS'] +# browser_options.add_option(:w3c, !!ENV['W3C']) + +Capybara.register_driver :selenium_safari do |app| + Capybara::Selenium::Driver.new(app, browser: :safari, options: browser_options, timeout: 30).tap do |driver| + # driver.browser.download_path = Capybara.save_path + end +end + +Capybara.register_driver :selenium_safari_not_clear_storage do |app| + safari_options = { + browser: :safari, + options: browser_options + } + Capybara::Selenium::Driver.new(app, safari_options.merge(clear_local_storage: false, clear_session_storage: false)) +end + +module TestSessions + Safari = Capybara::Session.new(SAFARI_DRIVER, TestApp) +end + +skipped_tests = %i[response_headers status_code trigger windows drag] + +$stdout.puts `#{Selenium::WebDriver::Safari.driver_path} --version` if ENV['CI'] + +Capybara::SpecHelper.run_specs TestSessions::Safari, SAFARI_DRIVER.to_s, capybara_skip: skipped_tests do |example| + case example.metadata[:full_description] + when /click_link can download a file/ + skip "safaridriver doesn't provide a way to set the download directory" + when /Capybara::Session selenium_safari Capybara::Window#maximize/ + pending "Safari headless doesn't support maximize" if ENV['HEADLESS'] + when /Capybara::Session selenium_safari #visit without a server/, + /Capybara::Session selenium_safari #visit with Capybara.app_host set should override server/, + /Capybara::Session selenium_safari #reset_session! When reuse_server == false raises any standard errors caught inside the server during a second session/ + skip "Safari webdriver doesn't support multiple sessions" + when /Capybara::Session selenium_safari #click_link with alternative text given to a contained image/, + 'Capybara::Session selenium_safari #click_link_or_button with enable_aria_label should click on link' + pending 'safaridriver thinks these links are non-interactable for some unknown reason' + when /Capybara::Session selenium_safari #attach_file with a block can upload by clicking the file input/ + skip "safaridriver doesn't allow clicking on file inputs" + when /Capybara::Session selenium_safari #attach_file with a block can upload by clicking the label/ + skip 'hangs tests' + when /Capybara::Session selenium_safari #check when checkbox hidden with Capybara.automatic_label_click == false with allow_label_click == true should check via the label if input is visible but blocked by another element/, + 'Capybara::Session selenium_safari node #click should not retry clicking when wait is disabled', + 'Capybara::Session selenium_safari node #click should allow to retry longer', + 'Capybara::Session selenium_safari node #click should retry clicking' + pending "safaridriver doesn't return a specific enough error to deal with this" + when /Capybara::Session selenium_safari #within_frame should find multiple nested frames/, + /Capybara::Session selenium_safari #within_frame works if the frame is closed/, + /Capybara::Session selenium_safari #switch_to_frame works if the frame is closed/ + skip 'switch_to_frame(:parent) appears to go to the root in Safari rather than parent' + when /Capybara::Session selenium_safari #reset_session! removes ALL cookies/ + skip 'Safari webdriver can only remove cookies for the current domain' + when /Capybara::Session selenium_safari #refresh it reposts/ + skip "Safari opens an alert that can't be closed" + when 'Capybara::Session selenium_safari node #double_click should allow to adjust the offset', + 'Capybara::Session selenium_safari node #double_click should double click an element' + pending "safardriver doesn't generate a double click event" + when 'Capybara::Session selenium_safari node #click should allow multiple modifiers', + /Capybara::Session selenium_safari node #(click|right_click|double_click) should allow modifiers/ + pending "safaridriver doesn't take key state into account when clicking" + when 'Capybara::Session selenium_safari #fill_in on a pre-populated textfield with a reformatting onchange should trigger change when clearing field' + pending "safardriver clear doesn't generate change event" + when 'Capybara::Session selenium_safari #go_back should fetch a response from the driver from the previous page', + 'Capybara::Session selenium_safari #go_forward should fetch a response from the driver from the previous page' + skip 'safaridriver loses the ability to find elements in the document after `go_back`' + when 'Capybara::Session selenium_safari node #send_keys should hold modifiers at top level' + skip 'Need to look into this' + end +end + +RSpec.describe 'Capybara::Session with safari' do + include Capybara::SpecHelper + include_examples 'Capybara::Session', TestSessions::Safari, SAFARI_DRIVER + include_examples Capybara::RSpecMatchers, TestSessions::Safari, SAFARI_DRIVER + + context 'storage' do + describe '#reset!' do + it 'clears storage by default' do + session = TestSessions::Safari + session.visit('/with_js') + session.find(:css, '#set-storage').click + session.reset! + session.visit('/with_js') + expect(session.evaluate_script('Object.keys(localStorage)')).to be_empty + expect(session.evaluate_script('Object.keys(sessionStorage)')).to be_empty + end + + it 'does not clear storage when false' do + skip "Safari webdriver doesn't support multiple sessions" + session = Capybara::Session.new(:selenium_safari_not_clear_storage, TestApp) + session.visit('/with_js') + session.find(:css, '#set-storage').click + session.reset! + session.visit('/with_js') + expect(session.evaluate_script('Object.keys(localStorage)')).not_to be_empty + expect(session.evaluate_script('Object.keys(sessionStorage)')).not_to be_empty + end + end + end + + context 'timeout' do + it 'sets the http client read timeout' do + expect(TestSessions::Safari.driver.browser.send(:bridge).http.read_timeout).to eq 30 + end + end + + describe 'filling in Safari-specific date and time fields with keystrokes' do + let(:datetime) { Time.new(1983, 6, 19, 6, 30) } + let(:session) { TestSessions::Safari } + + before do + skip 'Too many other things broken currently' + session.visit('/form') + end + + it 'should fill in a date input with a String' do + session.fill_in('form_date', with: '06/19/1983') + session.click_button('awesome') + expect(Date.parse(extract_results(session)['date'])).to eq datetime.to_date + end + + it 'should fill in a time input with a String' do + session.fill_in('form_time', with: '06:30A') + session.click_button('awesome') + results = extract_results(session)['time'] + expect(Time.parse(results).strftime('%r')).to eq datetime.strftime('%r') + end + + it 'should fill in a datetime input with a String' do + session.fill_in('form_datetime', with: "06/19/1983\t06:30A") + session.click_button('awesome') + expect(Time.parse(extract_results(session)['datetime'])).to eq datetime + end + end +end diff --git a/spec/shared_selenium_session.rb b/spec/shared_selenium_session.rb index 2a79b67f..46dc613d 100644 --- a/spec/shared_selenium_session.rb +++ b/spec/shared_selenium_session.rb @@ -72,6 +72,7 @@ RSpec.shared_examples 'Capybara::Session' do |session, mode| context '#fill_in_with empty string and no options' do it 'should trigger change when clearing a field' do + pending "safaridriver doesn't trigger change for clear" if safari?(session) session.visit('/with_js') session.fill_in('with_change_event', with: '') # click outside the field to trigger the change event @@ -116,6 +117,7 @@ RSpec.shared_examples 'Capybara::Session' do |session, mode| it 'should only trigger onchange once' do session.visit('/with_js') + sleep 2 if safari?(session) # Safari needs a delay (to load event handlers maybe ???) session.fill_in('with_change_event', with: 'some value', fill_options: { clear: :backspace }) @@ -140,13 +142,16 @@ RSpec.shared_examples 'Capybara::Session' do |session, mode| with: '', fill_options: { clear: :backspace }) # click outside the field to trigger the change event - session.find(:css, 'body').click + # session.find(:css, 'body').click + session.find(:css, 'h1', text: 'FooBar').click expect(session).to have_xpath('//p[@class="input_event_triggered"]', count: 13) end end context '#fill_in with { clear: :none } fill_options' do it 'should append to content in a field' do + pending 'Safari overwrites by default - need to figure out a workaround' if safari?(session) + session.visit('/form') session.fill_in('form_first_name', with: 'Harry', @@ -166,17 +171,20 @@ RSpec.shared_examples 'Capybara::Session' do |session, mode| }); JS # work around weird FF issue where it would create an extra focus issue in some cases - session.find(:css, 'body').click + session.find(:css, 'h1', text: 'Form').click + # session.find(:css, 'body').click end it 'should generate standard events on changing value' do pending "IE 11 doesn't support date input type" if ie?(session) + pending "Safari doesn't support date input type" if safari?(session) session.fill_in('form_date', with: Date.today) expect(session.evaluate_script('window.capybara_formDateFiredEvents')).to eq %w[focus input change] end it 'should not generate input and change events if the value is not changed' do pending "IE 11 doesn't support date input type" if ie?(session) + pending "Safari doesn't support date input type" if safari?(session) session.fill_in('form_date', with: Date.today) session.fill_in('form_date', with: Date.today) # Chrome adds an extra focus for some reason - ok for now @@ -264,6 +272,7 @@ RSpec.shared_examples 'Capybara::Session' do |session, mode| describe '#evaluate_async_script' do it 'will timeout if the script takes too long' do + skip 'safaridriver returns the wrong error type' if safari?(session) session.visit('/with_js') expect do session.using_wait_time(1) do @@ -298,6 +307,7 @@ RSpec.shared_examples 'Capybara::Session' do |session, mode| before do skip "Firefox < 62 doesn't support a DataTransfer constuctor" if firefox_lt?(62.0, session) skip "IE doesn't support a DataTransfer constuctor" if ie?(session) + skip "Safari doesn't support" if safari?(session) end it 'should HTML5 drag and drop an object' do @@ -351,6 +361,7 @@ RSpec.shared_examples 'Capybara::Session' do |session, mode| pending "Headless Chrome doesn't support directory upload - https://bugs.chromium.org/p/chromedriver/issues/detail?id=2521&q=directory%20upload&colspec=ID%20Status%20Pri%20Owner%20Summary" if chrome?(session) && ENV['HEADLESS'] pending "IE doesn't support uploading a directory" if ie?(session) pending 'Chrome/chromedriver 73 breaks this' unless chrome_lt?(73, session) + pending "Safari doesn't support uploading a directory" if safari?(session) session.visit('/form') test_file_dir = File.expand_path('./fixtures', File.dirname(__FILE__)) @@ -372,6 +383,7 @@ RSpec.shared_examples 'Capybara::Session' do |session, mode| describe 'Capybara#disable_animation' do context 'when set to `true`' do before(:context) do # rubocop:disable RSpec/BeforeAfterAll + skip "Safari doesn't support multiple sessions" if safari?(session) # NOTE: Although Capybara.SpecHelper.reset! sets Capybara.disable_animation to false, # it doesn't affect any of these tests because the settings are applied per-session Capybara.disable_animation = true @@ -393,6 +405,7 @@ RSpec.shared_examples 'Capybara::Session' do |session, mode| context 'if we pass in css that matches elements' do before(:context) do # rubocop:disable RSpec/BeforeAfterAll + skip "safaridriver doesn't support multiple sessions" if safari?(session) # NOTE: Although Capybara.SpecHelper.reset! sets Capybara.disable_animation to false, # it doesn't affect any of these tests because the settings are applied per-session Capybara.disable_animation = '#with_animation a' @@ -414,6 +427,7 @@ RSpec.shared_examples 'Capybara::Session' do |session, mode| context 'if we pass in css that does not match elements' do before(:context) do # rubocop:disable RSpec/BeforeAfterAll + skip "Safari doesn't support multiple sessions" if safari?(session) # NOTE: Although Capybara.SpecHelper.reset! sets Capybara.disable_animation to false, # it doesn't affect any of these tests because the settings are applied per-session Capybara.disable_animation = '.this-class-matches-nothing' diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index d4e1ac36..a4d34dd1 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -40,6 +40,10 @@ module Capybara %i[internet_explorer ie].include?(browser_name(session)) end + def safari?(session) + %i[safari Safari Safari_Technology_Preview].include?(browser_name(session)) + end + def browser_name(session) session.driver.browser.browser if session.respond_to?(:driver) end