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
+ +