Skip to content
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 6 additions & 6 deletions lib/capybara/cuprite/driver.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
24 changes: 21 additions & 3 deletions lib/capybara/cuprite/finder.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
120 changes: 94 additions & 26 deletions lib/capybara/cuprite/javascripts/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
FORM: ["submit"]
}

const UNMATCHED_LIMIT = 50;

class Cuprite {
find(method, selector, within = document) {
try {
Expand All @@ -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;
}
Expand All @@ -48,15 +101,17 @@
}
if (node instanceof ShadowRoot) {
return Array.from(node.children)
.map(child => this.visibleText(child))
.map(child => this.visibleText(child, visibility))
.filter(text => text)
.join(" ");
}
return node.innerText;
}
}

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") {
Expand All @@ -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 <details> only the first <summary> (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 <details> only the first <summary> (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) {
Expand Down
35 changes: 20 additions & 15 deletions lib/capybara/cuprite/node.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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

Expand Down
Loading
Loading