Allow drivers to provide initial element visibility with found elements and receive other optional filtering options for find_xpath/css.

Implement initial visibility return for Selenium
Use text content to pre-filter XPath searches
This commit is contained in:
Thomas Walpole 2019-01-03 13:00:38 -08:00
parent be3dcf3309
commit 2ffbb3c6a5
18 changed files with 172 additions and 46 deletions

View File

@ -5,7 +5,7 @@ source 'https://rubygems.org'
gem 'bundler', '< 3.0' gem 'bundler', '< 3.0'
gemspec gemspec
gem 'xpath', git: 'git://github.com/teamcapybara/xpath.git' gem 'xpath', github: 'teamcapybara/xpath'
group :doc do group :doc do
gem 'redcarpet', platforms: :mri gem 'redcarpet', platforms: :mri

View File

@ -37,6 +37,7 @@ Gem::Specification.new do |s|
s.add_development_dependency('cucumber', ['>= 2.3.0']) s.add_development_dependency('cucumber', ['>= 2.3.0'])
s.add_development_dependency('erubi') # dependency specification needed by rbx s.add_development_dependency('erubi') # dependency specification needed by rbx
s.add_development_dependency('fuubar', ['>= 1.0.0']) s.add_development_dependency('fuubar', ['>= 1.0.0'])
s.add_development_dependency('irb')
s.add_development_dependency('launchy', ['>= 2.0.4']) s.add_development_dependency('launchy', ['>= 2.0.4'])
s.add_development_dependency('minitest') s.add_development_dependency('minitest')
s.add_development_dependency('puma') s.add_development_dependency('puma')
@ -45,6 +46,7 @@ Gem::Specification.new do |s|
s.add_development_dependency('rubocop') s.add_development_dependency('rubocop')
s.add_development_dependency('rubocop-rspec') s.add_development_dependency('rubocop-rspec')
s.add_development_dependency('selenium-webdriver', ['~>3.5']) s.add_development_dependency('selenium-webdriver', ['~>3.5'])
s.add_development_dependency('selenium_statistics')
s.add_development_dependency('sinatra', ['>= 1.4.0']) s.add_development_dependency('sinatra', ['>= 1.4.0'])
s.add_development_dependency('webdrivers', ['>=3.6.0']) if ENV['CI'] s.add_development_dependency('webdrivers', ['>=3.6.0']) if ENV['CI']
s.add_development_dependency('yard', ['>= 0.9.0']) s.add_development_dependency('yard', ['>= 0.9.0'])

View File

@ -3,5 +3,5 @@ source 'https://rubygems.org'
gem 'bundler', '< 3.0' gem 'bundler', '< 3.0'
gemspec path: '..' gemspec path: '..'
gem 'xpath', :git => 'git://github.com/teamcapybara/xpath.git' gem 'xpath', github: 'teamcapybara/xpath'
gem 'nokogumbo' gem 'nokogumbo'

View File

@ -15,11 +15,11 @@ class Capybara::Driver::Base
raise NotImplementedError raise NotImplementedError
end end
def find_xpath(query) def find_xpath(query, **options)
raise NotImplementedError raise NotImplementedError
end end
def find_css(query) def find_css(query, **options)
raise NotImplementedError raise NotImplementedError
end end

View File

@ -3,11 +3,12 @@
module Capybara module Capybara
module Driver module Driver
class Node class Node
attr_reader :driver, :native attr_reader :driver, :native, :initial_visibility
def initialize(driver, native) def initialize(driver, native, initial_visibility = nil)
@driver = driver @driver = driver
@native = native @native = native
@initial_visibility = initial_visibility
end end
def all_text def all_text

View File

@ -95,13 +95,21 @@ module Capybara
end end
# @api private # @api private
def find_css(css) def find_css(css, **options)
base.find_css(css) if base.method(:find_css).arity != 1
base.find_css(css, **options)
else
base.find_css(css)
end
end end
# @api private # @api private
def find_xpath(xpath) def find_xpath(xpath, **options)
base.find_xpath(xpath) if base.method(:find_css).arity != 1
base.find_xpath(xpath, **options)
else
base.find_xpath(xpath)
end
end end
# @api private # @api private

View File

