From 7592b83b1363f3bf6d13c948b4863d4570836041 Mon Sep 17 00:00:00 2001 From: Thomas Walpole Date: Wed, 7 Aug 2019 14:14:28 -0700 Subject: [PATCH] Start of spatial filtering --- lib/capybara/queries/selector_query.rb | 47 +++++++++++++++++++- lib/capybara/rspec/matchers/base.rb | 2 + lib/capybara/rspec/matchers/spatial_sugar.rb | 38 ++++++++++++++++ lib/capybara/selenium/extensions/find.rb | 28 ++++++++---- lib/capybara/spec/session/find_spec.rb | 28 ++++++++++++ lib/capybara/spec/session/has_css_spec.rb | 17 +++++++ lib/capybara/spec/views/spatial.erb | 27 +++++++++++ 7 files changed, 177 insertions(+), 10 deletions(-) create mode 100644 lib/capybara/rspec/matchers/spatial_sugar.rb create mode 100644 lib/capybara/spec/views/spatial.erb diff --git a/lib/capybara/queries/selector_query.rb b/lib/capybara/queries/selector_query.rb index 5453c3bbe2..7a292ee989 100644 --- a/lib/capybara/queries/selector_query.rb +++ b/lib/capybara/queries/selector_query.rb @@ -4,7 +4,8 @@ module Capybara module Queries class SelectorQuery < Queries::BaseQuery attr_reader :expression, :selector, :locator, :options - VALID_KEYS = COUNT_KEYS + + SPATIAL_KEYS = %i[above below left_of right_of near].freeze + VALID_KEYS = SPATIAL_KEYS + COUNT_KEYS + %i[text id class style visible obscured exact exact_text normalize_ws match wait filter_set] VALID_MATCH = %i[first smart prefer_exact one].freeze @@ -18,6 +19,8 @@ def initialize(*args, @resolved_node = nil @resolved_count = 0 @options = options.dup + @filter_cache = Hash.new { |hsh, key| hsh[key] = {} } + super(@options) self.session_options = session_options @@ -87,6 +90,7 @@ def matches_filters?(node, node_filter_errors = []) matches_locator_filter?(node) && matches_system_filters?(node) && + matches_spatial_filters?(node) && matches_node_filters?(node, node_filter_errors) && matches_filter_block?(node) rescue *(node.respond_to?(:session) ? node.session.driver.invalid_element_errors : []) @@ -125,8 +129,10 @@ def css # @api private def resolve_for(node, exact = nil) applied_filters.clear + @filter_cache.clear @resolved_node = node @resolved_count += 1 + node.synchronize do children = find_nodes_by_selector_format(node, exact).map(&method(:to_element)) Capybara::Result.new(children, self) @@ -208,6 +214,7 @@ def find_nodes_by_selector_format(node, exact) hints[:uses_visibility] = true unless visible == :all hints[:texts] = text_fragments unless selector_format == :xpath hints[:styles] = options[:style] if use_default_style_filter? + hints[:position] = true if use_spatial_filter? if selector_format == :css if node.method(:find_css).arity != 1 @@ -333,6 +340,10 @@ def use_default_style_filter? options.key?(:style) && !custom_keys.include?(:style) end + def use_spatial_filter? + options.values_at(*SPATIAL_KEYS).compact.any? + end + def apply_expression_filters(expression) unapplied_options = options.keys - valid_keys expression_filters.inject(expression) do |expr, (name, ef)| @@ -397,6 +408,40 @@ def matches_system_filters?(node) matches_exact_text_filter?(node) end + def position_cache(key) + @filter_cache[key][:position] ||= key.evaluate_script('this.getBoundingClientRect()') + end + + def matches_spatial_filters?(node) + return true unless use_spatial_filter? + + node_pos = node.initial_cache[:position] || node.evaluate_script('this.getBoundingClientRect()') + + if options[:above] + el_pos = position_cache(options[:above]) + return false unless node_pos['bottom'] < el_pos['top'] + end + + if options[:below] + el_pos = position_cache(options[:below]) + return false unless el_pos['bottom'] < node_pos['top'] + end + + if options[:left_of] + el_pos = position_cache(options[:left_of]) + return false unless el_pos['left'] > node_pos['right'] + end + + if options[:right_of] + el_pos = position_cache(options[:right_of]) + return false unless node_pos['left'] > el_pos['right'] + end + + raise 'Not yet implemented' if options[:near] + + true + end + def matches_id_filter?(node) return true unless use_default_id_filter? && options[:id].is_a?(Regexp) diff --git a/lib/capybara/rspec/matchers/base.rb b/lib/capybara/rspec/matchers/base.rb index f5134d7db7..05f165d13b 100644 --- a/lib/capybara/rspec/matchers/base.rb +++ b/lib/capybara/rspec/matchers/base.rb @@ -2,6 +2,7 @@ require 'capybara/rspec/matchers/compound' require 'capybara/rspec/matchers/count_sugar' +require 'capybara/rspec/matchers/spatial_sugar' module Capybara module RSpecMatchers @@ -68,6 +69,7 @@ def wrap(actual) class CountableWrappedElementMatcher < WrappedElementMatcher include ::Capybara::RSpecMatchers::CountSugar + include ::Capybara::RSpecMatchers::SpatialSugar end class NegatedMatcher diff --git a/lib/capybara/rspec/matchers/spatial_sugar.rb b/lib/capybara/rspec/matchers/spatial_sugar.rb new file mode 100644 index 0000000000..920cc5db29 --- /dev/null +++ b/lib/capybara/rspec/matchers/spatial_sugar.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module Capybara + module RSpecMatchers + module SpatialSugar + def above(el) + options[:above] = el + self + end + + def below(el) + options[:below] = el + self + end + + def left_of(el) + options[:left_of] = el + self + end + + def right_of(el) + options[:right_of] = el + self + end + + def near(el) + options[:near] = el + self + end + + private + + def options + (@args.last.is_a?(Hash) ? @args : @args.push({})).last + end + end + end +end diff --git a/lib/capybara/selenium/extensions/find.rb b/lib/capybara/selenium/extensions/find.rb index 8519b837a4..6d2f110873 100644 --- a/lib/capybara/selenium/extensions/find.rb +++ b/lib/capybara/selenium/extensions/find.rb @@ -3,34 +3,35 @@ module Capybara module Selenium module Find - def find_xpath(selector, uses_visibility: false, styles: nil, **_options) - find_by(:xpath, selector, uses_visibility: uses_visibility, texts: [], styles: styles) + def find_xpath(selector, uses_visibility: false, styles: nil, position: false, **_options) + find_by(:xpath, selector, uses_visibility: uses_visibility, texts: [], styles: styles, position: position) end - def find_css(selector, uses_visibility: false, texts: [], styles: nil, **_options) - find_by(:css, selector, uses_visibility: uses_visibility, texts: texts, styles: styles) + def find_css(selector, uses_visibility: false, texts: [], styles: nil, position: false, **_options) + find_by(:css, selector, uses_visibility: uses_visibility, texts: texts, styles: styles, position: position) end private - def find_by(format, selector, uses_visibility:, texts:, styles:) + def find_by(format, selector, uses_visibility:, texts:, styles:, position:) els = find_context.find_elements(format, selector) hints = [] if (els.size > 2) && !ENV['DISABLE_CAPYBARA_SELENIUM_OPTIMIZATIONS'] els = filter_by_text(els, texts) unless texts.empty? - hints = gather_hints(els, uses_visibility: uses_visibility, styles: styles) + hints = gather_hints(els, uses_visibility: uses_visibility, styles: styles, position: position) end els.map.with_index { |el, idx| build_node(el, hints[idx] || {}) } end - def gather_hints(elements, uses_visibility:, styles:) - hints_js, functions = build_hints_js(uses_visibility, styles) + def gather_hints(elements, uses_visibility:, styles:, position:) + hints_js, functions = build_hints_js(uses_visibility, styles, position) return [] unless functions.any? es_context.execute_script(hints_js, elements).map! do |results| hint = {} hint[:style] = results.pop if functions.include?(:style_func) + hint[:position] = results.pop if functions.include?(:position_func) hint[:visible] = results.pop if functions.include?(:vis_func) hint end @@ -50,7 +51,7 @@ def filter_by_text(elements, texts) JS end - def build_hints_js(uses_visibility, styles) + def build_hints_js(uses_visibility, styles, position) functions = [] hints_js = +'' @@ -61,6 +62,15 @@ def build_hints_js(uses_visibility, styles) functions << :vis_func end + if position + hints_js << <<~POSITION_JS + var position_func = function(el){ + return el.getBoundingClientRect(); + }; + POSITION_JS + functions << :position_func + end + if styles.is_a? Hash hints_js << <<~STYLE_JS var style_func = function(el){ diff --git a/lib/capybara/spec/session/find_spec.rb b/lib/capybara/spec/session/find_spec.rb index 9195d9870c..18eb0bacab 100644 --- a/lib/capybara/spec/session/find_spec.rb +++ b/lib/capybara/spec/session/find_spec.rb @@ -428,6 +428,34 @@ expect(@session.find(:css, 'input', &:disabled?)[:name]).to eq('disabled_text') end + context 'with spatial filters', :focus_ do + before do + @session.visit('/spatial') + @center = @session.find(:css, 'div.center') + end + + it 'should find an element above another element' do + expect(@session.find(:css, 'div:not(.corner)', above: @center).text).to eq('2') + end + + it 'should find an element below another element' do + expect(@session.find(:css, 'div:not(.corner)', below: @center).text).to eq('8') + end + + it 'should find an element left of another element' do + expect(@session.find(:css, 'div:not(.corner)', left_of: @center).text).to eq('4') + end + + it 'should find an element right of another element' do + expect(@session.find(:css, 'div:not(.corner)', right_of: @center).text).to eq('6') + end + + it 'should combine spatial filters' do + expect(@session.find(:css, 'div', left_of: @center, above: @center).text).to eq('1') + expect(@session.find(:css, 'div', right_of: @center, below: @center).text).to eq('9') + end + end + context 'within a scope' do before do @session.visit('/with_scope') diff --git a/lib/capybara/spec/session/has_css_spec.rb b/lib/capybara/spec/session/has_css_spec.rb index 961ab8eabd..03ed166ce8 100644 --- a/lib/capybara/spec/session/has_css_spec.rb +++ b/lib/capybara/spec/session/has_css_spec.rb @@ -231,6 +231,23 @@ end end + context 'with spatial requirements', :focus_ do + before do + @session.visit('/spatial') + @center = @session.find(:css, '.center') + end + + it 'accepts spatial options' do + expect(@session).to have_css('div', above: @center).thrice + expect(@session).to have_css('div', above: @center, right_of: @center).once + end + + it 'supports spatial sugar' do + expect(@session).to have_css('div').left_of(@center).thrice + expect(@session).to have_css('div').below(@center).right_of(@center).once + end + end + it 'should allow escapes in the CSS selector' do expect(@session).to have_css('p[data-random="abc\\\\def"]') expect(@session).to have_css("p[data-random='#{Capybara::Selector::CSS.escape('abc\def')}']") diff --git a/lib/capybara/spec/views/spatial.erb b/lib/capybara/spec/views/spatial.erb new file mode 100644 index 0000000000..97292e6e4e --- /dev/null +++ b/lib/capybara/spec/views/spatial.erb @@ -0,0 +1,27 @@ + + + + spatial + + + + +
1
+
2
+
3
+
4
+
5
+
6
+
7
+
8
+
9
+ + +