Allow filter names to be Regexp and add :element selector type
This commit is contained in:
parent
b1d98f7c9b
commit
522a94a3c4
|
@ -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(", ")
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue