From 8715327c43bf885639af7176722309a2b458cb1b Mon Sep 17 00:00:00 2001 From: Thomas Walpole Date: Fri, 29 Jan 2016 16:31:35 -0800 Subject: [PATCH] add match_xxx methods and matchers to check whether an element matches css, xpath, selector --- lib/capybara.rb | 4 +- lib/capybara/node/finders.rb | 4 +- lib/capybara/node/matchers.rb | 73 +++++++++- lib/capybara/queries/match_query.rb | 21 +++ lib/capybara/queries/selector_query.rb | 136 ++++++++++++++++++ lib/capybara/query.rb | 133 +---------------- lib/capybara/rspec/matchers.rb | 56 +++++++- lib/capybara/session.rb | 4 +- lib/capybara/spec/public/test.js | 6 + .../session/element/assert_match_selector.rb | 31 ++++ .../spec/session/element/match_css_spec.rb | 17 +++ .../spec/session/element/match_xpath_spec.rb | 23 +++ .../session/element/matches_selector_spec.rb | 63 ++++++++ lib/capybara/spec/views/with_html.erb | 1 + lib/capybara/spec/views/with_js.erb | 4 + 15 files changed, 435 insertions(+), 141 deletions(-) create mode 100644 lib/capybara/queries/match_query.rb create mode 100644 lib/capybara/queries/selector_query.rb create mode 100644 lib/capybara/spec/session/element/assert_match_selector.rb create mode 100644 lib/capybara/spec/session/element/match_css_spec.rb create mode 100644 lib/capybara/spec/session/element/match_xpath_spec.rb create mode 100644 lib/capybara/spec/session/element/matches_selector_spec.rb diff --git a/lib/capybara.rb b/lib/capybara.rb index fa98b0ed..fe4ed26d 100644 --- a/lib/capybara.rb +++ b/lib/capybara.rb @@ -419,10 +419,12 @@ module Capybara require 'capybara/version' require 'capybara/queries/base_query' - require 'capybara/query' + require 'capybara/queries/selector_query' require 'capybara/queries/text_query' require 'capybara/queries/title_query' require 'capybara/queries/current_path_query' + require 'capybara/queries/match_query' + require 'capybara/query' require 'capybara/node/finders' require 'capybara/node/matchers' diff --git a/lib/capybara/node/finders.rb b/lib/capybara/node/finders.rb index 56f24612..a6e1e4e9 100644 --- a/lib/capybara/node/finders.rb +++ b/lib/capybara/node/finders.rb @@ -29,7 +29,7 @@ module Capybara # @raise [Capybara::ElementNotFound] If the element can't be found before time expires # def find(*args) - query = Capybara::Query.new(*args) + query = Capybara::Queries::SelectorQuery.new(*args) synchronize(query.wait) do if query.match == :smart or query.match == :prefer_exact result = query.resolve_for(self, true) @@ -178,7 +178,7 @@ module Capybara # @return [Capybara::Result] A collection of found elements # def all(*args) - query = Capybara::Query.new(*args) + query = Capybara::Queries::SelectorQuery.new(*args) synchronize(query.wait) do result = query.resolve_for(self) raise Capybara::ExpectationNotMet, result.failure_message unless result.matches_count? diff --git a/lib/capybara/node/matchers.rb b/lib/capybara/node/matchers.rb index 6912b8ed..ec393144 100644 --- a/lib/capybara/node/matchers.rb +++ b/lib/capybara/node/matchers.rb @@ -56,6 +56,36 @@ module Capybara return false end + ## + # + # Checks if the current node matches given selector + # Usage is identical to Capybara::Node::Matchers#has_selector? + # + # @param (see Capybara::Node::Finders#has_selector?) + # @return [Boolean] + # + def match_selector?(*args) + assert_match_selector(*args) + rescue Capybara::ExpectationNotMet + return false + end + + + ## + # + # Checks if the current node does not match given selector + # Usage is identical to Capybara::Node::Matchers#has_selector? + # + # @param (see Capybara::Node::Finders#has_selector?) + # @return [Boolean] + # + def not_match_selector?(*args) + assert_not_match_selector(*args) + rescue Capybara::ExpectationNotMet + return false + end + + ## # # Asserts that a given selector is on the page or current node. @@ -90,7 +120,7 @@ module Capybara # @raise [Capybara::ExpectationNotMet] If the selector does not exist # def assert_selector(*args) - query = Capybara::Query.new(*args) + query = Capybara::Queries::SelectorQuery.new(*args) synchronize(query.wait) do result = query.resolve_for(self) matches_count = Capybara::Helpers.matches_count?(result.size, query.options) @@ -118,7 +148,7 @@ module Capybara # @raise [Capybara::ExpectationNotMet] If the selector exists # def assert_no_selector(*args) - query = Capybara::Query.new(*args) + query = Capybara::Queries::SelectorQuery.new(*args) synchronize(query.wait) do result = query.resolve_for(self) matches_count = Capybara::Helpers.matches_count?(result.size, query.options) @@ -130,6 +160,45 @@ module Capybara end alias_method :refute_selector, :assert_no_selector + ## + # + # Asserts that the current_node matches a given selector + # + # node.assert_match_selector('p#foo') + # node.assert_match_selector(:xpath, '//p[@id="foo"]') + # node.assert_match_selector(:foo) + # + # It also accepts all options that {Capybara::Node::Finders#all} accepts, + # such as :text and :visible. + # + # node.assert_match_selector('li', :text => 'Horse', :visible => true) + # + # @param (see Capybara::Node::Finders#all) + # @raise [Capybara::ExpectationNotMet] If the selector does not match + # + def assert_match_selector(*args) + query = Capybara::Queries::MatchQuery.new(*args) + synchronize(query.wait) do + result = query.resolve_for(self.parent) + unless result.include? self + raise Capybara::ExpectationNotMet, "Item does not match the provided selector" + end + end + return true + end + + def assert_not_match_selector(*args) + query = Capybara::Queries::MatchQuery.new(*args) + synchronize(query.wait) do + result = query.resolve_for(self.parent) + if result.include? self + raise Capybara::ExpectationNotMet, 'Item matched the provided selector' + end + end + return true + end + alias_method :refute_match_selector, :assert_not_match_selector + ## # # Checks if a given XPath expression is on the page or current node. diff --git a/lib/capybara/queries/match_query.rb b/lib/capybara/queries/match_query.rb new file mode 100644 index 00000000..acd379a5 --- /dev/null +++ b/lib/capybara/queries/match_query.rb @@ -0,0 +1,21 @@ +module Capybara + module Queries + class MatchQuery < Capybara::Queries::SelectorQuery + VALID_KEYS = [:text, :visible, :exact, :wait] + + def visible + if options.has_key?(:visible) + super + else + :all + end + end + + private + + def valid_keys + [:text, :visible, :exact, :wait] + @selector.custom_filters.keys + end + end + end +end \ No newline at end of file diff --git a/lib/capybara/queries/selector_query.rb b/lib/capybara/queries/selector_query.rb new file mode 100644 index 00000000..fbfa2c37 --- /dev/null +++ b/lib/capybara/queries/selector_query.rb @@ -0,0 +1,136 @@ +# frozen_string_literal: true +module Capybara + module Queries + class SelectorQuery < Queries::BaseQuery + attr_accessor :selector, :locator, :options, :expression, :find, :negative + + VALID_KEYS = [:text, :visible, :between, :count, :maximum, :minimum, :exact, :match, :wait] + VALID_MATCH = [:first, :smart, :prefer_exact, :one] + + def initialize(*args) + @options = if args.last.is_a?(Hash) then args.pop.dup else {} end + + if args[0].is_a?(Symbol) + @selector = Selector.all[args[0]] + @locator = args[1] + else + @selector = Selector.all.values.find { |s| s.match?(args[0]) } + @locator = args[0] + end + @selector ||= Selector.all[Capybara.default_selector] + + # for compatibility with Capybara 2.0 + if Capybara.exact_options and @selector == Selector.all[:option] + @options[:exact] = true + end + + @expression = @selector.call(@locator) + assert_valid_keys + end + + def name; selector.name; end + def label; selector.label or selector.name; end + + def description + @description = String.new("#{label} #{locator.inspect}") + @description << " with text #{options[:text].inspect}" if options[:text] + @description << selector.description(options) + @description + end + + def matches_filters?(node) + if options[:text] + regexp = options[:text].is_a?(Regexp) ? options[:text] : Regexp.escape(options[:text].to_s) + return false if not node.text(visible).match(regexp) + end + case visible + when :visible then return false unless node.visible? + when :hidden then return false if node.visible? + end + selector.custom_filters.each do |name, filter| + if options.has_key?(name) + return false unless filter.matches?(node, options[name]) + elsif filter.default? + return false unless filter.matches?(node, filter.default) + end + end + end + + def visible + if options.has_key?(:visible) + case @options[:visible] + when true then :visible + when false then :all + else @options[:visible] + end + else + if Capybara.ignore_hidden_elements + :visible + else + :all + end + end + end + + def exact? + if options.has_key?(:exact) + @options[:exact] + else + Capybara.exact + end + end + + def match + if options.has_key?(:match) + @options[:match] + else + Capybara.match + end + end + + def xpath(exact=nil) + exact = self.exact? if exact == nil + if @expression.respond_to?(:to_xpath) and exact + @expression.to_xpath(:exact) + else + @expression.to_s + end + end + + def css + @expression + end + + # @api private + def resolve_for(node, exact = nil) + node.synchronize do + children = if selector.format == :css + node.find_css(self.css) + else + node.find_xpath(self.xpath(exact)) + end.map do |child| + if node.is_a?(Capybara::Node::Base) + Capybara::Node::Element.new(node.session, child, node, self) + else + Capybara::Node::Simple.new(child) + end + end + Capybara::Result.new(children, self) + end + end + + private + + def valid_keys + COUNT_KEYS + [:text, :visible, :exact, :match, :wait] + @selector.custom_filters.keys + end + + def assert_valid_keys + super + unless VALID_MATCH.include?(match) + raise ArgumentError, "invalid option #{match.inspect} for :match, should be one of #{VALID_MATCH.map(&:inspect).join(", ")}" + end + end + end + end +end diff --git a/lib/capybara/query.rb b/lib/capybara/query.rb index 5ff43a2a..ec1dc758 100644 --- a/lib/capybara/query.rb +++ b/lib/capybara/query.rb @@ -1,136 +1,7 @@ # frozen_string_literal: true +require 'capybara/queries/selector_query' module Capybara # @deprecated This class and its methods are not supposed to be used by users of Capybara's public API. # It may be removed in future versions of Capybara. - class Query < Queries::BaseQuery - attr_accessor :selector, :locator, :options, :expression, :find, :negative - - VALID_KEYS = [:text, :visible, :between, :count, :maximum, :minimum, :exact, :match, :wait] - VALID_MATCH = [:first, :smart, :prefer_exact, :one] - - def initialize(*args) - @options = if args.last.is_a?(Hash) then args.pop.dup else {} end - - if args[0].is_a?(Symbol) - @selector = Selector.all[args[0]] - @locator = args[1] - else - @selector = Selector.all.values.find { |s| s.match?(args[0]) } - @locator = args[0] - end - @selector ||= Selector.all[Capybara.default_selector] - - # for compatibility with Capybara 2.0 - if Capybara.exact_options and @selector == Selector.all[:option] - @options[:exact] = true - end - - @expression = @selector.call(@locator) - assert_valid_keys - end - - def name; selector.name; end - def label; selector.label or selector.name; end - - def description - @description = String.new("#{label} #{locator.inspect}") - @description << " with text #{options[:text].inspect}" if options[:text] - @description << selector.description(options) - @description - end - - def matches_filters?(node) - if options[:text] - regexp = options[:text].is_a?(Regexp) ? options[:text] : Regexp.escape(options[:text].to_s) - return false if not node.text(visible).match(regexp) - end - case visible - when :visible then return false unless node.visible? - when :hidden then return false if node.visible? - end - selector.custom_filters.each do |name, filter| - if options.has_key?(name) - return false unless filter.matches?(node, options[name]) - elsif filter.default? - return false unless filter.matches?(node, filter.default) - end - end - end - - def visible - if options.has_key?(:visible) - case @options[:visible] - when true then :visible - when false then :all - else @options[:visible] - end - else - if Capybara.ignore_hidden_elements - :visible - else - :all - end - end - end - - def exact? - if options.has_key?(:exact) - @options[:exact] - else - Capybara.exact - end - end - - def match - if options.has_key?(:match) - @options[:match] - else - Capybara.match - end - end - - def xpath(exact=nil) - exact = self.exact? if exact == nil - if @expression.respond_to?(:to_xpath) and exact - @expression.to_xpath(:exact) - else - @expression.to_s - end - end - - def css - @expression - end - - # @api private - def resolve_for(node, exact = nil) - node.synchronize do - children = if selector.format == :css - node.find_css(self.css) - else - node.find_xpath(self.xpath(exact)) - end.map do |child| - if node.is_a?(Capybara::Node::Base) - Capybara::Node::Element.new(node.session, child, node, self) - else - Capybara::Node::Simple.new(child) - end - end - Capybara::Result.new(children, self) - end - end - - private - - def valid_keys - COUNT_KEYS + [:text, :visible, :exact, :match, :wait] + @selector.custom_filters.keys - end - - def assert_valid_keys - super - unless VALID_MATCH.include?(match) - raise ArgumentError, "invalid option #{match.inspect} for :match, should be one of #{VALID_MATCH.map(&:inspect).join(", ")}" - end - end - end + Query = Queries::SelectorQuery end diff --git a/lib/capybara/rspec/matchers.rb b/lib/capybara/rspec/matchers.rb index 7daf225d..862a3540 100644 --- a/lib/capybara/rspec/matchers.rb +++ b/lib/capybara/rspec/matchers.rb @@ -2,7 +2,7 @@ module Capybara module RSpecMatchers class Matcher - include ::RSpec::Matchers::Composable if defined?(::RSpec::Expectations::Version) && RSpec::Expectations::Version::STRING.to_f >= 3.0 + include ::RSpec::Matchers::Composable if defined?(::RSpec::Expectations::Version) && (Gem::Version.new(RSpec::Expectations::Version::STRING) >= Gem::Version.new('3.0')) def wrap(actual) if actual.respond_to?("has_selector?") @@ -39,7 +39,7 @@ module Capybara end def query - @query ||= Capybara::Query.new(*@args) + @query ||= Capybara::Queries::SelectorQuery.new(*@args) end # RSpec 2 compatibility: @@ -161,7 +161,7 @@ module Capybara class BecomeClosed def initialize(options) - @wait_time = Capybara::Query.new(options).wait + @wait_time = Capybara::Queries::SelectorQuery.new(options).wait end def matches?(window) @@ -187,18 +187,68 @@ module Capybara alias_method :failure_message_for_should_not, :failure_message_when_negated end + class MatchSelector < Matcher + attr_reader :failure_message, :failure_message_when_negated + + def initialize(*args) + @args = args + end + + def matches?(actual) + actual.assert_match_selector(*@args) + rescue Capybara::ExpectationNotMet => e + @failure_message = e.message + return false + end + + def does_not_match?(actual) + actual.assert_not_match_selector(*@args) + rescue Capybara::ExpectationNotMet => e + @failure_message_when_negated = e.message + return false + end + + def description + "match #{query.description}" + end + + def query + @query ||= Capybara::Queries::MatchQuery.new(*@args) + end + + # RSpec 2 compatibility: + alias_method :failure_message_for_should, :failure_message + alias_method :failure_message_for_should_not, :failure_message_when_negated + end + def have_selector(*args) HaveSelector.new(*args) end + def match_selector(*args) + MatchSelector.new(*args) + end + # defined_negated_matcher was added in RSpec 3.1 - it's syntactic sugar only since a user can do + # expect(page).not_to match_selector, so not sure we really need to support not_match_selector for prior to RSpec 3.1 + ::RSpec::Matchers.define_negated_matcher :not_match_selector, :match_selector if defined?(::RSpec::Expectations::Version) && (Gem::Version.new(RSpec::Expectations::Version::STRING) >= Gem::Version.new('3.1')) + + def have_xpath(xpath, options={}) HaveSelector.new(:xpath, xpath, options) end + def match_xpath(xpath, options={}) + MatchSelector.new(:xpath, xpath, options) + end + def have_css(css, options={}) HaveSelector.new(:css, css, options) end + def match_css(css, options={}) + MatchSelector.new(:css, css, options) + end + def have_text(*args) HaveText.new(*args) end diff --git a/lib/capybara/session.rb b/lib/capybara/session.rb index 87beabaa..906dc2e8 100644 --- a/lib/capybara/session.rb +++ b/lib/capybara/session.rb @@ -404,7 +404,7 @@ module Capybara driver.switch_to_window(window.handle) window else - wait_time = Capybara::Query.new(options).wait + wait_time = Capybara::Queries::SelectorQuery.new(options).wait document.synchronize(wait_time, errors: [Capybara::WindowError]) do original_window_handle = driver.current_window_handle begin @@ -501,7 +501,7 @@ module Capybara old_handles = driver.window_handles block.call - wait_time = Capybara::Query.new(options).wait + wait_time = Capybara::Queries::SelectorQuery.new(options).wait document.synchronize(wait_time, errors: [Capybara::WindowError]) do opened_handles = (driver.window_handles - old_handles) if opened_handles.size != 1 diff --git a/lib/capybara/spec/public/test.js b/lib/capybara/spec/public/test.js index 2a6dc9df..21780980 100644 --- a/lib/capybara/spec/public/test.js +++ b/lib/capybara/spec/public/test.js @@ -117,4 +117,10 @@ $(function() { $('#with-key-events').keydown(function(e){ $('#key-events-output').append('keydown:'+e.which+' ') }); + $('#disable-on-click').click(function(e){ + var input = this + setTimeout(function() { + input.disabled = true; + }, 500) + }) }); diff --git a/lib/capybara/spec/session/element/assert_match_selector.rb b/lib/capybara/spec/session/element/assert_match_selector.rb new file mode 100644 index 00000000..48160db3 --- /dev/null +++ b/lib/capybara/spec/session/element/assert_match_selector.rb @@ -0,0 +1,31 @@ +Capybara::SpecHelper.spec '#assert_match_selector' do + before do + @session.visit('/with_html') + @element = @session.find(:css, 'span', text: '42') + end + + it "should be true if the given selector matches the element" do + expect(@element.assert_match_selector(:css, '.number')).to be true + end + + it "should be false if the given selector does not match the element" do + expect { @element.assert_match_selector(:css, '.not_number') }.to raise_error(Capybara::ElementNotFound) + end + + it "should not be callable on the session" do + expect { @session.assert_match_selector(:css, '.number') }.to raise_error(NoMethodError) + end + + it "should wait for match to occur", requires: [:js] do + @session.visit('/with_js') + input = @session.find(:css, '#disable-on-click') + + expect(input.assert_match_selector(:css, 'input:enabled')).to be true + input.click + expect(input.assert_match_selector(:css, 'input:disabled')).to be true + end + + it "should not accept count options" do + expect { @element.assert_match_selector(:css, '.number', count: 1) }.to raise_error(ArgumentError) + end +end diff --git a/lib/capybara/spec/session/element/match_css_spec.rb b/lib/capybara/spec/session/element/match_css_spec.rb new file mode 100644 index 00000000..12e58d4c --- /dev/null +++ b/lib/capybara/spec/session/element/match_css_spec.rb @@ -0,0 +1,17 @@ +Capybara::SpecHelper.spec '#match_css?' do + before do + @session.visit('/with_html') + @element = @session.find(:css, 'span', text: '42') + end + + it "should be true if the given selector matches the element" do + expect(@element).to match_css("span") + expect(@element).to match_css("span.number") + end + + it "should be false if the given selector does not match" do + expect(@element).not_to match_css("div") + expect(@element).not_to match_css("p a#doesnotexist") + expect(@element).not_to match_css("p.nosuchclass") + end +end diff --git a/lib/capybara/spec/session/element/match_xpath_spec.rb b/lib/capybara/spec/session/element/match_xpath_spec.rb new file mode 100644 index 00000000..a8a21e29 --- /dev/null +++ b/lib/capybara/spec/session/element/match_xpath_spec.rb @@ -0,0 +1,23 @@ +Capybara::SpecHelper.spec '#match_xpath?' do + before do + @session.visit('/with_html') + @element = @session.find(:css, 'span.number') + end + + it "should be true if the given selector is on the page" do + expect(@element).to match_xpath("//span") + expect(@element).to match_xpath("//span[@class='number']") + end + + it "should be false if the given selector is not on the page" do + expect(@element).not_to match_xpath("//abbr") + expect(@element).not_to match_xpath("//div") + expect(@element).not_to match_xpath("//span[@class='not_a_number']") + end + + it "should use xpath even if default selector is CSS" do + Capybara.default_selector = :css + expect(@element).not_to have_xpath("//span[@class='not_a_number']") + expect(@element).not_to have_xpath("//div[@class='number']") + end +end \ No newline at end of file diff --git a/lib/capybara/spec/session/element/matches_selector_spec.rb b/lib/capybara/spec/session/element/matches_selector_spec.rb new file mode 100644 index 00000000..1b548569 --- /dev/null +++ b/lib/capybara/spec/session/element/matches_selector_spec.rb @@ -0,0 +1,63 @@ +Capybara::SpecHelper.spec '#match_xpath?' do + before do + @session.visit('/with_html') + @element = @session.find('//span', text: '42') + end + + it "should be true if the element matches the given selector" do + expect(@element).to match_selector(:xpath, "//span") + expect(@element).to match_selector(:css, 'span.number') + expect(@element.match_selector?(:css, 'span.number')).to be true + end + + it "should be false if the element does not match the given selector" do + expect(@element).not_to match_selector(:xpath, "//div") + expect(@element).not_to match_selector(:css, "span.not_a_number") + expect(@element.match_selector?(:css, "span.not_a_number")).to be false + end + + it "should use default selector" do + Capybara.default_selector = :css + expect(@element).not_to match_selector("span.not_a_number") + expect(@element).to match_selector("span.number") + end + + context "with text" do + it "should discard all matches where the given string is not contained" do + expect(@element).to match_selector("//span", :text => "42") + expect(@element).not_to match_selector("//span", :text => "Doesnotexist") + end + end +end + +Capybara::SpecHelper.spec '#not_match_selector?' do + before do + @session.visit('/with_html') + @element = @session.find(:css, "span", text: 42) + end + + it "should be false if the given selector matches the element" do + expect(@element).not_to not_match_selector(:xpath, "//span") + expect(@element).not_to not_match_selector(:css, "span.number") + expect(@element.not_match_selector?(:css, "span.number")).to be false + end + + it "should be true if the given selector does not match the element" do + expect(@element).to not_match_selector(:xpath, "//abbr") + expect(@element).to not_match_selector(:css, "p a#doesnotexist") + expect(@element.not_match_selector?(:css, "p a#doesnotexist")).to be true + end + + it "should use default selector" do + Capybara.default_selector = :css + expect(@element).to not_match_selector("p a#doesnotexist") + expect(@element).not_to not_match_selector("span.number") + end + + context "with text" do + it "should discard all matches where the given string is contained" do + expect(@element).not_to not_match_selector(:css, "span.number", :text => "42") + expect(@element).to not_match_selector(:css, "span.number", :text => "Doesnotexist") + end + end +end if Gem::Version.new(RSpec::Expectations::Version::STRING) >= Gem::Version.new('3.1') diff --git a/lib/capybara/spec/views/with_html.erb b/lib/capybara/spec/views/with_html.erb index 8d7a32e3..bcac6656 100644 --- a/lib/capybara/spec/views/with_html.erb +++ b/lib/capybara/spec/views/with_html.erb @@ -11,6 +11,7 @@

Header Class Test Five

42 +Other span

Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod diff --git a/lib/capybara/spec/views/with_js.erb b/lib/capybara/spec/views/with_js.erb index e1aabbb3..374efc93 100644 --- a/lib/capybara/spec/views/with_js.erb +++ b/lib/capybara/spec/views/with_js.erb @@ -94,6 +94,10 @@ Open prompt

+

+ +

+

Change page Non-escaped query options