From 5ed6188767a30c3453b86493b6a2e2b0c4eedfd5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joel=20Junstr=C3=B6m?= Date: Mon, 19 Oct 2026 09:32:05 +0200 Subject: [PATCH] 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 callFunctionOn returns each node behind its answers as JSON, read from the array's handle with getProperties, since Ferrum only resolves arrays that hold nothing but nodes. 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/browser.rb | 30 +++++++---- lib/capybara/cuprite/driver.rb | 12 ++--- lib/capybara/cuprite/javascripts/index.js | 33 ++++++++++++ lib/capybara/cuprite/node.rb | 18 +++---- lib/capybara/cuprite/page.rb | 32 ++++++++++++ spec/features/session_spec.rb | 64 +++++++++++++++++++++++ spec/support/views/find_hints.erb | 16 ++++++ 8 files changed, 183 insertions(+), 25 deletions(-) create mode 100644 spec/support/views/find_hints.erb diff --git a/CHANGELOG.md b/CHANGELOG.md index 14f05b6..ad8cfd4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ - `Driver#downloads` to wait for downloads and list them, files are saved to `Capybara.save_path` [#181] ### Changed +- `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 diff --git a/lib/capybara/cuprite/browser.rb b/lib/capybara/cuprite/browser.rb index b3b82ee..55a5f65 100644 --- a/lib/capybara/cuprite/browser.rb +++ b/lib/capybara/cuprite/browser.rb @@ -94,7 +94,11 @@ def status_code end def find(method, selector) - find_all(method, selector) + find_with_hints(method, selector).map(&:first) + end + + def find_with_hints(method, selector, hints = {}) + find_all(method, selector, hints) end def property(node, name) @@ -102,9 +106,13 @@ def property(node, name) end def find_within(node, method, selector) + find_within_with_hints(node, method, selector).map(&:first) + end + + def find_within_with_hints(node, method, selector, hints = {}) resolved = page.command("DOM.resolveNode", nodeId: node.node_id) object_id = resolved.dig("object", "objectId") - find_all(method, selector, { "objectId" => object_id }) + find_all(method, selector, hints, object_id) end def window_handle @@ -265,18 +273,20 @@ def all_text(node) private - def find_all(method, selector, within = nil) + def find_all(method, selector, hints, within = nil) # 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 = if within - evaluate("_cuprite.find(arguments[0], arguments[1], arguments[2])", method, selector, within) - else - evaluate("_cuprite.find(arguments[0], arguments[1])", method, selector) - end - - 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) + } + + page.find_with_hints(method, selector, hints, within) rescue Ferrum::JavaScriptError => e raise InvalidSelector.new(e.response, method, selector) if e.class_name == "InvalidSelector" diff --git a/lib/capybara/cuprite/driver.rb b/lib/capybara/cuprite/driver.rb index e1b07cf..63781ee 100644 --- a/lib/capybara/cuprite/driver.rb +++ b/lib/capybara/cuprite/driver.rb @@ -76,16 +76,16 @@ def active_element Node.new(self, browser.active_element) 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 find(method, selector) - browser.find(method, selector).map { |native| Node.new(self, native) } + def find(method, selector, **hints) + browser.find_with_hints(method, selector, hints).map { |native, cache| Node.new(self, native, cache) } end def click(x, y) diff --git a/lib/capybara/cuprite/javascripts/index.js b/lib/capybara/cuprite/javascripts/index.js index db9b3bb..15d83e9 100644 --- a/lib/capybara/cuprite/javascripts/index.js +++ b/lib/capybara/cuprite/javascripts/index.js @@ -38,6 +38,39 @@ class Cuprite { } } + // Each answer is serialized on its own, as a page may override Array.prototype.toJSON. + 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.flatMap(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 [this._json.stringify(cache), node]; + }); + } + + // 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) || "")); + } + parents(node) { let nodes = []; let parent = node.parentNode; diff --git a/lib/capybara/cuprite/node.rb b/lib/capybara/cuprite/node.rb index 8160703..4a9ed28 100644 --- a/lib/capybara/cuprite/node.rb +++ b/lib/capybara/cuprite/node.rb @@ -14,8 +14,8 @@ class Node < Capybara::Driver::Node DRAG_MODIFIER_ALIASES = { control: :ctrl, command: :meta, cmd: :meta }.freeze - def initialize(driver, node) - super(driver, self) + def initialize(driver, node, initial_cache = {}) + super(driver, self, initial_cache) @node = node end @@ -40,17 +40,17 @@ def parents end 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 find(method, selector) - command(:find_within, method, selector).map do |node| - self.class.new(driver, node) + def find(method, selector, **hints) + command(:find_within_with_hints, method, selector, hints).map do |node, cache| + self.class.new(driver, node, cache) end end diff --git a/lib/capybara/cuprite/page.rb b/lib/capybara/cuprite/page.rb index a053eae..3004b3f 100644 --- a/lib/capybara/cuprite/page.rb +++ b/lib/capybara/cuprite/page.rb @@ -148,6 +148,30 @@ def switch_to_frame(handle) end end + # Ferrum resolves a returned array only when it holds nothing but nodes, so the hints and + # nodes `_cuprite.findWithHints` returns together are read from the array's handle here. + def find_with_hints(method, selector, hints, within = nil) + arguments = [{ value: method }, { value: selector }, { value: hints }] + arguments << { objectId: within } if within + + Ferrum::Utils::Attempt.with_retry(errors: [Ferrum::NodeNotFoundError, Ferrum::NoExecutionContextError], + max: Ferrum::Frame::Runtime::INTERMITTENT_ATTEMPTS, + wait: Ferrum::Frame::Runtime::INTERMITTENT_SLEEP) do + response = command("Runtime.callFunctionOn", + slowmoable: true, + executionContextId: execution_id!, + functionDeclaration: "function() { return _cuprite.findWithHints(...arguments) }", + arguments: arguments) + raise Ferrum::JavaScriptError, response["exceptionDetails"] if response["exceptionDetails"] + + array_items(response.dig("result", "objectId")).each_slice(2).map do |cache, node| + description = command("DOM.describeNode", objectId: node["objectId"])["node"] + node = Ferrum::Node.new(active_frame, target_id, description, object_id: node["objectId"]) + [node, JSON.parse(cache["value"]).transform_keys(&:to_sym)] + end + end + end + def frame_name evaluate("window.name") end @@ -227,6 +251,14 @@ def find_position(node, **options) raise end + def array_items(object_id) + properties = command("Runtime.getProperties", objectId: object_id, ownProperties: true)["result"] + properties.each_with_object([]) do |property, items| + index = Integer(property["name"], exception: false) + items[index] = property["value"] if index + end + end + def active_frame if @frame_stack.empty? main_frame diff --git a/spec/features/session_spec.rb b/spec/features/session_spec.rb index 224d013..3dd972a 100644 --- a/spec/features/session_spec.rb +++ b/spec/features/session_spec.rb @@ -556,6 +556,70 @@ end end + describe "finder hints" do + before do + @session.visit("/cuprite/find_hints") + end + + def ids(nodes) + nodes.map { |node| node[:id] } + end + + it "answers visibility, styles and position in the initial cache" do + shown, hidden = @session.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(@session.driver.find_css("li").map(&:initial_cache)).to all(eq({})) + end + + it "keeps only the nodes holding every text" do + expect(ids(@session.driver.find_css("li", texts: %w[Shown item]))).to eq(%w[shown]) + expect(ids(@session.driver.find_css("li", texts: %w[Hidden]))).to eq(%w[hidden]) + expect(@session.driver.find_css("li", texts: %w[shown])).to be_empty + end + + it "matches the visible text only when visibility is asked for" do + expect(@session.driver.find_css("li", texts: %w[Splitword])).to be_empty + expect(ids(@session.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 "answers hints within a node" do + items = @session.find(:css, "#items") + nodes = items.base.find_css("*", texts: %w[Shown], uses_visibility: true) + + expect(ids(nodes)).to eq(%w[shown]) + expect(nodes.first.initial_cache).to eq(visible: true) + end + + it "returns nothing when nothing matches" do + expect(@session.driver.find_css("#missing", uses_visibility: true, position: true)).to be_empty + expect(@session.driver.find_xpath("//li[@id='missing']", uses_visibility: true)).to be_empty + expect(@session.find(:css, "#items").base.find_css("p", texts: %w[Shown])).to be_empty + end + end + describe "Node#shadow_root" do it "produces error messages when failing" 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..1d83ca9 --- /dev/null +++ b/spec/support/views/find_hints.erb @@ -0,0 +1,16 @@ + + + + +

Shown item

+ +