From f9a420f2a5af6f09a6b745f9c979db64439afef4 Mon Sep 17 00:00:00 2001 From: Aaron Rosenberg Date: Sun, 17 May 2026 15:04:39 -0400 Subject: [PATCH 1/2] Add wait_for_pending_js: kwarg to Mouse#click and Node#click Mouse#click issues three Input.dispatchMouseEvent CDP commands and returns. The mouseReleased ack returns before the renderer has run any microtasks queued by the click handler, including those queued behind a dynamic import() resolution (the Stimulus + dynamic-import pattern). Tests pass on fast machines and fail on slower CI. Add an opt-in wait_for_pending_js: kwarg. When true, follow the click with a no-op Runtime.evaluate; that command runs at the renderer's microtask checkpoint, so its CDP ack does not return until pending microtasks have settled. The kwarg is named after the caller's intent (wait until pending JS settles) rather than the mechanism (drain microtasks via a no-op Runtime.evaluate). The name survives an implementation change (e.g. swapping to Runtime.awaitPromise or another barrier). Node#click now delegates all three modes (:left, :right, :double) to Mouse#click so the wait logic lives in one place. :right passes button: :right and :double passes count: 2; both pass wait: 0 to preserve the historical no-network-wait behavior of those non-primary clicks (Mouse#click's default wait: CLICK_WAIT is preserved for :left, which Capybara has historically relied on to block for short navigations). A comment in Node#click marks this choice as load-bearing. Mouse#wait_for_pending_js! is private since the kwarg on #click is the only public surface and Node delegates rather than reaching in. RBS signatures updated. Regression specs spy on Page#command and assert Runtime.evaluate is or is not issued depending on the option, rather than asserting on post-click DOM state. node.text and friends go through Runtime.* themselves and drain microtasks regardless of the new code path, so they can't distinguish bug from fix. Refs #584 Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 1 + lib/ferrum/mouse.rb | 17 ++++++++++++++- lib/ferrum/node.rb | 25 +++++++++++++--------- sig/ferrum/mouse.rbs | 4 +++- sig/ferrum/node.rbs | 2 +- spec/mouse_spec.rb | 51 ++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 87 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eb83b329..1673ac85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ ## [Unreleased](https://github.com/rubycdp/ferrum/compare/v0.17.2...main) ## ### Added +- `Ferrum::Mouse#click` and `Ferrum::Node#click` accept `wait_for_pending_js:` to drain pending JS microtasks after the click [#586] ### Changed diff --git a/lib/ferrum/mouse.rb b/lib/ferrum/mouse.rb index 74c98655..581c3277 100644 --- a/lib/ferrum/mouse.rb +++ b/lib/ferrum/mouse.rb @@ -66,6 +66,13 @@ def scroll_to(top, left) # # @param [Float] wait # + # @param [Boolean] wait_for_pending_js + # When `true`, follows the click with a no-op `Runtime.evaluate` so the + # renderer's microtask checkpoint runs before this method returns. + # Useful when a click handler depends on state set inside a `.then()` + # chain (for example, a dynamically `import()`-ed Stimulus controller). + # Defaults to `false`, preserving the historical behavior. + # # @param [Hash{Symbol => Object}] options # Additional keyword arguments. # @@ -79,13 +86,14 @@ def scroll_to(top, left) # # @return [self] # - def click(x:, y:, delay: 0, wait: CLICK_WAIT, **options) + def click(x:, y:, delay: 0, wait: CLICK_WAIT, wait_for_pending_js: false, **options) move(x: x, y: y) down(**options) sleep(delay) # Potential wait because if some network event is triggered then we have # to wait until it's over and frame is loaded or failed to load. up(wait: wait, **options) + wait_for_pending_js! if wait_for_pending_js self end @@ -164,6 +172,13 @@ def move(x:, y:, steps: 1) private + # Forces the renderer's microtask checkpoint to drain before this call + # returns by issuing a no-op `Runtime.evaluate`. Private because the + # `wait_for_pending_js:` kwarg on `#click` is the public surface. + def wait_for_pending_js! + @page.command("Runtime.evaluate", expression: "") + end + def mouse_event(type:, button: :left, count: 1, modifiers: nil, wait: 0) button = validate_button(button) register_event_button(type, button) diff --git a/lib/ferrum/node.rb b/lib/ferrum/node.rb index b09e678f..a770e50a 100644 --- a/lib/ferrum/node.rb +++ b/lib/ferrum/node.rb @@ -63,23 +63,28 @@ def type(*keys) # mode: (:left | :right | :double) # keys: (:alt, (:ctrl | :control), (:meta | :command), :shift) # offset: { :x, :y, :position (:top | :center) } - def click(mode: :left, keys: [], offset: {}, delay: 0) + # wait_for_pending_js: see Mouse#click. Applies to all click modes. + # + # All three modes delegate to Mouse#click so `wait_for_pending_js:` lives + # in one place. The `:right` and `:double` modes pass `wait: 0` to + # preserve the historical no-network-wait behavior of `Node#click` for + # non-primary buttons; `:left` uses Mouse#click's default `wait: CLICK_WAIT` + # because Capybara has historically relied on the primary click to block + # for short navigations. + def click(mode: :left, keys: [], offset: {}, delay: 0, wait_for_pending_js: false) x, y = find_position(**offset) modifiers = page.keyboard.modifiers(keys) case mode when :right - page.mouse.move(x: x, y: y) - page.mouse.down(button: :right, modifiers: modifiers) - sleep(delay) - page.mouse.up(button: :right, modifiers: modifiers) + page.mouse.click(x: x, y: y, button: :right, modifiers: modifiers, delay: delay, + wait: 0, wait_for_pending_js: wait_for_pending_js) when :double - page.mouse.move(x: x, y: y) - page.mouse.down(modifiers: modifiers, count: 2) - sleep(delay) - page.mouse.up(modifiers: modifiers, count: 2) + page.mouse.click(x: x, y: y, count: 2, modifiers: modifiers, delay: delay, + wait: 0, wait_for_pending_js: wait_for_pending_js) when :left - page.mouse.click(x: x, y: y, modifiers: modifiers, delay: delay) + page.mouse.click(x: x, y: y, modifiers: modifiers, delay: delay, + wait_for_pending_js: wait_for_pending_js) end self diff --git a/sig/ferrum/mouse.rbs b/sig/ferrum/mouse.rbs index 45d57253..fbec36ea 100644 --- a/sig/ferrum/mouse.rbs +++ b/sig/ferrum/mouse.rbs @@ -15,7 +15,7 @@ module Ferrum def scroll_to: (::Numeric top, ::Numeric left) -> self - def click: (x: ::Numeric, y: ::Numeric, ?delay: ::Numeric, ?wait: ::Numeric, ?button: Symbol, ?count: ::Integer, ?modifiers: ::Integer) -> self + def click: (x: ::Numeric, y: ::Numeric, ?delay: ::Numeric, ?wait: ::Numeric, ?wait_for_pending_js: bool, ?button: Symbol, ?count: ::Integer, ?modifiers: ::Integer) -> self def down: (?button: Symbol, ?count: ::Integer, ?modifiers: ::Integer) -> self @@ -25,6 +25,8 @@ module Ferrum private + def wait_for_pending_js!: () -> Hash[String, untyped] + def mouse_event: (type: String, ?button: Symbol, ?count: ::Integer, ?modifiers: ::Integer?, ?wait: ::Numeric) -> Hash[String, untyped] def validate_button: (Symbol button) -> String diff --git a/sig/ferrum/node.rbs b/sig/ferrum/node.rbs index 63872cb5..c99efa18 100644 --- a/sig/ferrum/node.rbs +++ b/sig/ferrum/node.rbs @@ -34,7 +34,7 @@ module Ferrum def type: (*untyped keys) -> untyped - def click: (?mode: ::Symbol, ?keys: untyped, ?offset: ::Hash[untyped, untyped], ?delay: ::Integer) -> self + def click: (?mode: ::Symbol, ?keys: untyped, ?offset: ::Hash[untyped, untyped], ?delay: ::Integer, ?wait_for_pending_js: bool) -> self def hover: () -> untyped diff --git a/spec/mouse_spec.rb b/spec/mouse_spec.rb index e823bbbc..afc4fb62 100644 --- a/spec/mouse_spec.rb +++ b/spec/mouse_spec.rb @@ -30,6 +30,57 @@ browser.at_xpath("//a[text() = 'Link']").click expect(browser.body).to include("Hello world") end + + # Regression specs for [#584]. The bug: Input.dispatchMouseEvent returns + # before the renderer has run any microtasks queued behind the click (for + # example, a Stimulus controller registered inside import().then(...)). We + # can't observe this from a black-box DOM assertion because every ferrum + # DOM read goes through Runtime.*, which itself drains microtasks. Instead, + # spy on Page#command and assert that Runtime.evaluate is or isn't issued + # after the click depending on the option. + context "with wait_for_pending_js: option" do + before { browser.go_to("/click_coordinates") } + + it "issues a no-op Runtime.evaluate after the click when true" do + allow(browser.page).to receive(:command).and_call_original + + browser.mouse.click(x: 100, y: 150, wait_for_pending_js: true) + + expect(browser.page).to have_received(:command).with("Runtime.evaluate", expression: "") + end + + it "does not issue Runtime.evaluate by default" do + allow(browser.page).to receive(:command).and_call_original + + browser.mouse.click(x: 100, y: 150) + + expect(browser.page).not_to have_received(:command).with("Runtime.evaluate", expression: "") + end + + it "is threaded through Node#click for :left mode" do + allow(browser.page).to receive(:command).and_call_original + + browser.at_xpath("//body").click(wait_for_pending_js: true) + + expect(browser.page).to have_received(:command).with("Runtime.evaluate", expression: "") + end + + it "is threaded through Node#click for :right mode" do + allow(browser.page).to receive(:command).and_call_original + + browser.at_xpath("//body").click(mode: :right, wait_for_pending_js: true) + + expect(browser.page).to have_received(:command).with("Runtime.evaluate", expression: "") + end + + it "is threaded through Node#click for :double mode" do + allow(browser.page).to receive(:command).and_call_original + + browser.at_xpath("//body").click(mode: :double, wait_for_pending_js: true) + + expect(browser.page).to have_received(:command).with("Runtime.evaluate", expression: "") + end + end end describe "#scroll_by" do From 2f92f02ee0bb86f5af73cf2485b7780a9172feb5 Mon Sep 17 00:00:00 2001 From: Aaron Rosenberg Date: Sun, 17 May 2026 15:06:23 -0400 Subject: [PATCH 2/2] Add FERRUM_WAIT_FOR_PENDING_JS env var as project-wide default Threading wait_for_pending_js: true through every call site is awkward for downstream projects (e.g. Cuprite-based suites) that want this behavior everywhere. Introduce Ferrum::Mouse::WAIT_FOR_PENDING_JS, driven by FERRUM_WAIT_FOR_PENDING_JS, and switch both Mouse#click and Node#click to a nil sentinel default that resolves to it. A project-wide opt-in covers both layers because Node#click delegates to Mouse#click for all modes (so the resolution happens in one place); an explicit true/false at the call site still wins (specs lock that contract in for both layers). Mirrors the existing FERRUM_CLICK_WAIT / CLICK_WAIT convention at the top of Mouse. The env var is read once at require time, so it must be set before ferrum loads. The CHANGELOG entry calls that out so Rails users don't put it in an initializer that runs after auto-load. The previous default-off spec now stub_consts WAIT_FOR_PENDING_JS to false up front so it is deterministic regardless of the developer's environment. New specs added for Mouse and Node env-var integration and for explicit-false beating the env-var default at both layers. The contract spec that asserts the constant defaults to false gracefully skips with a clear reason when FERRUM_WAIT_FOR_PENDING_JS is set, so the ferrum suite does not break for developers who have opted in. Downstream usage in a config file like cuprite.rb: ENV["FERRUM_WAIT_FOR_PENDING_JS"] ||= "true" require "capybara/cuprite" Refs #584 Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 1 + lib/ferrum/mouse.rb | 14 ++++++++--- lib/ferrum/node.rb | 5 +++- sig/ferrum/mouse.rbs | 4 ++- sig/ferrum/node.rbs | 2 +- spec/mouse_spec.rb | 58 ++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 77 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1673ac85..cef2a711 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ### Added - `Ferrum::Mouse#click` and `Ferrum::Node#click` accept `wait_for_pending_js:` to drain pending JS microtasks after the click [#586] +- `Ferrum::Mouse::WAIT_FOR_PENDING_JS` (set by `FERRUM_WAIT_FOR_PENDING_JS`, default `false`) sets the project-wide default for `wait_for_pending_js:`. Read once at load time, so the env var must be set before `require "ferrum"` (e.g. before `require "capybara/cuprite"`) [#586] ### Changed diff --git a/lib/ferrum/mouse.rb b/lib/ferrum/mouse.rb index 581c3277..fb82ffea 100644 --- a/lib/ferrum/mouse.rb +++ b/lib/ferrum/mouse.rb @@ -3,6 +3,7 @@ module Ferrum class Mouse CLICK_WAIT = ENV.fetch("FERRUM_CLICK_WAIT", 0.1).to_f + WAIT_FOR_PENDING_JS = ENV.fetch("FERRUM_WAIT_FOR_PENDING_JS", "false") == "true" BUTTON_MASKS = { "none" => 0, "left" => 1, @@ -66,12 +67,16 @@ def scroll_to(top, left) # # @param [Float] wait # - # @param [Boolean] wait_for_pending_js + # @param [Boolean, nil] wait_for_pending_js # When `true`, follows the click with a no-op `Runtime.evaluate` so the # renderer's microtask checkpoint runs before this method returns. # Useful when a click handler depends on state set inside a `.then()` # chain (for example, a dynamically `import()`-ed Stimulus controller). - # Defaults to `false`, preserving the historical behavior. + # + # When `nil` (the default), resolves to {WAIT_FOR_PENDING_JS}, which is + # driven by the `FERRUM_WAIT_FOR_PENDING_JS` env var. Set the env var + # to `"true"` before requiring ferrum to flip the project-wide default. + # It is read once at load time. # # @param [Hash{Symbol => Object}] options # Additional keyword arguments. @@ -86,14 +91,15 @@ def scroll_to(top, left) # # @return [self] # - def click(x:, y:, delay: 0, wait: CLICK_WAIT, wait_for_pending_js: false, **options) + def click(x:, y:, delay: 0, wait: CLICK_WAIT, wait_for_pending_js: nil, **options) + should_wait = wait_for_pending_js.nil? ? WAIT_FOR_PENDING_JS : wait_for_pending_js move(x: x, y: y) down(**options) sleep(delay) # Potential wait because if some network event is triggered then we have # to wait until it's over and frame is loaded or failed to load. up(wait: wait, **options) - wait_for_pending_js! if wait_for_pending_js + wait_for_pending_js! if should_wait self end diff --git a/lib/ferrum/node.rb b/lib/ferrum/node.rb index a770e50a..1eb2f99d 100644 --- a/lib/ferrum/node.rb +++ b/lib/ferrum/node.rb @@ -64,6 +64,9 @@ def type(*keys) # keys: (:alt, (:ctrl | :control), (:meta | :command), :shift) # offset: { :x, :y, :position (:top | :center) } # wait_for_pending_js: see Mouse#click. Applies to all click modes. + # Default `nil` resolves to Mouse::WAIT_FOR_PENDING_JS so a single + # FERRUM_WAIT_FOR_PENDING_JS setting covers both layers. Explicit + # true/false at the call site wins over the env-var default. # # All three modes delegate to Mouse#click so `wait_for_pending_js:` lives # in one place. The `:right` and `:double` modes pass `wait: 0` to @@ -71,7 +74,7 @@ def type(*keys) # non-primary buttons; `:left` uses Mouse#click's default `wait: CLICK_WAIT` # because Capybara has historically relied on the primary click to block # for short navigations. - def click(mode: :left, keys: [], offset: {}, delay: 0, wait_for_pending_js: false) + def click(mode: :left, keys: [], offset: {}, delay: 0, wait_for_pending_js: nil) x, y = find_position(**offset) modifiers = page.keyboard.modifiers(keys) diff --git a/sig/ferrum/mouse.rbs b/sig/ferrum/mouse.rbs index fbec36ea..e6b9811f 100644 --- a/sig/ferrum/mouse.rbs +++ b/sig/ferrum/mouse.rbs @@ -2,6 +2,8 @@ module Ferrum class Mouse CLICK_WAIT: ::Float + WAIT_FOR_PENDING_JS: bool + BUTTON_MASKS: ::Hash[String, ::Integer] @page: Page @@ -15,7 +17,7 @@ module Ferrum def scroll_to: (::Numeric top, ::Numeric left) -> self - def click: (x: ::Numeric, y: ::Numeric, ?delay: ::Numeric, ?wait: ::Numeric, ?wait_for_pending_js: bool, ?button: Symbol, ?count: ::Integer, ?modifiers: ::Integer) -> self + def click: (x: ::Numeric, y: ::Numeric, ?delay: ::Numeric, ?wait: ::Numeric, ?wait_for_pending_js: bool?, ?button: Symbol, ?count: ::Integer, ?modifiers: ::Integer) -> self def down: (?button: Symbol, ?count: ::Integer, ?modifiers: ::Integer) -> self diff --git a/sig/ferrum/node.rbs b/sig/ferrum/node.rbs index c99efa18..b26d0616 100644 --- a/sig/ferrum/node.rbs +++ b/sig/ferrum/node.rbs @@ -34,7 +34,7 @@ module Ferrum def type: (*untyped keys) -> untyped - def click: (?mode: ::Symbol, ?keys: untyped, ?offset: ::Hash[untyped, untyped], ?delay: ::Integer, ?wait_for_pending_js: bool) -> self + def click: (?mode: ::Symbol, ?keys: untyped, ?offset: ::Hash[untyped, untyped], ?delay: ::Integer, ?wait_for_pending_js: bool?) -> self def hover: () -> untyped diff --git a/spec/mouse_spec.rb b/spec/mouse_spec.rb index afc4fb62..15dce03b 100644 --- a/spec/mouse_spec.rb +++ b/spec/mouse_spec.rb @@ -50,6 +50,9 @@ end it "does not issue Runtime.evaluate by default" do + # Explicitly stub so the spec is deterministic regardless of whether + # FERRUM_WAIT_FOR_PENDING_JS is set in the developer's environment. + stub_const("Ferrum::Mouse::WAIT_FOR_PENDING_JS", false) allow(browser.page).to receive(:command).and_call_original browser.mouse.click(x: 100, y: 150) @@ -81,6 +84,61 @@ expect(browser.page).to have_received(:command).with("Runtime.evaluate", expression: "") end end + + # The FERRUM_WAIT_FOR_PENDING_JS env var is read once at require time + # into Mouse::WAIT_FOR_PENDING_JS. Use stub_const to flip it. The + # Node#click default of `wait_for_pending_js: nil` also resolves to this + # constant (via Mouse#click's resolution), so one env-var setting covers + # both layers. + context "with FERRUM_WAIT_FOR_PENDING_JS env var" do + it "Ferrum::Mouse::WAIT_FOR_PENDING_JS is false by default", + skip: (ENV["FERRUM_WAIT_FOR_PENDING_JS"] == "true" ? "skipped: FERRUM_WAIT_FOR_PENDING_JS=true" : false) do + # Documents the contract: default off unless opted in. Skips (rather + # than fails) when the developer has opted in via env var, so the + # ferrum suite doesn't break in environments that exercise that path. + expect(Ferrum::Mouse::WAIT_FOR_PENDING_JS).to be(false) + end + + it "flips the default for Mouse#click when set to true" do + stub_const("Ferrum::Mouse::WAIT_FOR_PENDING_JS", true) + browser.go_to("/click_coordinates") + allow(browser.page).to receive(:command).and_call_original + + browser.mouse.click(x: 100, y: 150) + + expect(browser.page).to have_received(:command).with("Runtime.evaluate", expression: "") + end + + it "flips the default for Node#click when set to true" do + stub_const("Ferrum::Mouse::WAIT_FOR_PENDING_JS", true) + browser.go_to("/click_coordinates") + allow(browser.page).to receive(:command).and_call_original + + browser.at_xpath("//body").click + + expect(browser.page).to have_received(:command).with("Runtime.evaluate", expression: "") + end + + it "lets an explicit wait_for_pending_js: false at the call site beat the env-var default" do + stub_const("Ferrum::Mouse::WAIT_FOR_PENDING_JS", true) + browser.go_to("/click_coordinates") + allow(browser.page).to receive(:command).and_call_original + + browser.mouse.click(x: 100, y: 150, wait_for_pending_js: false) + + expect(browser.page).not_to have_received(:command).with("Runtime.evaluate", expression: "") + end + + it "lets an explicit wait_for_pending_js: false on Node#click beat the env-var default" do + stub_const("Ferrum::Mouse::WAIT_FOR_PENDING_JS", true) + browser.go_to("/click_coordinates") + allow(browser.page).to receive(:command).and_call_original + + browser.at_xpath("//body").click(wait_for_pending_js: false) + + expect(browser.page).not_to have_received(:command).with("Runtime.evaluate", expression: "") + end + end end describe "#scroll_by" do