From 59f5c09854ade060fec9225cfb6b6cc436196f6e Mon Sep 17 00:00:00 2001 From: Thomas Walpole Date: Wed, 7 Sep 2016 00:34:15 -0700 Subject: [PATCH] Add class as selectable attribute to some selectors --- gemfiles/Gemfile.beta-versions | 5 + lib/capybara/node/actions.rb | 28 +++-- lib/capybara/node/finders.rb | 9 ++ lib/capybara/rspec/features.rb | 4 +- lib/capybara/selector.rb | 35 +++--- lib/capybara/selector/selector.rb | 119 +++++++++++++++++++- lib/capybara/spec/session/selectors_spec.rb | 6 + lib/capybara/spec/views/form.erb | 6 +- 8 files changed, 175 insertions(+), 37 deletions(-) diff --git a/gemfiles/Gemfile.beta-versions b/gemfiles/Gemfile.beta-versions index fd6ee19f..9f8fa09e 100644 --- a/gemfiles/Gemfile.beta-versions +++ b/gemfiles/Gemfile.beta-versions @@ -6,3 +6,8 @@ gemspec :path => '..' gem 'xpath', github: 'jnicklas/xpath' gem 'rack', github: 'rack/rack' gem 'sinatra', github: 'sinatra/sinatra', branch: 'master' +gem 'rspec', github: 'rspec/rspec' +gem 'rspec-core', github: 'rspec/rspec-core' +gem 'rspec-support', github: 'rspec/rspec-support' +gem 'rspec-mocks', github: 'rspec/rspec-mocks' +gem 'rspec-expectations', github: 'rspec/rspec-expectations' diff --git a/lib/capybara/node/actions.rb b/lib/capybara/node/actions.rb index 90dd1450..60fd9550 100644 --- a/lib/capybara/node/actions.rb +++ b/lib/capybara/node/actions.rb @@ -64,16 +64,18 @@ module Capybara # # page.fill_in 'Name', :with => 'Bob' # - # @macro waiting_behavior # - # @param [String] locator Which field to fill in - # @param [Hash] options - # @option options [String] :with The value to fill in - required - # @option options [Hash] :fill_options Driver specific options regarding how to fill fields - # @option options [Boolean] :multiple Match fields that can have multiple values? - # @option options [String] id Match fields that match the id attribute - # @option options [String] name Match fields that match the name attribute - # @option options [String] placeholder Match fields that match the placeholder attribute + # @overload fill_in([locator], options={}) + # @param [String] locator Which field to fill in + # @param [Hash] options + # @macro waiting_behavior + # @option options [String] :with The value to fill in - required + # @option options [Hash] :fill_options Driver specific options regarding how to fill fields + # @option options [Boolean] :multiple Match fields that can have multiple values? + # @option options [String] :id Match fields that match the id attribute + # @option options [String] :name Match fields that match the name attribute + # @option options [String] :placeholder Match fields that match the placeholder attribute + # @option options [String, Array] :class Match links that match the class(es) provided # def fill_in(locator, options={}) locator, options = nil, locator if locator.is_a? Hash @@ -97,8 +99,9 @@ module Capybara # @param [String] locator Which radio button to choose # # @option options [String] :option Value of the radio_button to choose - # @option options [String] id Match fields that match the id attribute - # @option options [String] name Match fields that match the name attribute + # @option options [String] :id Match fields that match the id attribute + # @option options [String] :name Match fields that match the name attribute + # @option options [String, Array] :class Match links that match the class(es) provided # @macro waiting_behavior # @macro label_click def choose(locator, options={}) @@ -133,6 +136,7 @@ module Capybara # @option options [String] :option Value of the checkbox to select # @option options [String] id Match fields that match the id attribute # @option options [String] name Match fields that match the name attribute + # @option options [String, Array] :class Match links that match the class(es) provided # @macro label_click # @macro waiting_behavior # @@ -168,6 +172,7 @@ module Capybara # @option options [String] :option Value of the checkbox to deselect # @option options [String] id Match fields that match the id attribute # @option options [String] name Match fields that match the name attribute + # @option options [String, Array] :class Match links that match the class(es) provided # @macro label_click # @macro waiting_behavior # @@ -253,6 +258,7 @@ module Capybara # @option options [Boolean] multiple Match field which allows multiple file selection # @option options [String] id Match fields that match the id attribute # @option options [String] name Match fields that match the name attribute + # @option options [String, Array] :class Match links that match the class(es) provided # def attach_file(locator, path, options={}) locator, path, options = nil, locator, path if path.is_a? Hash diff --git a/lib/capybara/node/finders.rb b/lib/capybara/node/finders.rb index 7f3c4205..eb7ee606 100644 --- a/lib/capybara/node/finders.rb +++ b/lib/capybara/node/finders.rb @@ -70,6 +70,7 @@ module Capybara # @option options [String] id Match fields that match the id attribute # @option options [String] name Match fields that match the name attribute # @option options [String] placeholder Match fields that match the placeholder attribute + # @option options [String, Array] Match fields that match the class(es) passed # @return [Capybara::Node::Element] The found element # @@ -89,6 +90,10 @@ module Capybara # @macro waiting_behavior # # @option options [String,Regexp] href Value to match against the links href + # @option options [String] id Match links with the id provided + # @option options [String] title Match links with the title provided + # @option options [String] alt Match links with a contained img element whose alt matches + # @option options [String, Array] class Match links that match the class(es) provided # @return [Capybara::Node::Element] The found element # def find_link(locator=nil, options={}) @@ -114,6 +119,10 @@ module Capybara # * true - only finds a disabled button # * false - only finds an enabled button # * :all - finds either an enabled or disabled button + # @option options [String] id Match buttons with the id provided + # @option options [String] title Match buttons with the title provided + # @option options [String] value Match buttons with the value provided + # @option options [String, Array] class Match links that match the class(es) provided # @return [Capybara::Node::Element] The found element # def find_button(locator=nil, options={}) diff --git a/lib/capybara/rspec/features.rb b/lib/capybara/rspec/features.rb index e74060c7..1b8ebbc4 100644 --- a/lib/capybara/rspec/features.rb +++ b/lib/capybara/rspec/features.rb @@ -51,5 +51,7 @@ else RSpec.describe(*args, &block) end - RSpec.configuration.include Capybara::Features, :capybara_feature => true + RSpec.configure do |config| + config.include(Capybara::Features, :capybara_feature => true) + end end diff --git a/lib/capybara/selector.rb b/lib/capybara/selector.rb index 44ae2c2c..72b7e7e9 100644 --- a/lib/capybara/selector.rb +++ b/lib/capybara/selector.rb @@ -32,7 +32,7 @@ Capybara.add_selector(:id) do end Capybara.add_selector(:field) do - xpath(:id, :name, :placeholder, :type) do |locator, options| + xpath(:id, :name, :placeholder, :type, :class) do |locator, options| xpath = XPath.descendant(:input, :textarea, :select)[~XPath.attr(:type).one_of('submit', 'image', 'hidden')] if options[:type] type=options[:type].to_s @@ -42,7 +42,8 @@ Capybara.add_selector(:field) do xpath = xpath[XPath.attr(:type).equals(type)] end end - locate_field(xpath, locator, options) + xpath=locate_field(xpath, locator, options) + xpath end filter_set(:_field) # checked/unchecked/disabled/multiple @@ -61,17 +62,18 @@ Capybara.add_selector(:field) do end Capybara.add_selector(:fieldset) do - xpath(:id, :legend) do |locator, options| + xpath(:id, :legend, :class) do |locator, options| xpath = XPath.descendant(:fieldset) xpath = xpath[XPath.attr(:id).equals(locator.to_s) | XPath.child(:legend)[XPath.string.n.is(locator.to_s)]] unless locator.nil? xpath = xpath[XPath.attr(:id).equals(options[:id])] if options[:id] xpath = xpath[XPath.child(:legend)[XPath.string.n.is(options[:legend])]] if options[:legend] + xpath = xpath[find_by_attr(:class, options[:class])] xpath end end Capybara.add_selector(:link) do - xpath(:id, :title, :alt) do |locator, options={}| + xpath(:id, :title, :alt, :class) do |locator, options={}| xpath = XPath.descendant(:a)[XPath.attr(:href)] unless locator.nil? locator = locator.to_s @@ -82,8 +84,7 @@ Capybara.add_selector(:link) do matchers |= XPath.attr(:'aria-label').is(locator) if Capybara.enable_aria_label xpath = xpath[matchers] end - xpath = xpath[XPath.attr(:id).equals(options[:id])] if options[:id] - xpath = xpath[XPath.attr(:title).equals(options[:title])] if options[:title] + xpath = [:id, :title, :class].inject(xpath) { |memo, ef| memo[find_by_attr(ef, options[ef])] } xpath = xpath[XPath.descendant(:img)[XPath.attr(:alt).equals(options[:alt])]] if options[:alt] xpath end @@ -100,7 +101,7 @@ Capybara.add_selector(:link) do end Capybara.add_selector(:button) do - xpath(:id, :value, :title) do |locator, options={}| + xpath(:id, :value, :title, :class) do |locator, options={}| input_btn_xpath = XPath.descendant(:input)[XPath.attr(:type).one_of('submit', 'reset', 'image', 'button')] btn_xpath = XPath.descendant(:button) image_btn_xpath = XPath.descendant(:input)[XPath.attr(:type).equals('image')] @@ -121,7 +122,7 @@ Capybara.add_selector(:button) do res_xpath = input_btn_xpath + btn_xpath + image_btn_xpath - (expression_filters & options.keys).inject(res_xpath) { |xpath, ef| xpath[XPath.attr(ef).equals(options[ef])] } + res_xpath = expression_filters.inject(res_xpath) { |memo, ef| memo[find_by_attr(ef, options[ef])] } res_xpath end @@ -149,7 +150,7 @@ end Capybara.add_selector(:fillable_field) do label "field" - xpath(:id, :name, :placeholder) do |locator, options| + xpath(:id, :name, :placeholder, :class) do |locator, options| xpath = XPath.descendant(:input, :textarea)[~XPath.attr(:type).one_of('submit', 'image', 'radio', 'checkbox', 'hidden', 'file')] locate_field(xpath, locator, options) end @@ -165,7 +166,7 @@ end Capybara.add_selector(:radio_button) do label "radio button" - xpath(:id, :name) do |locator, options| + xpath(:id, :name, :class) do |locator, options| xpath = XPath.descendant(:input)[XPath.attr(:type).equals('radio')] locate_field(xpath, locator, options) end @@ -183,7 +184,7 @@ Capybara.add_selector(:radio_button) do end Capybara.add_selector(:checkbox) do - xpath(:id, :name) do |locator, options| + xpath(:id, :name, :class) do |locator, options| xpath = XPath.descendant(:input)[XPath.attr(:type).equals('checkbox')] locate_field(xpath, locator, options) end @@ -202,7 +203,7 @@ end Capybara.add_selector(:select) do label "select box" - xpath(:id, :name, :placeholder) do |locator, options| + xpath(:id, :name, :placeholder, :class) do |locator, options| xpath = XPath.descendant(:select) locate_field(xpath, locator, options) end @@ -259,7 +260,7 @@ end Capybara.add_selector(:file_field) do label "file field" - xpath(:id, :name) do |locator, options| + xpath(:id, :name, :class) do |locator, options| xpath = XPath.descendant(:input)[XPath.attr(:type).equals('file')] locate_field(xpath, locator, options) end @@ -301,11 +302,11 @@ Capybara.add_selector(:label) do end Capybara.add_selector(:table) do - xpath(:id, :caption) do |locator, options| + xpath(:id, :caption, :class) do |locator, options| xpath = XPath.descendant(:table) xpath = xpath[XPath.attr(:id).equals(locator.to_s) | XPath.descendant(:caption).is(locator.to_s)] unless locator.nil? - xpath = xpath[XPath.attr(:id).equals(options[:id])] if options[:id] xpath = xpath[XPath.descendant(:caption).equals(options[:caption])] if options[:caption] + xpath = [:id, :class].inject(xpath) { |memo, ef| memo[find_by_attr(ef, options[ef])] } xpath end @@ -318,10 +319,10 @@ Capybara.add_selector(:table) do end Capybara.add_selector(:frame) do - xpath(:id, :name) do |locator, options| + xpath(:id, :name, :class) do |locator, options| xpath = XPath.descendant(:iframe) + XPath.descendant(:frame) xpath = xpath[XPath.attr(:id).equals(locator.to_s) | XPath.attr(:name).equals(locator)] unless locator.nil? - [:id, :name].each { |ef| xpath = xpath[XPath.attr(ef).equals(options[ef])] if options[ef] } + xpath = expression_filters.inject(xpath) { |memo, ef| memo[find_by_attr(ef, options[ef])] } xpath end diff --git a/lib/capybara/selector/selector.rb b/lib/capybara/selector/selector.rb index ce2a329b..b691ad3d 100644 --- a/lib/capybara/selector/selector.rb +++ b/lib/capybara/selector/selector.rb @@ -1,5 +1,20 @@ # frozen_string_literal: true require 'capybara/selector/filter_set' +require 'xpath' + +#Patch XPath to allow a nil condition in where +module XPath + class Renderer + def where(on, condition) + condition = condition.to_s + if !condition.empty? + "#{on}[#{condition}]" + else + "#{on}" + end + end + end +end module Capybara class Selector @@ -33,6 +48,7 @@ module Capybara @description = nil @format = nil @expression = nil + @expression_filters = [] instance_eval(&block) end @@ -40,26 +56,79 @@ module Capybara @filter_set.filters end + ## + # + # Define a selector by an xpath expression + # + # @overload xpath(*expression_filters, &block) + # @param [Array] expression_filters ([]) Names of filters that can be implemented via this expression + # @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(*expression_filters, &block) @format, @expression_filters, @expression = :xpath, expression_filters.flatten, block if block format == :xpath ? @expression : nil 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(*expression_filters, &block) @format, @expression_filters, @expression = :css, expression_filters.flatten, block if block format == :css ? @expression : nil 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 + # + # @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 description(options={}) @filter_set.description(options) end @@ -73,10 +142,30 @@ module Capybara end 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 and @match.call(locator) end + ## + # + # Define a non-expression filter for use with this selector + # @overload filter(name, *types, options={}, &block) + # @param [Symbol] 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 + # def filter(name, *types_and_options, &block) options = types_and_options.last.is_a?(Hash) ? types_and_options.pop.dup : {} types_and_options.each { |k| options[k] = true} @@ -98,7 +187,7 @@ module Capybara private def locate_field(xpath, locator, options={}) - locate_field = xpath + locate_xpath = xpath #need to save original xpath for the label wrap if locator locator = locator.to_s attr_matchers = XPath.attr(:id).equals(locator) | @@ -107,11 +196,31 @@ module Capybara XPath.attr(:id).equals(XPath.anywhere(:label)[XPath.string.n.is(locator)].attr(:for)) attr_matchers |= XPath.attr(:'aria-label').is(locator) if Capybara.enable_aria_label - locate_field = locate_field[attr_matchers] - locate_field += XPath.descendant(:label)[XPath.string.n.is(locator)].descendant(xpath) + locate_xpath = locate_xpath[attr_matchers] + locate_xpath += XPath.descendant(:label)[XPath.string.n.is(locator)].descendant(xpath) + end + + locate_xpath = [:id, :name, :placeholder, :class].inject(locate_xpath) { |memo, ef| memo[find_by_attr(ef, options[ef])] } + locate_xpath + end + + def find_by_attr(attribute, value) + finder_name = "find_by_#{attribute.to_s}_attr" + if respond_to?(finder_name, true) + send(finder_name, value) + else + value ? XPath.attr(attribute).equals(value) : nil + end + end + + def find_by_class_attr(classes) + if classes + Array(classes).map do |klass| + "contains(concat(' ',normalize-space(@class),' '),' #{klass} ')" + end.join(" and ").to_sym + else + nil end - [:id, :name, :placeholder].each { |ef| locate_field = locate_field[XPath.attr(ef).equals(options[ef])] if options[ef] } - locate_field end end end diff --git a/lib/capybara/spec/session/selectors_spec.rb b/lib/capybara/spec/session/selectors_spec.rb index 731623f8..a896067b 100644 --- a/lib/capybara/spec/session/selectors_spec.rb +++ b/lib/capybara/spec/session/selectors_spec.rb @@ -51,5 +51,11 @@ Capybara::SpecHelper.spec Capybara::Selector do expect(@session.find(:field, 'Confusion', type: 'text')['id']).to eq 'confusion_text' expect(@session.find(:field, 'Confusion', type: 'textarea')['id']).to eq 'confusion_textarea' end + + it "can find by class" do + expect(@session.find(:field, class: 'confusion-checkbox')['id']).to eq 'confusion_checkbox' + expect(@session).to have_selector(:field, class: 'confusion', count: 3) + expect(@session.find(:field, class: ['confusion','confusion-textarea'])['id']).to eq 'confusion_textarea' + end end end \ No newline at end of file diff --git a/lib/capybara/spec/views/form.erb b/lib/capybara/spec/views/form.erb index c0b9686f..6ae7809c 100644 --- a/lib/capybara/spec/views/form.erb +++ b/lib/capybara/spec/views/form.erb @@ -527,14 +527,14 @@ New line after and before textarea tag