diff --git a/lib/capybara/queries/selector_query.rb b/lib/capybara/queries/selector_query.rb index dd038ee0..b28f3ad2 100644 --- a/lib/capybara/queries/selector_query.rb +++ b/lib/capybara/queries/selector_query.rb @@ -237,7 +237,7 @@ module Capybara conditions = {} conditions[:id] = options[:id] if use_default_id_filter? conditions[:class] = options[:class] if use_default_class_filter? - builder.add_attribute_conditions(expr, conditions) + builder(expr).add_attribute_conditions(conditions) end def use_default_id_filter? @@ -359,8 +359,8 @@ module Capybara @selector.default_visibility(session_options.ignore_hidden_elements, options) end - def builder - selector.builder + def builder(expr) + selector.builder(expr) end end end diff --git a/lib/capybara/selector.rb b/lib/capybara/selector.rb index 30e8028e..43ff49fa 100644 --- a/lib/capybara/selector.rb +++ b/lib/capybara/selector.rb @@ -34,7 +34,7 @@ Capybara.add_selector(:css) do end Capybara.add_selector(:id) do - xpath { |id| builder.add_attribute_conditions(XPath.descendant, id: id) } + xpath { |id| builder(XPath.descendant).add_attribute_conditions(id: id) } locator_filter { |node, id| id.is_a?(Regexp) ? node[:id] =~ id : true } end @@ -92,8 +92,7 @@ end Capybara.add_selector(:link) do xpath do |locator, href: true, alt: nil, title: nil, **| - xpath = XPath.descendant(:a) - xpath = xpath[@href_conditions = builder.attribute_conditions(href: href)] + xpath = builder(XPath.descendant(:a)).add_attribute_conditions(href: href) unless locator.nil? locator = locator.to_s @@ -119,25 +118,18 @@ Capybara.add_selector(:link) do end expression_filter(:download, valid_values: [true, false, String]) do |expr, download| - builder.add_attribute_conditions(expr, download: download) + builder(expr).add_attribute_conditions(download: download) end describe_expression_filters do |**options| desc = +'' if (href = options[:href]) - if !href.is_a?(Regexp) - desc << " with href #{href.inspect}" - elsif @href_conditions - desc << " with href matching #{href.inspect}" - end + desc << " with href #{'matching ' if href.is_a? Regexp}#{href.inspect}" + elsif options.key?(:href) # is nil/false specified? + desc << ' with no href attribute' end - desc << ' with no href attribute' if options.fetch(:href, true).nil? desc end - - describe_node_filters do |href: nil, **| - " with href matching #{href.inspect}" if href.is_a?(Regexp) && @href_conditions.nil? - end end Capybara.add_selector(:button) do @@ -489,7 +481,7 @@ Capybara.add_selector(:element) do end expression_filter(:attributes, matcher: /.+/) do |xpath, name, val| - builder.add_attribute_conditions(xpath, name => val) + builder(xpath).add_attribute_conditions(name => val) end node_filter(:attributes, matcher: /.+/) do |node, name, val| diff --git a/lib/capybara/selector/builders/css_builder.rb b/lib/capybara/selector/builders/css_builder.rb index 73104a61..44290125 100644 --- a/lib/capybara/selector/builders/css_builder.rb +++ b/lib/capybara/selector/builders/css_builder.rb @@ -6,69 +6,73 @@ module Capybara class Selector # @api private class CSSBuilder - class << self - def attribute_conditions(attributes) - attributes.map do |attribute, value| - case value - when XPath::Expression - raise ArgumentError, "XPath expressions are not supported for the :#{attribute} filter with CSS based selectors" - when Regexp - Selector::RegexpDisassembler.new(value).substrings.map do |str| - "[#{attribute}*='#{str}'#{' i' if value.casefold?}]" - end.join - when true - "[#{attribute}]" - when false - ':not([attribute])' - else - if attribute == :id - "##{::Capybara::Selector::CSS.escape(value)}" - else - "[#{attribute}='#{value}']" - end - end - end.join - end + def initialize(expression) + @expression = expression || '' + end - def add_attribute_conditions(selector, **attributes) - attributes.inject(selector) do |css, (name, value)| - conditions = if name == :class - class_conditions(value) - elsif value.is_a? Regexp - Selector::RegexpDisassembler.new(value).alternated_substrings.map do |strs| - strs.map do |str| - "[#{name}*='#{str}'#{' i' if value.casefold?}]" - end.join - end - else - [attribute_conditions(name => value)] - end + attr_reader :expression - ::Capybara::Selector::CSS.split(css).map do |sel| - next sel if conditions.empty? - - conditions.map { |cond| sel + cond }.join(', ') - end.join(', ') - end - end - - private - - def class_conditions(classes) - case classes - when XPath::Expression - raise ArgumentError, 'XPath expressions are not supported for the :class filter with CSS based selectors' - when Regexp - Selector::RegexpDisassembler.new(classes).alternated_substrings.map do |strs| + def add_attribute_conditions(**attributes) + @expression = attributes.inject(expression) do |css, (name, value)| + conditions = if name == :class + class_conditions(value) + elsif value.is_a? Regexp + Selector::RegexpDisassembler.new(value).alternated_substrings.map do |strs| strs.map do |str| - "[class*='#{str}'#{' i' if classes.casefold?}]" + "[#{name}*='#{str}'#{' i' if value.casefold?}]" end.join end else - cls = Array(classes).group_by { |cl| cl.start_with?('!') && !cl.start_with?('!!!') } - [(cls[false].to_a.map { |cl| ".#{Capybara::Selector::CSS.escape(cl.sub(/^!!/, ''))}" } + - cls[true].to_a.map { |cl| ":not(.#{Capybara::Selector::CSS.escape(cl.slice(1..-1))})" }).join] + [attribute_conditions(name => value)] end + + ::Capybara::Selector::CSS.split(css).map do |sel| + next sel if conditions.empty? + + conditions.map { |cond| sel + cond }.join(', ') + end.join(', ') + end + end + + private + + def attribute_conditions(attributes) + attributes.map do |attribute, value| + case value + when XPath::Expression + raise ArgumentError, "XPath expressions are not supported for the :#{attribute} filter with CSS based selectors" + when Regexp + Selector::RegexpDisassembler.new(value).substrings.map do |str| + "[#{attribute}*='#{str}'#{' i' if value.casefold?}]" + end.join + when true + "[#{attribute}]" + when false + ':not([attribute])' + else + if attribute == :id + "##{::Capybara::Selector::CSS.escape(value)}" + else + "[#{attribute}='#{value}']" + end + end + end.join + end + + def class_conditions(classes) + case classes + when XPath::Expression + raise ArgumentError, 'XPath expressions are not supported for the :class filter with CSS based selectors' + when Regexp + Selector::RegexpDisassembler.new(classes).alternated_substrings.map do |strs| + strs.map do |str| + "[class*='#{str}'#{' i' if classes.casefold?}]" + end.join + end + else + cls = Array(classes).group_by { |cl| cl.start_with?('!') && !cl.start_with?('!!!') } + [(cls[false].to_a.map { |cl| ".#{Capybara::Selector::CSS.escape(cl.sub(/^!!/, ''))}" } + + cls[true].to_a.map { |cl| ":not(.#{Capybara::Selector::CSS.escape(cl.slice(1..-1))})" }).join] end end end diff --git a/lib/capybara/selector/builders/xpath_builder.rb b/lib/capybara/selector/builders/xpath_builder.rb index 88e8d71d..810af21e 100644 --- a/lib/capybara/selector/builders/xpath_builder.rb +++ b/lib/capybara/selector/builders/xpath_builder.rb @@ -6,59 +6,63 @@ module Capybara class Selector # @api private class XPathBuilder - class << self - def attribute_conditions(attributes) - attributes.map do |attribute, value| - case value - when XPath::Expression - XPath.attr(attribute)[value] - when Regexp - XPath.attr(attribute)[regexp_to_xpath_conditions(value)] - when true - XPath.attr(attribute) - when false, nil - !XPath.attr(attribute) + def initialize(expression) + @expression = expression || '' + end + + attr_reader :expression + + def add_attribute_conditions(**conditions) + @expression = conditions.inject(expression) do |xp, (name, value)| + conditions = name == :class ? class_conditions(value) : attribute_conditions(name => value) + if xp.is_a? XPath::Expression + xp[conditions] + else + "(#{xp})[#{conditions}]" + end + end + end + + private + + def attribute_conditions(attributes) + attributes.map do |attribute, value| + case value + when XPath::Expression + XPath.attr(attribute)[value] + when Regexp + XPath.attr(attribute)[regexp_to_xpath_conditions(value)] + when true + XPath.attr(attribute) + when false, nil + !XPath.attr(attribute) + else + XPath.attr(attribute) == value.to_s + end + end.reduce(:&) + end + + def class_conditions(classes) + case classes + when XPath::Expression, Regexp + attribute_conditions(class: classes) + else + Array(classes).map do |klass| + if klass.start_with?('!') && !klass.start_with?('!!!') + !XPath.attr(:class).contains_word(klass.slice(1..-1)) else - XPath.attr(attribute) == value.to_s + XPath.attr(:class).contains_word(klass.sub(/^!!/, '')) end end.reduce(:&) end + end - def add_attribute_conditions(xpath, **conditions) - conditions.inject(xpath) do |xp, (name, value)| - conditions = name == :class ? class_conditions(value) : attribute_conditions(name => value) - if xp.is_a? XPath::Expression - xp[conditions] - else - "(#{xp})[#{conditions}]" - end - end - end - - private - - def class_conditions(classes) - case classes - when XPath::Expression, Regexp - attribute_conditions(class: classes) - else - Array(classes).map do |klass| - if klass.start_with?('!') && !klass.start_with?('!!!') - !XPath.attr(:class).contains_word(klass.slice(1..-1)) - else - XPath.attr(:class).contains_word(klass.sub(/^!!/, '')) - end - end.reduce(:&) - end - end - - def regexp_to_xpath_conditions(regexp) - condition = XPath.current - condition = condition.uppercase if regexp.casefold? - Selector::RegexpDisassembler.new(regexp).alternated_substrings.map do |strs| - strs.map { |str| condition.contains(str) }.reduce(:&) - end.reduce(:|) - end + def regexp_to_xpath_conditions(regexp) + condition = XPath.current + condition = condition.uppercase if regexp.casefold? + Selector::RegexpDisassembler.new(regexp).alternated_substrings.map do |strs| + strs.map { |str| condition.contains(str) }.reduce(:&) + end.reduce(:|) end end end diff --git a/lib/capybara/selector/selector.rb b/lib/capybara/selector/selector.rb index 195513e3..4684439d 100644 --- a/lib/capybara/selector/selector.rb +++ b/lib/capybara/selector/selector.rb @@ -409,7 +409,7 @@ module Capybara end # @api private - def builder + def builder(expr = nil) case format when :css Capybara::Selector::CSSBuilder @@ -417,7 +417,7 @@ module Capybara Capybara::Selector::XPathBuilder else raise NotImplementedError, "No builder exists for selector of type #{format}" - end + end.new(expr) end # @api private diff --git a/lib/capybara/spec/session/click_link_spec.rb b/lib/capybara/spec/session/click_link_spec.rb index 50666ce3..539aca96 100644 --- a/lib/capybara/spec/session/click_link_spec.rb +++ b/lib/capybara/spec/session/click_link_spec.rb @@ -83,7 +83,7 @@ Capybara::SpecHelper.spec '#click_link' do end it "should raise error if link wasn't found" do - expect { @session.click_link('labore', href: 'invalid_href') }.to raise_error(Capybara::ElementNotFound) + expect { @session.click_link('labore', href: 'invalid_href') }.to raise_error(Capybara::ElementNotFound, /with href "invalid_href/) end end @@ -104,8 +104,8 @@ Capybara::SpecHelper.spec '#click_link' do end it "should raise an error if no link's href matched the pattern" do - expect { @session.click_link('labore', href: /invalid_pattern/) }.to raise_error(Capybara::ElementNotFound) - expect { @session.click_link('labore', href: /.+d+/) }.to raise_error(Capybara::ElementNotFound) + expect { @session.click_link('labore', href: /invalid_pattern/) }.to raise_error(Capybara::ElementNotFound, %r{with href matching /invalid_pattern/}) + expect { @session.click_link('labore', href: /.+d+/) }.to raise_error(Capybara::ElementNotFound, /#{Regexp.quote "with href matching /.+d+/"}/) end context 'href: nil' do @@ -114,8 +114,8 @@ Capybara::SpecHelper.spec '#click_link' do end it 'should raise an error if href attribute exists' do - expect { @session.click_link('Blank Href', href: nil) }.to raise_error(Capybara::ElementNotFound) - expect { @session.click_link('Normal Anchor', href: nil) }.to raise_error(Capybara::ElementNotFound) + expect { @session.click_link('Blank Href', href: nil) }.to raise_error(Capybara::ElementNotFound, /with no href attribute/) + expect { @session.click_link('Normal Anchor', href: nil) }.to raise_error(Capybara::ElementNotFound, /with no href attribute/) end end end diff --git a/spec/css_builder_spec.rb b/spec/css_builder_spec.rb index d6aed792..b3804ff4 100644 --- a/spec/css_builder_spec.rb +++ b/spec/css_builder_spec.rb @@ -4,94 +4,94 @@ require 'spec_helper' RSpec.describe Capybara::Selector::CSSBuilder do let :builder do - ::Capybara::Selector::CSSBuilder + ::Capybara::Selector::CSSBuilder.new(@css) end context 'add_attribute_conditions' do it 'adds a single string condition to a single selector' do - css = 'div' - selector = builder.add_attribute_conditions(css, random: 'abc') + @css = 'div' + selector = builder.add_attribute_conditions(random: 'abc') expect(selector).to eq %(div[random='abc']) end it 'adds multiple string conditions to a single selector' do - css = 'div' - selector = builder.add_attribute_conditions(css, random: 'abc', other: 'def') + @css = 'div' + selector = builder.add_attribute_conditions(random: 'abc', other: 'def') expect(selector).to eq %(div[random='abc'][other='def']) end it 'adds a single string condition to a multiple selector' do - css = 'div, ul' - selector = builder.add_attribute_conditions(css, random: 'abc') + @css = 'div, ul' + selector = builder.add_attribute_conditions(random: 'abc') expect(selector).to eq %(div[random='abc'], ul[random='abc']) end it 'adds multiple string conditions to a multiple selector' do - css = 'div, ul' - selector = builder.add_attribute_conditions(css, random: 'abc', other: 'def') + @css = 'div, ul' + selector = builder.add_attribute_conditions(random: 'abc', other: 'def') expect(selector).to eq %(div[random='abc'][other='def'], ul[random='abc'][other='def']) end it 'adds simple regexp conditions to a single selector' do - css = 'div' - selector = builder.add_attribute_conditions(css, random: /abc/, other: /def/) + @css = 'div' + selector = builder.add_attribute_conditions(random: /abc/, other: /def/) expect(selector).to eq %(div[random*='abc'][other*='def']) end it 'adds wildcard regexp conditions to a single selector' do - css = 'div' - selector = builder.add_attribute_conditions(css, random: /abc.*def/, other: /def.*ghi/) + @css = 'div' + selector = builder.add_attribute_conditions(random: /abc.*def/, other: /def.*ghi/) expect(selector).to eq %(div[random*='abc'][random*='def'][other*='def'][other*='ghi']) end it 'adds alternated regexp conditions to a single selector' do - css = 'div' - selector = builder.add_attribute_conditions(css, random: /abc|def/, other: /def|ghi/) + @css = 'div' + selector = builder.add_attribute_conditions(random: /abc|def/, other: /def|ghi/) expect(selector).to eq %(div[random*='abc'][other*='def'], div[random*='abc'][other*='ghi'], div[random*='def'][other*='def'], div[random*='def'][other*='ghi']) end it 'adds alternated regexp conditions to a multiple selector' do - css = 'div,ul' - selector = builder.add_attribute_conditions(css, other: /def.*ghi|jkl/) + @css = 'div,ul' + selector = builder.add_attribute_conditions(other: /def.*ghi|jkl/) expect(selector).to eq %(div[other*='def'][other*='ghi'], div[other*='jkl'], ul[other*='def'][other*='ghi'], ul[other*='jkl']) end it "returns original selector when regexp can't be substringed" do - css = 'div' - selector = builder.add_attribute_conditions(css, other: /.+/) + @css = 'div' + selector = builder.add_attribute_conditions(other: /.+/) expect(selector).to eq 'div' end context ':class' do it 'handles string with CSS .' do - css = 'a' - selector = builder.add_attribute_conditions(css, class: 'my_class') + @css = 'a' + selector = builder.add_attribute_conditions(class: 'my_class') expect(selector).to eq 'a.my_class' end it 'handles negated string with CSS .' do - css = 'a' - selector = builder.add_attribute_conditions(css, class: '!my_class') + @css = 'a' + selector = builder.add_attribute_conditions(class: '!my_class') expect(selector).to eq 'a:not(.my_class)' end it 'handles array of string with CSS .' do - css = 'a' - selector = builder.add_attribute_conditions(css, class: %w[my_class my_other_class]) + @css = 'a' + selector = builder.add_attribute_conditions(class: %w[my_class my_other_class]) expect(selector).to eq 'a.my_class.my_other_class' end it 'handles array of string with CSS . when negated included' do - css = 'a' - selector = builder.add_attribute_conditions(css, class: %w[my_class !my_other_class]) + @css = 'a' + selector = builder.add_attribute_conditions(class: %w[my_class !my_other_class]) expect(selector).to eq 'a.my_class:not(.my_other_class)' end end context ':id' do it 'handles string with CSS #' do - css = 'ul' - selector = builder.add_attribute_conditions(css, id: 'my_id') + @css = 'ul' + selector = builder.add_attribute_conditions(id: 'my_id') expect(selector).to eq 'ul#my_id' end end diff --git a/spec/xpath_builder_spec.rb b/spec/xpath_builder_spec.rb index 43c148f5..e7546df0 100644 --- a/spec/xpath_builder_spec.rb +++ b/spec/xpath_builder_spec.rb @@ -4,87 +4,87 @@ require 'spec_helper' RSpec.describe Capybara::Selector::XPathBuilder do let :builder do - ::Capybara::Selector::XPathBuilder + ::Capybara::Selector::XPathBuilder.new(@xpath) end context 'add_attribute_conditions' do it 'adds a single string condition to a single selector' do - xpath = './/div' - selector = builder.add_attribute_conditions(xpath, random: 'abc') + @xpath = './/div' + selector = builder.add_attribute_conditions(random: 'abc') expect(selector).to eq %((.//div)[(./@random = 'abc')]) end it 'adds multiple string conditions to a single selector' do - xpath = './/div' - selector = builder.add_attribute_conditions(xpath, random: 'abc', other: 'def') + @xpath = './/div' + selector = builder.add_attribute_conditions(random: 'abc', other: 'def') expect(selector).to eq %(((.//div)[(./@random = 'abc')])[(./@other = 'def')]) end it 'adds a single string condition to a multiple selector' do - xpath = XPath.descendant(:div, :ul) - selector = builder.add_attribute_conditions(xpath, random: 'abc') - expect(selector.to_s).to eq xpath[XPath.attr(:random) == 'abc'].to_s + @xpath = XPath.descendant(:div, :ul) + selector = builder.add_attribute_conditions(random: 'abc') + expect(selector.to_s).to eq @xpath[XPath.attr(:random) == 'abc'].to_s end it 'adds multiple string conditions to a multiple selector' do - xpath = XPath.descendant(:div, :ul) - selector = builder.add_attribute_conditions(xpath, random: 'abc', other: 'def') + @xpath = XPath.descendant(:div, :ul) + selector = builder.add_attribute_conditions(random: 'abc', other: 'def') expect(selector.to_s).to eq %(.//*[self::div | self::ul][(./@random = 'abc')][(./@other = 'def')]) end it 'adds simple regexp conditions to a single selector' do - xpath = XPath.descendant(:div) - selector = builder.add_attribute_conditions(xpath, random: /abc/, other: /def/) + @xpath = XPath.descendant(:div) + selector = builder.add_attribute_conditions(random: /abc/, other: /def/) expect(selector.to_s).to eq %(.//div[./@random[contains(., 'abc')]][./@other[contains(., 'def')]]) end it 'adds wildcard regexp conditions to a single selector' do - xpath = './/div' - selector = builder.add_attribute_conditions(xpath, random: /abc.*def/, other: /def.*ghi/) + @xpath = './/div' + selector = builder.add_attribute_conditions(random: /abc.*def/, other: /def.*ghi/) expect(selector).to eq %(((.//div)[./@random[(contains(., 'abc') and contains(., 'def'))]])[./@other[(contains(., 'def') and contains(., 'ghi'))]]) end it 'adds alternated regexp conditions to a single selector' do - xpath = XPath.descendant(:div) - selector = builder.add_attribute_conditions(xpath, random: /abc|def/, other: /def|ghi/) + @xpath = XPath.descendant(:div) + selector = builder.add_attribute_conditions(random: /abc|def/, other: /def|ghi/) expect(selector.to_s).to eq %(.//div[./@random[(contains(., 'abc') or contains(., 'def'))]][./@other[(contains(., 'def') or contains(., 'ghi'))]]) end it 'adds alternated regexp conditions to a multiple selector' do - xpath = XPath.descendant(:div, :ul) - selector = builder.add_attribute_conditions(xpath, other: /def.*ghi|jkl/) + @xpath = XPath.descendant(:div, :ul) + selector = builder.add_attribute_conditions(other: /def.*ghi|jkl/) expect(selector.to_s).to eq %(.//*[self::div | self::ul][./@other[((contains(., 'def') and contains(., 'ghi')) or contains(., 'jkl'))]]) end it "returns original selector when regexp can't be substringed" do - xpath = './/div' - selector = builder.add_attribute_conditions(xpath, other: /.+/) + @xpath = './/div' + selector = builder.add_attribute_conditions(other: /.+/) expect(selector).to eq '(.//div)[./@other]' end context ':class' do it 'handles string' do - xpath = './/a' - selector = builder.add_attribute_conditions(xpath, class: 'my_class') + @xpath = './/a' + selector = builder.add_attribute_conditions(class: 'my_class') expect(selector).to eq %((.//a)[contains(concat(' ', normalize-space(./@class), ' '), ' my_class ')]) end it 'handles negated strings' do - xpath = XPath.descendant(:a) - selector = builder.add_attribute_conditions(xpath, class: '!my_class') - expect(selector.to_s).to eq xpath[!XPath.attr(:class).contains_word('my_class')].to_s + @xpath = XPath.descendant(:a) + selector = builder.add_attribute_conditions(class: '!my_class') + expect(selector.to_s).to eq @xpath[!XPath.attr(:class).contains_word('my_class')].to_s end it 'handles array of strings' do - xpath = './/a' - selector = builder.add_attribute_conditions(xpath, class: %w[my_class my_other_class]) + @xpath = './/a' + selector = builder.add_attribute_conditions(class: %w[my_class my_other_class]) expect(selector).to eq %((.//a)[(contains(concat(' ', normalize-space(./@class), ' '), ' my_class ') and contains(concat(' ', normalize-space(./@class), ' '), ' my_other_class '))]) end it 'handles array of string when negated included' do - xpath = XPath.descendant(:a) - selector = builder.add_attribute_conditions(xpath, class: %w[my_class !my_other_class]) - expect(selector.to_s).to eq xpath[XPath.attr(:class).contains_word('my_class') & !XPath.attr(:class).contains_word('my_other_class')].to_s + @xpath = XPath.descendant(:a) + selector = builder.add_attribute_conditions(class: %w[my_class !my_other_class]) + expect(selector.to_s).to eq @xpath[XPath.attr(:class).contains_word('my_class') & !XPath.attr(:class).contains_word('my_other_class')].to_s end end end