Skip to content

Commit

Permalink
Start of spatial filtering
Browse files Browse the repository at this point in the history
  • Loading branch information
twalpole committed Aug 8, 2019
1 parent b03d78c commit 7592b83
Show file tree
Hide file tree
Showing 7 changed files with 177 additions and 10 deletions.
47 changes: 46 additions & 1 deletion lib/capybara/queries/selector_query.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand Down Expand Up @@ -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 : [])
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)|
Expand Down Expand Up @@ -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)

Expand Down
2 changes: 2 additions & 0 deletions lib/capybara/rspec/matchers/base.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -68,6 +69,7 @@ def wrap(actual)

class CountableWrappedElementMatcher < WrappedElementMatcher
include ::Capybara::RSpecMatchers::CountSugar
include ::Capybara::RSpecMatchers::SpatialSugar
end

class NegatedMatcher
Expand Down
38 changes: 38 additions & 0 deletions lib/capybara/rspec/matchers/spatial_sugar.rb
Original file line number Diff line number Diff line change
@@ -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
28 changes: 19 additions & 9 deletions lib/capybara/selenium/extensions/find.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 = +''

Expand All @@ -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){
Expand Down
28 changes: 28 additions & 0 deletions lib/capybara/spec/session/find_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
17 changes: 17 additions & 0 deletions lib/capybara/spec/session/has_css_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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')}']")
Expand Down
27 changes: 27 additions & 0 deletions lib/capybara/spec/views/spatial.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<html>
<head>
<meta http-equiv="Content-type" content="text/html; charset=utf-8"/>
<title>spatial</title>
<style>
#spatial {
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-gap: 10px;
grid-auto-rows: minmax(100px, auto);
}
</style>
</head>

<body id="spatial">
<div class="corner">1</div>
<div>2</div>
<div class="corner">3</div>
<div>4</div>
<div class="center">5</div>
<div>6</div>
<div class="corner">7</div>
<div>8</div>
<div class="corner">9</div>
</body>
</html>

0 comments on commit 7592b83

Please sign in to comment.