diff --git a/lib/capybara/queries/base_query.rb b/lib/capybara/queries/base_query.rb index d1749eee..b9ab4dec 100644 --- a/lib/capybara/queries/base_query.rb +++ b/lib/capybara/queries/base_query.rb @@ -87,8 +87,7 @@ module Capybara end def assert_valid_keys - regex_keys, string_keys = valid_keys.group_by { |k| k.is_a? Regexp }.fetch_values(true, false) { [] } - invalid_keys = (@options.keys - string_keys).reject { |k| regex_keys.any? { |r| r =~ k } } + invalid_keys = @options.keys - valid_keys return if invalid_keys.empty? invalid_names = invalid_keys.map(&:inspect).join(", ") diff --git a/lib/capybara/queries/match_query.rb b/lib/capybara/queries/match_query.rb index 330f0e74..51e0de1b 100644 --- a/lib/capybara/queries/match_query.rb +++ b/lib/capybara/queries/match_query.rb @@ -9,6 +9,14 @@ module Capybara private + def assert_valid_keys + invalid_options = @options.keys & COUNT_KEYS + unless invalid_options.empty? + raise ArgumentError, "Match queries don't support quantity options. Invalid keys - #{invalid_options.join(', ')}" + end + super + end + def valid_keys super - COUNT_KEYS end diff --git a/lib/capybara/queries/selector_query.rb b/lib/capybara/queries/selector_query.rb index 6b01923b..c0915d8f 100644 --- a/lib/capybara/queries/selector_query.rb +++ b/lib/capybara/queries/selector_query.rb @@ -129,8 +129,8 @@ module Capybara unapplied_options = options.keys - valid_keys node_filters.all? do |filter_name, filter| - if filter_name.is_a?(Regexp) - unapplied_options.grep(filter_name).all? do |option_name| + 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]) end @@ -174,27 +174,57 @@ module Capybara end def assert_valid_keys - super - return if VALID_MATCH.include?(match) - raise ArgumentError, "invalid option #{match.inspect} for :match, should be one of #{VALID_MATCH.map(&:inspect).join(', ')}" + 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 } || + node_filters.any? { |_name, nf| nf.handles_option? option_name } + end + + return if unhandled_options.empty? + invalid_names = unhandled_options.map(&:inspect).join(", ") + valid_names = valid_keys.map(&:inspect).join(", ") + raise ArgumentError, "invalid keys #{invalid_names}, should be one of #{valid_names}" end def filtered_xpath(expr) - expr = "(#{expr})[#{XPath.attr(:id) == options[:id]}]" if options.key?(:id) && !custom_keys.include?(:id) + if options.key?(:id) && !custom_keys.include?(:id) + expr = if options[:id].is_a? XPath::Expression + "(#{expr})[#{XPath.attr(:id)[options[:id]]}]" + else + "(#{expr})[#{XPath.attr(:id) == options[:id]}]" + end + end if options.key?(:class) && !custom_keys.include?(:class) - class_xpath = Array(options[:class]).map do |klass| - XPath.attr(:class).contains_word(klass) - end.reduce(:&) + class_xpath = if options[:class].is_a?(XPath::Expression) + XPath.attr(:class)[options[:class]] + else + Array(options[:class]).map do |klass| + XPath.attr(:class).contains_word(klass) + end.reduce(:&) + end expr = "(#{expr})[#{class_xpath}]" end expr end def filtered_css(expr) + process_id = options.key?(:id) && !custom_keys.include?(:id) + process_class = options.key?(:class) && !custom_keys.include?(:class) + + if process_id && options[:id].is_a?(XPath::Expression) + raise ArgumentError, "XPath expressions are not supported for the :id filter with CSS based selectors" + end + if process_class && options[:class].is_a?(XPath::Expression) + raise ArgumentError, "XPath expressions are not supported for the :class filter with CSS based selectors" + end + css_selectors = expr.split(',').map(&:rstrip) expr = css_selectors.map do |sel| - sel += "##{Capybara::Selector::CSS.escape(options[:id])}" if options.key?(:id) && !custom_keys.include?(:id) - sel += Array(options[:class]).map { |k| ".#{Capybara::Selector::CSS.escape(k)}" }.join if options.key?(:class) && !custom_keys.include?(:class) + sel += "##{Capybara::Selector::CSS.escape(options[:id])}" if process_id + sel += Array(options[:class]).map { |k| ".#{Capybara::Selector::CSS.escape(k)}" }.join if process_class sel end.join(", ") expr @@ -203,8 +233,8 @@ module Capybara def apply_expression_filters(expr) unapplied_options = options.keys - valid_keys expression_filters.inject(expr) do |memo, (name, ef)| - if name.is_a?(Regexp) - unapplied_options.grep(name).each do |option_name| + if ef.matcher? + unapplied_options.select { |option_name| ef.handles_option?(option_name) }.each do |option_name| unapplied_options.delete(option_name) memo = ef.apply_filter(memo, option_name, options[option_name]) end diff --git a/lib/capybara/selector.rb b/lib/capybara/selector.rb index b790479b..822dea55 100644 --- a/lib/capybara/selector.rb +++ b/lib/capybara/selector.rb @@ -606,7 +606,7 @@ Capybara.add_selector(:element) do XPath.descendant((locator || '@').to_sym) end - expression_filter(/.+/) do |xpath, name, val| + expression_filter(:attributes, matcher: /.+/) do |xpath, name, val| case val when Regexp xpath @@ -617,7 +617,7 @@ Capybara.add_selector(:element) do end end - filter(/.+/) do |node, name, val| + filter(:attributes, matcher: /.+/) do |node, name, val| val.is_a?(Regexp) ? node[name] =~ val : true end diff --git a/lib/capybara/selector/filter_set.rb b/lib/capybara/selector/filter_set.rb index d56dbe9c..292ce59b 100644 --- a/lib/capybara/selector/filter_set.rb +++ b/lib/capybara/selector/filter_set.rb @@ -60,12 +60,12 @@ module Capybara options end - def add_filter(name, filter_class, *types, **options, &block) + def add_filter(name, filter_class, *types, matcher: nil, **options, &block) # rubocop:disable Metrics/ParameterLists types.each { |k| options[k] = true } if filter_class <= Filters::ExpressionFilter - @expression_filters[name] = filter_class.new(name, block, options) + @expression_filters[name] = filter_class.new(name, matcher, block, options) else - @node_filters[name] = filter_class.new(name, block, options) + @node_filters[name] = filter_class.new(name, matcher, block, options) end end end diff --git a/lib/capybara/selector/filters/base.rb b/lib/capybara/selector/filters/base.rb index a84cca32..7169828d 100644 --- a/lib/capybara/selector/filters/base.rb +++ b/lib/capybara/selector/filters/base.rb @@ -4,8 +4,9 @@ module Capybara class Selector module Filters class Base - def initialize(name, block, **options) + def initialize(name, matcher, block, **options) @name = name + @matcher = matcher @block = block @options = options @options[:valid_values] = [true, false] if options[:boolean] @@ -23,6 +24,18 @@ module Capybara @options.key?(:skip_if) && value == @options[:skip_if] end + def matcher? + !@matcher.nil? + end + + def handles_option?(option_name) + if matcher? + option_name =~ @matcher + else + @name == option_name + end + end + private def apply(subject, name, value, skip_value) diff --git a/lib/capybara/selector/selector.rb b/lib/capybara/selector/selector.rb index ac102975..3eedfeb8 100644 --- a/lib/capybara/selector/selector.rb +++ b/lib/capybara/selector/selector.rb @@ -170,6 +170,7 @@ module Capybara # @option options [Array<>] :valid_values Valid values for this filter # @option options :default The default value of the filter (if any) # @option options :skip_if Value of the filter that will cause it to be skipped + # @option options [Regexp] :matcher (nil) A Regexp used to check whether a specific option is handled by this filter. If not provided the filter will be used for options matching the filter name. # # If a Symbol is passed for the name the block should accept | node, option_value |, while if a Regexp # is passed for the name the block should accept | node, option_name, option_value |. In either case @@ -181,11 +182,13 @@ module Capybara # # @overload expression_filter(name, *types, options={}, &block) # @param [Symbol, Regexp] name The filter name + # @param [Regexp] matcher (nil) A Regexp used to check whether a specific option is handled by this filter # @param [Array] types The types of the filter - currently valid types are [:boolean] # @param [Hash] options ({}) Options of the filter # @option options [Array<>] :valid_values Valid values for this filter # @option options :default The default value of the filter (if any) # @option options :skip_if Value of the filter that will cause it to be skipped + # @option options [Regexp] :matcher (nil) A Regexp used to check whether a specific option is handled by this filter. If not provided the filter will be used for options matching the filter name. # # If a Symbol is passed for the name the block should accept | current_expression, option_value |, while if a Regexp # is passed for the name the block should accept | current_expression, option_name, option_value |. In either case @@ -248,10 +251,10 @@ module Capybara end def describe_all_expression_filters(**opts) - expression_filters.keys.map do |ef_name| - if ef_name.is_a?(Regexp) + expression_filters.map do |ef_name, ef| + if ef.matcher? opts.keys.map do |k| - " with #{k} #{opts[k]}" if k =~ ef_name && !::Capybara::Queries::SelectorQuery::VALID_KEYS.include?(k) + " with #{ef_name}[#{k} => #{opts[k]}]" if ef.handles_option?(k) && !::Capybara::Queries::SelectorQuery::VALID_KEYS.include?(k) end.join elsif opts.key?(ef_name) " with #{ef_name} #{opts[ef_name]}" diff --git a/lib/capybara/spec/session/element/assert_match_selector_spec.rb b/lib/capybara/spec/session/element/assert_match_selector_spec.rb index b790fda6..7d974555 100644 --- a/lib/capybara/spec/session/element/assert_match_selector_spec.rb +++ b/lib/capybara/spec/session/element/assert_match_selector_spec.rb @@ -28,7 +28,7 @@ Capybara::SpecHelper.spec '#assert_matches_selector' do end it "should not accept count options" do - expect { @element.assert_matches_selector(:css, '.number', count: 1) }.to raise_error(ArgumentError) + expect { @element.assert_matches_selector(:css, '.number', count: 1) }.to raise_error(ArgumentError, /count/) end it "should accept a filter block" do diff --git a/spec/selector_spec.rb b/spec/selector_spec.rb index 78c7f391..27eaf258 100644 --- a/spec/selector_spec.rb +++ b/spec/selector_spec.rb @@ -21,6 +21,9 @@ RSpec.describe Capybara do
+
+ Something +
@@ -37,7 +40,7 @@ RSpec.describe Capybara do -
STRING @@ -52,6 +55,10 @@ RSpec.describe Capybara do Capybara.add_selector :custom_css_selector do css { |selector| selector } end + + Capybara.add_selector :custom_xpath_selector do + xpath { |selector| selector } + end end describe "adding a selector" do @@ -121,6 +128,16 @@ RSpec.describe Capybara do expect(string.find(:custom_css_selector, "div", id: "#special")[:id]).to eq '#special' expect(string.find(:custom_css_selector, "input", id: "2checkbox")[:id]).to eq '2checkbox' end + + it "accepts XPath expression for xpath based selectors" do + expect(string.find(:custom_xpath_selector, './/div', id: XPath.contains('peci'))[:id]).to eq '#special' + expect(string.find(:custom_xpath_selector, './/input', id: XPath.ends_with('box'))[:id]).to eq '2checkbox' + end + + it "errors XPath expression for CSS based selectors" do + expect { string.find(:custom_css_selector, "div", id: XPath.contains('peci')) } + .to raise_error(ArgumentError, /not supported/) + end end context "with :class option" do @@ -133,6 +150,16 @@ RSpec.describe Capybara do expect(string.find(:custom_css_selector, "input", class: ".special")[:id]).to eq 'file' expect(string.find(:custom_css_selector, "input", class: "2checkbox")[:id]).to eq '2checkbox' end + + it "accepts XPath expression for xpath based selectors" do + expect(string.find(:custom_xpath_selector, './/div', class: XPath.contains('dom wor'))[:id]).to eq 'random_words' + expect(string.find(:custom_xpath_selector, './/div', class: XPath.ends_with('words'))[:id]).to eq 'random_words' + end + + it "errors XPath expression for CSS based selectors" do + expect { string.find(:custom_css_selector, "div", class: XPath.contains('random')) } + .to raise_error(ArgumentError, /not supported/) + end end # :css, :xpath, :id, :field, :fieldset, :link, :button, :link_or_button, :fillable_field, :radio_button, :checkbox, :select, @@ -222,7 +249,7 @@ RSpec.describe Capybara do expect { string.find(:element, 'input', not_there: 'bad', count: 1) } .to(raise_error do |e| expect(e).to be_a(Capybara::ElementNotFound) - expect(e.message).to include "not_there bad" + expect(e.message).to include "not_there => bad" expect(e.message).not_to include "count 1" end) end