From 548c8c00a6656520b751f0c02c0a69cfb68912bd Mon Sep 17 00:00:00 2001 From: Thomas Walpole Date: Mon, 10 Jul 2017 14:42:15 -0700 Subject: [PATCH] Add `ancestor` and `sibling` methods to node finders --- History.md | 1 + lib/capybara.rb | 2 + lib/capybara/node/finders.rb | 95 ++++++++++++++++++---- lib/capybara/queries/ancestor_query.rb | 25 ++++++ lib/capybara/queries/sibling_query.rb | 25 ++++++ lib/capybara/spec/session/ancestor_spec.rb | 85 +++++++++++++++++++ lib/capybara/spec/session/sibling_spec.rb | 52 ++++++++++++ lib/capybara/spec/views/with_html.erb | 28 ++++++- 8 files changed, 296 insertions(+), 17 deletions(-) create mode 100644 lib/capybara/queries/ancestor_query.rb create mode 100644 lib/capybara/queries/sibling_query.rb create mode 100644 lib/capybara/spec/session/ancestor_spec.rb create mode 100644 lib/capybara/spec/session/sibling_spec.rb diff --git a/History.md b/History.md index c1f59d20..d946324c 100644 --- a/History.md +++ b/History.md @@ -4,6 +4,7 @@ Release data: unreleased ### Added +* `sibling` and `ancestor` finders added [Thomas Walpole] * Added built-in driver registrations `:selenium_chrome` and `:selenium_chrome_headless` [Thomas Walpole] * Add `and_then` to Capybara RSpec matchers which behaves like the previous `and` compounder. [Thomas Walpole] * Compound RSpec expectations with Capybara matchers now run both matchers inside a retry loop rather diff --git a/lib/capybara.rb b/lib/capybara.rb index 4ba32454..3e7bc80f 100644 --- a/lib/capybara.rb +++ b/lib/capybara.rb @@ -413,6 +413,8 @@ module Capybara require 'capybara/queries/title_query' require 'capybara/queries/current_path_query' require 'capybara/queries/match_query' + require 'capybara/queries/ancestor_query' + require 'capybara/queries/sibling_query' require 'capybara/query' require 'capybara/node/finders' diff --git a/lib/capybara/node/finders.rb b/lib/capybara/node/finders.rb index 5fdea240..c1db1776 100644 --- a/lib/capybara/node/finders.rb +++ b/lib/capybara/node/finders.rb @@ -34,22 +34,66 @@ module Capybara else args.push(session_options: session_options) end - query = Capybara::Queries::SelectorQuery.new(*args, &optional_filter_block) - synchronize(query.wait) do - if (query.match == :smart or query.match == :prefer_exact) - result = query.resolve_for(self, true) - result = query.resolve_for(self, false) if result.empty? && query.supports_exact? && !query.exact? - else - result = query.resolve_for(self) - end - if query.match == :one or query.match == :smart and result.size > 1 - raise Capybara::Ambiguous.new("Ambiguous match, found #{result.size} elements matching #{query.description}") - end - if result.empty? - raise Capybara::ElementNotFound.new("Unable to find #{query.description}") - end - result.first - end.tap(&:allow_reload!) + synced_resolve Capybara::Queries::SelectorQuery.new(*args, &optional_filter_block) + end + + ## + # + # Find an {Capybara::Node::Element} based on the given arguments that is also an ancestor of the element called on. +ancestor+ will raise an error if the element + # is not found. + # + # +ancestor+ takes the same options as +find+. + # + # element.ancestor('#foo').find('.bar') + # element.ancestor(:xpath, './/div[contains(., "bar")]') + # element.ancestor('ul', text: 'Quox').click_link('Delete') + # + # @param (see Capybara::Node::Finders#find) + # + # @!macro waiting_behavior + # + # @option options [Boolean] match The matching strategy to use. + # + # @return [Capybara::Node::Element] The found element + # @raise [Capybara::ElementNotFound] If the element can't be found before time expires + # + def ancestor(*args, &optional_filter_block) + if args.last.is_a? Hash + args.last[:session_options] = session_options + else + args.push(session_options: session_options) + end + synced_resolve Capybara::Queries::AncestorQuery.new(*args, &optional_filter_block) + end + + ## + # + # Find an {Capybara::Node::Element} based on the given arguments that is also a sibling of the element called on. +sibling+ will raise an error if the element + # is not found. + # + # + # +sibling+ takes the same options as +find+. + # + # element.sibling('#foo').find('.bar') + # element.sibling(:xpath, './/div[contains(., "bar")]') + # element.sibling('ul', text: 'Quox').click_link('Delete') + # + # @param (see Capybara::Node::Finders#find) + # + # @macro waiting_behavior + # + # @option options [Boolean] match The matching strategy to use. + # + # @return [Capybara::Node::Element] The found element + # @raise [Capybara::ElementNotFound] If the element can't be found before time expires + # + def sibling(*args, &optional_filter_block) + if args.last.is_a? Hash + args.last[:session_options] = session_options + else + args.push(session_options: session_options) + end + synced_resolve Capybara::Queries::SiblingQuery.new(*args, &optional_filter_block) end ## @@ -252,6 +296,25 @@ module Capybara nil end + private + + def synced_resolve(query) + synchronize(query.wait) do + if (query.match == :smart or query.match == :prefer_exact) + result = query.resolve_for(self, true) + result = query.resolve_for(self, false) if result.empty? && query.supports_exact? && !query.exact? + else + result = query.resolve_for(self) + end + if query.match == :one or query.match == :smart and result.size > 1 + raise Capybara::Ambiguous.new("Ambiguous match, found #{result.size} elements matching #{query.description}") + end + if result.empty? + raise Capybara::ElementNotFound.new("Unable to find #{query.description}") + end + result.first + end.tap(&:allow_reload!) + end end end end diff --git a/lib/capybara/queries/ancestor_query.rb b/lib/capybara/queries/ancestor_query.rb new file mode 100644 index 00000000..d618657f --- /dev/null +++ b/lib/capybara/queries/ancestor_query.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true +module Capybara + module Queries + class AncestorQuery < MatchQuery + # @api private + def resolve_for(node, exact = nil) + @resolved_node = node + node.synchronize do + match_results = super(node.session.current_scope, exact) + node.all(:xpath, XPath.ancestor) do |el| + match_results.include?(el) + end + end + end + + def description + desc = super + if @resolved_node && (child_query = @resolved_node.instance_variable_get(:@query)) + desc += " that is an ancestor of #{child_query.description}" + end + desc + end + end + end +end diff --git a/lib/capybara/queries/sibling_query.rb b/lib/capybara/queries/sibling_query.rb new file mode 100644 index 00000000..b0e183fc --- /dev/null +++ b/lib/capybara/queries/sibling_query.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true +module Capybara + module Queries + class SiblingQuery < MatchQuery + # @api private + def resolve_for(node, exact = nil) + @resolved_node = node + node.synchronize do + match_results = super(node.session.current_scope, exact) + node.all(:xpath, XPath.preceding_sibling.union(XPath.following_sibling)) do |el| + match_results.include?(el) + end + end + end + + def description + desc = super + if @resolved_node && (child_query = @resolved_node.instance_variable_get(:@query)) + desc += " that is a sibling of #{child_query.description}" + end + desc + end + end + end +end diff --git a/lib/capybara/spec/session/ancestor_spec.rb b/lib/capybara/spec/session/ancestor_spec.rb new file mode 100644 index 00000000..f3addf70 --- /dev/null +++ b/lib/capybara/spec/session/ancestor_spec.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true +Capybara::SpecHelper.spec '#ancestor' do + before do + @session.visit('/with_html') + end + + after do + Capybara::Selector.remove(:monkey) + end + + it "should find the ancestor element using the given locator" do + el = @session.find(:css, '#first_image') + expect(el.ancestor('//p')).to have_text('Lorem ipsum dolor') + expect(el.ancestor("//a")[:'aria-label']).to eq('Go to simple') + end + + it "should find the ancestor element using the given locator and options" do + el = @session.find(:css, '#child') + expect(el.ancestor('//div', text: 'Ancestor Ancestor Ancestor')[:id]).to eq('ancestor3') + end + + it "should raise an error if there are multiple matches" do + el = @session.find(:css, '#child') + expect { el.ancestor('//div') }.to raise_error(Capybara::Ambiguous) + expect { el.ancestor('//div', text: 'Ancestor') }.to raise_error(Capybara::Ambiguous) + end + + context "with css selectors" do + it "should find the first element using the given locator" do + el = @session.find(:css, '#first_image') + expect(el.ancestor(:css, 'p')).to have_text('Lorem ipsum dolor') + expect(el.ancestor(:css, 'a')[:'aria-label']).to eq('Go to simple') + end + + it "should support pseudo selectors" do + el = @session.find(:css, '#button_img') + expect(el.ancestor(:css, 'button:disabled')[:id]).to eq('ancestor_button') + end + end + + context "with xpath selectors" do + it "should find the first element using the given locator" do + el = @session.find(:css, '#first_image') + expect(el.ancestor(:xpath, '//p')).to have_text('Lorem ipsum dolor') + expect(el.ancestor(:xpath, "//a")[:'aria-label']).to eq('Go to simple') + end + end + + context "with custom selector" do + it "should use the custom selector" do + Capybara.add_selector(:level) do + xpath { |num| ".//*[@id='ancestor#{num}']" } + end + el = @session.find(:css, '#child') + expect(el.ancestor(:level, 1).text).to eq('Ancestor Child') + expect(el.ancestor(:level, 3).text).to eq('Ancestor Ancestor Ancestor Child') + end + end + + + it "should raise ElementNotFound with a useful default message if nothing was found" do + el = @session.find(:css, '#child') + expect do + el.ancestor(:xpath, '//div[@id="nosuchthing"]') + end.to raise_error(Capybara::ElementNotFound, "Unable to find xpath \"//div[@id=\\\"nosuchthing\\\"]\" that is an ancestor of visible css \"#child\"") + end + + + + context "within a scope" do + it "should limit the ancestors to inside the scope" do + @session.within(:css, '#ancestor2') do + el = @session.find(:css, '#child') + expect(el.ancestor(:css,'div', text: 'Ancestor')[:id]).to eq('ancestor1') + end + end + end + + it "should raise if selector type is unknown" do + el = @session.find(:css, '#child') + expect do + el.ancestor(:unknown, '//h1') + end.to raise_error(ArgumentError) + end +end diff --git a/lib/capybara/spec/session/sibling_spec.rb b/lib/capybara/spec/session/sibling_spec.rb new file mode 100644 index 00000000..893d9315 --- /dev/null +++ b/lib/capybara/spec/session/sibling_spec.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true +Capybara::SpecHelper.spec '#sibling' do + before do + @session.visit('/with_html') + end + + after do + Capybara::Selector.remove(:monkey) + end + + it "should find a prior sibling element using the given locator" do + el = @session.find(:css, '#mid_sibling') + expect(el.sibling('//div[@data-pre]')[:id]).to eq('pre_sibling') + end + + it "should find a following sibling element using the given locator" do + el = @session.find(:css, '#mid_sibling') + expect(el.sibling('//div[@data-post]')[:id]).to eq('post_sibling') + end + + it "should raise an error if there are multiple matches" do + el = @session.find(:css, '#mid_sibling') + expect { el.sibling('//div') }.to raise_error(Capybara::Ambiguous) + end + + context "with css selectors" do + it "should find the first element using the given locator" do + el = @session.find(:css, '#mid_sibling') + expect(el.sibling(:css, '#pre_sibling')).to have_text('Pre Sibling') + expect(el.sibling(:css, '#post_sibling')).to have_text('Post Sibling') + end + end + + context "with custom selector" do + it "should use the custom selector" do + Capybara.add_selector(:data_attribute) do + xpath { |attr| ".//*[@data-#{attr}]" } + end + el = @session.find(:css, '#mid_sibling') + expect(el.sibling(:data_attribute, 'pre').text).to eq('Pre Sibling') + expect(el.sibling(:data_attribute, 'post').text).to eq('Post Sibling') + end + end + + + it "should raise ElementNotFound with a useful default message if nothing was found" do + el = @session.find(:css, '#child') + expect do + el.sibling(:xpath, '//div[@id="nosuchthing"]') + end.to raise_error(Capybara::ElementNotFound, "Unable to find xpath \"//div[@id=\\\"nosuchthing\\\"]\" that is a sibling of visible css \"#child\"") + end +end diff --git a/lib/capybara/spec/views/with_html.erb b/lib/capybara/spec/views/with_html.erb index 3f04a591..e8da0b3d 100644 --- a/lib/capybara/spec/views/with_html.erb +++ b/lib/capybara/spec/views/with_html.erb @@ -19,7 +19,7 @@ et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. - awesome image + awesome image

@@ -121,3 +121,29 @@ banana No href Blank href + +

+ Ancestor +
+ Ancestor +
+ Ancestor +
Child
+
+
+ +
+ +
+
+
Pre Sibling
+
Mid Sibling
+
Post Sibling
+
+
+
Pre Sibling
+
Post Sibling
+
+
\ No newline at end of file