From 9890f832ad7eacf5923a8547033af0b3f6f2a187 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joel=20Junstr=C3=B6m?= Date: Sun, 25 Oct 2026 18:11:36 +0100 Subject: [PATCH 1/8] feat(driver): answer Capybara's finder hints in find_css and find_xpath Capybara passes uses_visibility, texts, styles and position to finders that take them and reads the answers from Node#initial_cache. Without them it asked each candidate for its visibility and text in round trips of its own. One evaluate returns each node with its answers, which Ferrum's deep serialization brings back in a single call. Texts drop a node only when neither all its text nor its visible text holds them, case sensitively, as Capybara matches. Browser#find and #find_within still return nodes. --- CHANGELOG.md | 3 ++ lib/capybara/cuprite/driver.rb | 12 ++--- lib/capybara/cuprite/finder.rb | 14 ++++-- lib/capybara/cuprite/javascripts/index.js | 32 +++++++++++++ lib/capybara/cuprite/node.rb | 18 +++---- spec/driver_spec.rb | 57 +++++++++++++++++++++++ spec/node_spec.rb | 13 ++++++ spec/support/views/find_hints.erb | 16 +++++++ 8 files changed, 147 insertions(+), 18 deletions(-) create mode 100644 spec/support/views/find_hints.erb diff --git a/CHANGELOG.md b/CHANGELOG.md index 814dbd03..3511a86f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,9 @@ - `: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 [#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 ab3777b2..d794e7bf 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).map { |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 c1bac586..12e18e24 100644 --- a/lib/capybara/cuprite/finder.rb +++ b/lib/capybara/cuprite/finder.rb @@ -5,13 +5,21 @@ 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) + } + + browser.evaluate("_cuprite.findWithHints(...arguments)", *[method, selector, hints, within].compact) + .map { |node, cache| [node, cache.transform_keys(&:to_sym)] } 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 22663782..2fc9916d 100644 --- a/lib/capybara/cuprite/javascripts/index.js +++ b/lib/capybara/cuprite/javascripts/index.js @@ -38,6 +38,38 @@ } } + findWithHints(method, selector, hints, within) { + let nodes = this.find(method, selector, within).filter(node => node.nodeType == Node.ELEMENT_NODE); + + if (hints.texts.length > 0) { + nodes = nodes.filter(node => this.containsTexts(node, hints.texts, hints.visible)); + } + + return nodes.map(node => { + let cache = {}; + if (hints.visible) { + cache.visible = this.isVisible(node); + } + if (hints.styles) { + let style = window.getComputedStyle(node); + cache.style = Object.fromEntries(hints.styles.map(name => [name, style.getPropertyValue(name)])); + } + if (hints.position) { + cache.position = node.getBoundingClientRect().toJSON(); + } + return [node, cache]; + }); + } + + // 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. + containsTexts(node, texts, visible) { + let containsAll = content => texts.every(text => content.includes(text)); + return containsAll(node.textContent.replace(/[\u200b\u200e\u200f]/g, "")) || + (visible && containsAll(this.visibleText(node) || "")); + } + visibleText(node) { if (this.isVisible(node)) { if (node.nodeName == "TEXTAREA") { diff --git a/lib/capybara/cuprite/node.rb b/lib/capybara/cuprite/node.rb index 1bb34f13..7fb23fc4 100644 --- a/lib/capybara/cuprite/node.rb +++ b/lib/capybara/cuprite/node.rb @@ -14,17 +14,17 @@ 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 def all_text @@ -237,9 +237,9 @@ 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 { Finder.find(driver.browser, method, selector, node, **hints) }.map do |node, cache| + self.class.new(driver, node, cache) end end diff --git a/spec/driver_spec.rb b/spec/driver_spec.rb index 4548e24c..7c91dc11 100644 --- a/spec/driver_spec.rb +++ b/spec/driver_spec.rb @@ -522,6 +522,58 @@ 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 + + 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 "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(@driver.find_css("li", texts: %w[shown])).to be_empty + end + + it "matches the visible text only when visibility is asked for" do + expect(@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 "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 nothing when nothing matches" do + expect(@driver.find_css("#missing", uses_visibility: true, position: true)).to be_empty + end + end end describe "#find_xpath" do @@ -540,6 +592,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 e95d0161..4845cc57 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 00000000..1d83ca99 --- /dev/null +++ b/spec/support/views/find_hints.erb @@ -0,0 +1,16 @@ + + + + +

