Evaluate filters in the context of the selector they are being used in and allow node filters to set detailed error messages

This commit is contained in:
Thomas Walpole 2018-11-09 16:30:43 -08:00
parent 0c4857e3e1
commit a280697da6
7 changed files with 116 additions and 42 deletions

View File

@ -60,14 +60,14 @@ module Capybara
description(true)
end
def matches_filters?(node)
def matches_filters?(node, node_filter_errors = [])
return true if (@resolved_node&.== node) && options[:allow_self]
applied_filters << :system
return false unless matches_system_filters?(node)
applied_filters << :node
matches_node_filters?(node) && matches_filter_block?(node)
matches_node_filters?(node, node_filter_errors) && matches_filter_block?(node)
rescue *(node.respond_to?(:session) ? node.session.driver.invalid_element_errors : [])
false
end
@ -163,21 +163,23 @@ module Capybara
VALID_KEYS + custom_keys
end
def matches_node_filters?(node)
def matches_node_filters?(node, errors)
unapplied_options = options.keys - valid_keys
node_filters.all? do |filter_name, filter|
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])
@selector.with_filter_errors(errors) do
node_filters.all? do |filter_name, filter|
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], @selector)
end
elsif options.key?(filter_name)
unapplied_options.delete(filter_name)
filter.matches?(node, filter_name, options[filter_name], @selector)
elsif filter.default?
filter.matches?(node, filter_name, filter.default, @selector)
else
true
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_name, filter.default)
else
true
end
end
end
@ -268,13 +270,13 @@ module Capybara
if ef.matcher?
unapplied_options.select(&ef.method(:handles_option?)).inject(expr) do |memo, option_name|
unapplied_options.delete(option_name)
ef.apply_filter(memo, option_name, options[option_name])
ef.apply_filter(memo, option_name, options[option_name], @selector)
end
elsif options.key?(name)
unapplied_options.delete(name)
ef.apply_filter(expr, name, options[name])
ef.apply_filter(expr, name, options[name], @selector)
elsif ef.default?
ef.apply_filter(expr, name, ef.default)
ef.apply_filter(expr, name, ef.default, @selector)
else
expr
end

View File

@ -28,7 +28,8 @@ module Capybara
def initialize(elements, query)
@elements = elements
@result_cache = []
@results_enum = lazy_select_elements { |node| query.matches_filters?(node) }
@filter_errors = []
@results_enum = lazy_select_elements { |node| query.matches_filters?(node, @filter_errors) }
@query = query
end
@ -113,7 +114,8 @@ module Capybara
end
unless rest.empty?
elements = rest.map { |el| el.text rescue '<<ERROR>>' }.map(&:inspect).join(', ') # rubocop:disable Style/RescueModifier
message << '. Also found ' << elements << ', which matched the selector but not all filters.'
message << '. Also found ' << elements << ', which matched the selector but not all filters. '
message << @filter_errors.join('. ') if (rest.size == 1) && count.zero?
end
message
end

View File

