From cadad8b3a49aa094669c7ff8ede6b5ac7c4b0d18 Mon Sep 17 00:00:00 2001 From: Thomas Walpole Date: Tue, 5 Mar 2019 17:01:16 -0800 Subject: [PATCH] Split selector definition and configured selector Allow selectors to specify multiple expression types --- lib/capybara/queries/selector_query.rb | 39 ++- lib/capybara/selector.rb | 87 ++++- lib/capybara/selector/css.rb | 2 + lib/capybara/selector/definition.rb | 273 +++++++++++++++ lib/capybara/selector/filters/base.rb | 4 + .../selector/filters/locator_filter.rb | 14 +- lib/capybara/selector/selector.rb | 318 +++--------------- lib/capybara/spec/session/all_spec.rb | 2 +- lib/capybara/spec/session/find_spec.rb | 2 +- lib/capybara/spec/session/first_spec.rb | 2 +- 10 files changed, 445 insertions(+), 298 deletions(-) create mode 100644 lib/capybara/selector/definition.rb diff --git a/lib/capybara/queries/selector_query.rb b/lib/capybara/queries/selector_query.rb index 0f94482ed3..358a3e5ad1 100644 --- a/lib/capybara/queries/selector_query.rb +++ b/lib/capybara/queries/selector_query.rb @@ -11,6 +11,7 @@ def initialize(*args, session_options:, enable_aria_label: session_options.enable_aria_label, test_id: session_options.test_id, + selector_format: nil, **options, &filter_block) @resolved_node = nil @@ -19,14 +20,18 @@ def initialize(*args, super(@options) self.session_options = session_options - @selector = find_selector(args[0].is_a?(Symbol) ? args.shift : args[0]) + @selector = Selector.new( + find_selector(args[0].is_a?(Symbol) ? args.shift : args[0]), + selector_config: { enable_aria_label: enable_aria_label, test_id: test_id }, + selector_format: selector_format + ) + @locator = args.shift @filter_block = filter_block raise ArgumentError, "Unused parameters passed to #{self.class.name} : #{args}" unless args.empty? - selector_config = { enable_aria_label: enable_aria_label, test_id: test_id } - @expression = selector.call(@locator, @options.merge(selector_config: selector_config)) + @expression = selector.call(@locator, @options) warn_exact_usage @@ -129,7 +134,9 @@ def resolve_for(node, exact = nil) # @api private def supports_exact? - @expression.respond_to? :to_xpath + return @expression.respond_to? :to_xpath if @selector.supports_exact?.nil? + + @selector.supports_exact? end def failure_message @@ -142,6 +149,10 @@ def negative_failure_message private + def selector_format + @selector.selector_format + end + def text_fragments text = (options[:text] || options[:exact_text]) text.is_a?(String) ? text.split : [] @@ -182,23 +193,23 @@ def find_selector(locator) def find_nodes_by_selector_format(node, exact) hints = {} hints[:uses_visibility] = true unless visible == :all - hints[:texts] = text_fragments unless selector.format == :xpath + hints[:texts] = text_fragments unless selector_format == :xpath hints[:styles] = options[:style] if use_default_style_filter? - if selector.format == :css + if selector_format == :css if node.method(:find_css).arity != 1 node.find_css(css, **hints) else node.find_css(css) end - elsif selector.format == :xpath + elsif selector_format == :xpath if node.method(:find_xpath).arity != 1 node.find_xpath(xpath(exact), **hints) else node.find_xpath(xpath(exact)) end else - raise ArgumentError, "Unknown format: #{selector.format}" + raise ArgumentError, "Unknown format: #{selector_format}" end end @@ -220,6 +231,8 @@ def matches_node_filters?(node, errors) unapplied_options = options.keys - valid_keys @selector.with_filter_errors(errors) do node_filters.all? do |filter_name, filter| + next true unless apply_filter?(filter) + if filter.matcher? unapplied_options.select { |option_name| filter.handles_option?(option_name) }.all? do |option_name| unapplied_options.delete(option_name) @@ -310,6 +323,8 @@ def use_default_style_filter? def apply_expression_filters(expression) unapplied_options = options.keys - valid_keys expression_filters.inject(expression) do |expr, (name, ef)| + next expr unless apply_filter?(ef) + if ef.matcher? unapplied_options.select(&ef.method(:handles_option?)).inject(expr) do |memo, option_name| unapplied_options.delete(option_name) @@ -348,10 +363,14 @@ def simple_root?(node) node.is_a?(::Capybara::Node::Simple) && node.path == '/' end + def apply_filter?(filter) + filter.format.nil? || (filter.format == selector_format) + end + def matches_locator_filter?(node) - return true unless @selector.locator_filter + return true unless @selector.locator_filter && apply_filter?(@selector.locator_filter) - @selector.locator_filter.matches?(node, @locator, @selector) + @selector.locator_filter.matches?(node, @locator, @selector, exact: exact?) end def matches_system_filters?(node) diff --git a/lib/capybara/selector.rb b/lib/capybara/selector.rb index 1f61d55ad5..05e8ac1de5 100644 --- a/lib/capybara/selector.rb +++ b/lib/capybara/selector.rb @@ -38,8 +38,9 @@ locator_filter { |node, id| id.is_a?(Regexp) ? node[:id] =~ id : true } end -Capybara.add_selector(:field, locator_type: [String, Symbol]) do +Capybara.add_selector(:field, locator_type: [String, Symbol], supports_exact: true) do visible { |options| :hidden if options[:type].to_s == 'hidden' } + xpath do |locator, **options| invalid_types = %w[submit image] invalid_types << 'hidden' unless options[:type].to_s == 'hidden' @@ -47,18 +48,86 @@ locate_field(xpath, locator, options) end + css do |_locator, **options| + invalid_types = %w[submit image] + invalid_types << 'hidden' unless options[:type].to_s == 'hidden' + invalid_attributes = invalid_types.map { |type| ":not([type=#{type}])" }.join + "input#{invalid_attributes}, textarea, select" + end + + locator_filter(skip_if: nil, format: :css) do |node, locator, exact:, **_| + optional_checks = +'' + optional_checks << "(field.getAttribute('aria-label') == locator)||" if enable_aria_label + optional_checks << "(field.getAttribute('#{test_id}') == locator)||" if test_id + + match_js = <<~JS + (function(field, locator){ + return ( + (field.id == locator) || + (field.name == locator) || + (field.placeholder == locator)|| + #{optional_checks} + Array.from(field.labels || []).some(function(label){ + return label.innerText#{exact ? '== locator' : '.includes(locator)'}; + }) + ); + })(this, arguments[0]) + JS + node.evaluate_script(match_js, locator) + end + expression_filter(:type) do |expr, type| type = type.to_s - if %w[textarea select].include?(type) - expr.self(type.to_sym) + case default_format + when :css + if %w[textarea select].include?(type) + ::Capybara::Selector::CSS.split(expr).select do |css_fragment| + css_fragment.start_with? type + end.join(',') + else + ::Capybara::Selector::CSSBuilder.new(expr).add_attribute_conditions(type: type) + end + when :xpath + if %w[textarea select].include?(type) + expr.self(type.to_sym) + else + expr[XPath.attr(:type) == type] + end else - expr[XPath.attr(:type) == type] + raise ArgumentError, "Unknown format type: #{default_format}" end end filter_set(:_field) # checked/unchecked/disabled/multiple/name/placeholder - node_filter(:readonly, :boolean) { |node, value| !(value ^ node.readonly?) } + expression_filter(:name) do |expr, val| + if default_format == :css + ::Capybara::Selector::CSSBuilder.new(expr).add_attribute_conditions(name: val) + else + expr[XPath.attr(:name) == val] + end + end + expression_filter(:placeholder) do |expr, val| + if default_format == :css + ::Capybara::Selector::CSSBuilder.new(expr).add_attribute_conditions(placeholder: val) + else + expr[XPath.attr(:placeholder) == val] + end + end + expression_filter(:readonly, :boolean, format: :css) do |expr, val| + ::Capybara::Selector::CSS.split(expr).map do |css_fragment| + if val + "#{css_fragment}:read-only" + else + "#{css_fragment}:read-write" + end + end.join(',') + end + + node_filter(:readonly, :boolean, format: :xpath) do |node, value| + !(value ^ node.readonly?) + end + node_filter(:with) do |node, with| val = node.value (with.is_a?(Regexp) ? val =~ with : val == with.to_s).tap do |res| @@ -169,8 +238,8 @@ Capybara.add_selector(:link_or_button, locator_type: [String, Symbol]) do label 'link or button' xpath do |locator, **options| - self.class.all.values_at(:link, :button).map do |selector| - instance_exec(locator, options, &selector.xpath) + %i[link button].map do |selector| + expression_for(selector, locator, format: :xpath, **options) end.reduce(:union) end @@ -284,7 +353,7 @@ expression_filter(:with_options) do |expr, options| options.inject(expr) do |xpath, option| - xpath[self.class.all[:option].call(option)] + xpath[expression_for(:option, option, format: :xpath)] end end @@ -337,7 +406,7 @@ expression_filter(:with_options) do |expr, options| options.inject(expr) do |xpath, option| - xpath[XPath.attr(:list) == XPath.anywhere(:datalist)[self.class.all[:datalist_option].call(option)].attr(:id)] + xpath[XPath.attr(:list) == XPath.anywhere(:datalist)[expression_for(:datalist_option, option, format: :xpath)].attr(:id)] end end diff --git a/lib/capybara/selector/css.rb b/lib/capybara/selector/css.rb index 58a574762a..556bb3d821 100644 --- a/lib/capybara/selector/css.rb +++ b/lib/capybara/selector/css.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require 'capybara/selector/selector' + module Capybara class Selector class CSS diff --git a/lib/capybara/selector/definition.rb b/lib/capybara/selector/definition.rb new file mode 100644 index 0000000000..7b084d646c --- /dev/null +++ b/lib/capybara/selector/definition.rb @@ -0,0 +1,273 @@ +# frozen_string_literal: true + +require 'capybara/selector/filter_set' +require 'capybara/selector/css' +require 'capybara/selector/regexp_disassembler' +require 'capybara/selector/builders/xpath_builder' +require 'capybara/selector/builders/css_builder' + +module Capybara + class Selector + class Definition + attr_reader :name, :expressions + extend Forwardable + + def initialize(name, locator_type: nil, raw_locator: false, supports_exact: nil, &block) + @name = name + @filter_set = Capybara::Selector::FilterSet.add(name) {} + @match = nil + @label = nil + @failure_message = nil + @expressions = {} + @expression_filters = {} + @locator_filter = nil + @default_visibility = nil + @locator_type = locator_type + @raw_locator = raw_locator + @supports_exact = supports_exact + instance_eval(&block) + end + + def custom_filters + warn "Deprecated: Selector#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 + @filter_set.node_filters + end + + def expression_filters + @filter_set.expression_filters + end + + ## + # + # Define a selector by an xpath expression + # + # @overload xpath(*expression_filters, &block) + # @param [Array] expression_filters ([]) Names of filters that are implemented via this expression, if not specified the names of any keyword parameters in the block will be used + # @yield [locator, options] The block to use to generate the XPath expression + # @yieldparam [String] locator The locator string passed to the query + # @yieldparam [Hash] options The options hash passed to the query + # @yieldreturn [#to_xpath, #to_s] An object that can produce an xpath expression + # + # @overload xpath() + # @return [#call] The block that will be called to generate the XPath expression + # + def xpath(*allowed_filters, &block) + expression(:xpath, allowed_filters, &block) + end + + ## + # + # Define a selector by a CSS selector + # + # @overload css(*expression_filters, &block) + # @param [Array] expression_filters ([]) Names of filters that can be implemented via this CSS selector + # @yield [locator, options] The block to use to generate the CSS selector + # @yieldparam [String] locator The locator string passed to the query + # @yieldparam [Hash] options The options hash passed to the query + # @yieldreturn [#to_s] An object that can produce a CSS selector + # + # @overload css() + # @return [#call] The block that will be called to generate the CSS selector + # + def css(*allowed_filters, &block) + expression(:css, allowed_filters, &block) + end + + ## + # + # Automatic selector detection + # + # @yield [locator] This block takes the passed in locator string and returns whether or not it matches the selector + # @yieldparam [String], locator The locator string used to determin if it matches the selector + # @yieldreturn [Boolean] Whether this selector matches the locator string + # @return [#call] The block that will be used to detect selector match + # + def match(&block) + @match = block if block + @match + end + + ## + # + # Set/get a descriptive label for the selector + # + # @overload label(label) + # @param [String] label A descriptive label for this selector - used in error messages + # @overload label() + # @return [String] The currently set label + # + def label(label = nil) + @label = label if label + @label + end + + ## + # + # Description of the selector + # + # @!method description(options) + # @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 + + ## + # + # Should this selector be used for the passed in locator + # + # This is used by the automatic selector selection mechanism when no selector type is passed to a selector query + # + # @param [String] locator The locator passed to the query + # @return [Boolean] Whether or not to use this selector + # + def match?(locator) + @match&.call(locator) + end + + ## + # + # Define a node filter for use with this selector + # + # @!method node_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 + # @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 + # the block should return `true` if the node passes the filer or `false` if it doesn't + + # @!method filter + # See {Selector#node_filter} + + ## + # + # Define an expression filter for use with this selector + # + # @!method expression_filter(name, *types, matcher: nil, **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] 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 + # the block should return the modified expression + + def_delegators :@filter_set, :node_filter, :expression_filter, :filter + + def locator_filter(*types, **options, &block) + types.each { |type| options[type] = true } + @locator_filter = Capybara::Selector::Filters::LocatorFilter.new(block, options) if block + @locator_filter + end + + def filter_set(name, filters_to_use = nil) + @filter_set.import(name, filters_to_use) + end + + def_delegator :@filter_set, :describe + + def describe_expression_filters(&block) + if block_given? + describe(:expression_filters, &block) + else + describe(:expression_filters) do |**options| + describe_all_expression_filters(options) + end + end + end + + def describe_all_expression_filters(**opts) + expression_filters.map do |ef_name, ef| + if ef.matcher? + handled_custom_keys(ef, opts.keys).map { |key| " with #{ef_name}[#{key} => #{opts[key]}]" }.join + elsif opts.key?(ef_name) + " with #{ef_name} #{opts[ef_name]}" + end + end.join + end + + def describe_node_filters(&block) + describe(:node_filters, &block) + end + + ## + # + # Set the default visibility mode that shouble be used if no visibile option is passed when using the selector. + # If not specified will default to the behavior indicated by Capybara.ignore_hidden_elements + # + # @param [Symbol] default_visibility Only find elements with the specified visibility: + # * :all - finds visible and invisible elements. + # * :hidden - only finds invisible elements. + # * :visible - only finds visible elements. + def visible(default_visibility = nil, &block) + @default_visibility = block || default_visibility + end + + def default_visibility(fallback = Capybara.ignore_hidden_elements, options = {}) + vis = if @default_visibility&.respond_to?(:call) + @default_visibility.call(options) + else + @default_visibility + end + vis.nil? ? fallback : vis + end + + # @api private + def raw_locator? + !!@raw_locator + end + + # @api private + def supports_exact? + @supports_exact + end + + def default_format + return nil if @expressions.keys.empty? + + if @expressions.size == 1 + @expressions.keys.first + else + :xpath + end + end + + # @api private + def locator_types + return nil unless @locator_type + + Array(@locator_type) + end + + private + + def parameter_names(block) + block.parameters.select { |(type, _name)| %i[key keyreq].include? type }.map { |(_type, name)| name } + end + + def expression(type, allowed_filters, &block) + if block + @expressions[type] = block + allowed_filters = parameter_names(block) if allowed_filters.empty? + allowed_filters.flatten.each do |ef| + expression_filters[ef] = Capybara::Selector::Filters::IdentityExpressionFilter.new(ef) + end + end + @expressions[type] + end + end + end +end diff --git a/lib/capybara/selector/filters/base.rb b/lib/capybara/selector/filters/base.rb index 5d2ad0cb63..a5f4d6b5a2 100644 --- a/lib/capybara/selector/filters/base.rb +++ b/lib/capybara/selector/filters/base.rb @@ -24,6 +24,10 @@ def skip?(value) @options.key?(:skip_if) && value == @options[:skip_if] end + def format + @options[:format] + end + def matcher? !@matcher.nil? end diff --git a/lib/capybara/selector/filters/locator_filter.rb b/lib/capybara/selector/filters/locator_filter.rb index a63ff8fcfc..89d4ec19d7 100644 --- a/lib/capybara/selector/filters/locator_filter.rb +++ b/lib/capybara/selector/filters/locator_filter.rb @@ -10,8 +10,18 @@ def initialize(block, **options) super(nil, nil, block, options) end - def matches?(node, value, context = nil) - super(node, nil, value, context) + def matches?(node, value, context = nil, exact:) + apply(node, value, true, context, exact: exact, format: context&.default_format) + rescue Capybara::ElementNotFound + false + end + + private + + def apply(subject, value, skip_value, ctx, **options) + return skip_value if skip?(value) + + filter_context(ctx).instance_exec(subject, value, **options, &@block) end end end diff --git a/lib/capybara/selector/selector.rb b/lib/capybara/selector/selector.rb index 45c7fdbf1d..f13cd31ac9 100644 --- a/lib/capybara/selector/selector.rb +++ b/lib/capybara/selector/selector.rb @@ -2,12 +2,6 @@ # rubocop:disable Style/AsciiComments -require 'capybara/selector/filter_set' -require 'capybara/selector/css' -require 'capybara/selector/regexp_disassembler' -require 'capybara/selector/builders/xpath_builder' -require 'capybara/selector/builders/css_builder' - module Capybara # # ## Built-in Selectors @@ -171,13 +165,11 @@ module Capybara # * Locator: Type of element ('div', 'a', etc) - if not specified defaults to '*' # * Filters: Matches on any element attribute # - class Selector - attr_reader :name, :format - extend Forwardable + class Selector < SimpleDelegator class << self def all - @selectors ||= {} # rubocop:disable Naming/MemoizedInstanceVariableName + @definitions ||= {} # rubocop:disable Naming/MemoizedInstanceVariableName end def [](name) @@ -185,7 +177,7 @@ def [](name) end def add(name, **options, &block) - all[name.to_sym] = Capybara::Selector.new(name.to_sym, **options, &block) + all[name.to_sym] = Definition.new(name.to_sym, **options, &block) end def update(name, &block) @@ -201,116 +193,34 @@ def for(locator) end end - def initialize(name, locator_type: nil, raw_locator: false, &block) - @name = name - @filter_set = FilterSet.add(name) {} - @match = nil - @label = nil - @failure_message = nil - @format = nil - @expression = nil - @expression_filters = {} - @locator_filter = nil - @default_visibility = nil - @locator_type = locator_type - @raw_locator = raw_locator - @config = { - enable_aria_label: false, - test_id: nil - } - instance_eval(&block) - end - - def custom_filters - warn "Deprecated: Selector#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 - @filter_set.node_filters - end + attr_reader :errors - def expression_filters - @filter_set.expression_filters + def initialize(definition, selector_config:, selector_format:) + definition = self.class[definition] unless definition.is_a? Definition + super(definition) + @definition = definition + @selector_config = selector_config + @selector_format = selector_format + @errors = [] end - ## - # - # Define a selector by an xpath expression - # - # @overload xpath(*expression_filters, &block) - # @param [Array] expression_filters ([]) Names of filters that are implemented via this expression, if not specified the names of any keyword parameters in the block will be used - # @yield [locator, options] The block to use to generate the XPath expression - # @yieldparam [String] locator The locator string passed to the query - # @yieldparam [Hash] options The options hash passed to the query - # @yieldreturn [#to_xpath, #to_s] An object that can produce an xpath expression - # - # @overload xpath() - # @return [#call] The block that will be called to generate the XPath expression - # - def xpath(*allowed_filters, &block) - expression(:xpath, allowed_filters, &block) + def selector_format + @selector_format || @definition.default_format end - ## - # - # Define a selector by a CSS selector - # - # @overload css(*expression_filters, &block) - # @param [Array] expression_filters ([]) Names of filters that can be implemented via this CSS selector - # @yield [locator, options] The block to use to generate the CSS selector - # @yieldparam [String] locator The locator string passed to the query - # @yieldparam [Hash] options The options hash passed to the query - # @yieldreturn [#to_s] An object that can produce a CSS selector - # - # @overload css() - # @return [#call] The block that will be called to generate the CSS selector - # - def css(*allowed_filters, &block) - expression(:css, allowed_filters, &block) - end - - ## - # - # Automatic selector detection - # - # @yield [locator] This block takes the passed in locator string and returns whether or not it matches the selector - # @yieldparam [String], locator The locator string used to determin if it matches the selector - # @yieldreturn [Boolean] Whether this selector matches the locator string - # @return [#call] The block that will be used to detect selector match - # - def match(&block) - @match = block if block - @match + def enable_aria_label + @selector_config[:enable_aria_label] end - ## - # - # Set/get a descriptive label for the selector - # - # @overload label(label) - # @param [String] label A descriptive label for this selector - used in error messages - # @overload label() - # @return [String] The currently set label - # - def label(label = nil) - @label = label if label - @label + def test_id + @selector_config[:test_id] end - ## - # - # Description of the selector - # - # @!method description(options) - # @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 call(locator, **options) + if selector_format + raise ArgumentError, "Selector #{@name} does not support #{selector_format}" unless expressions.key?(selector_format) - def call(locator, selector_config: {}, **options) - @config.merge! selector_config - if format - @expression.call(locator, options) + instance_exec(locator, options, &expressions[selector_format]) else warn 'Selector has no format' end @@ -318,152 +228,37 @@ def call(locator, selector_config: {}, **options) warn "Locator #{locator.inspect} must #{locator_description}. This will raise an error in a future version of Capybara." unless locator_valid?(locator) end - ## - # - # Should this selector be used for the passed in locator - # - # This is used by the automatic selector selection mechanism when no selector type is passed to a selector query - # - # @param [String] locator The locator passed to the query - # @return [Boolean] Whether or not to use this selector - # - def match?(locator) - @match&.call(locator) - end - - ## - # - # Define a node filter for use with this selector - # - # @!method node_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 - # @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 - # the block should return `true` if the node passes the filer or `false` if it doesn't - - # @!method filter - # See {Selector#node_filter} - - ## - # - # Define an expression filter for use with this selector - # - # @!method expression_filter(name, *types, matcher: nil, **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] 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 - # the block should return the modified expression - - def_delegators :@filter_set, :node_filter, :expression_filter, :filter - - def locator_filter(*types, **options, &block) - types.each { |type| options[type] = true } - @locator_filter = Filters::LocatorFilter.new(block, options) if block - @locator_filter - end - - def filter_set(name, filters_to_use = nil) - @filter_set.import(name, filters_to_use) - end - - def_delegator :@filter_set, :describe - - def describe_expression_filters(&block) - if block_given? - describe(:expression_filters, &block) - else - describe(:expression_filters) do |**options| - describe_all_expression_filters(options) - end - end - end - - def describe_node_filters(&block) - describe(:node_filters, &block) - end - - ## - # - # Set the default visibility mode that shouble be used if no visibile option is passed when using the selector. - # If not specified will default to the behavior indicated by Capybara.ignore_hidden_elements - # - # @param [Symbol] default_visibility Only find elements with the specified visibility: - # * :all - finds visible and invisible elements. - # * :hidden - only finds invisible elements. - # * :visible - only finds visible elements. - def visible(default_visibility = nil, &block) - @default_visibility = block || default_visibility + def add_error(error_msg) + errors << error_msg end - def default_visibility(fallback = Capybara.ignore_hidden_elements, options = {}) - vis = if @default_visibility&.respond_to?(:call) - @default_visibility.call(options) - else - @default_visibility - end - vis.nil? ? fallback : vis + def expression_for(name, locator, config: @selector_config, format: selector_format, **options) + Selector.new(name, selector_config: config, selector_format: format).call(locator, **options) end - def add_error(error_msg) - errors << error_msg + # @api private + def with_filter_errors(errors) + old_errors = @errors + @errors = errors + yield + ensure + @errors = old_errors end # @api private def builder(expr = nil) - case format + case selector_format when :css Capybara::Selector::CSSBuilder when :xpath Capybara::Selector::XPathBuilder else - raise NotImplementedError, "No builder exists for selector of type #{format}" + raise NotImplementedError, "No builder exists for selector of type #{default_format}" end.new(expr) 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 - - # @api private - def raw_locator? - !!@raw_locator - end - private - def locator_types - return nil unless @locator_type - - Array(@locator_type) - end - - def locator_valid?(locator) - return true unless locator && locator_types - - locator_types&.any? do |type_or_method| - type_or_method.is_a?(Symbol) ? locator.respond_to?(type_or_method) : type_or_method === locator # rubocop:disable Style/CaseEquality - end - end - def locator_description locator_types.group_by { |lt| lt.is_a? Symbol }.map do |symbol, types_or_methods| if symbol @@ -474,16 +269,12 @@ def locator_description end.join(' or ') end - def errors - Thread.current["capybara_#{object_id}_errors"] || [] - end - - def enable_aria_label - @config[:enable_aria_label] - end + def locator_valid?(locator) + return true unless locator && locator_types - def test_id - @config[:test_id] + locator_types&.any? do |type_or_method| + type_or_method.is_a?(Symbol) ? locator.respond_to?(type_or_method) : type_or_method === locator # rubocop:disable Style/CaseEquality + end end def locate_field(xpath, locator, **_options) @@ -502,22 +293,6 @@ def locate_field(xpath, locator, **_options) locate_xpath + XPath.descendant(:label)[XPath.string.n.is(locator)].descendant(xpath) end - def describe_all_expression_filters(**opts) - expression_filters.map do |ef_name, ef| - if ef.matcher? - handled_custom_keys(ef, opts.keys).map { |key| " with #{ef_name}[#{key} => #{opts[key]}]" }.join - elsif opts.key?(ef_name) - " with #{ef_name} #{opts[ef_name]}" - end - end.join - end - - def handled_custom_keys(filter, keys) - keys.select do |key| - filter.handles_option?(key) && !::Capybara::Queries::SelectorQuery::VALID_KEYS.include?(key) - end - end - def find_by_attr(attribute, value) finder_name = "find_by_#{attribute}_attr" if respond_to?(finder_name, true) @@ -531,19 +306,14 @@ def find_by_class_attr(classes) Array(classes).map { |klass| XPath.attr(:class).contains_word(klass) }.reduce(:&) end - def parameter_names(block) - block.parameters.select { |(type, _name)| %i[key keyreq].include? type }.map { |(_type, name)| name } - end - - def expression(type, allowed_filters, &block) - if block - @format, @expression = type, block - allowed_filters = parameter_names(block) if allowed_filters.empty? - allowed_filters.flatten.each { |ef| expression_filters[ef] = Filters::IdentityExpressionFilter.new(ef) } + def handled_custom_keys(filter, keys) + keys.select do |key| + filter.handles_option?(key) && !::Capybara::Queries::SelectorQuery::VALID_KEYS.include?(key) end - format == type ? @expression : nil end end end # rubocop:enable Style/AsciiComments + +require 'capybara/selector/definition' diff --git a/lib/capybara/spec/session/all_spec.rb b/lib/capybara/spec/session/all_spec.rb index 49d66b81ac..b1476663fd 100644 --- a/lib/capybara/spec/session/all_spec.rb +++ b/lib/capybara/spec/session/all_spec.rb @@ -30,7 +30,7 @@ it 'should accept an XPath instance', :exact_false do @session.visit('/form') - @xpath = Capybara::Selector[:fillable_field].call('Name') + @xpath = Capybara::Selector.new(:fillable_field, selector_config: {}, selector_format: :xpath).call('Name') expect(@xpath).to be_a(::XPath::Union) @result = @session.all(@xpath).map(&:value) expect(@result).to include('Smith', 'John', 'John Smith') diff --git a/lib/capybara/spec/session/find_spec.rb b/lib/capybara/spec/session/find_spec.rb index 801c82e4a1..52027921b3 100644 --- a/lib/capybara/spec/session/find_spec.rb +++ b/lib/capybara/spec/session/find_spec.rb @@ -235,7 +235,7 @@ it 'should accept an XPath instance' do @session.visit('/form') - @xpath = Capybara::Selector[:fillable_field].call('First Name') + @xpath = Capybara::Selector.new(:fillable_field, selector_config: {}, selector_format: :xpath).call('First Name') expect(@xpath).to be_a(::XPath::Union) expect(@session.find(@xpath).value).to eq('John') end diff --git a/lib/capybara/spec/session/first_spec.rb b/lib/capybara/spec/session/first_spec.rb index 386c270aa7..c1d2c6c3ef 100644 --- a/lib/capybara/spec/session/first_spec.rb +++ b/lib/capybara/spec/session/first_spec.rb @@ -24,7 +24,7 @@ it 'should accept an XPath instance' do @session.visit('/form') - @xpath = Capybara::Selector[:fillable_field].call('First Name') + @xpath = Capybara::Selector.new(:fillable_field, selector_config: {}, selector_format: :xpath).call('First Name') expect(@xpath).to be_a(::XPath::Union) expect(@session.first(@xpath).value).to eq('John') end