From 522a94a3c43b02fdf375b29debb8e3b36ae04155 Mon Sep 17 00:00:00 2001 From: Thomas Walpole Date: Thu, 24 May 2018 15:27:34 -0700 Subject: [PATCH] Allow filter names to be Regexp and add :element selector type --- lib/capybara/queries/base_query.rb | 3 +- lib/capybara/queries/selector_query.rb | 30 +++++-- lib/capybara/selector.rb | 26 ++++++ lib/capybara/selector/filter_set.rb | 28 +++--- lib/capybara/selector/filters/base.rb | 10 +++ .../selector/filters/expression_filter.rb | 8 +- lib/capybara/selector/filters/node_filter.rb | 6 +- lib/capybara/selector/selector.rb | 88 ++++++++++--------- lib/capybara/selector/xpath.rb | 25 ++++++ lib/capybara/spec/session/find_field_spec.rb | 2 +- spec/filter_set_spec.rb | 9 ++ spec/selector_spec.rb | 27 ++++++ 12 files changed, 187 insertions(+), 75 deletions(-) create mode 100644 lib/capybara/selector/xpath.rb diff --git a/lib/capybara/queries/base_query.rb b/lib/capybara/queries/base_query.rb index b9ab4dec..d1749eee 100644 --- a/lib/capybara/queries/base_query.rb +++ b/lib/capybara/queries/base_query.rb @@ -87,7 +87,8 @@ module Capybara end def assert_valid_keys - invalid_keys = @options.keys - 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 } } return if invalid_keys.empty? invalid_names = invalid_keys.map(&:inspect).join(", ") diff --git a/lib/capybara/queries/selector_query.rb b/lib/capybara/queries/selector_query.rb index 93cc554a..6b01923b 100644 --- a/lib/capybara/queries/selector_query.rb +++ b/lib/capybara/queries/selector_query.rb @@ -126,11 +126,19 @@ module Capybara end def matches_node_filters?(node) - node_filters.all? do |name, filter| - if options.key?(name) - filter.matches?(node, options[name]) + 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| + unapplied_options.delete(option_name) + filter.matches?(node, option_name, options[option_name]) + 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.default) + filter.matches?(node, filter_name, filter.default) else true end @@ -193,11 +201,19 @@ module Capybara end def apply_expression_filters(expr) + unapplied_options = options.keys - valid_keys expression_filters.inject(expr) do |memo, (name, ef)| - if options.key?(name) - ef.apply_filter(memo, options[name]) + if name.is_a?(Regexp) + unapplied_options.grep(name).each do |option_name| + unapplied_options.delete(option_name) + memo = 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]) elsif ef.default? - ef.apply_filter(memo, ef.default) + ef.apply_filter(memo, name, ef.default) else memo end diff --git a/lib/capybara/selector.rb b/lib/capybara/selector.rb index 13cacd37..b790479b 100644 --- a/lib/capybara/selector.rb +++ b/lib/capybara/selector.rb @@ -601,5 +601,31 @@ Capybara.add_selector(:frame) do end end +Capybara.add_selector(:element) do + xpath do |locator, **_options| + XPath.descendant((locator || '@').to_sym) + end + + expression_filter(/.+/) do |xpath, name, val| + case val + when Regexp + xpath + when XPath::Expression + xpath[XPath.attr(name)[val]] + else + xpath[XPath.attr(name.to_sym) == val] + end + end + + filter(/.+/) do |node, name, val| + val.is_a?(Regexp) ? node[name] =~ val : true + end + + describe do |**options| + desc = +"" + desc << describe_all_expression_filters(options) + desc + end +end # rubocop:enable Metrics/BlockLength # rubocop:enable Metrics/ParameterLists diff --git a/lib/capybara/selector/filter_set.rb b/lib/capybara/selector/filter_set.rb index a8176554..d56dbe9c 100644 --- a/lib/capybara/selector/filter_set.rb +++ b/lib/capybara/selector/filter_set.rb @@ -5,11 +5,13 @@ require 'capybara/selector/filter' module Capybara class Selector class FilterSet - attr_reader :descriptions + attr_reader :descriptions, :node_filters, :expression_filters def initialize(name, &block) @name = name @descriptions = [] + @expression_filters = {} + @node_filters = {} instance_eval(&block) end @@ -32,18 +34,6 @@ module Capybara end.join end - def filters - @filters ||= {} - end - - def node_filters - filters.reject { |_n, f| f.nil? || f.is_a?(Filters::ExpressionFilter) }.freeze - end - - def expression_filters - filters.select { |_n, f| f.nil? || f.is_a?(Filters::ExpressionFilter) }.freeze - end - class << self def all @filter_sets ||= {} # rubocop:disable Naming/MemoizedInstanceVariableName @@ -62,15 +52,21 @@ module Capybara def options_with_defaults(options) options = options.dup - filters.each do |name, filter| - options[name] = filter.default if filter.default? && !options.key?(name) + [expression_filters, node_filters].each do |filters| + filters.each do |name, filter| + options[name] = filter.default if filter.default? && !options.key?(name) + end end options end def add_filter(name, filter_class, *types, **options, &block) types.each { |k| options[k] = true } - filters[name] = filter_class.new(name, block, options) + if filter_class <= Filters::ExpressionFilter + @expression_filters[name] = filter_class.new(name, block, options) + else + @node_filters[name] = filter_class.new(name, block, options) + end end end end diff --git a/lib/capybara/selector/filters/base.rb b/lib/capybara/selector/filters/base.rb index 4cd7a484..a84cca32 100644 --- a/lib/capybara/selector/filters/base.rb +++ b/lib/capybara/selector/filters/base.rb @@ -25,6 +25,16 @@ module Capybara private + def apply(subject, name, value, skip_value) + return skip_value if skip?(value) + raise ArgumentError, "Invalid value #{value.inspect} passed to #{self.class.name.split('::').last} #{name}#{" : #{@name}" if @name.is_a?(Regexp)}" unless valid_value?(value) + if @block.arity == 2 + @block.call(subject, value) + else + @block.call(subject, name, value) + end + end + def valid_value?(value) !@options.key?(:valid_values) || Array(@options[:valid_values]).include?(value) end diff --git a/lib/capybara/selector/filters/expression_filter.rb b/lib/capybara/selector/filters/expression_filter.rb index 557344d4..c5a6c74c 100644 --- a/lib/capybara/selector/filters/expression_filter.rb +++ b/lib/capybara/selector/filters/expression_filter.rb @@ -6,17 +6,15 @@ module Capybara class Selector module Filters class ExpressionFilter < Base - def apply_filter(expr, value) - return expr if skip?(value) - raise "ArgumentError", "Invalid value #{value.inspect} passed to expression filter #{@name}" unless valid_value?(value) - @block.call(expr, value) + def apply_filter(expr, name, value) + apply(expr, name, value, expr) end end class IdentityExpressionFilter < ExpressionFilter def initialize; end def default?; false; end - def apply_filter(expr, _value); expr; end + def apply_filter(expr, _name, _value); expr; end end end end diff --git a/lib/capybara/selector/filters/node_filter.rb b/lib/capybara/selector/filters/node_filter.rb index 1fd7ba99..c04a5fa1 100644 --- a/lib/capybara/selector/filters/node_filter.rb +++ b/lib/capybara/selector/filters/node_filter.rb @@ -6,10 +6,8 @@ module Capybara class Selector module Filters class NodeFilter < Base - def matches?(node, value) - return true if skip?(value) - raise ArgumentError, "Invalid value #{value.inspect} passed to filter #{@name}" unless valid_value?(value) - @block.call(node, value) + def matches?(node, name, value) + apply(node, name, value, true) end end end diff --git a/lib/capybara/selector/selector.rb b/lib/capybara/selector/selector.rb index cd7239ed..1cb5bf6f 100644 --- a/lib/capybara/selector/selector.rb +++ b/lib/capybara/selector/selector.rb @@ -2,26 +2,12 @@ require 'capybara/selector/filter_set' require 'capybara/selector/css' -require 'xpath' - -# Patch XPath to allow a nil condition in where -module XPath - class Renderer - undef :where if method_defined?(:where) - def where(on, condition) - condition = condition.to_s - if !condition.empty? - "#{on}[#{condition}]" - else - on.to_s - end - end - end -end +require 'capybara/selector/xpath' module Capybara class Selector attr_reader :name, :format + extend Forwardable class << self def all @@ -56,7 +42,8 @@ module Capybara end def custom_filters - @filter_set.filters + warn "Deprecated: #custom_filters is not valid when same named expression and node filter exist - don't use" + node_filters.merge(expression_filters).freeze end def node_filters @@ -81,10 +68,10 @@ module Capybara # @overload xpath() # @return [#call] The block that will be called to generate the XPath expression # - def xpath(*expression_filters, &block) + def xpath(*allowed_filters, &block) if block @format, @expression = :xpath, block - expression_filters.flatten.each { |ef| custom_filters[ef] = Filters::IdentityExpressionFilter.new } + allowed_filters.flatten.each { |ef| expression_filters[ef] = Filters::IdentityExpressionFilter.new } end format == :xpath ? @expression : nil end @@ -103,10 +90,10 @@ module Capybara # @overload css() # @return [#call] The block that will be called to generate the CSS selector # - def css(*expression_filters, &block) + def css(*allowed_filters, &block) if block @format, @expression = :css, block - expression_filters.flatten.each { |ef| custom_filters[ef] = nil } + allowed_filters.flatten.each { |ef| expression_filters[ef] = nil } end format == :css ? @expression : nil end @@ -146,6 +133,8 @@ module Capybara # @param [Hash] options The options of the query used to generate the description # @return [String] Description of the selector when used with the options passed # + + def_delegator :@filter_set, :description def description(**options) @filter_set.description(options) end @@ -173,36 +162,50 @@ module Capybara ## # - # Define a non-expression filter for use with this selector + # Define a node filter for use with this selector # # @overload filter(name, *types, options={}, &block) - # @param [Symbol] name The filter name + # @param [Symbol, Regexp] name The filter name # @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 # - def filter(name, *types_and_options, &block) - add_filter(name, Filters::NodeFilter, *types_and_options, &block) - end + # 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 + # the block should return `true` if the node passes the filer or `false` if it doesn't - def expression_filter(name, *types_and_options, &block) - add_filter(name, Filters::ExpressionFilter, *types_and_options, &block) - end + ## + # + # Define an expression filter for use with this selector + # + # @overload expression_filter(name, *types, options={}, &block) + # @param [Symbol, Regexp] name The filter name + # @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 + # + # 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 + # the block should return the modified expression + + def_delegators :@filter_set, :filter, :expression_filter def filter_set(name, filters_to_use = nil) f_set = FilterSet.all[name] - f_set.filters.each do |n, filter| - custom_filters[n] = filter if filters_to_use.nil? || filters_to_use.include?(n) + f_set.expression_filters.each do |n, filter| + @filter_set.expression_filters[n] = filter if filters_to_use.nil? || filters_to_use.include?(n) + end + f_set.node_filters.each do |n, filter| + @filter_set.node_filters[n] = filter if filters_to_use.nil? || filters_to_use.include?(n) end - f_set.descriptions.each { |desc| @filter_set.describe(&desc) } end - def describe(&block) - @filter_set.describe(&block) - end + def_delegator :@filter_set, :describe ## # @@ -227,11 +230,6 @@ module Capybara private - def add_filter(name, filter_class, *types, **options, &block) - types.each { |k| options[k] = true } - custom_filters[name] = filter_class.new(name, block, options) - end - def locate_field(xpath, locator, enable_aria_label: false, **_options) locate_xpath = xpath # Need to save original xpath for the label wrap if locator @@ -251,7 +249,15 @@ module Capybara end def describe_all_expression_filters(**opts) - expression_filters.keys.map { |ef| " with #{ef} #{opts[ef]}" if opts.key?(ef) }.join + expression_filters.keys.map do |ef_name| + if ef_name.is_a?(Regexp) + opts.keys.map do |k| + " with #{k} #{opts[k]}" if k =~ ef_name && !::Capybara::Queries::SelectorQuery::VALID_KEYS.include?(k) + end.join + elsif opts.key?(ef_name) + " with #{ef_name} #{opts[ef_name]}" + end + end.join end def find_by_attr(attribute, value) diff --git a/lib/capybara/selector/xpath.rb b/lib/capybara/selector/xpath.rb new file mode 100644 index 00000000..ec96dbd0 --- /dev/null +++ b/lib/capybara/selector/xpath.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require 'xpath' + +# Patch XPath to allow a nil condition in where +module XPath + class Renderer + undef :where if method_defined?(:where) + + def where(on, condition) + condition = condition.to_s + if !condition.empty? + "#{on}[#{condition}]" + else + on.to_s + end + end + end + + module DSL + def ends_with(suffix) + function(:substring, current, function(:'string-length', current).minus(function(:'string-length', suffix)).plus(1)) == suffix + end + end +end diff --git a/lib/capybara/spec/session/find_field_spec.rb b/lib/capybara/spec/session/find_field_spec.rb index 09035993..5076c336 100644 --- a/lib/capybara/spec/session/find_field_spec.rb +++ b/lib/capybara/spec/session/find_field_spec.rb @@ -39,7 +39,7 @@ Capybara::SpecHelper.spec '#find_field' do it "should raise error if filter option is invalid" do expect do @session.find_field('Dog', disabled: nil) - end.to raise_error ArgumentError, "Invalid value nil passed to filter disabled" + end.to raise_error ArgumentError, "Invalid value nil passed to NodeFilter disabled" end context "with :exact option" do diff --git a/spec/filter_set_spec.rb b/spec/filter_set_spec.rb index c5358b84..359996c2 100644 --- a/spec/filter_set_spec.rb +++ b/spec/filter_set_spec.rb @@ -26,4 +26,13 @@ RSpec.describe Capybara::Selector::FilterSet do expect(fs.expression_filters.keys).to include(:expression_test) expect(fs.expression_filters.keys).not_to include(:node_test) end + + it "allows node filter and expression filter with the same name" do + fs = Capybara::Selector::FilterSet.add(:test) do + filter(:test, :boolean) { |_node, _value| true } + expression_filter(:test, :boolean) { |_expr, _value| true } + end + + expect(fs.expression_filters[:test]).not_to eq fs.node_filters[:test] + end end diff --git a/spec/selector_spec.rb b/spec/selector_spec.rb index ed3107ad..78c7f391 100644 --- a/spec/selector_spec.rb +++ b/spec/selector_spec.rb @@ -208,6 +208,33 @@ RSpec.describe Capybara do expect { string.find(:button, 'click me', title: 'click me') }.to raise_error(/with title click me/) end end + + describe ":element selector" do + it "finds by any attributes" do + expect(string.find(:element, 'input', type: 'submit').value).to eq 'click me' + end + + it "still works with system keys" do + expect { string.all(:element, 'input', type: 'submit', count: 1) }.not_to raise_error + end + + it "includes wildcarded keys in description" 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).not_to include "count 1" + end) + end + + it "accepts XPath::Expression" do + expect(string.find(:element, 'input', type: XPath.starts_with('subm')).value).to eq 'click me' + expect(string.find(:element, 'input', type: XPath.ends_with('ext'))[:type]).to eq 'text' + expect(string.find(:element, 'input', type: XPath.contains('ckb'))[:type]).to eq 'checkbox' + expect(string.find(:element, 'input', title: XPath.contains_word('submit'))[:type]).to eq 'submit' + expect(string.find(:element, 'input', title: XPath.contains_word('button'))[:type]).to eq 'submit' + end + end end end end