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.
-
+