@ -465,6 +465,14 @@ module Capybara
%(Obsolete #<Capybara::Node::Element>) %(Obsolete #<Capybara::Node::Element>)
end end
def initial_visibility
if base.respond_to? :initial_visibility
base.initial_visibility
else
nil
end
end
STYLE_SCRIPT = <<~JS STYLE_SCRIPT = <<~JS
(function(){ (function(){
var s = window.getComputedStyle(this); var s = window.getComputedStyle(this);

View File

@ -164,12 +164,12 @@ module Capybara
end end
# @api private # @api private
def find_css(css) def find_css(css, **_options)
native.css(css) native.css(css)
end end
# @api private # @api private
def find_xpath(xpath) def find_xpath(xpath, **_options)
native.xpath(xpath) native.xpath(xpath)
end end
@ -178,6 +178,10 @@ module Capybara
Capybara.session_options Capybara.session_options
end end
def initial_visibility
nil
end
private private
def option_value(option) def option_value(option)

View File

@ -14,6 +14,7 @@ module Capybara
**options, **options,
&filter_block) &filter_block)
@resolved_node = nil @resolved_node = nil
@resolved_count = 0
@options = options.dup @options = options.dup
super(@options) super(@options)
self.session_options = session_options self.session_options = session_options
@ -106,7 +107,9 @@ module Capybara
exact = exact? if exact.nil? exact = exact? if exact.nil?
expr = apply_expression_filters(@expression) expr = apply_expression_filters(@expression)
expr = exact ? expr.to_xpath(:exact) : expr.to_s if expr.respond_to?(:to_xpath) expr = exact ? expr.to_xpath(:exact) : expr.to_s if expr.respond_to?(:to_xpath)
filtered_expression(expr) expr = filtered_expression(expr)
expr = "(#{expr})[#{xpath_text_conditions}]" if try_text_match_in_expression?
expr
end end
def css def css
@ -117,6 +120,7 @@ module Capybara
def resolve_for(node, exact = nil) def resolve_for(node, exact = nil)
applied_filters.clear applied_filters.clear
@resolved_node = node @resolved_node = node
@resolved_count += 1
node.synchronize do node.synchronize do
children = find_nodes_by_selector_format(node, exact).map(&method(:to_element)) children = find_nodes_by_selector_format(node, exact).map(&method(:to_element))
Capybara::Result.new(children, self) Capybara::Result.new(children, self)
@ -138,6 +142,26 @@ module Capybara
private private
def text_fragments
text = (options[:text] || options[:exact_text])
text.is_a?(String) ? text.split : []
end
def xpath_text_conditions
(options[:text] || options[:exact_text]).split.map { |txt| XPath.contains(txt) }.reduce(&:&)
end
def try_text_match_in_expression?
first_try? &&
(options[:text] || options[:exact_text]).is_a?(String) &&
@resolved_node&.respond_to?(:session) &&
@resolved_node.session.driver.wait?
end
def first_try?
@resolved_count == 1
end
def show_for_stage(only_applied) def show_for_stage(only_applied)
lambda do |stage = :any| lambda do |stage = :any|
!only_applied || (stage == :any ? applied_filters.any? : applied_filters.include?(stage)) !only_applied || (stage == :any ? applied_filters.any? : applied_filters.include?(stage))
@ -156,10 +180,24 @@ module Capybara
end end
def find_nodes_by_selector_format(node, exact) def find_nodes_by_selector_format(node, exact)
options = {}
options[:uses_visibility] = true unless visible == :all
options[:texts] = text_fragments unless selector.format == :xpath
if selector.format == :css if selector.format == :css
node.find_css(css) if node.method(:find_css).arity != 1
node.find_css(css, **options)
else
node.find_css(css)
end
elsif selector.format == :xpath
if node.method(:find_xpath).arity != 1
node.find_xpath(xpath(exact), **options)
else
node.find_xpath(xpath(exact))
end
else else
node.find_xpath(xpath(exact)) raise ArgumentError, "Unknown format: #{selector.format}"
end end
end end
@ -318,12 +356,12 @@ module Capybara
def matches_system_filters?(node) def matches_system_filters?(node)
applied_filters << :system applied_filters << :system
matches_id_filter?(node) && matches_visible_filter?(node) &&
matches_id_filter?(node) &&
matches_class_filter?(node) && matches_class_filter?(node) &&
matches_style_filter?(node) && matches_style_filter?(node) &&
matches_text_filter?(node) && matches_text_filter?(node) &&
matches_exact_text_filter?(node) && matches_exact_text_filter?(node)
matches_visible_filter?(node)
end end
def matches_id_filter?(node) def matches_id_filter?(node)
@ -377,8 +415,10 @@ module Capybara
def matches_visible_filter?(node) def matches_visible_filter?(node)
case visible case visible
when :visible then node.visible? when :visible then
when :hidden then !node.visible? node.initial_visibility || (node.initial_visibility.nil? && node.visible?)
when :hidden then
(node.initial_visibility == false) || (node.initial_visibility.nil? && !node.visible?)
else true else true
end end
end end

View File

@ -4,6 +4,8 @@ require 'uri'
require 'English' require 'English'
class Capybara::Selenium::Driver < Capybara::Driver::Base class Capybara::Selenium::Driver < Capybara::Driver::Base
include Capybara::Selenium::Find
DEFAULT_OPTIONS = { DEFAULT_OPTIONS = {
browser: :firefox, browser: :firefox,
clear_local_storage: nil, clear_local_storage: nil,
@ -73,14 +75,6 @@ class Capybara::Selenium::Driver < Capybara::Driver::Base
browser.current_url browser.current_url
end end
def find_xpath(selector)
browser.find_elements(:xpath, selector).map(&method(:build_node))
end
def find_css(selector)
browser.find_elements(:css, selector).map(&method(:build_node))
end
def wait?; true; end def wait?; true; end
def needs_server?; true; end def needs_server?; true; end
@ -348,8 +342,12 @@ private
end end
end end
def build_node(native_node) def find_context
::Capybara::Selenium::Node.new(self, native_node) browser
end
def build_node(native_node, initial_visibility = nil)
::Capybara::Selenium::Node.new(self, native_node, initial_visibility)
end end
def specialize_driver(sel_driver) def specialize_driver(sel_driver)

View File

@ -51,8 +51,8 @@ private
result['value'] result['value']
end end
def build_node(native_node) def build_node(native_node, initial_visibility = nil)
::Capybara::Selenium::ChromeNode.new(self, native_node) ::Capybara::Selenium::ChromeNode.new(self, native_node, initial_visibility)
end end
def bridge def bridge

View File

@ -44,7 +44,7 @@ module Capybara::Selenium::Driver::FirefoxDriver
private private
def build_node(native_node) def build_node(native_node, initial_visibility = nil)
::Capybara::Selenium::FirefoxNode.new(self, native_node) ::Capybara::Selenium::FirefoxNode.new(self, native_node, initial_visibility)
end end
end end

View File

@ -0,0 +1,50 @@
# frozen_string_literal: true
module Capybara
module Selenium
module Find
def find_xpath(selector, uses_visibility: false, **_options)
find_by(:xpath, selector, uses_visibility: uses_visibility, texts: [])
end
def find_css(selector, uses_visibility: false, texts: [], **_options)
find_by(:css, selector, uses_visibility: uses_visibility, texts: texts)
end
private
def find_by(format, selector, uses_visibility:, texts:)
els = find_context.find_elements(format, selector)
els = filter_by_text(els, texts) if (els.size > 1) && !texts.empty?
visibilities = if uses_visibility && els.size > 1
element_visibilities(els)
else
[]
end
els.map.with_index { |el, idx| build_node(el, visibilities[idx]) }
end
def element_visibilities(elements)
es_context = respond_to?(:execute_script) ? self : driver
es_context.execute_script <<~JS, elements
return arguments[0].map(#{is_displayed_atom})
JS
end
def filter_by_text(elements, texts)
es_context = respond_to?(:execute_script) ? self : driver
es_context.execute_script <<~JS, elements, texts
var texts = arguments[1]
return arguments[0].filter(function(el){
var content = el.textContent.toLowerCase();
return texts.every(function(txt){ return content.includes(txt.toLowerCase()) });
})
JS
end
def is_displayed_atom # rubocop:disable Naming/PredicateName
@is_displayed_atom ||= browser.send(:bridge).send(:read_atom, 'isDisplayed')
end
end
end
end

View File

@ -2,9 +2,11 @@
# Selenium specific implementation of the Capybara::Driver::Node API # Selenium specific implementation of the Capybara::Driver::Node API
require 'capybara/selenium/extensions/find'
require 'capybara/selenium/extensions/scroll' require 'capybara/selenium/extensions/scroll'
class Capybara::Selenium::Node < Capybara::Driver::Node class Capybara::Selenium::Node < Capybara::Driver::Node
include Capybara::Selenium::Find
include Capybara::Selenium::Scroll include Capybara::Selenium::Scroll
def visible_text def visible_text
@ -153,14 +155,6 @@ class Capybara::Selenium::Node < Capybara::Driver::Node
native.attribute('isContentEditable') native.attribute('isContentEditable')
end end
def find_xpath(locator)
native.find_elements(:xpath, locator).map { |el| self.class.new(driver, el) }
end
def find_css(locator)
native.find_elements(:css, locator).map { |el| self.class.new(driver, el) }
end
def ==(other) def ==(other)
native == other.native native == other.native
end end
@ -331,8 +325,12 @@ private
each_key(keys) { |key| actions.key_up(key) } each_key(keys) { |key| actions.key_up(key) }
end end
def browser
driver.browser
end
def browser_action def browser_action
driver.browser.action browser.action
end end
def each_key(keys) def each_key(keys)
@ -347,6 +345,14 @@ private
end end
end end
def find_context
native
end
def build_node(native_node, initial_visibility = nil)
self.class.new(driver, native_node, initial_visibility)
end
GET_XPATH_SCRIPT = <<~'JS' GET_XPATH_SCRIPT = <<~'JS'
(function(el, xml){ (function(el, xml){
var xpath = ''; var xpath = '';

View File

@ -56,6 +56,7 @@ Capybara::SpecHelper.spec '#has_selector?' do
context 'with text' do context 'with text' do
it 'should discard all matches where the given string is not contained' do it 'should discard all matches where the given string is not contained' do
expect(@session).to have_selector('//p//a', text: 'Redirect', count: 1) expect(@session).to have_selector('//p//a', text: 'Redirect', count: 1)
expect(@session).to have_selector(:css, 'p a', text: 'Redirect', count: 1)
expect(@session).not_to have_selector('//p', text: 'Doesnotexist') expect(@session).not_to have_selector('//p', text: 'Doesnotexist')
end end
@ -191,5 +192,11 @@ Capybara::SpecHelper.spec '#has_no_selector?' do
expect(@session).not_to have_no_selector('//p//a', text: /re[dab]i/i, count: 1) expect(@session).not_to have_no_selector('//p//a', text: /re[dab]i/i, count: 1)
expect(@session).to have_no_selector('//p//a', text: /Red$/) expect(@session).to have_no_selector('//p//a', text: /Red$/)
end end
it 'should error when matching element exists' do
expect do
expect(@session).to have_no_selector('//h2', text: 'Header Class Test Five')
end.to raise_error RSpec::Expectations::ExpectationNotMetError
end
end end
end end

View File

@ -12,7 +12,7 @@
<h2 class="head" id="h2two">Header Class Test Two</h2> <h2 class="head" id="h2two">Header Class Test Two</h2>
<h2 class="head" id="h2_">Header Class Test Three</h2> <h2 class="head" id="h2_">Header Class Test Three</h2>
<h2 class="head">Header Class Test Four</h2> <h2 class="head">Header Class Test Four</h2>
<h2 class="head">Header Class Test Five</h2> <h2 class="head">Header Class <span style="display: none">Random</span>Test Five</h2>
<span class="number">42</span> <span class="number">42</span>
<span>Other span</span> <span>Other span</span>

View File

@ -88,7 +88,7 @@ RSpec.describe Capybara::Result do
it 'should catch invalid element errors during filtering' do it 'should catch invalid element errors during filtering' do
allow_any_instance_of(Capybara::Node::Simple).to receive(:text).and_raise(StandardError) allow_any_instance_of(Capybara::Node::Simple).to receive(:text).and_raise(StandardError)
allow_any_instance_of(Capybara::Node::Simple).to receive(:session).and_return( allow_any_instance_of(Capybara::Node::Simple).to receive(:session).and_return(
instance_double('Capybara::Session', driver: instance_double('Capybara::Driver::Base', invalid_element_errors: [StandardError])) instance_double('Capybara::Session', driver: instance_double('Capybara::Driver::Base', invalid_element_errors: [StandardError], wait?: false))
) )
result = string.all('//li', text: 'Alpha') result = string.all('//li', text: 'Alpha')
expect(result.size).to eq 0 expect(result.size).to eq 0
@ -97,7 +97,7 @@ RSpec.describe Capybara::Result do
it 'should return non-invalid element errors during filtering' do it 'should return non-invalid element errors during filtering' do
allow_any_instance_of(Capybara::Node::Simple).to receive(:text).and_raise(StandardError) allow_any_instance_of(Capybara::Node::Simple).to receive(:text).and_raise(StandardError)
allow_any_instance_of(Capybara::Node::Simple).to receive(:session).and_return( allow_any_instance_of(Capybara::Node::Simple).to receive(:session).and_return(
instance_double('Capybara::Session', driver: instance_double('Capybara::Driver::Base', invalid_element_errors: [ArgumentError])) instance_double('Capybara::Session', driver: instance_double('Capybara::Driver::Base', invalid_element_errors: [ArgumentError], wait?: false))
) )
expect do expect do
string.all('//li', text: 'Alpha').to_a string.all('//li', text: 'Alpha').to_a

View File

@ -3,6 +3,7 @@
require 'rspec/expectations' require 'rspec/expectations'
require 'capybara/spec/spec_helper' require 'capybara/spec/spec_helper'
require 'webdrivers' if ENV['CI'] require 'webdrivers' if ENV['CI']
require 'selenium_statistics'
module Capybara module Capybara
module SpecHelper module SpecHelper
@ -53,4 +54,5 @@ RSpec.configure do |config|
Capybara::SpecHelper.configure(config) Capybara::SpecHelper.configure(config)
config.filter_run_including focus_: true unless ENV['CI'] config.filter_run_including focus_: true unless ENV['CI']
config.run_all_when_everything_filtered = true config.run_all_when_everything_filtered = true
config.after(:suite) { SeleniumStatistics.print_results }
end end