diff --git a/CHANGELOG.md b/CHANGELOG.md index eb83b329..cef2a711 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,8 @@ ## [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] +- `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 74c98655..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,6 +67,17 @@ def scroll_to(top, left) # # @param [Float] wait # + # @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). + # + # 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. # @@ -79,13 +91,15 @@ 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: 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 should_wait self end @@ -164,6 +178,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..1eb2f99d 100644 --- a/lib/ferrum/node.rb +++ b/lib/ferrum/node.rb @@ -63,23 +63,31 @@ 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. + # 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 + # 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: nil) 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..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, ?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 +27,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..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) -> 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..15dce03b 100644 --- a/spec/mouse_spec.rb +++ b/spec/mouse_spec.rb @@ -30,6 +30,115 @@ 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 + # 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) + + 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 + + # 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