@ -59,7 +59,10 @@ Capybara.add_selector(:field) do
node_filter(:readonly, :boolean) { |node, value| !(value ^ node.readonly?) }
node_filter(:with) do |node, with|
with.is_a?(Regexp) ? node.value =~ with : node.value == with.to_s
val = node.value
(with.is_a?(Regexp) ? val =~ with : val == with.to_s).tap do |res|
add_error("Expected value to be #{with.inspect} but was #{val.inspect}") unless res
end
end
describe_expression_filters do |type: nil, **options|
@ -109,7 +112,9 @@ Capybara.add_selector(:link) do
node_filter(:href) do |node, href|
# If not a Regexp it's been handled in the main XPath
href.is_a?(Regexp) ? node[:href].match(href) : true
(href.is_a?(Regexp) ? node[:href].match(href) : true).tap do |res|
add_error "Expected href to match #{href.inspect} but it was #{node[:href].inspect}" unless res
end
end
expression_filter(:download, valid_values: [true, false, String]) do |expr, download|
@ -178,7 +183,9 @@ end
Capybara.add_selector(:link_or_button) do
label 'link or button'
xpath do |locator, **options|
self.class.all.values_at(:link, :button).map { |selector| selector.call(locator, **options, selector_config: @config) }.reduce(:union)
self.class.all.values_at(:link, :button).map do |selector|
instance_exec(locator, options, &selector.xpath)
end.reduce(:union)
end
node_filter(:disabled, :boolean, default: false, skip_if: :all) { |node, value| node.tag_name == 'a' || !(value ^ node.disabled?) }
@ -210,7 +217,10 @@ Capybara.add_selector(:fillable_field) do
filter_set(:_field, %i[disabled multiple name placeholder])
node_filter(:with) do |node, with|
with.is_a?(Regexp) ? node.value =~ with : node.value == with.to_s
val = node.value
(with.is_a?(Regexp) ? val =~ with : val == with.to_s).tap do |res|
add_error("Expected value to be #{with.inspect} but was #{val.inspect}") unless res
end
end
describe_expression_filters
@ -231,7 +241,12 @@ Capybara.add_selector(:radio_button) do
filter_set(:_field, %i[checked unchecked disabled name])
node_filter(:option) { |node, value| node.value == value.to_s }
node_filter(:option) do |node, value|
val = node.value
(val == value.to_s).tap do |res|
add_error("Expected option value to be #{value.inspect} but it was #{val.inspect}") unless res
end
end
describe_expression_filters
describe_node_filters do |option: nil, **|
@ -249,7 +264,12 @@ Capybara.add_selector(:checkbox) do
filter_set(:_field, %i[checked unchecked disabled name])
node_filter(:option) { |node, value| node.value == value.to_s }
node_filter(:option) do |node, value|
val = node.value
(val == value.to_s).tap do |res|
add_error("Expected option value to be #{value.inspect} but it was #{val.inspect}") unless res
end
end
describe_expression_filters
describe_node_filters do |option: nil, **|
@ -273,7 +293,9 @@ Capybara.add_selector(:select) do
else
node.all(:xpath, './/option', visible: false, wait: false).map { |option| option.text(:all) }
end
options.sort == actual.sort
(options.sort == actual.sort).tap do |res|
add_error("Expected options #{options.inspect} found #{actual.inspect}") unless res
end
end
expression_filter(:with_options) do |expr, options|
@ -284,12 +306,16 @@ Capybara.add_selector(:select) do
node_filter(:selected) do |node, selected|
actual = node.all(:xpath, './/option', visible: false, wait: false).select(&:selected?).map { |option| option.text(:all) }
Array(selected).sort == actual.sort
(Array(selected).sort == actual.sort).tap do |res|
add_error("Expected #{selected.inspect} to be selected found #{actual.inspect}") unless res
end
end
node_filter(:with_selected) do |node, selected|
actual = node.all(:xpath, './/option', visible: false, wait: false).select(&:selected?).map { |option| option.text(:all) }
(Array(selected) - actual).empty?
(Array(selected) - actual).empty?.tap do |res|
add_error("Expected at least #{selected.inspect} to be selected found #{actual.inspect}") unless res
end
end
describe_expression_filters do |with_options: nil, **opts|
@ -320,7 +346,9 @@ Capybara.add_selector(:datalist_input) do
node_filter(:options) do |node, options|
actual = node.find("//datalist[@id=#{node[:list]}]", visible: :all).all(:datalist_option, wait: false).map(&:value)
options.sort == actual.sort
(options.sort == actual.sort).tap do |res|
add_error("Expected #{options.inspect} options found #{actual.inspect}") unless res
end
end
expression_filter(:with_options) do |expr, options|
@ -471,7 +499,11 @@ Capybara.add_selector(:element) do
end
node_filter(:attributes, matcher: /.+/) do |node, name, val|
val.is_a?(Regexp) ? node[name] =~ val : true
next true unless val.is_a?(Regexp)
(node[name] =~ val).tap do |res|
add_error("Expected #{name} to match #{val.inspect} but it was #{node[name]}") unless res
end
end
describe_expression_filters do |**options|

View File

@ -7,18 +7,9 @@ module Capybara
def initialize(name, matcher, block, **options)
@name = name
@matcher = matcher
@block = block
@options = options
@options[:valid_values] = [true, false] if options[:boolean]
@block = if boolean?
proc do |node, value|
error_cnt = errors.size
block.call(node, value).tap do |res|
add_error("Expected #{@name} #{value} but it wasn't") if !res && error_cnt == errors.size
end
end
else
block
end
end
def default?

View File

@ -6,7 +6,21 @@ module Capybara
class Selector
module Filters
class NodeFilter < Base
def matches?(node, name, value, context=nil)
def initialize(name, matcher, block, **options)
super
@block = if boolean?
proc do |node, value|
error_cnt = errors.size
block.call(node, value).tap do |res|
add_error("Expected #{name} #{value} but it wasn't") if !res && error_cnt == errors.size
end
end
else
block
end
end
def matches?(node, name, value, context = nil)
apply(node, name, value, true, context)
rescue Capybara::ElementNotFound
false

View File

@ -389,6 +389,10 @@ module Capybara
vis.nil? ? fallback : vis
end
def add_error(error_msg)
errors << error_msg
end
# @api private
def builder
case format
@ -401,8 +405,20 @@ module Capybara
end
end
# @api private
def with_filter_errors(errors)
Thread.current["capybara_#{object_id}_errors"] = errors
yield
ensure
Thread.current["capybara_#{object_id}_errors"] = nil
end
private
def errors
Thread.current["capybara_#{object_id}_errors"] || []
end
def enable_aria_label
@config[:enable_aria_label]
end

View File

@ -41,6 +41,23 @@ Capybara::SpecHelper.spec '#has_field' do
expect(@session).not_to have_field('First Name', with: 'John')
expect(@session).not_to have_field('First Name', with: /John|Paul|George|Ringo/)
end
it 'should output filter errors if only one element matched the selector but failed the filters' do
@session.fill_in('First Name', with: 'Thomas')
expect do
expect(@session).to have_field('First Name', with: 'Jonas')
end.to raise_exception(RSpec::Expectations::ExpectationNotMetError, /Expected value to be "Jonas" but was "Thomas"/)
# native boolean node filter
expect do
expect(@session).to have_field('First Name', readonly: true)
end.to raise_exception(RSpec::Expectations::ExpectationNotMetError, /Expected readonly true but it wasn't/)
# inherited boolean node filter
expect do
expect(@session).to have_field('form_pets_cat', checked: true)
end.to raise_exception(RSpec::Expectations::ExpectationNotMetError, /Expected checked true but it wasn't/)
end
end
context 'with type' do