Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
- `Node#drag_to` with `drop_modifiers:` holds the modifiers while moving to the target and sends keydown and keyup for
Expand Down
31 changes: 21 additions & 10 deletions lib/capybara/cuprite/browser.rb
Original file line number Diff line number Diff line change
Expand Up @@ -94,17 +94,25 @@ 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)
node.property(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, { "objectId" => object_id })
end

def window_handle
Expand Down Expand Up @@ -262,18 +270,21 @@ 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)
}

evaluate("_cuprite.findWithHints(...arguments)", *[method, selector, hints, within].compact)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Avoid nested transport overhead on supported Ferrum 0.19.1

The gemspec allows Ferrum ~> 0.19.1, whose serializer recursively fetches object properties. Returning [node, {}] pairs adds two Runtime.getProperties requests per match even when no hints are requested. For 300 list items, page.all("li", visible: :all).size increases from 303 to 903 CDP commands; direct Browser#find has the same regression.

Ferrum main’s deep serializer avoids this (both variants use one command), but the Gemfile’s main-branch dependency does not protect released-gem users. Could the no-hints path transport a plain node array and construct empty-cache pairs in Ruby, or require a released Ferrum version containing deep serialization before shipping?

.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"

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 @@ -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)
Expand Down
32 changes: 32 additions & 0 deletions lib/capybara/cuprite/javascripts/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,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));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Bound eager hint work for large candidate sets

first can stop filtering after its first match, but this path computes text hints and visibility for every retained candidate. In a synthetic worst-case fixture with 1,000 nested divs containing needle at the leaf, page.first(:css, "div", text: "needle", wait: 0) had local three-run medians of approximately 13 ms on base and 174 ms on this branch with Ferrum main fb7d858, excluding DOM construction. The visibility ancestor walks alone perform quadratic work: instrumenting getComputedStyle during hint collection counted 5,250 / 20,500 / 81,000 calls for 100 / 200 / 400 nested candidates.

An experimental fallback above 512 candidates reduced the median to approximately 17 ms. Could we bound eager work and leave larger candidate sets to Capybara’s lazy filtering? The cutoff is experimental; a fallback would still need to exclude non-elements and preserve the node/cache representation.

}

return nodes.map(node => {
let cache = {};
if (hints.visible) {
cache.visible = this.isVisible(node);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P3, non-blocking] Make the changed lazy-result semantics explicit

This changes how not-yet-filtered candidates behave after the DOM changes. Starting with <div id="first">first</div><div id="second">second</div>:

result = page.all("div", minimum: 1)
page.execute_script("document.querySelector('#second').style.display = 'none'")
result.map { |node| node[:id] }

Base returns ["first"] while this branch returns ["first", "second"]. Style and position filters behave similarly because later candidates retain their initial hints.

Capybara’s Selenium optimization already uses these caches for larger candidate sets, so this is a compatibility decision rather than an unsupported use of initial_cache. Could we explicitly test and document the intended snapshot behavior?

}
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) || ""));
}

parents(node) {
let nodes = [];
let parent = node.parentNode;
Expand Down
18 changes: 9 additions & 9 deletions lib/capybara/cuprite/node.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand Down
64 changes: 64 additions & 0 deletions spec/features/session_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
16 changes: 16 additions & 0 deletions spec/support/views/find_hints.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html>
<body>
<ul id="items">
<li id="shown" style="color: rgb(255, 0, 0)">Shown item</li>
<li id="hidden" style="display: none">Hidden item</li>
<li id="split">Split<span style="display: none">hidden</span>word</li>
<li id="zero_width">Zero&#8203;width</li>
<li id="shouted" style="text-transform: uppercase">shouted</li>
<li id="spaced">Spaced
out</li>
<li id="non_breaking">Non&nbsp;breaking</li>
</ul>
<p id="outside">Shown item</p>
</body>
</html>
Loading