diff --git a/CHANGELOG.md b/CHANGELOG.md index 814dbd0..1887acd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ - `:pending_connection_errors` is handled by Cuprite instead of being passed to Ferrum, which deprecates it. `visit` raises `Capybara::Cuprite::PendingConnectionsError`, a subclass of Ferrum's, and rescuing Ferrum's warns [rubycdp/ferrum#689] +- `find_css` and `find_xpath` answer Capybara's finder hints in the call that finds the nodes: nodes that can't hold + the text are dropped there, and visibility, styles and position come back in `Node#initial_cache`, so Capybara no + longer asks for each node's visibility and text in a round trip of its own. As with Selenium, the hints are taken + when the nodes are found, so a result Capybara filters lazily keeps them if the page changes in between [#347] ### Fixed - A click on a node the page removes before the hit test raises `ObsoleteNode`, which Capybara retries, instead of diff --git a/lib/capybara/cuprite/driver.rb b/lib/capybara/cuprite/driver.rb index ab3777b..250e86e 100644 --- a/lib/capybara/cuprite/driver.rb +++ b/lib/capybara/cuprite/driver.rb @@ -90,12 +90,12 @@ def active_element Node.new(self, browser.evaluate("document.activeElement")) end - def find_xpath(selector) - find(:xpath, selector) + def find_xpath(selector, **hints) + find(:xpath, selector, **hints) end - def find_css(selector) - find(:css, selector) + def find_css(selector, **hints) + find(:css, selector, **hints) end def evaluate_script(script, *args) @@ -229,8 +229,8 @@ def dismiss_modal(type, options = {}) attr_reader :screen_size - def find(method, selector) - Finder.find(browser, method, selector).map { |native| Node.new(self, native) } + def find(method, selector, **hints) + Finder.find(browser, method, selector, **hints) { |native, cache| Node.new(self, native, cache) } end def within_window(handle) diff --git a/lib/capybara/cuprite/finder.rb b/lib/capybara/cuprite/finder.rb index c1bac58..f2a10a9 100644 --- a/lib/capybara/cuprite/finder.rb +++ b/lib/capybara/cuprite/finder.rb @@ -5,13 +5,31 @@ module Cuprite module Finder module_function - def find(browser, method, selector, within = nil) + def find(browser, method, selector, within = nil, **hints) # Capybara appends its text-match hint as `(expr)[...]`, but with `exact_text: true` the hint # is empty and the result `(expr)[]` is invalid XPath. An empty predicate is never valid, so # dropping it can't change a valid selector. Remove once teamcapybara/capybara#2860 is released. selector = selector.delete_suffix("[]") if method == :xpath - nodes = browser.evaluate("_cuprite.find(...arguments)", *[method, selector, within].compact) - nodes.select(&:node?) + styles = hints[:styles] + hints = { + visible: hints.fetch(:uses_visibility, false), + texts: hints.fetch(:texts, []), + styles: (styles.keys.map(&:to_s) if styles.is_a?(Hash)), + position: hints.fetch(:position, false) + } + + nodes, hidden, style, position, texts = + browser.evaluate("_cuprite.findWithHints(...arguments)", *[method, selector, hints, within].compact) + hidden = hidden&.to_set + all_texts, visible_texts = texts + nodes.each_with_index.map do |node, index| + cache = {} + cache[:visible] = !hidden.include?(index) if hidden + cache[:style] = style.transform_values { |values| values[index] } if style + cache[:position] = position[index] if position + cache.merge!(all_text: all_texts[index], visible_text: visible_texts[index]) if texts + yield node, cache + end rescue Ferrum::JavaScriptError => e raise InvalidSelector.new(e.response, method, selector) if e.class_name == "InvalidSelector" diff --git a/lib/capybara/cuprite/javascripts/index.js b/lib/capybara/cuprite/javascripts/index.js index 2266378..2472d4c 100644 --- a/lib/capybara/cuprite/javascripts/index.js +++ b/lib/capybara/cuprite/javascripts/index.js @@ -13,6 +13,8 @@ FORM: ["submit"] } + const UNMATCHED_LIMIT = 50; + class Cuprite { find(method, selector, within = document) { try { @@ -38,8 +40,59 @@ } } - visibleText(node) { - if (this.isVisible(node)) { + findWithHints(method, selector, hints, within) { + let nodes = this.find(method, selector, within).filter(node => node.nodeType == Node.ELEMENT_NODE); + + let visibility = [new Map(), new Map()]; + let texts = null; + if (hints.texts.length > 0) { + let matches = this.filterTexts(nodes, hints.texts, hints.visible, visibility); + // Capybara describes a failure from the nodes it filtered out itself, so when none hold the + // texts the first ones come back, with the texts it reads to filter and describe them. + if (matches.length > 0) { + nodes = matches; + } else { + nodes = nodes.slice(0, UNMATCHED_LIMIT); + texts = [nodes.map(node => node.textContent), nodes.map(node => this.visibleText(node, visibility))]; + } + } + + // Chrome serializes every returned object and value at a cost, so the answers come as one + // list per hint, and visibility only as the indexes of the hidden nodes. They come in an array + // because Ferrum awaits a returned plain object in case it's a promise, which is a round trip. + let hidden = hints.visible ? [...nodes.keys()].filter(index => !this.isVisible(nodes[index], visibility)) : null; + let style = null; + if (hints.styles) { + let styles = nodes.map(node => window.getComputedStyle(node)); + style = Object.fromEntries(hints.styles.map(name => [name, styles.map(style => style.getPropertyValue(name))])); + } + let position = hints.position ? nodes.map(node => node.getBoundingClientRect().toJSON()) : null; + return [nodes, hidden, style, position, texts]; + } + + // Capybara matches text case sensitively against the visible text, or against all text + // with zero width characters removed. The texts hold no whitespace, so a node can only + // match when one of these contains every text. A node's text is part of its ancestors' + // text, so walking from the last node first lets a match vouch for its ancestors. + filterTexts(nodes, texts, visible, visibility) { + let containsAll = content => texts.every(text => content.includes(text)); + let matched = new Set(); + return [...nodes].reverse().filter(node => { + if (matched.has(node)) { + return true; + } + if (containsAll(node.textContent.replace(/[\u200b\u200e\u200f]/g, ""))) { + for (let parent = node.parentNode; parent && !matched.has(parent); parent = parent.parentNode) { + matched.add(parent); + } + return true; + } + return visible && containsAll(this.visibleText(node, visibility) || ""); + }).reverse(); + } + + visibleText(node, visibility) { + if (this.isVisible(node, visibility)) { if (node.nodeName == "TEXTAREA") { return node.textContent; } @@ -48,7 +101,7 @@ } if (node instanceof ShadowRoot) { return Array.from(node.children) - .map(child => this.visibleText(child)) + .map(child => this.visibleText(child, visibility)) .filter(text => text) .join(" "); } @@ -56,7 +109,9 @@ } } - isVisible(node) { + // Pass the same cache to several calls to share their ancestor walks. An ancestor's + // result depends on whether its opacity still applies, so each case is cached apart. + isVisible(node, cache = [new Map(), new Map()]) { let mapName, style; // if node is area, check visibility of relevant image if (node.tagName === "AREA") { @@ -68,39 +123,52 @@ } let ancestorOpacityApplies = true; + let walked = []; + let visible = true; while (node) { if (node instanceof ShadowRoot) { node = node.host; - } else { - style = window.getComputedStyle(node); - if (style.display === "none" || style.visibility === "hidden" || (ancestorOpacityApplies && parseFloat(style.opacity) === 0)) { - return false; - } + continue; + } - // A node matching :modal or :popover-open is a top-layer element, so - // an ancestor's opacity no longer applies to it once shown. - if (ancestorOpacityApplies) { - try { - if (node.matches(":modal, :popover-open")) { - ancestorOpacityApplies = false; - } - } catch (e) {} - } + let known = cache[+ancestorOpacityApplies].get(node); + if (known !== undefined) { + visible = known; + break; + } + walked.push([node, ancestorOpacityApplies]); - let parent = node.parentElement; - if (parent && parent.tagName === "DETAILS" && !parent.open) { - // In a closed
only the first (and its subtree) is rendered. - if (node !== parent.querySelector(":scope > summary")) { - return false; + style = window.getComputedStyle(node); + if (style.display === "none" || style.visibility === "hidden" || (ancestorOpacityApplies && parseFloat(style.opacity) === 0)) { + visible = false; + break; + } + + // A node matching :modal or :popover-open is a top-layer element, so + // an ancestor's opacity no longer applies to it once shown. + if (ancestorOpacityApplies) { + try { + if (node.matches(":modal, :popover-open")) { + ancestorOpacityApplies = false; } - } + } catch (e) {} + } - node = parent ?? (node.getRootNode() instanceof ShadowRoot && node.getRootNode()); + let parent = node.parentElement; + if (parent && parent.tagName === "DETAILS" && !parent.open) { + // In a closed
only the first (and its subtree) is rendered. + if (node !== parent.querySelector(":scope > summary")) { + visible = false; + break; + } } + + node = parent ?? (node.getRootNode() instanceof ShadowRoot && node.getRootNode()); } - return true; + walked.forEach(([node, opacityApplies]) => cache[+opacityApplies].set(node, visible)); + return visible; } isDisabled(node) { diff --git a/lib/capybara/cuprite/node.rb b/lib/capybara/cuprite/node.rb index 1bb34f1..237e8d8 100644 --- a/lib/capybara/cuprite/node.rb +++ b/lib/capybara/cuprite/node.rb @@ -14,29 +14,32 @@ class Node < Capybara::Driver::Node DRAG_HTML5_JS = File.read(File.expand_path("javascripts/drag.js", __dir__)) TRIGGER_CLICK_WAIT = ENV.fetch("CUPRITE_TRIGGER_CLICK_WAIT", 0.1).to_f - def initialize(driver, node) - super(driver, self) + def initialize(driver, node, initial_cache = {}) + super(driver, self, initial_cache) @node = node end - def find_xpath(selector) - find(:xpath, selector) + def find_xpath(selector, **hints) + find(:xpath, selector, **hints) end - def find_css(selector) - find(:css, selector) + def find_css(selector, **hints) + find(:css, selector, **hints) end + # A text in the initial cache comes only with a node that can't match its query's text, which + # Capybara reads to filter it out and to describe the failure, and never hands to the caller. def all_text - filter_text(command { node.text }) + filter_text(initial_cache.fetch(:all_text) { command { node.text } }) end def visible_text - command(:visibleText).to_s - .gsub(/\A[[:space:]&&[^\u00a0]]+/, "") - .gsub(/[[:space:]&&[^\u00a0]]+\z/, "") - .gsub(/\n+/, "\n") - .tr("\u00a0", " ") + text = initial_cache.fetch(:visible_text) { command(:visibleText) } + text.to_s + .gsub(/\A[[:space:]&&[^\u00a0]]+/, "") + .gsub(/[[:space:]&&[^\u00a0]]+\z/, "") + .gsub(/\n+/, "\n") + .tr("\u00a0", " ") end def [](name) @@ -237,9 +240,11 @@ def inspect private - def find(method, selector) - command { Finder.find(driver.browser, method, selector, node) }.map do |node| - self.class.new(driver, node) + def find(method, selector, **hints) + command do + Finder.find(driver.browser, method, selector, node, **hints) do |native, cache| + self.class.new(driver, native, cache) + end end end diff --git a/spec/driver_spec.rb b/spec/driver_spec.rb index 4548e24..47033ef 100644 --- a/spec/driver_spec.rb +++ b/spec/driver_spec.rb @@ -522,6 +522,134 @@ def session_url(path) @session.visit "/cuprite/table" expect { @session.find(:css, "table tr:last") }.to raise_error(Capybara::Cuprite::InvalidSelector) end + + context "with finder hints" do + before { @session.visit("/cuprite/find_hints") } + + def ids(nodes) + nodes.map { |node| node[:id] } + end + + def matched_ids(nodes) + ids(nodes.reject { |node| node.initial_cache.key?(:all_text) }) + end + + def no_matches_message(description) + texts = ["Shown item", "", "Splitword", "Zero\u200bwidth", "SHOUTED", "Spaced out", "Non breaking"] + "expected to find #{description} but there were no matches. " \ + "Also found #{texts.map(&:inspect).join(', ')}, which matched the selector but not all filters. " + end + + it "answers visibility, styles and position in the initial cache" do + shown, hidden = @driver.find_css("#shown, #hidden", uses_visibility: true, + styles: { color: "red" }, + position: true) + + expect(shown.initial_cache).to include(visible: true, style: { "color" => "rgb(255, 0, 0)" }) + expect(shown.initial_cache[:position]).to eq(shown.rect) + expect(hidden.initial_cache).to include(visible: false) + end + + it "answers visibility for each node when they share ancestors" do + @session.execute_script("document.getElementById('dialog').showModal()") + nodes = @driver.find_css("#faded p", uses_visibility: true) + + expect(nodes.to_h { |node| [node[:id], node.initial_cache[:visible]] }) + .to eq("before_dialog" => false, "in_dialog" => true, "after_dialog" => false) + end + + it "leaves the initial cache empty without hints" do + expect(@driver.find_css("li").map(&:initial_cache)).to all(eq({})) + end + + it "keeps only the nodes holding every text" do + expect(ids(@driver.find_css("li", texts: %w[Shown item]))).to eq(%w[shown]) + expect(ids(@driver.find_css("li", texts: %w[Hidden]))).to eq(%w[hidden]) + expect(matched_ids(@driver.find_css("li", texts: %w[shown]))).to be_empty + end + + it "keeps the ancestors of a node holding every text, in document order" do + expect(ids(@driver.find_css("#items, #split, #split span", texts: %w[Splithidden]))).to eq(%w[items split]) + end + + it "matches the visible text only when visibility is asked for" do + expect(matched_ids(@driver.find_css("li", texts: %w[Splitword]))).to be_empty + expect(ids(@driver.find_css("li", texts: %w[Splitword], uses_visibility: true))).to eq(%w[split]) + end + + it "matches the visible text of nodes that share ancestors" do + expect(ids(@driver.find_css("ul, li", texts: %w[SHOUTED], uses_visibility: true))).to eq(%w[items shouted]) + expect(matched_ids(@driver.find_css("ul, li", texts: %w[Splitword Hidden], uses_visibility: true))).to be_empty + end + + it "finds what Capybara matches on text" do + expect(@session.find(:css, "li", text: "Splitword")[:id]).to eq("split") + expect(@session.find(:css, "li", text: "Zerowidth", visible: :all)[:id]).to eq("zero_width") + expect(@session.find(:css, "li", text: "SHOUTED")[:id]).to eq("shouted") + expect(@session.find(:css, "li", text: "Spaced out")[:id]).to eq("spaced") + expect(@session.find(:css, "li", text: "Non breaking")[:id]).to eq("non_breaking") + end + + it "skips what Capybara doesn't match on text" do + expect(@session).to have_no_css("li", text: "shown item") + expect(@session).to have_no_css("li", text: "shouted") + expect(@session).to have_no_css("li", text: "Zerowidth") + expect(@session).to have_no_css("li", text: "Hidden item") + end + + it "returns the nodes with the texts Capybara reads when none hold every text" do + nodes = @driver.find_css("li", texts: %w[Missing], uses_visibility: true) + + expect(ids(nodes)).to eq(%w[shown hidden split zero_width shouted spaced non_breaking]) + expect(nodes.first.initial_cache).to eq(visible: true, all_text: "Shown item", visible_text: "Shown item") + expect(nodes[1].initial_cache).to eq(visible: false, all_text: "Hidden item", visible_text: nil) + end + + it "returns at most 50 nodes when none hold every text" do + @session.execute_script(<<~JS) + document.getElementById("items").innerHTML = "
  • Row
  • ".repeat(60); + JS + + expect(@driver.find_css("li", texts: %w[Missing]).size).to eq(50) + end + + it "reads the text of a node holding every text live" do + node = @driver.find_css("#shown", texts: %w[Shown], uses_visibility: true).first + @session.execute_script("document.getElementById('shown').textContent = 'Changed'") + + expect([node.all_text, node.visible_text]).to eq(%w[Changed Changed]) + end + + it "describes a failed find as Capybara does without hints" do + expect { @session.find(:css, "#items li", text: "Missing", wait: 0) } + .to raise_error(Capybara::ElementNotFound, 'Unable to find visible css "#items li" with text "Missing"') + end + + it "describes a failed assert_selector as Capybara does without hints" do + expect { @session.assert_selector(:css, "#items li", text: "Missing", wait: 0) } + .to raise_error(Capybara::ExpectationNotMet, + no_matches_message(%(visible css "#items li" with text "Missing"))) + end + + it "describes a failed assert_selector for all nodes as Capybara does without hints" do + items = @session.find(:css, "#items") + + expect { items.assert_selector(:css, "li", text: "Missing", visible: :all, wait: 0) } + .to raise_error(Capybara::ExpectationNotMet, + no_matches_message(%(css "li" with text "Missing" within #{items.inspect}))) + end + + it "returns nothing when nothing matches" do + expect(@driver.find_css("#missing", uses_visibility: true, position: true)).to be_empty + end + + it "filters the rest of a result by the hints from when it was found, as Selenium does" do + result = @session.all(:css, "#items li", minimum: 1) + @session.execute_script("document.getElementById('split').style.display = 'none'") + + expect(ids(result)).to include("split") + end + end end describe "#find_xpath" do @@ -540,6 +668,11 @@ def session_url(path) @session.visit "/with_html" expect(@session.find_link("A link", exact_text: true)[:href]).to end_with("/with_simple_html") end + + it "returns nothing with finder hints when nothing matches" do + @session.visit("/cuprite/find_hints") + expect(@driver.find_xpath("//li[@id='missing']", uses_visibility: true)).to be_empty + end end describe "#evaluate_script" do diff --git a/spec/node_spec.rb b/spec/node_spec.rb index e95d016..4845cc5 100644 --- a/spec/node_spec.rb +++ b/spec/node_spec.rb @@ -924,6 +924,19 @@ end end + describe "#find_css" do + it "answers finder hints within the node" do + @session.visit("/cuprite/find_hints") + items = @session.find(:css, "#items").base + + nodes = items.find_css("*", texts: %w[Shown], uses_visibility: true) + + expect(nodes.map { |node| node[:id] }).to eq(%w[shown]) + expect(nodes.first.initial_cache).to eq(visible: true) + expect(items.find_css("p", texts: %w[Shown])).to be_empty + end + end + describe "#shadow_root" do it "describes the shadow root in failure messages" do @session.visit("/with_shadow") diff --git a/spec/support/views/find_hints.erb b/spec/support/views/find_hints.erb new file mode 100644 index 0000000..019f5da --- /dev/null +++ b/spec/support/views/find_hints.erb @@ -0,0 +1,21 @@ + + + +
      +
    • Shown item
    • + +
    • Splithiddenword
    • +
    • Zero​width
    • +
    • shouted
    • +
    • Spaced + out
    • +
    • Non breaking
    • +
    +

    Shown item

    +
    +

    Before

    +

    In dialog

    +

    After

    +
    + +