Support XPath:Expression for built-in id and class filters when using XPath based selectors

Replace Regexp named filters with a :matcher option for filters
This commit is contained in:
Thomas Walpole 2018-05-26 10:26:44 -07:00
parent f14a2dd5b4
commit daa6a4c7da
9 changed files with 107 additions and 27 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<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
# @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]}"

View File

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

View File

@ -21,6 +21,9 @@ RSpec.describe Capybara do
</div>
<div id="#special">
</div>
<div class="some random words" id="random_words">
Something
</div>
<input id="2checkbox" class="2checkbox" type="checkbox"/>
<input type="radio"/>
<label for="my_text_input">My Text Input</label>
@ -37,7 +40,7 @@ RSpec.describe Capybara do
</select>
<table>
<tr><td></td></tr>
</table
</table>
</body>
</html>
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