# frozen_string_literal: true # 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 # # * **:xpath** - Select elements by XPath expression # * Locator: An XPath expression # # * **:css** - Select elements by CSS selector # * Locator: A CSS selector # # * **:id** - Select element by id # * Locator: (String, Regexp, XPath::Expression) The id of the element to match # # * **:field** - Select field elements (input [not of type submit, image, or hidden], textarea, select) # * Locator: Matches against the id, Capybara.test_id attribute, name, or placeholder # * Filters: # * :id (String, Regexp, XPath::Expression) — Matches the id attribute # * :name (String) — Matches the name attribute # * :placeholder (String) — Matches the placeholder attribute # * :type (String) — Matches the type attribute of the field or element type for 'textarea' and 'select' # * :readonly (Boolean) # * :with (String) — Matches the current value of the field # * :class (String, Array, Regexp, XPath::Expression) — Matches the class(es) provided # * :checked (Boolean) — Match checked fields? # * :unchecked (Boolean) — Match unchecked fields? # * :disabled (Boolean) — Match disabled field? # * :multiple (Boolean) — Match fields that accept multiple values # * :style (String, Regexp, Hash) # # * **:fieldset** - Select fieldset elements # * Locator: Matches id or contents of wrapped legend # * Filters: # * :id (String, Regexp, XPath::Expression) — Matches id attribute # * :legend (String) — Matches contents of wrapped legend # * :class (String, Array, Regexp, XPath::Expression) — Matches the class(es) provided # * :style (String, Regexp, Hash) # # * **:link** - Find links ( elements with an href attribute ) # * Locator: Matches the id or title attributes, or the string content of the link, or the alt attribute of a contained img element # * Filters: # * :id (String, Regexp, XPath::Expression) — Matches the id attribute # * :title (String) — Matches the title attribute # * :alt (String) — Matches the alt attribute of a contained img element # * :class (String, Array, Regexp, XPath::Expression) — Matches the class(es) provided # * :href (String, Regexp, nil) — Matches the normalized href of the link, if nil will find elements with no href attribute # * :style (String, Regexp, Hash) # # * **:button** - Find buttons ( input [of type submit, reset, image, button] or button elements ) # * Locator: Matches the id, Capybara.test_id attribute, value, or title attributes, string content of a button, or the alt attribute of an image type button or of a descendant image of a button # * Filters: # * :id (String, Regexp, XPath::Expression) — Matches the id attribute # * :name (String) - Matches the name attribute # * :title (String) — Matches the title attribute # * :class (String, Array, Regexp, XPath::Expression) — Matches the class(es) provided # * :value (String) — Matches the value of an input button # * :type # * :style (String, Regexp, Hash) # # * **:link_or_button** - Find links or buttons # * Locator: See :link and :button selectors # # * **:fillable_field** - Find text fillable fields ( textarea, input [not of type submit, image, radio, checkbox, hidden, file] ) # * Locator: Matches against the id, Capybara.test_id attribute, name, or placeholder # * Filters: # * :id (String, Regexp, XPath::Expression) — Matches the id attribute # * :name (String) — Matches the name attribute # * :placeholder (String) — Matches the placeholder attribute # * :with (String) — Matches the current value of the field # * :type (String) — Matches the type attribute of the field or element type for 'textarea' # * :class (String, Array, Regexp, XPath::Expression) — Matches the class(es) provided # * :disabled (Boolean) — Match disabled field? # * :multiple (Boolean) — Match fields that accept multiple values # * :style (String, Regexp, Hash) # # * **:radio_button** - Find radio buttons # * Locator: Match id, Capybara.test_id attribute, name, or associated label text # * Filters: # * :id (String, Regexp, XPath::Expression) — Matches the id attribute # * :name (String) — Matches the name attribute # * :class (String, Array, Regexp, XPath::Expression) — Matches the class(es) provided # * :checked (Boolean) — Match checked fields? # * :unchecked (Boolean) — Match unchecked fields? # * :disabled (Boolean) — Match disabled field? # * :option (String) — Match the value # * :style (String, Regexp, Hash) # # * **:checkbox** - Find checkboxes # * Locator: Match id, Capybara.test_id attribute, name, or associated label text # * Filters: # * *:id (String, Regexp, XPath::Expression) — Matches the id attribute # * *:name (String) — Matches the name attribute # * *:class (String, Array, Regexp, XPath::Expression) — Matches the class(es) provided # * *:checked (Boolean) — Match checked fields? # * *:unchecked (Boolean) — Match unchecked fields? # * *:disabled (Boolean) — Match disabled field? # * *:option (String) — Match the value # * :style (String, Regexp, Hash) # # * **:select** - Find select elements # * Locator: Match id, Capybara.test_id attribute, name, placeholder, or associated label text # * Filters: # * :id (String, Regexp, XPath::Expression) — Matches the id attribute # * :name (String) — Matches the name attribute # * :placeholder (String) — Matches the placeholder attribute # * :class (String, Array, Regexp, XPath::Expression) — Matches the class(es) provided # * :disabled (Boolean) — Match disabled field? # * :multiple (Boolean) — Match fields that accept multiple values # * :options (Array) — Exact match options # * :with_options (Array) — Partial match options # * :selected (String, Array) — Match the selection(s) # * :with_selected (String, Array) — Partial match the selection(s) # * :style (String, Regexp, Hash) # # * **:option** - Find option elements # * Locator: Match text of option # * Filters: # * :disabled (Boolean) — Match disabled option # * :selected (Boolean) — Match selected option # # * **:datalist_input** # * Locator: # * Filters: # * :disabled # * :name # * :placeholder # # * **:datalist_option** # * Locator: # # * **:file_field** - Find file input elements # * Locator: Match id, Capybara.test_id attribute, name, or associated label text # * Filters: # * :id (String, Regexp, XPath::Expression) — Matches the id attribute # * :name (String) — Matches the name attribute # * :class (String, Array, Regexp, XPath::Expression) — Matches the class(es) provided # * :disabled (Boolean) — Match disabled field? # * :multiple (Boolean) — Match field that accepts multiple values # * :style (String, Regexp, Hash) # # * **:label** - Find label elements # * Locator: Match id or text contents # * Filters: # * :for (Element, String) — The element or id of the element associated with the label # # * **:table** - Find table elements # * Locator: id or caption text of table # * Filters: # * :id (String, Regexp, XPath::Expression) — Match id attribute of table # * :caption (String) — Match text of associated caption # * :class ((String, Array, Regexp, XPath::Expression) — Matches the class(es) provided # * :style (String, Regexp, Hash) # * :with_rows (Array>, Array>) - Partial match data - visibility of elements is not considered # * :rows (Array>) — Match all s - visibility of elements is not considered # * :with_cols (Array>, Array>) - Partial match data - visibility of elements is not considered # * :cols (Array>) — Match all s - visibility of elements is not considered # # * **:table_row** - Find table row # * Locator: Array, Hash table row contents - visibility of elements is not considered # # * **:frame** - Find frame/iframe elements # * Locator: Match id or name # * Filters: # * :id (String, Regexp, XPath::Expression) — Match id attribute # * :name (String) — Match name attribute # * :class (String, Array, Regexp, XPath::Expression) — Matches the class(es) provided # * :style (String, Regexp, Hash) # # * **:element** # * 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 << self def all @selectors ||= {} # rubocop:disable Naming/MemoizedInstanceVariableName end def [](name) all.fetch(name.to_sym) { |sel_type| raise ArgumentError, "Unknown selector type (:#{sel_type})" } end def add(name, **options, &block) all[name.to_sym] = Capybara::Selector.new(name.to_sym, **options, &block) end def update(name, &block) self[name].instance_eval(&block) end def remove(name) all.delete(name.to_sym) end def for(locator) all.values.find { |sel| sel.match?(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 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 def call(locator, selector_config: {}, **options) @config.merge! selector_config if format @expression.call(locator, options) else warn 'Selector has no format' end ensure 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 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 def add_error(error_msg) errors << error_msg end # @api private def builder(expr = nil) case format when :css Capybara::Selector::CSSBuilder when :xpath Capybara::Selector::XPathBuilder else raise NotImplementedError, "No builder exists for selector of type #{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 "respond to #{types_or_methods.join(' or ')}" else "be an instance of #{types_or_methods.join(' or ')}" end end.join(' or ') end def errors Thread.current["capybara_#{object_id}_errors"] || [] end def enable_aria_label @config[:enable_aria_label] end def test_id @config[:test_id] end def locate_field(xpath, locator, **_options) return xpath if locator.nil? locate_xpath = xpath # Need to save original xpath for the label wrap locator = locator.to_s attr_matchers = [XPath.attr(:id) == locator, XPath.attr(:name) == locator, XPath.attr(:placeholder) == locator, XPath.attr(:id) == XPath.anywhere(:label)[XPath.string.n.is(locator)].attr(:for)].reduce(:|) attr_matchers |= XPath.attr(:'aria-label').is(locator) if enable_aria_label attr_matchers |= XPath.attr(test_id) == locator if test_id locate_xpath = locate_xpath[attr_matchers] 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) send(finder_name, value) else value ? XPath.attr(attribute) == value : nil end end 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) } end format == type ? @expression : nil end end end # rubocop:enable Style/AsciiComments