From 73620615f7690838041e2381499b4869719b833d Mon Sep 17 00:00:00 2001 From: Oleksii Leonov Date: Sun, 27 Sep 2026 20:52:58 +0000 Subject: [PATCH] feat(browser): add :ignore_js_errors to skip known errors with js_errors `js_errors: true` re-raises every uncaught JavaScript exception a page throws, with no way to leave some out. Some pages (especially in system specs) can throw exceptions that are benign ("ResizeObserver loop completed with undelivered notifications.", etc). Right now there is no mechanism to ignore them. The new option `ignore_js_errors:` takes an Array of Regexp (defaults to an empty array). When `js_errors` is on and a matches the error message, the exception is dropped instead of raised. --- CHANGELOG.md | 2 ++ docs/2-customization.md | 8 +++++++ lib/ferrum/browser.rb | 4 ++++ lib/ferrum/browser/options.rb | 35 ++++++++++++++++++++++++++++- lib/ferrum/page.rb | 5 ++++- sig/ferrum/browser/options.rbs | 7 ++++++ spec/browser/options_spec.rb | 41 ++++++++++++++++++++++++++++++++++ spec/frame/runtime_spec.rb | 28 +++++++++++++++++++++++ 8 files changed, 128 insertions(+), 2 deletions(-) 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