diff --git a/CHANGELOG.md b/CHANGELOG.md index 9754673e..0acf9009 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,8 @@ ## [Unreleased](https://github.com/rubycdp/ferrum/compare/v0.18.0...main) ## ### Added +- `:ignore_js_errors` browser option, an `Array` of `Regexp` (empty by default) matched against the message of the + JavaScript errors `js_errors: true` would re-raise; a matching error is dropped instead - `Ferrum::Frame#wait_for_selector` (also reachable from `Page`/`Browser`), which waits for an element to show up in the DOM. It retries `at_css`/`at_xpath` every `interval` until a match is found or time runs out, returning `nil` on timeout [#82] diff --git a/docs/2-customization.md b/docs/2-customization.md index 602a1604..f7e18134 100644 --- a/docs/2-customization.md +++ b/docs/2-customization.md @@ -30,6 +30,8 @@ Ferrum::Browser.new(options) * `:timeout` (Numeric) - The number of seconds we'll wait for a response when communicating with browser. Default is 5. * `:js_errors` (Boolean) - When true, JavaScript errors get re-raised in Ruby. + * `:ignore_js_errors` (Array) - JavaScript errors that are not re-raised + even when `:js_errors` is true, matched against the error message. Default is `[]`. * `:pending_connection_errors` (Boolean) - Raise `PendingConnectionsError` when main frame is still waiting for slow responses and timeout is reached. Default is false. * `:browser_name` (Symbol) - `:chrome` by default, `:opera` and `:edge` are @@ -130,6 +132,12 @@ Ferrum::Browser.new(url: "http://localhost:9222") # Enable JavaScript error raising Ferrum::Browser.new(js_errors: true) +# Raise JavaScript errors except for known browser and library noise +Ferrum::Browser.new( + js_errors: true, + ignore_js_errors: [/ResizeObserver loop completed with undelivered notifications\./, /^AbortError: /] +) + # Set custom timeout and slowmo for debugging Ferrum::Browser.new(timeout: 10, slowmo: 0.5) diff --git a/lib/ferrum/browser.rb b/lib/ferrum/browser.rb index 4966ab39..7c773fe3 100644 --- a/lib/ferrum/browser.rb +++ b/lib/ferrum/browser.rb @@ -97,6 +97,10 @@ class Browser # @option options [Boolean] :js_errors # When true, JavaScript errors get re-raised in Ruby. # + # @option options [Array] :ignore_js_errors ([]) + # JavaScript errors not re-raised even when `:js_errors` is true, matched + # against the error message. + # # @option options [Boolean] :pending_connection_errors (true) # When main frame is still waiting for slow responses while timeout is # reached {PendingConnectionsError} is raised. It's better to figure out diff --git a/lib/ferrum/browser/options.rb b/lib/ferrum/browser/options.rb index 0a6cbec0..4f1500f7 100644 --- a/lib/ferrum/browser/options.rb +++ b/lib/ferrum/browser/options.rb @@ -19,7 +19,7 @@ class Options DEBUG_MODE = !ENV.fetch("FERRUM_DEBUG", nil).nil? attr_reader :window_size, :logger, :ws_max_receive_size, - :js_errors, :base_url, :slowmo, :pending_connection_errors, + :js_errors, :ignore_js_errors, :base_url, :slowmo, :pending_connection_errors, :url, :ws_url, :env, :process_timeout, :browser_name, :browser_path, :save_path, :proxy, :port, :host, :headless, :incognito, :dockerize, :browser_options, :ignore_default_browser_options, :xvfb, :flatten @@ -34,6 +34,7 @@ def initialize(options = nil) @protocol_timeout = @options.fetch(:protocol_timeout, DEFAULT_PROTOCOL_TIMEOUT) @window_size = @options.fetch(:window_size, WINDOW_SIZE) @js_errors = @options.fetch(:js_errors, false) + @ignore_js_errors = validate_ignore_js_errors(@options.fetch(:ignore_js_errors, [])) @headless = @options.fetch(:headless, true) @incognito = @options.fetch(:incognito, true) @dockerize = @options.fetch(:dockerize, false) @@ -108,6 +109,38 @@ def validate_proxy(options) options end + # + # Validates the `:ignore_js_errors` option. + # + # @param [Array] patterns + # The `:ignore_js_errors` option as passed to {#initialize}. + # + # @return [Array] + # The same patterns, frozen. + # + # @raise [ArgumentError] + # If `patterns` is not an `Array`, or holds anything but `Regexp`. + # + def validate_ignore_js_errors(patterns) + unless patterns.is_a?(Array) && patterns.all?(Regexp) + raise ArgumentError, "ignore_js_errors must be an Array of Regexp" + end + + patterns.dup.freeze + end + + # + # Whether a JavaScript error message matches any `:ignore_js_errors` pattern. + # + # @param [String] message + # The error message, as {JavaScriptError#message} builds it. + # + # @return [Boolean] + # + def ignore_js_error?(message) + @ignore_js_errors.any? { |pattern| pattern.match?(message) } + end + # # Raw options hash used to initialize the browser. # diff --git a/lib/ferrum/page.rb b/lib/ferrum/page.rb index f813114a..a9ea14f8 100644 --- a/lib/ferrum/page.rb +++ b/lib/ferrum/page.rb @@ -534,8 +534,11 @@ def subscribe if @options.js_errors on("Runtime.exceptionThrown") do |params| + error = JavaScriptError.new(params["exceptionDetails"]) + next if @options.ignore_js_error?(error.message) + # FIXME: https://jvns.ca/blog/2015/11/27/why-rubys-timeout-is-dangerous-and-thread-dot-raise-is-terrifying/ - Thread.main.raise JavaScriptError, params["exceptionDetails"] + Thread.main.raise error end end diff --git a/sig/ferrum/browser/options.rbs b/sig/ferrum/browser/options.rbs index 87080fde..33ce5886 100644 --- a/sig/ferrum/browser/options.rbs +++ b/sig/ferrum/browser/options.rbs @@ -31,6 +31,8 @@ module Ferrum attr_reader js_errors: bool + attr_reader ignore_js_errors: ::Array[Regexp] + attr_reader base_url: ::Addressable::URI? attr_reader slowmo: ::Float @@ -76,6 +78,7 @@ module Ferrum @protocol_timeout: ::Numeric @window_size: ::Array[::Integer] @js_errors: bool + @ignore_js_errors: ::Array[Regexp] @headless: bool @incognito: bool @flatten: bool @@ -106,6 +109,10 @@ module Ferrum def validate_proxy: (Hash[Symbol, (String | ::Integer)]? options) -> Hash[Symbol, (String | ::Integer)]? + def validate_ignore_js_errors: (::Array[Regexp] patterns) -> ::Array[Regexp] + + def ignore_js_error?: (String message) -> bool + def to_h: () -> Hash[Symbol, untyped] private diff --git a/spec/browser/options_spec.rb b/spec/browser/options_spec.rb index b37966a8..fc6f8196 100644 --- a/spec/browser/options_spec.rb +++ b/spec/browser/options_spec.rb @@ -1,6 +1,47 @@ # frozen_string_literal: true describe Ferrum::Browser::Options do + describe "#ignore_js_errors" do + it "defaults to no patterns" do + expect(described_class.new.ignore_js_errors).to eq([]) + end + + it "keeps the given patterns" do + expect(described_class.new(ignore_js_errors: [/omg/, /zomg/]).ignore_js_errors).to eq([/omg/, /zomg/]) + end + + it "rejects a single pattern that is not wrapped in an Array" do + expect { described_class.new(ignore_js_errors: /omg/) } + .to raise_error(ArgumentError, "ignore_js_errors must be an Array of Regexp") + end + + it "rejects nil" do + expect { described_class.new(ignore_js_errors: nil) } + .to raise_error(ArgumentError, "ignore_js_errors must be an Array of Regexp") + end + + it "rejects a String pattern" do + expect { described_class.new(ignore_js_errors: ["omg"]) } + .to raise_error(ArgumentError, "ignore_js_errors must be an Array of Regexp") + end + end + + describe "#ignore_js_error?" do + let(:options) { described_class.new(ignore_js_errors: [/Transition was skipped/, /^ReferenceError: /]) } + + it "matches a message any pattern matches" do + expect(options.ignore_js_error?("ReferenceError: omg is not defined")).to be(true) + end + + it "does not match a message no pattern covers" do + expect(options.ignore_js_error?("TypeError: Cannot read properties of undefined")).to be(false) + end + + it "matches nothing by default" do + expect(described_class.new.ignore_js_error?("ReferenceError: omg is not defined")).to be(false) + end + end + describe "#protocol_timeout" do it "defaults to a low value, sufficient for internal CDP bookkeeping" do options = described_class.new diff --git a/spec/frame/runtime_spec.rb b/spec/frame/runtime_spec.rb index 12504a63..a27a68c0 100644 --- a/spec/frame/runtime_spec.rb +++ b/spec/frame/runtime_spec.rb @@ -74,6 +74,34 @@ browser&.quit end end + + context "with ignored javascript errors" do + let(:browser) do + Ferrum::Browser.new(base_url: base_url, js_errors: true, + ignore_js_errors: [/ResizeObserver loop/, /ReferenceError: (omg|foo)/]) + end + + after { browser.quit } + + it "does not propagate an error whose message matches an ignored pattern" do + browser.execute "setTimeout(function() { omg }, 0)" + sleep 0.1 + expect(browser.evaluate("1+1")).to eq(2) + end + + it "does not propagate an ignored error thrown during page load" do + browser.go_to("/js_error") + expect(browser.body).to include("hello") + end + + it "propagates an error that matches no ignored pattern" do + expect do + browser.execute "setTimeout(function() { bar }, 0)" + sleep 0.01 + browser.execute "" + end.to raise_error(Ferrum::JavaScriptError, /ReferenceError.*bar/) + end + end end describe "#evaluate" do