diff --git a/CHANGELOG.md b/CHANGELOG.md index 266c3922..e57771e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,15 @@ a block with this page, after which the page is closed. - `Ferrum::Page#bypass_csp` accepts hash as argument `enabled: true` by default - `Ferrum::Context#has_target?` -> `Ferrum::Context#target?` - We now start looking for Chrome first instead of Chromium, the order for checking binaries has changed +- Multiple methods are moved into `Utils`: + - Ferrum.with_attempts -> Ferrum::Utils::Attempt.with_retry + - Ferrum.started -> Ferrum::Utils::ElapsedTime.start + - Ferrum.elapsed_time -> Ferrum::Utils::ElapsedTime.elapsed_time + - Ferrum.monotonic_time -> Ferrum::Utils::ElapsedTime.monotonic_time + - Ferrum.timeout? -> Ferrum::Utils::ElapsedTime.timeout? + - Ferrum.windows? -> Ferrum::Utils::Platform.windows? + - Ferrum.mac? -> Ferrum::Utils::Platform.mac? + - Ferrum.mri? -> Ferrum::Utils::Platform.mri? ## [0.11](https://github.com/rubycdp/ferrum/compare/v0.10.2...v0.11) - (Mar 11, 2021) ## diff --git a/lib/ferrum.rb b/lib/ferrum.rb index b78ded3e..6354f2b5 100644 --- a/lib/ferrum.rb +++ b/lib/ferrum.rb @@ -1,161 +1,11 @@ # frozen_string_literal: true -require "concurrent-ruby" +require "ferrum/utils/platform" +require "ferrum/utils/elapsed_time" +require "ferrum/utils/attempt" +require "ferrum/errors" require "ferrum/browser" require "ferrum/node" module Ferrum - class Error < StandardError; end - - class NoSuchPageError < Error; end - - class NoSuchTargetError < Error; end - - class NotImplementedError < Error; end - - class StatusError < Error - def initialize(url, message = nil) - super(message || "Request to #{url} failed to reach server, check DNS and server status") - end - end - - class PendingConnectionsError < StatusError - attr_reader :pendings - - def initialize(url, pendings = []) - @pendings = pendings - - message = "Request to #{url} reached server, but there are still pending connections: #{pendings.join(', ')}" - - super(url, message) - end - end - - class TimeoutError < Error - def message - "Timed out waiting for response. It's possible that this happened " \ - "because something took a very long time (for example a page load " \ - "was slow). If so, setting the :timeout option to a higher value might " \ - "help." - end - end - - class ScriptTimeoutError < Error - def message - "Timed out waiting for evaluated script to return a value" - end - end - - class ProcessTimeoutError < Error - attr_reader :output - - def initialize(timeout, output) - @output = output - super("Browser did not produce websocket url within #{timeout} seconds, try to increase `:process_timeout`. See https://github.com/rubycdp/ferrum#customization") - end - end - - class DeadBrowserError < Error - def initialize(message = "Browser is dead or given window is closed") - super - end - end - - class NodeMovingError < Error - def initialize(node, prev, current) - @node = node - @prev = prev - @current = current - super(message) - end - - def message - "#{@node.inspect} that you're trying to click is moving, hence " \ - "we cannot. Previously it was at #{@prev.inspect} but now at " \ - "#{@current.inspect}." - end - end - - class CoordinatesNotFoundError < Error - def initialize(message = "Could not compute content quads") - super - end - end - - class BrowserError < Error - attr_reader :response - - def initialize(response) - @response = response - super(response["message"]) - end - - def code - response["code"] - end - - def data - response["data"] - end - end - - class NodeNotFoundError < BrowserError; end - - class NoExecutionContextError < BrowserError - def initialize(response = nil) - response ||= { "message" => "There's no context available" } - super(response) - end - end - - class JavaScriptError < BrowserError - attr_reader :class_name, :message, :stack_trace - - def initialize(response, stack_trace = nil) - @class_name, @message = response.values_at("className", "description") - @stack_trace = stack_trace - super(response.merge("message" => @message)) - end - end - - class << self - def windows? - RbConfig::CONFIG["host_os"] =~ /mingw|mswin|cygwin/ - end - - def mac? - RbConfig::CONFIG["host_os"] =~ /darwin/ - end - - def mri? - defined?(RUBY_ENGINE) && RUBY_ENGINE == "ruby" - end - - def started - @started ||= monotonic_time - end - - def elapsed_time(start = nil) - monotonic_time - (start || @started) - end - - def monotonic_time - Concurrent.monotonic_time - end - - def timeout?(start, timeout) - elapsed_time(start) > timeout - end - - def with_attempts(errors:, max:, wait:) - attempts ||= 1 - yield - rescue *Array(errors) - raise if attempts >= max - - attempts += 1 - sleep(wait) - retry - end - end end diff --git a/lib/ferrum/browser.rb b/lib/ferrum/browser.rb index 650dd7c3..8926195f 100644 --- a/lib/ferrum/browser.rb +++ b/lib/ferrum/browser.rb @@ -152,7 +152,7 @@ def crash private def start - Ferrum.started + Utils::ElapsedTime.start @process = Process.start(@options) @client = Client.new(self, @process.ws_url) @contexts = Contexts.new(self) diff --git a/lib/ferrum/browser/options/base.rb b/lib/ferrum/browser/options/base.rb index 592580c7..f8e6b045 100644 --- a/lib/ferrum/browser/options/base.rb +++ b/lib/ferrum/browser/options/base.rb @@ -24,9 +24,9 @@ def except(*keys) end def detect_path - if Ferrum.mac? + if Utils::Platform.mac? self.class::MAC_BIN_PATH.find { |n| File.exist?(n) } - elsif Ferrum.windows? + elsif Utils::Platform.windows? self.class::WINDOWS_BIN_PATH.find { |path| File.exist?(path) } else self.class::LINUX_BIN_PATH.find do |name| diff --git a/lib/ferrum/browser/process.rb b/lib/ferrum/browser/process.rb index 093d7491..03d115e3 100644 --- a/lib/ferrum/browser/process.rb +++ b/lib/ferrum/browser/process.rb @@ -31,15 +31,15 @@ def self.start(*args) def self.process_killer(pid) proc do - if Ferrum.windows? + if Utils::Platform.windows? # Process.kill is unreliable on Windows ::Process.kill("KILL", pid) unless system("taskkill /f /t /pid #{pid} >NUL 2>NUL") else ::Process.kill("USR1", pid) - start = Ferrum.monotonic_time + start = Utils::ElapsedTime.monotonic_time while ::Process.wait(pid, ::Process::WNOHANG).nil? sleep(WAIT_KILLED) - next unless Ferrum.timeout?(start, KILL_TIMEOUT) + next unless Utils::ElapsedTime.timeout?(start, KILL_TIMEOUT) ::Process.kill("KILL", pid) ::Process.wait(pid) @@ -88,7 +88,7 @@ def start begin read_io, write_io = IO.pipe process_options = { in: File::NULL } - process_options[:pgroup] = true unless Ferrum.windows? + process_options[:pgroup] = true unless Utils::Platform.windows? process_options[:out] = process_options[:err] = write_io if @command.xvfb? @@ -135,10 +135,10 @@ def remove_user_data_dir def parse_ws_url(read_io, timeout) output = "" - start = Ferrum.monotonic_time + start = Utils::ElapsedTime.monotonic_time max_time = start + timeout regexp = %r{DevTools listening on (ws://.*)} - while (now = Ferrum.monotonic_time) < max_time + while (now = Utils::ElapsedTime.monotonic_time) < max_time begin output += read_io.read_nonblock(512) rescue IO::WaitReadable diff --git a/lib/ferrum/browser/web_socket.rb b/lib/ferrum/browser/web_socket.rb index f92f5f09..8fc00026 100644 --- a/lib/ferrum/browser/web_socket.rb +++ b/lib/ferrum/browser/web_socket.rb @@ -61,7 +61,7 @@ def on_message(event) output.sub!(/{"data":"(.*)"}/, %("Set FERRUM_LOGGING_SCREENSHOTS=true to see screenshots in Base64")) end - @logger&.puts(" ◀ #{Ferrum.elapsed_time} #{output}\n") + @logger&.puts(" ◀ #{Utils::ElapsedTime.elapsed_time} #{output}\n") end def on_close(_event) @@ -74,7 +74,7 @@ def send_message(data) json = data.to_json @driver.text(json) - @logger&.puts("\n\n▶ #{Ferrum.elapsed_time} #{json}") + @logger&.puts("\n\n▶ #{Utils::ElapsedTime.elapsed_time} #{json}") end def write(data) diff --git a/lib/ferrum/errors.rb b/lib/ferrum/errors.rb new file mode 100644 index 00000000..0d479add --- /dev/null +++ b/lib/ferrum/errors.rb @@ -0,0 +1,116 @@ +# frozen_string_literal: true + +module Ferrum + class Error < StandardError; end + + class NoSuchPageError < Error; end + + class NoSuchTargetError < Error; end + + class NotImplementedError < Error; end + + class StatusError < Error + def initialize(url, message = nil) + super(message || "Request to #{url} failed to reach server, check DNS and server status") + end + end + + class PendingConnectionsError < StatusError + attr_reader :pendings + + def initialize(url, pendings = []) + @pendings = pendings + + message = "Request to #{url} reached server, but there are still pending connections: #{pendings.join(', ')}" + + super(url, message) + end + end + + class TimeoutError < Error + def message + "Timed out waiting for response. It's possible that this happened " \ + "because something took a very long time (for example a page load " \ + "was slow). If so, setting the :timeout option to a higher value might " \ + "help." + end + end + + class ScriptTimeoutError < Error + def message + "Timed out waiting for evaluated script to return a value" + end + end + + class ProcessTimeoutError < Error + attr_reader :output + + def initialize(timeout, output) + @output = output + super("Browser did not produce websocket url within #{timeout} seconds, try to increase `:process_timeout`. See https://github.com/rubycdp/ferrum#customization") + end + end + + class DeadBrowserError < Error + def initialize(message = "Browser is dead or given window is closed") + super + end + end + + class NodeMovingError < Error + def initialize(node, prev, current) + @node = node + @prev = prev + @current = current + super(message) + end + + def message + "#{@node.inspect} that you're trying to click is moving, hence " \ + "we cannot. Previously it was at #{@prev.inspect} but now at " \ + "#{@current.inspect}." + end + end + + class CoordinatesNotFoundError < Error + def initialize(message = "Could not compute content quads") + super + end + end + + class BrowserError < Error + attr_reader :response + + def initialize(response) + @response = response + super(response["message"]) + end + + def code + response["code"] + end + + def data + response["data"] + end + end + + class NodeNotFoundError < BrowserError; end + + class NoExecutionContextError < BrowserError + def initialize(response = nil) + response ||= { "message" => "There's no context available" } + super(response) + end + end + + class JavaScriptError < BrowserError + attr_reader :class_name, :message, :stack_trace + + def initialize(response, stack_trace = nil) + @class_name, @message = response.values_at("className", "description") + @stack_trace = stack_trace + super(response.merge("message" => @message)) + end + end +end diff --git a/lib/ferrum/frame/runtime.rb b/lib/ferrum/frame/runtime.rb index 2944c0a4..926cc783 100644 --- a/lib/ferrum/frame/runtime.rb +++ b/lib/ferrum/frame/runtime.rb @@ -122,7 +122,7 @@ def call(expression:, arguments: [], on: nil, wait: 0, handle: true, **options) sleep = INTERMITTENT_SLEEP attempts = INTERMITTENT_ATTEMPTS - Ferrum.with_attempts(errors: errors, max: attempts, wait: sleep) do + Utils::Attempt.with_retry(errors: errors, max: attempts, wait: sleep) do params = options.dup if on diff --git a/lib/ferrum/network.rb b/lib/ferrum/network.rb index 5250ef43..1722be94 100644 --- a/lib/ferrum/network.rb +++ b/lib/ferrum/network.rb @@ -29,10 +29,10 @@ def initialize(page) end def wait_for_idle(connections: 0, duration: 0.05, timeout: @page.browser.timeout) - start = Ferrum.monotonic_time + start = Utils::ElapsedTime.monotonic_time until idle?(connections) - raise TimeoutError if Ferrum.timeout?(start, timeout) + raise TimeoutError if Utils::ElapsedTime.timeout?(start, timeout) sleep(duration) end diff --git a/lib/ferrum/node.rb b/lib/ferrum/node.rb index b50bfa35..80c3af99 100644 --- a/lib/ferrum/node.rb +++ b/lib/ferrum/node.rb @@ -39,7 +39,7 @@ def focusable? end def wait_for_stop_moving(delay: MOVING_WAIT_DELAY, attempts: MOVING_WAIT_ATTEMPTS) - Ferrum.with_attempts(errors: NodeMovingError, max: attempts, wait: 0) do + Utils::Attempt.with_retry(errors: NodeMovingError, max: attempts, wait: 0) do previous, current = content_quads_with(delay: delay) raise NodeMovingError.new(self, previous, current) if previous != current diff --git a/lib/ferrum/utils/attempt.rb b/lib/ferrum/utils/attempt.rb new file mode 100644 index 00000000..334b7cc6 --- /dev/null +++ b/lib/ferrum/utils/attempt.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Ferrum + module Utils + module Attempt + module_function + + def with_retry(errors:, max:, wait:) + attempts ||= 1 + yield + rescue *Array(errors) + raise if attempts >= max + + attempts += 1 + sleep(wait) + retry + end + end + end +end diff --git a/lib/ferrum/utils/elapsed_time.rb b/lib/ferrum/utils/elapsed_time.rb new file mode 100644 index 00000000..0dc06997 --- /dev/null +++ b/lib/ferrum/utils/elapsed_time.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require "concurrent-ruby" + +module Ferrum + module Utils + module ElapsedTime + module_function + + def start + @start ||= monotonic_time + end + + def elapsed_time(start = nil) + monotonic_time - (start || @start) + end + + def monotonic_time + Concurrent.monotonic_time + end + + def timeout?(start, timeout) + elapsed_time(start) > timeout + end + end + end +end diff --git a/lib/ferrum/utils/platform.rb b/lib/ferrum/utils/platform.rb new file mode 100644 index 00000000..f80f491e --- /dev/null +++ b/lib/ferrum/utils/platform.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Ferrum + module Utils + module Platform + module_function + + def windows? + RbConfig::CONFIG["host_os"] =~ /mingw|mswin|cygwin/ + end + + def mac? + RbConfig::CONFIG["host_os"] =~ /darwin/ + end + + def mri? + defined?(RUBY_ENGINE) && RUBY_ENGINE == "ruby" + end + end + end +end diff --git a/spec/browser_spec.rb b/spec/browser_spec.rb index 14c21f78..7bc6672e 100644 --- a/spec/browser_spec.rb +++ b/spec/browser_spec.rb @@ -150,7 +150,7 @@ module Ferrum expect(browser.body).to include("x: 100, y: 150") end - it "supports stopping the session", skip: Ferrum.windows? do + it "supports stopping the session", skip: Utils::Platform.windows? do browser = Browser.new pid = browser.process.pid @@ -787,7 +787,7 @@ module Ferrum end end - if Ferrum.mri? && !Ferrum.windows? + if Utils::Platform.mri? && !Utils::Platform.windows? require "pty" require "timeout" diff --git a/spec/keyboard_spec.rb b/spec/keyboard_spec.rb index a6ce0b8d..d3c211ec 100644 --- a/spec/keyboard_spec.rb +++ b/spec/keyboard_spec.rb @@ -91,7 +91,7 @@ module Ferrum it "sends sequences with modifiers and symbols" do input = browser.at_css("#empty_input") - keys = Ferrum.mac? ? %i[Alt Left] : %i[Ctrl Left] + keys = Utils::Platform.mac? ? %i[Alt Left] : %i[Ctrl Left] input.focus.type("t", "r", "i", "n", "g", keys, "s") @@ -101,7 +101,7 @@ module Ferrum it "sends sequences with multiple modifiers and symbols" do input = browser.at_css("#empty_input") - keys = Ferrum.mac? ? %i[Alt Shift Left] : %i[Ctrl Shift Left] + keys = Utils::Platform.mac? ? %i[Alt Shift Left] : %i[Ctrl Shift Left] input.focus.type("t", "r", "i", "n", "g", keys, "s") @@ -165,7 +165,7 @@ module Ferrum end context "type" do - let(:delete_all) { [[(Ferrum.mac? ? :alt : :ctrl), :shift, :right], :backspace] } + let(:delete_all) { [[(Utils::Platform.mac? ? :alt : :ctrl), :shift, :right], :backspace] } before { browser.go_to("/ferrum/set") } @@ -293,7 +293,7 @@ module Ferrum end it "clears the input" do - keys = Ferrum.mac? ? %i[Alt Shift Left] : %i[Ctrl Shift Left] + keys = Utils::Platform.mac? ? %i[Alt Shift Left] : %i[Ctrl Shift Left] change_me.type(2.times.map { keys }, :backspace) expect(change_me.value).to eq("") end diff --git a/spec/network/error_spec.rb b/spec/network/error_spec.rb index 302771a8..83575bf9 100644 --- a/spec/network/error_spec.rb +++ b/spec/network/error_spec.rb @@ -9,7 +9,7 @@ class Network expect(network.idle?).to be_falsey # FIXME: Hack to wait for content in the browser - Ferrum.with_attempts(errors: RuntimeError, max: 10, wait: 0.1) do + Utils::Attempt.with_retry(errors: RuntimeError, max: 10, wait: 0.1) do page.at_xpath("//h1[text() = 'Canceled']") || raise("Node not found") end diff --git a/spec/support/server.rb b/spec/support/server.rb index 3860c556..92c4c4dd 100644 --- a/spec/support/server.rb +++ b/spec/support/server.rb @@ -72,9 +72,9 @@ def base_url(path = nil) end def wait_for_pending_requests - start = Ferrum.monotonic_time + start = Utils::ElapsedTime.monotonic_time while pending_requests? - raise "Requests did not finish in #{KILL_TIMEOUT} seconds" if Ferrum.timeout?(start, KILL_TIMEOUT) + raise "Requests did not finish in #{KILL_TIMEOUT} seconds" if Utils::ElapsedTime.timeout?(start, KILL_TIMEOUT) sleep 0.01 end @@ -83,11 +83,11 @@ def wait_for_pending_requests def boot! return if responsive? - start = Ferrum.monotonic_time + start = Utils::ElapsedTime.monotonic_time @server_thread = Thread.new { run } until responsive? - raise "Rack application timed out during boot" if Ferrum.timeout?(start, KILL_TIMEOUT) + raise "Rack application timed out during boot" if Utils::ElapsedTime.timeout?(start, KILL_TIMEOUT) @server_thread.join(0.1) end diff --git a/spec/unit/process_spec.rb b/spec/unit/process_spec.rb index f84f3d69..9996fb48 100644 --- a/spec/unit/process_spec.rb +++ b/spec/unit/process_spec.rb @@ -5,7 +5,7 @@ class Browser describe Process do subject { Browser.new(port: 6000, host: "127.0.0.1") } - unless Ferrum.windows? + unless Utils::Platform.windows? it "forcibly kills the child if it does not respond to SIGTERM" do allow(::Process).to receive_messages(spawn: 5678) allow(::Process).to receive(:wait).and_return(nil)