From 98a22dcc14381a53ad966643c542396c86709d49 Mon Sep 17 00:00:00 2001 From: Thomas Klemm Date: Tue, 3 Nov 2026 23:09:26 +0000 Subject: [PATCH] Raise ObsoleteNode when a node is detached before the click hit test Node#command checks isConnected once, then before_click runs several evaluates. If the page removes the node in between, mouseEventTest called getRootNode() on the detached subtree, which has no elementFromPoint, and the click failed with a JavaScriptError that Capybara does not retry. Fixes #359 Amp-Thread-ID: https://ampcode.com/threads/T-ea46fac7-7b01-42f9-a324-72b9afb4985c --- CHANGELOG.md | 2 ++ lib/capybara/cuprite/javascripts/index.js | 5 +++++ lib/capybara/cuprite/node.rb | 1 + spec/node_spec.rb | 13 +++++++++++++ 4 files changed, 21 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 36a939c..6f35c30 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,8 @@ [rubycdp/ferrum#689] ### Fixed +- A click on a node the page removes before the hit test raises `ObsoleteNode`, which Capybara retries, instead of + `Ferrum::JavaScriptError: node.getRootNode(...).elementFromPoint is not a function` [#359] ### Removed - Methods deprecated in 0.20, see its Deprecated section for replacements diff --git a/lib/capybara/cuprite/javascripts/index.js b/lib/capybara/cuprite/javascripts/index.js index 8787e89..2266378 100644 --- a/lib/capybara/cuprite/javascripts/index.js +++ b/lib/capybara/cuprite/javascripts/index.js @@ -4,6 +4,7 @@ class InvalidSelector extends Error {} class TimedOutPromise extends Error {} class MouseEventFailed extends Error {} + class ObsoleteNode extends Error {} const EVENTS = { FOCUS: ["blur", "focus", "focusin", "focusout"], @@ -329,6 +330,10 @@ } mouseEventTest(node, name, x, y) { + // The page can remove the node after Ruby checked it; getRootNode() would + // then return the detached subtree, which has no elementFromPoint. + if (!node.isConnected) throw new ObsoleteNode(); + let frameOffset = this._frameOffset(); x -= frameOffset.left; y -= frameOffset.top; diff --git a/lib/capybara/cuprite/node.rb b/lib/capybara/cuprite/node.rb index a7a81b8..9d1511e 100644 --- a/lib/capybara/cuprite/node.rb +++ b/lib/capybara/cuprite/node.rb @@ -274,6 +274,7 @@ def before_click(name, offset = {}) js(:mouseEventTest, name.to_s, x, y) rescue Ferrum::JavaScriptError => e raise MouseEventFailed, e.message if e.class_name == "MouseEventFailed" + raise ObsoleteNode.new(self, e.response) if e.class_name == "ObsoleteNode" raise end diff --git a/spec/node_spec.rb b/spec/node_spec.rb index 9343a4b..e95d016 100644 --- a/spec/node_spec.rb +++ b/spec/node_spec.rb @@ -95,6 +95,19 @@ .to raise_error(Capybara::Cuprite::MouseEventFailed) end + it "raises ObsoleteNode for an element removed before the hit test" do + @session.visit("/cuprite/with_js") + node = @session.find(:css, "#remove_me") + # The page removes the node after Ruby checked it and before the hit test runs. + @session.execute_script(<<~JS) + const test = _cuprite.mouseEventTest; + _cuprite.mouseEventTest = function(node, ...args) { node.remove(); return test.call(this, node, ...args); }; + JS + + # native.click bypasses Capybara::Node::Element#reload, which would re-find and retry. + expect { node.native.click }.to raise_error(Capybara::Cuprite::ObsoleteNode) + end + it "clicks an element brought into the viewport" do @session.visit("/cuprite/with_js") @session.execute_script %($("#off-the-left").animate({left: "10"});)