diff --git a/lib/capybara/node/actions.rb b/lib/capybara/node/actions.rb index 571db12c..94676d48 100644 --- a/lib/capybara/node/actions.rb +++ b/lib/capybara/node/actions.rb @@ -214,8 +214,8 @@ module Capybara # @overload attach_file([locator], paths, **options) # @macro waiting_behavior # - # @param [String] locator Which field to attach the file to - # @param [String, Array] paths The path(s) of the file(s) that will be attached, or an array of paths + # @param [String] locator Which field to attach the file to + # @param [String, Array] paths The path(s) of the file(s) that will be attached # # @option options [Symbol] match (Capybara.match) The matching strategy to use (:one, :first, :prefer_exact, :smart). # @option options [Boolean] exact (Capybara.exact) Match the exact label name/contents or accept a partial match. diff --git a/lib/capybara/node/document_matchers.rb b/lib/capybara/node/document_matchers.rb index e25dddd1..c1edcc8b 100644 --- a/lib/capybara/node/document_matchers.rb +++ b/lib/capybara/node/document_matchers.rb @@ -55,9 +55,7 @@ module Capybara def _verify_title(title, options) query = Capybara::Queries::TitleQuery.new(title, options) - synchronize(query.wait) do - yield(query) - end + synchronize(query.wait) { yield(query) } true end end diff --git a/lib/capybara/queries/selector_query.rb b/lib/capybara/queries/selector_query.rb index 02d9ca96..30e5dd8f 100644 --- a/lib/capybara/queries/selector_query.rb +++ b/lib/capybara/queries/selector_query.rb @@ -3,8 +3,7 @@ module Capybara module Queries class SelectorQuery < Queries::BaseQuery - attr_accessor :selector, :locator, :options, :expression, :find, :negative - + attr_reader :expression, :selector, :locator, :options VALID_KEYS = COUNT_KEYS + %i[text id class visible exact exact_text match wait filter_set] VALID_MATCH = %i[first smart prefer_exact one].freeze @@ -25,7 +24,7 @@ module Capybara raise ArgumentError, "Unused parameters passed to #{self.class.name} : #{args}" unless args.empty? - @expression = @selector.call(@locator, @options.merge(selector_config: { enable_aria_label: enable_aria_label, test_id: test_id })) + @expression = selector.call(@locator, @options.merge(selector_config: { enable_aria_label: enable_aria_label, test_id: test_id })) warn_exact_usage @@ -36,22 +35,22 @@ module Capybara def label; selector.label || selector.name; end def description(applied = false) - @description = +'' - if !applied || @applied_filters - @description << 'visible ' if visible == :visible - @description << 'non-visible ' if visible == :hidden + desc = +'' + if !applied || applied_filters + desc << 'visible ' if visible == :visible + desc << 'non-visible ' if visible == :hidden end - @description << "#{label} #{locator.inspect}" - if !applied || @applied_filters - @description << " with#{' exact' if exact_text == true} text #{options[:text].inspect}" if options[:text] - @description << " with exact text #{exact_text}" if exact_text.is_a?(String) + desc << "#{label} #{locator.inspect}" + if !applied || applied_filters + desc << " with#{' exact' if exact_text == true} text #{options[:text].inspect}" if options[:text] + desc << " with exact text #{exact_text}" if exact_text.is_a?(String) end - @description << " with id #{options[:id]}" if options[:id] - @description << " with classes [#{Array(options[:class]).join(',')}]" if options[:class] - @description << selector.description(node_filters: !applied || (@applied_filters == :node), **options) - @description << ' that also matches the custom filter block' if @filter_block && (!applied || (@applied_filters == :node)) - @description << " within #{@resolved_node.inspect}" if describe_within? - @description + desc << " with id #{options[:id]}" if options[:id] + desc << " with classes [#{Array(options[:class]).join(',')}]" if options[:class] + desc << selector.description(node_filters: !applied || (applied_filters == :node), **options) + desc << ' that also matches the custom filter block' if @filter_block && (!applied || (applied_filters == :node)) + desc << " within #{@resolved_node.inspect}" if describe_within? + desc end def applied_description @@ -136,6 +135,10 @@ module Capybara private + def applied_filters + @applied_filters ||= false + end + def find_selector(locator) selector = if locator.is_a?(Symbol) Selector.all.fetch(locator) { |sel_type| raise ArgumentError, "Unknown selector type (:#{sel_type})" } @@ -171,7 +174,6 @@ module Capybara def matches_filter_block?(node) return true unless @filter_block - if node.respond_to?(:session) node.session.using_wait_time(0) { @filter_block.call(node) } else @@ -201,9 +203,9 @@ module Capybara unless VALID_MATCH.include?(match) raise ArgumentError, "invalid option #{match.inspect} for :match, should be one of #{VALID_MATCH.map(&:inspect).join(', ')}" end - unhandled_options = @options.keys - valid_keys - unhandled_options -= @options.keys.select do |option_name| - expression_filters.any? { |_nmae, ef| ef.handles_option? option_name } || + unhandled_options = @options.keys.reject do |option_name| + valid_keys.include?(option_name) || + expression_filters.any? { |_name, ef| ef.handles_option? option_name } || node_filters.any? { |_name, nf| nf.handles_option? option_name } end @@ -266,22 +268,21 @@ module Capybara classes[true].to_a.map { |cl| ":not(.#{Capybara::Selector::CSS.escape(cl.slice(1))})" }).join end - def apply_expression_filters(expr) + def apply_expression_filters(expression) unapplied_options = options.keys - valid_keys - expression_filters.inject(expr) do |memo, (name, ef)| + expression_filters.inject(expression) do |expr, (name, ef)| if ef.matcher? - unapplied_options.select { |option_name| ef.handles_option?(option_name) }.each do |option_name| + unapplied_options.select { |option_name| ef.handles_option?(option_name) }.inject(expr) do |memo, option_name| unapplied_options.delete(option_name) - memo = ef.apply_filter(memo, option_name, options[option_name]) + ef.apply_filter(memo, option_name, options[option_name]) end - memo elsif options.key?(name) unapplied_options.delete(name) - ef.apply_filter(memo, name, options[name]) + ef.apply_filter(expr, name, options[name]) elsif ef.default? - ef.apply_filter(memo, name, ef.default) + ef.apply_filter(expr, name, ef.default) else - memo + expr end end end diff --git a/lib/capybara/result.rb b/lib/capybara/result.rb index 6f373159..62aca48b 100644 --- a/lib/capybara/result.rb +++ b/lib/capybara/result.rb @@ -62,10 +62,7 @@ module Capybara if max_idx.nil? full_results[*args] else - loop do - break if @result_cache.size > max_idx - @result_cache << @results_enum.next - end + load_up_to(max_idx + 1) @result_cache[*args] end end @@ -77,40 +74,26 @@ module Capybara def compare_count # Only check filters for as many elements as necessary to determine result - if @query.options[:count] - count_opt = Integer(@query.options[:count]) - loop do - break if @result_cache.size > count_opt - @result_cache << @results_enum.next - end - return @result_cache.size <=> count_opt + if (count = @query.options[:count]) + count = Integer(count) + return load_up_to(count + 1) <=> count end - if @query.options[:minimum] - min_opt = Integer(@query.options[:minimum]) - begin - @result_cache << @results_enum.next while @result_cache.size < min_opt - rescue StopIteration - return -1 - end + if (min = @query.options[:minimum]) + min = Integer(min) + return -1 if load_up_to(min) < min end - if @query.options[:maximum] - max_opt = Integer(@query.options[:maximum]) - loop do - return 1 if @result_cache.size > max_opt - @result_cache << @results_enum.next - end + if (max = @query.options[:maximum]) + max = Integer(max) + return 1 if load_up_to(max + 1) > max end - if @query.options[:between] - min, max = @query.options[:between].minmax - loop do - break if @result_cache.size > max - @result_cache << @results_enum.next - end - return 0 if @query.options[:between].include? @result_cache.size - return @result_cache.size <=> min + if (between = @query.options[:between]) + min, max = between.minmax + size = load_up_to(max + 1) + return 0 if between.include? size + return size <=> min end 0 @@ -144,6 +127,14 @@ module Capybara private + def load_up_to(num) + loop do + break if @result_cache.size >= num + @result_cache << @results_enum.next + end + @result_cache.size + end + def full_results loop { @result_cache << @results_enum.next } @result_cache diff --git a/lib/capybara/selector/selector.rb b/lib/capybara/selector/selector.rb index fbc76a7c..e5e3c1ea 100644 --- a/lib/capybara/selector/selector.rb +++ b/lib/capybara/selector/selector.rb @@ -185,7 +185,6 @@ module Capybara @match = nil @label = nil @failure_message = nil - @description = nil @format = nil @expression = nil @expression_filters = {} diff --git a/lib/capybara/selenium/driver.rb b/lib/capybara/selenium/driver.rb index 70d3ceda..56b95dd4 100644 --- a/lib/capybara/selenium/driver.rb +++ b/lib/capybara/selenium/driver.rb @@ -49,7 +49,7 @@ class Capybara::Selenium::Driver < Capybara::Driver::Base @app = app @browser = nil @exit_status = nil - @frame_handles = {} + @frame_handles = Hash.new { |hash, handle| hash[handle] = [] } @options = DEFAULT_OPTIONS.merge(options) @node_class = ::Capybara::Selenium::Node end @@ -170,19 +170,19 @@ class Capybara::Selenium::Driver < Capybara::Driver::Base end def switch_to_frame(frame) + handles = @frame_handles[current_window_handle] case frame when :top - @frame_handles[browser.window_handle] = [] + handles.clear browser.switch_to.default_content when :parent # would love to use browser.switch_to.parent_frame here # but it has an issue if the current frame is removed from within it - @frame_handles[browser.window_handle].pop + handles.pop browser.switch_to.default_content - @frame_handles[browser.window_handle].each { |fh| browser.switch_to.frame(fh) } + handles.each { |fh| browser.switch_to.frame(fh) } else - @frame_handles[browser.window_handle] ||= [] - @frame_handles[browser.window_handle] << frame.native + handles << frame.native browser.switch_to.frame(frame.native) end end diff --git a/lib/capybara/selenium/node.rb b/lib/capybara/selenium/node.rb index 3b30db8f..c9285165 100644 --- a/lib/capybara/selenium/node.rb +++ b/lib/capybara/selenium/node.rb @@ -1,5 +1,6 @@ # frozen_string_literal: true +# Selenium specific implementation of the Capybara::Driver::Node API class Capybara::Selenium::Node < Capybara::Driver::Node def visible_text native.text @@ -82,13 +83,11 @@ class Capybara::Selenium::Node < Capybara::Driver::Node end def click(keys = [], **options) - if keys.empty? && !coords?(options) - native.click - else - scroll_if_needed do - action_with_modifiers(keys, options) do |action| - coords?(options) ? action.click : action.click(native) - end + click_options = ClickOptions.new(keys, options) + return native.click if click_options.empty? + scroll_if_needed do + action_with_modifiers(click_options) do |action| + click_options.coords? ? action.click : action.click(native) end end rescue StandardError => err @@ -101,17 +100,19 @@ class Capybara::Selenium::Node < Capybara::Driver::Node end def right_click(keys = [], **options) + click_options = ClickOptions.new(keys, options) scroll_if_needed do - action_with_modifiers(keys, options) do |action| - coords?(options) ? action.context_click : action.context_click(native) + action_with_modifiers(click_options) do |action| + click_options.coords? ? action.context_click : action.context_click(native) end end end def double_click(keys = [], **options) + click_options = ClickOptions.new(keys, options) scroll_if_needed do - action_with_modifiers(keys, options) do |action| - coords?(options) ? action.double_click : action.double_click(native) + action_with_modifiers(click_options) do |action| + click_options.coords? ? action.double_click : action.double_click(native) end end end @@ -121,11 +122,11 @@ class Capybara::Selenium::Node < Capybara::Driver::Node end def hover - scroll_if_needed { driver.browser.action.move_to(native).perform } + scroll_if_needed { browser_action.move_to(native).perform } end def drag_to(element) - scroll_if_needed { driver.browser.action.drag_and_drop(native, element.native).perform } + scroll_if_needed { browser_action.drag_and_drop(native, element.native).perform } end def tag_name @@ -192,10 +193,6 @@ class Capybara::Selenium::Node < Capybara::Driver::Node private - def coords?(options) - options[:x] && options[:y] - end - def boolean_attr(val) val && (val != 'false') end @@ -206,22 +203,23 @@ private end def set_text(value, clear: nil, **_unused) - if value.to_s.empty? && clear.nil? + value = value.to_s + if value.empty? && clear.nil? native.clear elsif clear == :backspace # Clear field by sending the correct number of backspace keys. backspaces = [:backspace] * self.value.to_s.length - send_keys(*([:end] + backspaces + [value.to_s])) + send_keys(*([:end] + backspaces + [value])) elsif clear == :none - send_keys(value.to_s) + send_keys(value) elsif clear.is_a? Array - send_keys(*clear, value.to_s) + send_keys(*clear, value) else # Clear field by JavaScript assignment of the value property. # Script can change a readonly element which user input cannot, so # don't execute if readonly. driver.execute_script "arguments[0].value = ''", self - send_keys(value.to_s) + send_keys(value) end end @@ -248,21 +246,24 @@ private end def set_date(value) # rubocop:disable Naming/AccessorMethodName - return set_text(value) if value.is_a?(String) || !value.respond_to?(:to_date) + value = SettableValue.new(value) + return set_text(value) unless value.dateable? # TODO: this would be better if locale can be detected and correct keystrokes sent - update_value_js(value.to_date.strftime('%Y-%m-%d')) + update_value_js(value.to_date_str) end def set_time(value) # rubocop:disable Naming/AccessorMethodName - return set_text(value) if value.is_a?(String) || !value.respond_to?(:to_time) + value = SettableValue.new(value) + return set_text(value) unless value.timeable? # TODO: this would be better if locale can be detected and correct keystrokes sent - update_value_js(value.to_time.strftime('%H:%M')) + update_value_js(value.to_time_str) end def set_datetime_local(value) # rubocop:disable Naming/AccessorMethodName - return set_text(value) if value.is_a?(String) || !value.respond_to?(:to_time) + value = SettableValue.new(value) + return set_text(value) unless value.timeable? # TODO: this would be better if locale can be detected and correct keystrokes sent - update_value_js(value.to_time.strftime('%Y-%m-%dT%H:%M')) + update_value_js(value.to_datetime_str) end def update_value_js(value) @@ -301,34 +302,33 @@ private # if we use the faster direct send_keys. For now just send_keys to the element # we've already focused. # native.send_keys(value.to_s) - driver.browser.action.send_keys(value.to_s).perform + browser_action.send_keys(value.to_s).perform end - def action_with_modifiers(keys, x: nil, y: nil) - actions = driver.browser.action - actions.move_to(native, x, y) - modifiers_down(actions, keys) + def action_with_modifiers(click_options) + actions = browser_action.move_to(native, *click_options.coords) + modifiers_down(actions, click_options.keys) yield actions - modifiers_up(actions, keys) + modifiers_up(actions, click_options.keys) actions.perform ensure - act = driver.browser.action + act = browser_action act.release_actions if act.respond_to?(:release_actions) end def modifiers_down(actions, keys) - keys.each do |key| - key = case key - when :ctrl then :control - when :command, :cmd then :meta - else - key - end - actions.key_down(key) - end + each_key(keys) { |key| actions.key_down(key) } end def modifiers_up(actions, keys) + each_key(keys) { |key| actions.key_up(key) } + end + + def browser_action + driver.browser.action + end + + def each_key(keys) keys.each do |key| key = case key when :ctrl then :control @@ -336,7 +336,60 @@ private else key end - actions.key_up(key) + yield key end end + + # SettableValue encapsulates time/date field formatting + class SettableValue + attr_reader :value + + def initialize(value) + @value = value + end + + def dateable? + !value.is_a?(String) && value.respond_to?(:to_date) + end + + def to_date_str + value.to_date.strftime('%Y-%m-%d') + end + + def timeable? + !value.is_a?(String) && value.respond_to?(:to_time) + end + + def to_time_str + value.to_time.strftime('%H:%M') + end + + def to_datetime_str + value.to_time.strftime('%Y-%m-%dT%H:%M') + end + end + private_constant :SettableValue + + # ClickOptions encapsulates click option logic + class ClickOptions + attr_reader :keys, :options + + def initialize(keys, options) + @keys = keys + @options = options + end + + def coords? + options[:x] && options[:y] + end + + def coords + [options[:x], options[:y]] + end + + def empty? + keys.empty? && !coords? + end + end + private_constant :ClickOptions end diff --git a/lib/capybara/selenium/nodes/chrome_node.rb b/lib/capybara/selenium/nodes/chrome_node.rb index bf2c6ac5..b2d5f1db 100644 --- a/lib/capybara/selenium/nodes/chrome_node.rb +++ b/lib/capybara/selenium/nodes/chrome_node.rb @@ -13,7 +13,7 @@ class Capybara::Selenium::ChromeNode < Capybara::Selenium::Node def drag_to(element) return super unless self[:draggable] == 'true' - scroll_if_needed { driver.browser.action.click_and_hold(native).perform } + scroll_if_needed { browser_action.click_and_hold(native).perform } driver.execute_script HTML5_DRAG_DROP_SCRIPT, self, element end diff --git a/lib/capybara/selenium/nodes/marionette_node.rb b/lib/capybara/selenium/nodes/marionette_node.rb index e20dab80..b96bb334 100644 --- a/lib/capybara/selenium/nodes/marionette_node.rb +++ b/lib/capybara/selenium/nodes/marionette_node.rb @@ -47,17 +47,15 @@ class Capybara::Selenium::MarionetteNode < Capybara::Selenium::Node return super(*args.map { |arg| arg == :space ? ' ' : arg }) if args.none? { |arg| arg.is_a? Array } native.click - actions = driver.browser.action - args.each do |keys| + args.each_with_object(browser_action) do |keys, actions| _send_keys(keys, actions) - end - actions.perform + end.perform end def drag_to(element) return super unless (browser_version >= 62.0) && (self[:draggable] == 'true') - scroll_if_needed { driver.browser.action.click_and_hold(native).perform } + scroll_if_needed { browser_action.click_and_hold(native).perform } driver.execute_script HTML5_DRAG_DROP_SCRIPT, self, element end diff --git a/lib/capybara/server.rb b/lib/capybara/server.rb index 5667d60e..7b6b03f9 100644 --- a/lib/capybara/server.rb +++ b/lib/capybara/server.rb @@ -31,7 +31,7 @@ module Capybara end def reset_error! - middleware.error = nil + middleware.clear_error end def error diff --git a/lib/capybara/server/middleware.rb b/lib/capybara/server/middleware.rb index 26ba9b6b..e30c98b8 100644 --- a/lib/capybara/server/middleware.rb +++ b/lib/capybara/server/middleware.rb @@ -20,7 +20,7 @@ module Capybara end end - attr_accessor :error + attr_reader :error def initialize(app, server_errors, extra_middleware = []) @app = app @@ -35,6 +35,10 @@ module Capybara @counter.value.positive? end + def clear_error + @error = nil + end + def call(env) if env['PATH_INFO'] == '/__identify__' [200, {}, [@app.object_id.to_s]] diff --git a/lib/capybara/session.rb b/lib/capybara/session.rb index a327768e..b6801138 100644 --- a/lib/capybara/session.rb +++ b/lib/capybara/session.rb @@ -137,7 +137,7 @@ module Capybara # Raise errors encountered in the server # def raise_server_error! - return if @server.nil? || !@server.error + return unless @server&.error # Force an explanation for the error being raised as the exception cause begin if config.raise_server_errors diff --git a/spec/shared_selenium_session.rb b/spec/shared_selenium_session.rb index 6510c8db..60d7e561 100644 --- a/spec/shared_selenium_session.rb +++ b/spec/shared_selenium_session.rb @@ -323,7 +323,7 @@ RSpec.shared_examples 'Capybara::Session' do |session, mode| end end - describe 'Capybara#disable_animation', :focus_ do + describe 'Capybara#disable_animation' do context 'when set to `true`' do before(:context) do # rubocop:disable RSpec/BeforeAfterAll # NOTE: Although Capybara.SpecHelper.reset! sets Capybara.disable_animation to false,