diff --git a/CHANGELOG.md b/CHANGELOG.md index a68e3303..725a773d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,8 @@ - `browser.add_extension` adds an extension for pages and iframes attached afterwards [#663] - `page.isolated_world(name)` and `frame.isolated_world(name)` evaluate JavaScript in a world hidden from the page - `Node#backend_node_id` and `Node#node_type` +- `page.wait_for_navigation { }` and `wait_for_navigation! { }` wait for a navigation the block starts, e.g. a click + made from JavaScript [#679] ### Changed - `evaluate` fetches the whole result in one CDP call instead of one per nested object, dates come back as ISO 8601 diff --git a/docs/3-navigation.md b/docs/3-navigation.md index 73919117..8d617602 100644 --- a/docs/3-navigation.md +++ b/docs/3-navigation.md @@ -54,6 +54,27 @@ page.go_to("https://github.com/") page.stop ``` +#### wait_for_navigation(wait = 0.1) { } : `Boolean` + +Runs the block and, if it starts a navigation within `wait` seconds, waits for the new page to load. Returns `false` +if it doesn't load within the page timeout. `click` already does this, use it for navigations started from +JavaScript. + +* wait `Numeric` seconds to watch for a navigation after the block, `FERRUM_CLICK_WAIT` or `0.1` by default + +```ruby +page.wait_for_navigation { page.at_css("a").evaluate("this.click()") } # => true +page.current_url # => the link's page +``` + +#### wait_for_navigation!(wait = 0.1) { } + +Same as `wait_for_navigation`, but raises `Ferrum::TimeoutError` if the new page doesn't load within the page timeout. + +```ruby +page.wait_for_navigation! { page.at_css("a").evaluate("this.click()") } +``` + #### position = \*\*options Set the position for the browser window diff --git a/lib/ferrum/browser.rb b/lib/ferrum/browser.rb index 6d698242..5b50e758 100644 --- a/lib/ferrum/browser.rb +++ b/lib/ferrum/browser.rb @@ -29,6 +29,7 @@ class Browser delegate %i[default_context] => :contexts delegate %i[targets create_target page pages windows workers service_workers attach_target] => :default_context delegate %i[go_to goto go back forward refresh reload stop wait_for_reload + wait_for_navigation wait_for_navigation! at_css at_xpath css xpath wait_for_selector current_url current_title url title body doctype content= headers cookies network console accessibility downloads diff --git a/lib/ferrum/page.rb b/lib/ferrum/page.rb index 577ae345..637591e4 100644 --- a/lib/ferrum/page.rb +++ b/lib/ferrum/page.rb @@ -398,6 +398,40 @@ def wait_for_reload(timeout = 1) @event.set end + # + # Runs the block and, if it starts a navigation of the main frame within + # `wait` seconds, waits for that navigation to finish, e.g. after a click + # on a link made from JavaScript. + # + # @param [Numeric] wait + # How many seconds to watch for a navigation after the block returns. + # + # @yield The code that may start a navigation. + # + # @return [Boolean] + # `false` when a navigation started but didn't finish within the page's + # `timeout`. + # + # @example + # page.wait_for_navigation { page.at_css("a").evaluate("this.click()") } # => true + # + def wait_for_navigation(wait = Mouse::CLICK_WAIT) + iteration = @event.reset + yield + @event.wait(wait) + iteration == @event.iteration || @event.wait(timeout) + end + + # + # Waits for a navigation the block starts or raises {Ferrum::TimeoutError}. + # Accepts the same arguments as {#wait_for_navigation}. + # + # @raise [Ferrum::TimeoutError] + # + def wait_for_navigation!(...) + raise TimeoutError unless wait_for_navigation(...) + end + # # Enables/disables CSP bypass. # @@ -496,21 +530,14 @@ def activate # page.command("Page.navigate", url: "https://github.com/") # def command(method, wait: 0, slowmoable: false, timeout: nil, **params) - iteration = @event.reset if wait.positive? - sleep(@options.slowmo) if slowmoable && @options.slowmo.positive? - result = client.command(method, timeout: timeout || self.timeout, **params) - if wait.positive? - # Wait a bit after command and check if iteration has - # changed, which means there was some network event for - # the main frame, and it started to load new content. - @event.wait(wait) - if iteration != @event.iteration - set = @event.wait(self.timeout) - raise TimeoutError unless set - end + result = nil + wait_for_navigation!(wait) { result = command(method, slowmoable:, timeout:, **params) } + return result end - result + + sleep(@options.slowmo) if slowmoable && @options.slowmo.positive? + client.command(method, timeout: timeout || self.timeout, **params) end # Subscribes to a CDP event, or to `:dialog`, `:request`, `:response`, diff --git a/sig/ferrum/page.rbs b/sig/ferrum/page.rbs index 643b0ace..259aa687 100644 --- a/sig/ferrum/page.rbs +++ b/sig/ferrum/page.rbs @@ -80,6 +80,10 @@ module Ferrum def wait_for_reload: (?::Numeric timeout) -> void + def wait_for_navigation: (?::Numeric wait) { () -> void } -> bool + + def wait_for_navigation!: (?::Numeric wait) { () -> void } -> void + def bypass_csp: (?enabled: bool) -> Hash[String, untyped] def evaluate_on_new_document: (String source, ?world_name: String?, ?run_immediately: bool) -> String diff --git a/spec/page_spec.rb b/spec/page_spec.rb index bc3d2861..e73a5fee 100644 --- a/spec/page_spec.rb +++ b/spec/page_spec.rb @@ -150,6 +150,54 @@ end end + describe "#wait_for_navigation" do + before { page.go_to("/") } + + it "waits for a navigation the block starts" do + navigated = loaded = false + page.on("Page.frameNavigated") { |params| navigated = params.dig("frame", "url").end_with?("/slow") } + page.on("Page.loadEventFired") { loaded = navigated } + + expect(page.wait_for_navigation { page.execute("location.href = '/slow'") }).to be(true) + expect(loaded).to be(true) + expect(page.body).to include("slow page") + end + + it "returns after the wait when nothing navigates" do + started = Process.clock_gettime(Process::CLOCK_MONOTONIC) + + expect(page.wait_for_navigation(0.2) { page.execute("1 + 1") }).to be(true) + expect(Process.clock_gettime(Process::CLOCK_MONOTONIC) - started).to be_between(0.2, page.timeout) + end + + it "returns false when the navigation doesn't finish within the page timeout" do + page.timeout = 1 + + expect(page.wait_for_navigation { page.execute("location.href = '/really_slow'") }).to be(false) + end + end + + describe "#wait_for_navigation!" do + before { page.go_to("/") } + + it "waits for a navigation the block starts" do + navigated = loaded = false + page.on("Page.frameNavigated") { |params| navigated = params.dig("frame", "url").end_with?("/slow") } + page.on("Page.loadEventFired") { loaded = navigated } + + page.wait_for_navigation! { page.execute("location.href = '/slow'") } + + expect(loaded).to be(true) + end + + it "raises when the navigation doesn't finish within the page timeout" do + page.timeout = 1 + + expect { page.wait_for_navigation! { page.execute("location.href = '/really_slow'") } } + .to raise_error(Ferrum::TimeoutError) + end + end + describe "#bypass_csp" do it "can bypass csp headers" do page.go_to("/csp")