Shown item

+ + From afa3620956b5b10ecf3c9f2406600e8ed205e8e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joel=20Junstr=C3=B6m?= Date: Wed, 11 Nov 2026 10:48:37 +0100 Subject: [PATCH 2/8] Share ancestor visibility walks across finder hint candidates Each candidate walked all its ancestors to answer the visibility hint, so nested candidates cost quadratic getComputedStyle calls: 1000 nested divs took 225ms against 12.5ms before the hints. Candidates found together now share one cache of ancestor results, so each element is checked once. An element inside a modal dialog or open popover ignores its ancestors' opacity, so results are cached apart for both cases. --- lib/capybara/cuprite/javascripts/index.js | 64 ++++++++++++++--------- spec/driver_spec.rb | 8 +++ spec/support/views/find_hints.erb | 5 ++ 3 files changed, 53 insertions(+), 24 deletions(-) diff --git a/lib/capybara/cuprite/javascripts/index.js b/lib/capybara/cuprite/javascripts/index.js index 2fc9916d..f597a5b2 100644 --- a/lib/capybara/cuprite/javascripts/index.js +++ b/lib/capybara/cuprite/javascripts/index.js @@ -45,10 +45,11 @@ nodes = nodes.filter(node => this.containsTexts(node, hints.texts, hints.visible)); } + let visibility = [new Map(), new Map()]; return nodes.map(node => { let cache = {}; if (hints.visible) { - cache.visible = this.isVisible(node); + cache.visible = this.isVisible(node, visibility); } if (hints.styles) { let style = window.getComputedStyle(node); @@ -88,7 +89,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") { @@ -100,39 +103,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]); + + style = window.getComputedStyle(node); + if (style.display === "none" || style.visibility === "hidden" || (ancestorOpacityApplies && parseFloat(style.opacity) === 0)) { + visible = false; + break; + } - 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; + // 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/spec/driver_spec.rb b/spec/driver_spec.rb index 7c91dc11..7a6f6d31 100644 --- a/spec/driver_spec.rb +++ b/spec/driver_spec.rb @@ -540,6 +540,14 @@ def ids(nodes) 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 diff --git a/spec/support/views/find_hints.erb b/spec/support/views/find_hints.erb index 1d83ca99..019f5da4 100644 --- a/spec/support/views/find_hints.erb +++ b/spec/support/views/find_hints.erb @@ -12,5 +12,10 @@
  • Non breaking
  • Shown item

    +
    +

    Before

    +

    In dialog

    +

    After

    +
    From ceaee435727d24a12e7e4f97474bf3830a31d31b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joel=20Junstr=C3=B6m?= Date: Wed, 11 Nov 2026 10:49:04 +0100 Subject: [PATCH 3/8] Pin finder hints as a snapshot, as Selenium does Capybara filters a result lazily, so candidates it hasn't reached yet are judged by the hints from when they were found, even if the page changed since. Selenium's finder hints behave the same way. A spec now covers it and the changelog says so. --- CHANGELOG.md | 3 ++- spec/driver_spec.rb | 7 +++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3511a86f..1887acd8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,8 @@ [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 [#347] + 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/spec/driver_spec.rb b/spec/driver_spec.rb index 7a6f6d31..aee708a7 100644 --- a/spec/driver_spec.rb +++ b/spec/driver_spec.rb @@ -581,6 +581,13 @@ def ids(nodes) 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 From 60a178f17d82aca5c59ef602c5bf5a9ad6657d05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joel=20Junstr=C3=B6m?= Date: Wed, 11 Nov 2026 13:09:20 +0100 Subject: [PATCH 4/8] Return finder hint answers as one list per hint Chrome takes time to serialize each object and value an evaluate returns, so a [node, cache] pair per node made first(".x") over 2000 rows 50% slower than without hints, and all("li", visible: :all) 29% slower even though it asks for nothing. The answers now come back as one list per hint next to the nodes, and visibility only as the indexes of the hidden nodes. Both cases are now as fast as without hints. --- lib/capybara/cuprite/finder.rb | 13 ++++++++-- lib/capybara/cuprite/javascripts/index.js | 30 +++++++++++------------ 2 files changed, 26 insertions(+), 17 deletions(-) diff --git a/lib/capybara/cuprite/finder.rb b/lib/capybara/cuprite/finder.rb index 12e18e24..bd8286c9 100644 --- a/lib/capybara/cuprite/finder.rb +++ b/lib/capybara/cuprite/finder.rb @@ -18,8 +18,17 @@ def find(browser, method, selector, within = nil, **hints) position: hints.fetch(:position, false) } - browser.evaluate("_cuprite.findWithHints(...arguments)", *[method, selector, hints, within].compact) - .map { |node, cache| [node, cache.transform_keys(&:to_sym)] } + answers = browser.evaluate("_cuprite.findWithHints(...arguments)", *[method, selector, hints, within].compact) + hidden = answers["hidden"]&.to_set + style = answers["style"] + position = answers["position"] + answers["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 + [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 f597a5b2..83c6ea79 100644 --- a/lib/capybara/cuprite/javascripts/index.js +++ b/lib/capybara/cuprite/javascripts/index.js @@ -45,21 +45,21 @@ nodes = nodes.filter(node => this.containsTexts(node, hints.texts, hints.visible)); } - let visibility = [new Map(), new Map()]; - return nodes.map(node => { - let cache = {}; - if (hints.visible) { - cache.visible = this.isVisible(node, visibility); - } - if (hints.styles) { - let style = window.getComputedStyle(node); - cache.style = Object.fromEntries(hints.styles.map(name => [name, style.getPropertyValue(name)])); - } - if (hints.position) { - cache.position = node.getBoundingClientRect().toJSON(); - } - return [node, cache]; - }); + // 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. + let result = { nodes }; + if (hints.visible) { + let visibility = [new Map(), new Map()]; + result.hidden = [...nodes.keys()].filter(index => !this.isVisible(nodes[index], visibility)); + } + if (hints.styles) { + let styles = nodes.map(node => window.getComputedStyle(node)); + result.style = Object.fromEntries(hints.styles.map(name => [name, styles.map(style => style.getPropertyValue(name))])); + } + if (hints.position) { + result.position = nodes.map(node => node.getBoundingClientRect().toJSON()); + } + return result; } // Capybara matches text case sensitively against the visible text, or against all text From 437e673b6b495c23db3aa3dfead562af46c66876 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joel=20Junstr=C3=B6m?= Date: Wed, 11 Nov 2026 13:09:34 +0100 Subject: [PATCH 5/8] Let a text match vouch for its node's ancestors The text filter read every candidate's textContent, which on nested candidates reads the same text again for each ancestor: first("div.c", text: ...) over 1200 nested divs spent most of its 49ms there. A node's text is part of its ancestors' text, so the filter now walks from the last node first and marks a match's ancestors as matches. The visible text fallback shares the visibility cache, so it no longer walks every ancestor again either. --- lib/capybara/cuprite/javascripts/index.js | 30 ++++++++++++++++------- spec/driver_spec.rb | 9 +++++++ 2 files changed, 30 insertions(+), 9 deletions(-) diff --git a/lib/capybara/cuprite/javascripts/index.js b/lib/capybara/cuprite/javascripts/index.js index 83c6ea79..df1ba78f 100644 --- a/lib/capybara/cuprite/javascripts/index.js +++ b/lib/capybara/cuprite/javascripts/index.js @@ -41,15 +41,15 @@ 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()]; if (hints.texts.length > 0) { - nodes = nodes.filter(node => this.containsTexts(node, hints.texts, hints.visible)); + nodes = this.filterTexts(nodes, hints.texts, hints.visible, 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. let result = { nodes }; if (hints.visible) { - let visibility = [new Map(), new Map()]; result.hidden = [...nodes.keys()].filter(index => !this.isVisible(nodes[index], visibility)); } if (hints.styles) { @@ -64,15 +64,27 @@ // 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. - containsTexts(node, texts, visible) { + // 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)); - return containsAll(node.textContent.replace(/[\u200b\u200e\u200f]/g, "")) || - (visible && containsAll(this.visibleText(node) || "")); + 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) { - if (this.isVisible(node)) { + visibleText(node, visibility) { + if (this.isVisible(node, visibility)) { if (node.nodeName == "TEXTAREA") { return node.textContent; } @@ -81,7 +93,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(" "); } diff --git a/spec/driver_spec.rb b/spec/driver_spec.rb index aee708a7..0c64a882 100644 --- a/spec/driver_spec.rb +++ b/spec/driver_spec.rb @@ -558,11 +558,20 @@ def ids(nodes) expect(@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(@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(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") From 40c1fabffa19343348f0046ffad0a6b0e25f4760 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joel=20Junstr=C3=B6m?= Date: Wed, 11 Nov 2026 14:05:40 +0100 Subject: [PATCH 6/8] Return finder hint answers in an array, not an object Ferrum's main branch awaits a returned plain object in case it's a promise, which costs a second round trip, so every find took two CDP commands where main takes one. An app's 1509 system tests sent 40,618 extra commands for it. --- lib/capybara/cuprite/finder.rb | 9 ++++----- lib/capybara/cuprite/javascripts/index.js | 17 +++++++---------- 2 files changed, 11 insertions(+), 15 deletions(-) diff --git a/lib/capybara/cuprite/finder.rb b/lib/capybara/cuprite/finder.rb index bd8286c9..ba408d31 100644 --- a/lib/capybara/cuprite/finder.rb +++ b/lib/capybara/cuprite/finder.rb @@ -18,11 +18,10 @@ def find(browser, method, selector, within = nil, **hints) position: hints.fetch(:position, false) } - answers = browser.evaluate("_cuprite.findWithHints(...arguments)", *[method, selector, hints, within].compact) - hidden = answers["hidden"]&.to_set - style = answers["style"] - position = answers["position"] - answers["nodes"].each_with_index.map do |node, index| + nodes, hidden, style, position = + browser.evaluate("_cuprite.findWithHints(...arguments)", *[method, selector, hints, within].compact) + hidden = hidden&.to_set + 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 diff --git a/lib/capybara/cuprite/javascripts/index.js b/lib/capybara/cuprite/javascripts/index.js index df1ba78f..2d5ffa72 100644 --- a/lib/capybara/cuprite/javascripts/index.js +++ b/lib/capybara/cuprite/javascripts/index.js @@ -47,19 +47,16 @@ } // 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. - let result = { nodes }; - if (hints.visible) { - result.hidden = [...nodes.keys()].filter(index => !this.isVisible(nodes[index], visibility)); - } + // 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)); - result.style = Object.fromEntries(hints.styles.map(name => [name, styles.map(style => style.getPropertyValue(name))])); - } - if (hints.position) { - result.position = nodes.map(node => node.getBoundingClientRect().toJSON()); + style = Object.fromEntries(hints.styles.map(name => [name, styles.map(style => style.getPropertyValue(name))])); } - return result; + let position = hints.position ? nodes.map(node => node.getBoundingClientRect().toJSON()) : null; + return [nodes, hidden, style, position]; } // Capybara matches text case sensitively against the visible text, or against all text From 721418d15206ce001667021c1c4beb12404898ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joel=20Junstr=C3=B6m?= Date: Wed, 11 Nov 2026 15:12:47 +0100 Subject: [PATCH 7/8] Keep Capybara's failure messages when no node holds the text Capybara describes a failed find from the nodes it filtered out itself. With the text filtered in the browser it got none back, so `Unable to find visible css "li" with text "nope"` became `Unable to find css "li"`, and assert_selector no longer listed what else it found. When no node holds the texts, the first 50 now come back with the texts Capybara reads to filter and describe them, so the messages are the same as before at about 1ms more on 2000 rows. --- lib/capybara/cuprite/finder.rb | 4 +- lib/capybara/cuprite/javascripts/index.js | 17 +++++-- lib/capybara/cuprite/node.rb | 15 +++--- spec/driver_spec.rb | 58 +++++++++++++++++++++-- 4 files changed, 81 insertions(+), 13 deletions(-) diff --git a/lib/capybara/cuprite/finder.rb b/lib/capybara/cuprite/finder.rb index ba408d31..d21e8f40 100644 --- a/lib/capybara/cuprite/finder.rb +++ b/lib/capybara/cuprite/finder.rb @@ -18,14 +18,16 @@ def find(browser, method, selector, within = nil, **hints) position: hints.fetch(:position, false) } - nodes, hidden, style, position = + 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 [node, cache] end rescue Ferrum::JavaScriptError => e diff --git a/lib/capybara/cuprite/javascripts/index.js b/lib/capybara/cuprite/javascripts/index.js index 2d5ffa72..2472d4c9 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 { @@ -42,8 +44,17 @@ 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) { - nodes = this.filterTexts(nodes, hints.texts, hints.visible, visibility); + 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 @@ -56,7 +67,7 @@ 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]; + return [nodes, hidden, style, position, texts]; } // Capybara matches text case sensitively against the visible text, or against all text @@ -66,7 +77,7 @@ filterTexts(nodes, texts, visible, visibility) { let containsAll = content => texts.every(text => content.includes(text)); let matched = new Set(); - return nodes.reverse().filter(node => { + return [...nodes].reverse().filter(node => { if (matched.has(node)) { return true; } diff --git a/lib/capybara/cuprite/node.rb b/lib/capybara/cuprite/node.rb index 7fb23fc4..d5aca871 100644 --- a/lib/capybara/cuprite/node.rb +++ b/lib/capybara/cuprite/node.rb @@ -27,16 +27,19 @@ 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) diff --git a/spec/driver_spec.rb b/spec/driver_spec.rb index 0c64a882..47033efb 100644 --- a/spec/driver_spec.rb +++ b/spec/driver_spec.rb @@ -530,6 +530,16 @@ 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" }, @@ -555,7 +565,7 @@ def ids(nodes) 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(@driver.find_css("li", texts: %w[shown])).to be_empty + 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 @@ -563,13 +573,13 @@ def ids(nodes) end it "matches the visible text only when visibility is asked for" do - expect(@driver.find_css("li", texts: %w[Splitword])).to be_empty + 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(ids(@driver.find_css("ul, li", texts: %w[Splitword Hidden], uses_visibility: true))).to be_empty + 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 @@ -587,6 +597,48 @@ def ids(nodes) 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 From 9c0c101cfc2d5e6ad82743fddb055bce1516e954 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joel=20Junstr=C3=B6m?= Date: Wed, 11 Nov 2026 16:23:27 +0100 Subject: [PATCH 8/8] Build finder nodes as they're found instead of from pairs The finder returned a [node, cache] pair per node for the callers to build nodes from, two objects more per node than without hints: all("li", visible: :all) over 300 nodes was 6% slower than before the hints. Callers now pass a block that gets each node and its cache, and allocations are back to the level before the hints. --- lib/capybara/cuprite/driver.rb | 2 +- lib/capybara/cuprite/finder.rb | 2 +- lib/capybara/cuprite/node.rb | 6 ++++-- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/lib/capybara/cuprite/driver.rb b/lib/capybara/cuprite/driver.rb index d794e7bf..250e86e1 100644 --- a/lib/capybara/cuprite/driver.rb +++ b/lib/capybara/cuprite/driver.rb @@ -230,7 +230,7 @@ def dismiss_modal(type, options = {}) attr_reader :screen_size def find(method, selector, **hints) - Finder.find(browser, method, selector, **hints).map { |native, cache| Node.new(self, native, cache) } + 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 d21e8f40..f2a10a9c 100644 --- a/lib/capybara/cuprite/finder.rb +++ b/lib/capybara/cuprite/finder.rb @@ -28,7 +28,7 @@ def find(browser, method, selector, within = nil, **hints) 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 - [node, cache] + 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/node.rb b/lib/capybara/cuprite/node.rb index d5aca871..237e8d8a 100644 --- a/lib/capybara/cuprite/node.rb +++ b/lib/capybara/cuprite/node.rb @@ -241,8 +241,10 @@ def inspect private def find(method, selector, **hints) - command { Finder.find(driver.browser, method, selector, node, **hints) }.map do |node, cache| - self.class.new(driver, node, cache) + command do + Finder.find(driver.browser, method, selector, node, **hints) do |native, cache| + self.class.new(driver, native, cache) + end end end