Allow filter names to be Regexp and add :element selector type

This commit is contained in:
Thomas Walpole 2018-05-24 15:27:34 -07:00
parent b1d98f7c9b
commit 522a94a3c4
12 changed files with 187 additions and 75 deletions

View File

@ -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(", ")

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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<Symbol>] 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<Symbol>] 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)

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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