diff --git a/lib/capybara/queries/selector_query.rb b/lib/capybara/queries/selector_query.rb index 94f8c15f..e60c9295 100644 --- a/lib/capybara/queries/selector_query.rb +++ b/lib/capybara/queries/selector_query.rb @@ -60,14 +60,14 @@ module Capybara description(true) end - def matches_filters?(node) + def matches_filters?(node, node_filter_errors = []) return true if (@resolved_node&.== node) && options[:allow_self] applied_filters << :system return false unless matches_system_filters?(node) applied_filters << :node - matches_node_filters?(node) && matches_filter_block?(node) + matches_node_filters?(node, node_filter_errors) && matches_filter_block?(node) rescue *(node.respond_to?(:session) ? node.session.driver.invalid_element_errors : []) false end @@ -163,21 +163,23 @@ module Capybara VALID_KEYS + custom_keys end - def matches_node_filters?(node) + def matches_node_filters?(node, errors) unapplied_options = options.keys - valid_keys - node_filters.all? do |filter_name, filter| - if filter.matcher? - unapplied_options.select { |option_name| filter.handles_option?(option_name) }.all? do |option_name| - unapplied_options.delete(option_name) - filter.matches?(node, option_name, options[option_name]) + @selector.with_filter_errors(errors) do + node_filters.all? do |filter_name, filter| + if filter.matcher? + unapplied_options.select { |option_name| filter.handles_option?(option_name) }.all? do |option_name| + unapplied_options.delete(option_name) + filter.matches?(node, option_name, options[option_name], @selector) + end + elsif options.key?(filter_name) + unapplied_options.delete(filter_name) + filter.matches?(node, filter_name, options[filter_name], @selector) + elsif filter.default? + filter.matches?(node, filter_name, filter.default, @selector) + else + true end - elsif options.key?(filter_name) - unapplied_options.delete(filter_name) - filter.matches?(node, filter_name, options[filter_name]) - elsif filter.default? - filter.matches?(node, filter_name, filter.default) - else - true end end end @@ -268,13 +270,13 @@ module Capybara if ef.matcher? unapplied_options.select(&ef.method(:handles_option?)).inject(expr) do |memo, option_name| unapplied_options.delete(option_name) - ef.apply_filter(memo, option_name, options[option_name]) + ef.apply_filter(memo, option_name, options[option_name], @selector) end elsif options.key?(name) unapplied_options.delete(name) - ef.apply_filter(expr, name, options[name]) + ef.apply_filter(expr, name, options[name], @selector) elsif ef.default? - ef.apply_filter(expr, name, ef.default) + ef.apply_filter(expr, name, ef.default, @selector) else expr end diff --git a/lib/capybara/result.rb b/lib/capybara/result.rb index a43ec18c..f20c0ec1 100644 --- a/lib/capybara/result.rb +++ b/lib/capybara/result.rb @@ -28,7 +28,8 @@ module Capybara def initialize(elements, query) @elements = elements @result_cache = [] - @results_enum = lazy_select_elements { |node| query.matches_filters?(node) } + @filter_errors = [] + @results_enum = lazy_select_elements { |node| query.matches_filters?(node, @filter_errors) } @query = query end @@ -113,7 +114,8 @@ module Capybara end unless rest.empty? elements = rest.map { |el| el.text rescue '<>' }.map(&:inspect).join(', ') # rubocop:disable Style/RescueModifier - message << '. Also found ' << elements << ', which matched the selector but not all filters.' + message << '. Also found ' << elements << ', which matched the selector but not all filters. ' + message << @filter_errors.join('. ') if (rest.size == 1) && count.zero? end message end diff --git a/lib/capybara/selector.rb b/lib/capybara/selector.rb index d7b86c7b..80b7273e 100644 --- a/lib/capybara/selector.rb +++ b/lib/capybara/selector.rb @@ -59,7 +59,10 @@ Capybara.add_selector(:field) do node_filter(:readonly, :boolean) { |node, value| !(value ^ node.readonly?) } node_filter(:with) do |node, with| - with.is_a?(Regexp) ? node.value =~ with : node.value == with.to_s + val = node.value + (with.is_a?(Regexp) ? val =~ with : val == with.to_s).tap do |res| + add_error("Expected value to be #{with.inspect} but was #{val.inspect}") unless res + end end describe_expression_filters do |type: nil, **options| @@ -109,7 +112,9 @@ Capybara.add_selector(:link) do node_filter(:href) do |node, href| # If not a Regexp it's been handled in the main XPath - href.is_a?(Regexp) ? node[:href].match(href) : true + (href.is_a?(Regexp) ? node[:href].match(href) : true).tap do |res| + add_error "Expected href to match #{href.inspect} but it was #{node[:href].inspect}" unless res + end end expression_filter(:download, valid_values: [true, false, String]) do |expr, download| @@ -178,7 +183,9 @@ end Capybara.add_selector(:link_or_button) do label 'link or button' xpath do |locator, **options| - self.class.all.values_at(:link, :button).map { |selector| selector.call(locator, **options, selector_config: @config) }.reduce(:union) + self.class.all.values_at(:link, :button).map do |selector| + instance_exec(locator, options, &selector.xpath) + end.reduce(:union) end node_filter(:disabled, :boolean, default: false, skip_if: :all) { |node, value| node.tag_name == 'a' || !(value ^ node.disabled?) } @@ -210,7 +217,10 @@ Capybara.add_selector(:fillable_field) do filter_set(:_field, %i[disabled multiple name placeholder]) node_filter(:with) do |node, with| - with.is_a?(Regexp) ? node.value =~ with : node.value == with.to_s + val = node.value + (with.is_a?(Regexp) ? val =~ with : val == with.to_s).tap do |res| + add_error("Expected value to be #{with.inspect} but was #{val.inspect}") unless res + end end describe_expression_filters @@ -231,7 +241,12 @@ Capybara.add_selector(:radio_button) do filter_set(:_field, %i[checked unchecked disabled name]) - node_filter(:option) { |node, value| node.value == value.to_s } + node_filter(:option) do |node, value| + val = node.value + (val == value.to_s).tap do |res| + add_error("Expected option value to be #{value.inspect} but it was #{val.inspect}") unless res + end + end describe_expression_filters describe_node_filters do |option: nil, **| @@ -249,7 +264,12 @@ Capybara.add_selector(:checkbox) do filter_set(:_field, %i[checked unchecked disabled name]) - node_filter(:option) { |node, value| node.value == value.to_s } + node_filter(:option) do |node, value| + val = node.value + (val == value.to_s).tap do |res| + add_error("Expected option value to be #{value.inspect} but it was #{val.inspect}") unless res + end + end describe_expression_filters describe_node_filters do |option: nil, **| @@ -273,7 +293,9 @@ Capybara.add_selector(:select) do else node.all(:xpath, './/option', visible: false, wait: false).map { |option| option.text(:all) } end - options.sort == actual.sort + (options.sort == actual.sort).tap do |res| + add_error("Expected options #{options.inspect} found #{actual.inspect}") unless res + end end expression_filter(:with_options) do |expr, options| @@ -284,12 +306,16 @@ Capybara.add_selector(:select) do node_filter(:selected) do |node, selected| actual = node.all(:xpath, './/option', visible: false, wait: false).select(&:selected?).map { |option| option.text(:all) } - Array(selected).sort == actual.sort + (Array(selected).sort == actual.sort).tap do |res| + add_error("Expected #{selected.inspect} to be selected found #{actual.inspect}") unless res + end end node_filter(:with_selected) do |node, selected| actual = node.all(:xpath, './/option', visible: false, wait: false).select(&:selected?).map { |option| option.text(:all) } - (Array(selected) - actual).empty? + (Array(selected) - actual).empty?.tap do |res| + add_error("Expected at least #{selected.inspect} to be selected found #{actual.inspect}") unless res + end end describe_expression_filters do |with_options: nil, **opts| @@ -320,7 +346,9 @@ Capybara.add_selector(:datalist_input) do node_filter(:options) do |node, options| actual = node.find("//datalist[@id=#{node[:list]}]", visible: :all).all(:datalist_option, wait: false).map(&:value) - options.sort == actual.sort + (options.sort == actual.sort).tap do |res| + add_error("Expected #{options.inspect} options found #{actual.inspect}") unless res + end end expression_filter(:with_options) do |expr, options| @@ -471,7 +499,11 @@ Capybara.add_selector(:element) do end node_filter(:attributes, matcher: /.+/) do |node, name, val| - val.is_a?(Regexp) ? node[name] =~ val : true + next true unless val.is_a?(Regexp) + + (node[name] =~ val).tap do |res| + add_error("Expected #{name} to match #{val.inspect} but it was #{node[name]}") unless res + end end describe_expression_filters do |**options| diff --git a/lib/capybara/selector/filters/base.rb b/lib/capybara/selector/filters/base.rb index 3cd6acc0..5d2ad0cb 100644 --- a/lib/capybara/selector/filters/base.rb +++ b/lib/capybara/selector/filters/base.rb @@ -7,18 +7,9 @@ module Capybara def initialize(name, matcher, block, **options) @name = name @matcher = matcher + @block = block @options = options @options[:valid_values] = [true, false] if options[:boolean] - @block = if boolean? - proc do |node, value| - error_cnt = errors.size - block.call(node, value).tap do |res| - add_error("Expected #{@name} #{value} but it wasn't") if !res && error_cnt == errors.size - end - end - else - block - end end def default? diff --git a/lib/capybara/selector/filters/node_filter.rb b/lib/capybara/selector/filters/node_filter.rb index 73f0b289..2731bdf6 100644 --- a/lib/capybara/selector/filters/node_filter.rb +++ b/lib/capybara/selector/filters/node_filter.rb @@ -6,7 +6,21 @@ module Capybara class Selector module Filters class NodeFilter < Base - def matches?(node, name, value, context=nil) + def initialize(name, matcher, block, **options) + super + @block = if boolean? + proc do |node, value| + error_cnt = errors.size + block.call(node, value).tap do |res| + add_error("Expected #{name} #{value} but it wasn't") if !res && error_cnt == errors.size + end + end + else + block + end + end + + def matches?(node, name, value, context = nil) apply(node, name, value, true, context) rescue Capybara::ElementNotFound false diff --git a/lib/capybara/selector/selector.rb b/lib/capybara/selector/selector.rb index 973d7d36..6c4a3729 100644 --- a/lib/capybara/selector/selector.rb +++ b/lib/capybara/selector/selector.rb @@ -389,6 +389,10 @@ module Capybara vis.nil? ? fallback : vis end + def add_error(error_msg) + errors << error_msg + end + # @api private def builder case format @@ -401,8 +405,20 @@ module Capybara end end + # @api private + def with_filter_errors(errors) + Thread.current["capybara_#{object_id}_errors"] = errors + yield + ensure + Thread.current["capybara_#{object_id}_errors"] = nil + end + private + def errors + Thread.current["capybara_#{object_id}_errors"] || [] + end + def enable_aria_label @config[:enable_aria_label] end diff --git a/lib/capybara/spec/session/has_field_spec.rb b/lib/capybara/spec/session/has_field_spec.rb index e0111b0e..0530af77 100644 --- a/lib/capybara/spec/session/has_field_spec.rb +++ b/lib/capybara/spec/session/has_field_spec.rb @@ -41,6 +41,23 @@ Capybara::SpecHelper.spec '#has_field' do expect(@session).not_to have_field('First Name', with: 'John') expect(@session).not_to have_field('First Name', with: /John|Paul|George|Ringo/) end + + it 'should output filter errors if only one element matched the selector but failed the filters' do + @session.fill_in('First Name', with: 'Thomas') + expect do + expect(@session).to have_field('First Name', with: 'Jonas') + end.to raise_exception(RSpec::Expectations::ExpectationNotMetError, /Expected value to be "Jonas" but was "Thomas"/) + + # native boolean node filter + expect do + expect(@session).to have_field('First Name', readonly: true) + end.to raise_exception(RSpec::Expectations::ExpectationNotMetError, /Expected readonly true but it wasn't/) + + # inherited boolean node filter + expect do + expect(@session).to have_field('form_pets_cat', checked: true) + end.to raise_exception(RSpec::Expectations::ExpectationNotMetError, /Expected checked true but it wasn't/) + end end context 'with type' do