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

Expand Down
30 changes: 20 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, object_id)
end

def window_handle
Expand Down Expand Up @@ -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"

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